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
48 changed files with 1014 additions and 471 deletions
+37 -131
View File
@@ -1,131 +1,37 @@
aiocache==0.12.3 ; python_version >= "3.10" and python_version < "4.0" playwright>=1.41.1,<2.0.0
aiofiles==23.2.1 ; python_version >= "3.10" and python_version < "4.0" nonebot-adapter-onebot>=2.3.1
aiosqlite==0.17.0 ; python_version >= "3.10" and python_version < "4.0" nonebot-plugin-apscheduler>=0.5,<0.6
annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0" tortoise-orm>=0.20.0,<0.21.0
alibabacloud-devops20210625==5.0.2 ; python_version >= "3.10" and python_version < "4.0" cattrs>=23.2.3,<24.0.0
anyio==4.8.0 ; python_version >= "3.10" and python_version < "4.0" ruamel.yaml>=0.18.5,<0.19.0
apscheduler==3.11.0 ; python_version >= "3.10" and python_version < "4.0" strenum>=0.4.15,<0.5.0
arclet-alconna-tools==0.7.10 ; python_version >= "3.10" and python_version < "4.0" nonebot-plugin-session>=0.3.2,<0.4.0
arclet-alconna==1.8.35 ; python_version >= "3.10" and python_version < "4.0" ujson>=5.9.0
arrow==1.3.0 ; python_version >= "3.10" and python_version < "4.0" nb-cli>=1.3.0
async-timeout==5.0.1 ; python_version == "3.10" nonebot2[fastapi]>=2.3.3
asyncpg==0.30.0 ; python_version >= "3.10" and python_version < "4.0" Pillow>=10.0.0,<11.0.0
attrs==25.1.0 ; python_version >= "3.10" and python_version < "4.0" retrying>=1.3.4,<2.0.0
beautifulsoup4==4.13.3 ; python_version >= "3.10" and python_version < "4.0" aiofiles>=23.2.1,<24.0.0
bilireq==0.2.3.post0 ; python_version >= "3.10" and python_version < "4.0" nonebot-plugin-htmlrender>=0.6.0,<1.0.0
binaryornot==0.4.4 ; python_version >= "3.10" and python_version < "4.0" pypinyin>=0.51.0
cashews==7.4.0 ; python_version >= "3.10" and python_version < "4.0" beautifulsoup4>=4.12.3,<5.0.0
cattrs==23.2.3 ; python_version >= "3.10" and python_version < "4.0" lxml>=5.1.0,<6.0.0
certifi==2025.1.31 ; python_version >= "3.10" and python_version < "4.0" psutil>=5.9.8,<6.0.0
cffi==1.17.1 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy" feedparser>=6.0.11,<7.0.0
chardet==5.2.0 ; python_version >= "3.10" and python_version < "4.0" ImageHash>=4.3.1,<5.0.0
charset-normalizer==3.4.1 ; python_version >= "3.10" and python_version < "4.0" cn2an>=0.5.22,<0.6.0
click==8.1.8 ; python_version >= "3.10" and python_version < "4.0" dateparser>=1.2.0,<2.0.0
cn2an==0.5.23 ; python_version >= "3.10" and python_version < "4.0" bilireq>=0.2.10
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and (platform_system == "Windows" or sys_platform == "win32") python-jose[cryptography]>=3.3.0,<4.0.0
cookiecutter==2.6.0 ; python_version >= "3.10" and python_version < "4.0" python-multipart>=0.0.9,<0.1.0
cryptography==44.0.1 ; python_version >= "3.10" and python_version < "4.0" aiocache[redis]>=0.12.3,<0.13.0
dateparser==1.2.1 ; python_version >= "3.10" and python_version < "4.0" py-cpuinfo>=9.0.0,<10.0.0
distlib==0.3.9 ; python_version >= "3.10" and python_version < "4.0" nonebot-plugin-alconna>=0.56.0
ecdsa==0.19.0 ; python_version >= "3.10" and python_version < "4.0" tenacity>=9.0.0,<10.0.0
exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "4.0" nonebot-plugin-uninfo>=0.7.3
fastapi==0.115.8 ; python_version >= "3.10" and python_version < "4.0" nonebot-plugin-waiter>=0.8.1,<0.9.0
feedparser==6.0.11 ; python_version >= "3.10" and python_version < "4.0" multidict>=6.0.0,<7.0.0,!=6.3.2
filelock==3.17.0 ; python_version >= "3.10" and python_version < "4.0" alibabacloud-devops20210625>=5.0.2,<6.0.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" redis>=5
h11==0.14.0 ; python_version >= "3.10" and python_version < "4.0" asyncpg>=0.20.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"
@@ -84,13 +84,16 @@ async def _(
): ):
result = "" result = ""
await MessageUtils.build_message("正在进行检查更新...").send(reply_to=True) 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 ver_type_str = ver_type.result
source_str = source.result source_str = source.result
if ver_type_str in {"main", "release"}: 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: try:
result += await UpdateManager.update_zhenxun( result += await UpdateManager.update_zhenxun(
bot, bot,
@@ -1,37 +1,135 @@
import asyncio
from typing import Literal from typing import Literal
from nonebot.adapters import Bot from nonebot.adapters import Bot
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version
from zhenxun.services.log import logger 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.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.manager.zhenxun_repo_manager import ( from zhenxun.utils.manager.zhenxun_repo_manager import (
ZhenxunRepoConfig, ZhenxunRepoConfig,
ZhenxunRepoManager, ZhenxunRepoManager,
) )
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.repo_utils import RepoFileManager
LOG_COMMAND = "AutoUpdate" LOG_COMMAND = "AutoUpdate"
class UpdateManager: 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 @classmethod
async def check_version(cls) -> str: async def check_version(cls) -> str:
"""检查更新版本 """检查真寻和资源的版本"""
bot_cur_version = cls.__get_version()
返回: release_task = ZhenxunRepoManager.zhenxun_get_latest_releases_data()
str: 更新信息 dev_version_task = RepoFileManager.get_file_content(
""" ZhenxunRepoConfig.ZHENXUN_BOT_GITHUB_URL, "__version__"
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')}"
) )
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 @classmethod
async def update_webui( async def update_webui(
@@ -125,6 +223,7 @@ class UpdateManager:
f"检测真寻已更新,当前版本:{cur_version}\n开始更新...", f"检测真寻已更新,当前版本:{cur_version}\n开始更新...",
user_id, user_id,
) )
result_message = ""
if zip: if zip:
new_version = await ZhenxunRepoManager.zhenxun_zip_update(version_type) new_version = await ZhenxunRepoManager.zhenxun_zip_update(version_type)
await PlatformUtils.send_superuser( await PlatformUtils.send_superuser(
@@ -133,7 +232,7 @@ class UpdateManager:
await VirtualEnvPackageManager.install_requirement( await VirtualEnvPackageManager.install_requirement(
ZhenxunRepoConfig.REQUIREMENTS_FILE ZhenxunRepoConfig.REQUIREMENTS_FILE
) )
return ( result_message = (
f"版本更新完成!\n版本: {cur_version} -> {new_version}\n" f"版本更新完成!\n版本: {cur_version} -> {new_version}\n"
"请重新启动真寻以完成更新!" "请重新启动真寻以完成更新!"
) )
@@ -155,13 +254,54 @@ class UpdateManager:
await VirtualEnvPackageManager.install_requirement( await VirtualEnvPackageManager.install_requirement(
ZhenxunRepoConfig.REQUIREMENTS_FILE ZhenxunRepoConfig.REQUIREMENTS_FILE
) )
return ( result_message = (
f"版本更新完成!\n" f"版本更新完成!\n"
f"版本: {cur_version} -> {result.new_version}\n" f"版本: {cur_version} -> {result.new_version}\n"
f"变更文件个数: {len(result.changed_files)}" f"变更文件个数: {len(result.changed_files)}"
f"{'' if source == 'git' else '(阿里云更新不支持查看变更文件)'}\n" 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 @classmethod
def __get_version(cls) -> str: 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.configs.utils import Command, PluginExtraData, RegisterConfig
from zhenxun.models.chat_history import ChatHistory from zhenxun.models.chat_history import ChatHistory
from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.services import avatar_service
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.ui.builders import TableBuilder from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import ImageCell, TextCell from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="消息统计", name="消息统计",
@@ -147,12 +147,14 @@ async def _(
user_in_group.user_name if user_in_group else f"{uid_str}(已退群)" 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( rows_data.append(
[ [
TextCell(content=str(len(rows_data) + 1)), 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=user_name),
TextCell(content=str(num), bold=True), TextCell(content=str(num), bold=True),
] ]
+7 -1
View File
@@ -78,12 +78,18 @@ _matcher = on_alconna(
Option("-s|--superuser", action=store_true, help_text="超级用户帮助"), Option("-s|--superuser", action=store_true, help_text="超级用户帮助"),
Option("-d|--detail", action=store_true, help_text="详细帮助"), Option("-d|--detail", action=store_true, help_text="详细帮助"),
), ),
aliases={"help", "帮助", "菜单"}, aliases={"help", "菜单"},
rule=to_me(), rule=to_me(),
priority=1, priority=1,
block=True, block=True,
) )
_matcher.shortcut(
r"帮助(?P<name>.*?)",
command="功能",
arguments=["{name}"],
prefix=True,
)
_matcher.shortcut( _matcher.shortcut(
r"详细帮助", r"详细帮助",
+3 -1
View File
@@ -13,6 +13,7 @@ from zhenxun.models.statistics import Statistics
from zhenxun.services import ( from zhenxun.services import (
LLMException, LLMException,
LLMMessage, LLMMessage,
avatar_service,
generate, generate,
) )
from zhenxun.services.log import logger from zhenxun.services.log import logger
@@ -105,7 +106,8 @@ async def create_help_img(
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id 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( builder = PluginMenuBuilder(
bot_name=BotConfig.self_nickname, bot_name=BotConfig.self_nickname,
+31 -1
View File
@@ -1,6 +1,7 @@
from typing import Any from typing import Any
from nonebot.adapters import Bot, Message from nonebot.adapters import Bot, Message
from nonebot.adapters.onebot.v11 import MessageSegment
from zhenxun.configs.config import Config from zhenxun.configs.config import Config
from zhenxun.models.bot_message_store import BotMessageStore from zhenxun.models.bot_message_store import BotMessageStore
@@ -40,6 +41,35 @@ def replace_message(message: Message) -> str:
return result return result
def format_message_for_log(message: Message) -> str:
"""
将消息对象转换为适合日志记录的字符串,对base64等长内容进行摘要处理。
"""
if not isinstance(message, Message):
return str(message)
log_parts = []
for seg in message:
seg: MessageSegment
if seg.type == "text":
log_parts.append(seg.data.get("text", ""))
elif seg.type in ("image", "record", "video"):
file_info = seg.data.get("file", "")
if isinstance(file_info, str) and file_info.startswith("base64://"):
b64_data = file_info[9:]
data_size_bytes = (len(b64_data) * 3) / 4 - b64_data.count("=", -2)
log_parts.append(
f"[{seg.type}: base64, size={data_size_bytes / 1024:.2f}KB]"
)
else:
log_parts.append(f"[{seg.type}]")
elif seg.type == "at":
log_parts.append(f"[@{seg.data.get('qq', 'unknown')}]")
else:
log_parts.append(f"[{seg.type}]")
return "".join(log_parts)
@Bot.on_called_api @Bot.on_called_api
async def handle_api_result( async def handle_api_result(
bot: Bot, exception: Exception | None, api: str, data: dict[str, Any], result: Any bot: Bot, exception: Exception | None, api: str, data: dict[str, Any], result: Any
@@ -78,7 +108,7 @@ async def handle_api_result(
else replace_message(message), else replace_message(message),
platform=PlatformUtils.get_platform(bot), platform=PlatformUtils.get_platform(bot),
) )
logger.debug(f"消息发送记录,message: {message}") logger.debug(f"消息发送记录,message: {format_message_for_log(message)}")
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"消息发送记录发生错误...data: {data}, result: {result}", f"消息发送记录发生错误...data: {data}, result: {result}",
+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.sign_user import SignUser
from zhenxun.models.statistics import Statistics from zhenxun.models.statistics import Statistics
from zhenxun.models.user_console import UserConsole from zhenxun.models.user_console import UserConsole
from zhenxun.services import avatar_service
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
RACE = [ RACE = [
@@ -139,9 +140,8 @@ async def get_user_info(
bytes: 图片数据 bytes: 图片数据
""" """
platform = PlatformUtils.get_platform(session) or "qq" platform = PlatformUtils.get_platform(session) or "qq"
avatar_url = ( avatar_path = await avatar_service.get_avatar_path(platform, user_id)
PlatformUtils.get_user_avatar_url(user_id, platform, session.self_id) or "" avatar_url = avatar_path.as_uri() if avatar_path else ""
)
user = await UserConsole.get_user(user_id, platform) user = await UserConsole.get_user(user_id, platform)
permission_level = await LevelUser.get_user_level(user_id, group_id) permission_level = await LevelUser.get_user_level(user_id, group_id)
@@ -11,6 +11,7 @@ from zhenxun.models.mahiro_bank import MahiroBank
from zhenxun.models.mahiro_bank_log import MahiroBankLog from zhenxun.models.mahiro_bank_log import MahiroBankLog
from zhenxun.models.sign_user import SignUser from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole from zhenxun.models.user_console import UserConsole
from zhenxun.services import avatar_service
from zhenxun.utils.enum import BankHandleType, GoldHandle from zhenxun.utils.enum import BankHandleType, GoldHandle
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
@@ -210,9 +211,8 @@ class BankManager:
for deposit in user_today_deposit for deposit in user_today_deposit
] ]
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
avatar_url = PlatformUtils.get_user_avatar_url( avatar_path = await avatar_service.get_avatar_path(platform, user_id)
user_id, platform, session.self_id avatar_url = avatar_path.as_uri() if avatar_path else ""
)
return { return {
"name": uname, "name": uname,
"rank": rank + 1, "rank": rank + 1,
@@ -1,3 +1,4 @@
import os
from pathlib import Path from pathlib import Path
import random import random
import shutil import shutil
@@ -10,6 +11,7 @@ from zhenxun.configs.path_config import TEMP_PATH
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.services.plugin_init import PluginInitManager from zhenxun.services.plugin_init import PluginInitManager
from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.repo_utils import RepoFileManager from zhenxun.utils.repo_utils import RepoFileManager
@@ -183,6 +185,8 @@ class StoreManager:
StorePluginInfo: 插件信息 StorePluginInfo: 插件信息
bool: 是否是外部插件 bool: 是否是外部插件
""" """
plugin_list: list[StorePluginInfo]
extra_plugin_list: list[StorePluginInfo]
plugin_list, extra_plugin_list = await cls.get_data() plugin_list, extra_plugin_list = await cls.get_data()
plugin_info = None plugin_info = None
is_external = False is_external = False
@@ -206,6 +210,12 @@ class StoreManager:
if is_remove: if is_remove:
if plugin_info.module not in modules: if plugin_info.module not in modules:
raise PluginStoreException(f"插件 {plugin_info.name} 未安装,无法移除") 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 return plugin_info, is_external
if is_update: if is_update:
@@ -237,9 +247,7 @@ class StoreManager:
plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}" plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}"
logger.info(f"正在安装插件 {plugin_info.name}...", LOG_COMMAND) logger.info(f"正在安装插件 {plugin_info.name}...", LOG_COMMAND)
await cls.install_plugin_with_repo( await cls.install_plugin_with_repo(
plugin_info.github_url, plugin_info,
plugin_info.module_path,
plugin_info.is_dir,
is_external, is_external,
source, source,
) )
@@ -248,9 +256,7 @@ class StoreManager:
@classmethod @classmethod
async def install_plugin_with_repo( async def install_plugin_with_repo(
cls, cls,
github_url: str, plugin_info: StorePluginInfo,
module_path: str,
is_dir: bool,
is_external: bool = False, is_external: bool = False,
source: str | None = None, source: str | None = None,
): ):
@@ -267,18 +273,26 @@ class StoreManager:
repo_type = RepoType.ALIYUN repo_type = RepoType.ALIYUN
elif source == "git": elif source == "git":
repo_type = RepoType.GITHUB repo_type = RepoType.GITHUB
replace_module_path = module_path.replace(".", "/") module_path = plugin_info.module_path
plugin_name = module_path.split(".")[-1] 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: if is_dir:
files = await RepoFileManager.list_directory_files( files = await RepoFileManager.list_directory_files(
github_url, replace_module_path, repo_type=repo_type github_url, replace_module_path, repo_type=repo_type
) )
else: else:
files = [RepoFileInfo(path=f"{replace_module_path}.py", is_dir=False)] 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 / "plugins" / plugin_name 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] files = [file for file in files if not file.is_dir]
download_files = [(file.path, local_path / file.path) for file in files] download_files = [(file.path, target_dir / file.path) for file in files]
result = await RepoFileManager.download_files( result = await RepoFileManager.download_files(
github_url, github_url,
download_files, download_files,
@@ -298,7 +312,7 @@ class StoreManager:
is_install_req = False is_install_req = False
for requirement_path in requirement_paths: for requirement_path in requirement_paths:
requirement_file = local_path / requirement_path.path requirement_file = target_dir / requirement_path.path
if requirement_file.exists(): if requirement_file.exists():
is_install_req = True is_install_req = True
await VirtualEnvPackageManager.install_requirement(requirement_file) await VirtualEnvPackageManager.install_requirement(requirement_file)
@@ -341,13 +355,11 @@ class StoreManager:
str: 返回消息 str: 返回消息
""" """
plugin_info, _ = await cls.get_plugin_by_value(index_or_module, is_remove=True) plugin_info, _ = await cls.get_plugin_by_value(index_or_module, is_remove=True)
path = BASE_PATH module_path = plugin_info.module_path
if plugin_info.github_url: module = module_path.split(".")[-1]
path = BASE_PATH / "plugins" path = BASE_PATH.parent / Path(module_path.replace(".", os.sep))
for p in plugin_info.module_path.split("."):
path = path / p
if not plugin_info.is_dir: if not plugin_info.is_dir:
path = Path(f"{path}.py") path = path.parent / f"{module}.py"
if not path.exists(): if not path.exists():
return f"插件 {plugin_info.name} 不存在..." return f"插件 {plugin_info.name} 不存在..."
logger.debug(f"尝试移除插件 {plugin_info.name} 文件: {path}", LOG_COMMAND) logger.debug(f"尝试移除插件 {plugin_info.name} 文件: {path}", LOG_COMMAND)
@@ -356,7 +368,7 @@ class StoreManager:
shutil.rmtree(path, onerror=win_on_rm_error) shutil.rmtree(path, onerror=win_on_rm_error)
else: else:
path.unlink() path.unlink()
await PluginInitManager.remove(f"zhenxun.{plugin_info.module_path}") await PluginInitManager.remove(module_path)
return f"插件 {plugin_info.name} 移除成功! 重启后生效" return f"插件 {plugin_info.name} 移除成功! 重启后生效"
@classmethod @classmethod
@@ -423,9 +435,7 @@ class StoreManager:
if plugin_info.github_url is None: if plugin_info.github_url is None:
plugin_info.github_url = DEFAULT_GITHUB_URL plugin_info.github_url = DEFAULT_GITHUB_URL
await cls.install_plugin_with_repo( await cls.install_plugin_with_repo(
plugin_info.github_url, plugin_info,
plugin_info.module_path,
plugin_info.is_dir,
is_external, is_external,
) )
return f"插件 {plugin_info.name} 更新成功! 重启后生效" return f"插件 {plugin_info.name} 更新成功! 重启后生效"
@@ -473,9 +483,7 @@ class StoreManager:
plugin_info.github_url = DEFAULT_GITHUB_URL plugin_info.github_url = DEFAULT_GITHUB_URL
is_external = False is_external = False
await cls.install_plugin_with_repo( await cls.install_plugin_with_repo(
plugin_info.github_url, plugin_info,
plugin_info.module_path,
plugin_info.is_dir,
is_external, is_external,
) )
update_success_list.append(plugin_info.name) update_success_list.append(plugin_info.name)
@@ -1,9 +1,11 @@
import asyncio import asyncio
from typing import Any from typing import Any
from zhenxun import ui
from zhenxun.models.scheduled_job import ScheduledJob from zhenxun.models.scheduled_job import ScheduledJob
from zhenxun.services.scheduler import scheduler_manager from zhenxun.services.scheduler import scheduler_manager
from zhenxun.utils._image_template import ImageTemplate, RowStyle from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
from zhenxun.utils.pydantic_compat import model_json_schema from zhenxun.utils.pydantic_compat import model_json_schema
@@ -118,19 +120,6 @@ def format_update_success(schedule_info: ScheduledJob) -> str:
return _format_operation_result_card("🔄️ 成功更新定时任务配置!", schedule_info) return _format_operation_result_card("🔄️ 成功更新定时任务配置!", schedule_info)
def _status_row_style(column: str, text: str) -> RowStyle:
"""为状态列设置颜色"""
style = RowStyle()
if column == "状态":
if text == "启用":
style.font_color = "#67C23A"
elif text == "暂停":
style.font_color = "#F56C6C"
elif text == "运行中":
style.font_color = "#409EFF"
return style
def _format_params(schedule_status: dict) -> str: def _format_params(schedule_status: dict) -> str:
"""将任务参数格式化为人类可读的字符串""" """将任务参数格式化为人类可读的字符串"""
if kwargs := schedule_status.get("job_kwargs"): if kwargs := schedule_status.get("job_kwargs"):
@@ -157,36 +146,47 @@ async def format_schedule_list_as_image(
] ]
all_statuses = await asyncio.gather(*status_tasks) all_statuses = await asyncio.gather(*status_tasks)
def get_status_text(status_value): data_list = []
if isinstance(status_value, bool): for s in all_statuses:
return "启用" if status_value else "暂停" if not s:
return str(status_value) continue
data_list = [ status_value = s["is_enabled"]
[ if status_value == "运行中":
s["id"], status_cell = StatusBadgeCell(text="运行中", status_type="info")
s["plugin_name"], else:
s.get("bot_id") or "N/A", is_enabled = status_value == "启用"
s["group_id"] or "全局", status_cell = StatusBadgeCell(
s["next_run_time"], text="启用" if is_enabled else "暂停",
_format_trigger_info(s), status_type="ok" if is_enabled else "error",
_format_params(s), )
get_status_text(s["is_enabled"]),
] data_list.append(
for s in all_statuses [
if s TextCell(content=str(s["id"])),
] TextCell(content=s["plugin_name"]),
TextCell(content=s.get("bot_id") or "N/A"),
TextCell(content=s["group_id"] or "全局"),
TextCell(content=s["next_run_time"]),
TextCell(content=_format_trigger_info(s)),
TextCell(content=_format_params(s)),
status_cell,
]
)
if not data_list: if not data_list:
return "没有找到任何相关的定时任务。" return "没有找到任何相关的定时任务。"
return await ImageTemplate.table_page( builder = TableBuilder(
head_text=title, title, f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务"
tip_text=f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务", )
column_name=["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"], builder.set_headers(
data_list=data_list, ["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"]
column_space=20, ).add_rows(data_list)
text_style=_status_row_style, return await ui.render(
builder.build(),
viewport={"width": 1400, "height": 10},
device_scale_factor=2,
) )
+1 -1
View File
@@ -153,7 +153,7 @@ async def _(session: Uninfo, arparma: Arparma, nickname: str = UserName()):
nickname, nickname,
PlatformUtils.get_platform(session), PlatformUtils.get_platform(session),
): ):
await MessageUtils.build_message(image.pic2bytes()).finish(reply_to=True) await MessageUtils.build_message(image).finish(reply_to=True) # type: ignore
return await MessageUtils.build_message("你的道具为空捏...").send(reply_to=True) 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_console import UserConsole
from zhenxun.models.user_gold_log import UserGoldLog from zhenxun.models.user_gold_log import UserGoldLog
from zhenxun.models.user_props_log import UserPropsLog from zhenxun.models.user_props_log import UserPropsLog
from zhenxun.services import avatar_service
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import GoldHandle, PropHandle from zhenxun.utils.enum import GoldHandle, PropHandle
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.pydantic_compat import model_dump from zhenxun.utils.pydantic_compat import model_dump
@@ -92,9 +93,7 @@ class ShopParam(BaseModel):
return model_dump(self, **kwargs) return model_dump(self, **kwargs)
async def gold_rank( async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes | str:
session: Uninfo, group_id: str | None, num: int
) -> BuildImage | str:
query = UserConsole query = UserConsole
if group_id: if group_id:
uid_list = await GroupInfoUser.filter(group_id=group_id).values_list( uid_list = await GroupInfoUser.filter(group_id=group_id).values_list(
@@ -125,16 +124,20 @@ async def gold_rank(
data_list = [] data_list = []
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
for i, user in enumerate(user_list): for i, user in enumerate(user_list):
ava_bytes = await PlatformUtils.get_user_avatar( avatar_path = await avatar_service.get_avatar_path(platform, user[0])
user[0], platform, session.self_id
)
data_list.append( data_list.append(
[ [
f"{i + 1}", TextCell(content=f"{i + 1}"),
(ava_bytes, 30, 30) if platform == "qq" else "", ImageCell(
uid2name.get(user[0]), src=avatar_path.as_uri() if avatar_path else "", shape="circle"
user[1], )
(PLATFORM_PATH.get(platform), 30, 30), 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: if group_id:
@@ -143,7 +146,11 @@ async def gold_rank(
else: else:
title = "金币全局排行" title = "金币全局排行"
tip = f"你的排名在全局第 {index} 位哦!" tip = f"你的排名在全局第 {index} 位哦!"
return await ImageTemplate.table_page(title, tip, column_name, data_list) from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(title, tip)
builder.set_headers(column_name).add_rows(data_list)
return await ui.render(builder.build())
class ShopManage: class ShopManage:
@@ -493,7 +500,7 @@ class ShopManage:
@classmethod @classmethod
async def my_props( async def my_props(
cls, user_id: str, name: str, platform: str | None = None cls, user_id: str, name: str, platform: str | None = None
) -> BuildImage | None: ) -> bytes | None:
"""获取道具背包 """获取道具背包
参数: 参数:
@@ -525,10 +532,10 @@ class ShopManage:
if not prop: if not prop:
continue continue
icon = "" icon = None
if prop.icon: if prop.icon:
icon_path = ICON_PATH / 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( table_rows.append(
[ [
@@ -544,12 +551,11 @@ class ShopManage:
return None return None
column_name = ["-", "使用ID", "名称", "数量", "简介"] column_name = ["-", "使用ID", "名称", "数量", "简介"]
return await ImageTemplate.table_page( from zhenxun.ui.builders import TableBuilder
f"{name}的道具仓库",
"通过 使用道具[ID/名称] 令道具生效", builder = TableBuilder(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
column_name, builder.set_headers(column_name).add_rows(table_rows)
table_rows, return await ui.render(builder.build())
)
@classmethod @classmethod
async def my_cost(cls, user_id: str, platform: str | None = None) -> int: async def my_cost(cls, user_id: str, platform: str | None = None) -> int:
+24 -12
View File
@@ -6,14 +6,16 @@ import secrets
from nonebot_plugin_uninfo import Uninfo from nonebot_plugin_uninfo import Uninfo
import pytz import pytz
from zhenxun import ui
from zhenxun.configs.path_config import IMAGE_PATH from zhenxun.configs.path_config import IMAGE_PATH
from zhenxun.models.friend_user import FriendUser from zhenxun.models.friend_user import FriendUser
from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.models.sign_log import SignLog from zhenxun.models.sign_log import SignLog
from zhenxun.models.sign_user import SignUser from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole from zhenxun.models.user_console import UserConsole
from zhenxun.services.avatar_service import avatar_service
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.image_utils import BuildImage, ImageTemplate from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
from ._random_event import random_event from ._random_event import random_event
@@ -33,7 +35,7 @@ class SignManage:
@classmethod @classmethod
async def rank( async def rank(
cls, session: Uninfo, num: int, group_id: str | None = None cls, session: Uninfo, num: int, group_id: str | None = None
) -> BuildImage | str: # sourcery skip: avoid-builtin-shadow ) -> bytes | str:
"""好感度排行 """好感度排行
参数: 参数:
@@ -42,7 +44,7 @@ class SignManage:
group_id: 群组id group_id: 群组id
返回: 返回:
BuildImage: 构造图片 bytes: 构造图片
""" """
query = SignUser query = SignUser
if group_id: if group_id:
@@ -78,17 +80,23 @@ class SignManage:
data_list = [] data_list = []
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
for i, user in enumerate(user_list): for i, user in enumerate(user_list):
bytes = await PlatformUtils.get_user_avatar( avatar_path = await avatar_service.get_avatar_path(
user[0], platform, session.self_id platform=user[3] or "qq", identifier=user[0]
) )
data_list.append( data_list.append(
[ [
f"{i + 1}", TextCell(content=f"{i + 1}"),
(bytes, 30, 30) if user[3] == "qq" else "", ImageCell(
uid2name.get(user[0]), src=avatar_path.as_uri() if avatar_path else "", shape="circle"
user[1], )
user[2], if avatar_path
(PLATFORM_PATH.get(user[3]), 30, 30), 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: if group_id:
@@ -97,7 +105,11 @@ class SignManage:
else: else:
title = "好感度全局排行" title = "好感度全局排行"
tip = f"你的排名在全局第 {index} 位哦!" tip = f"你的排名在全局第 {index} 位哦!"
return await ImageTemplate.table_page(title, tip, column_name, data_list) from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(title, tip)
builder.set_headers(column_name).add_rows(data_list)
return await ui.render(builder.build())
@classmethod @classmethod
async def sign( async def sign(
+8 -13
View File
@@ -11,6 +11,7 @@ from nonebot_plugin_uninfo import Uninfo
from zhenxun import ui from zhenxun import ui
from zhenxun.configs.config import BotConfig, Config from zhenxun.configs.config import BotConfig, Config
from zhenxun.models.sign_user import SignUser from zhenxun.models.sign_user import SignUser
from zhenxun.services import avatar_service
from zhenxun.utils.manager.priority_manager import PriorityLifecycle from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
@@ -212,13 +213,13 @@ async def _generate_html_card(
if len(nickname) > 6: if len(nickname) > 6:
font_size = 27 font_size = 27
avatar_path = await avatar_service.get_avatar_path(
PlatformUtils.get_platform(session), user.user_id
)
user_info = { user_info = {
"nickname": nickname, "nickname": nickname,
"uid_str": uid_formatted, "uid_str": uid_formatted,
"avatar_url": PlatformUtils.get_user_avatar_url( "avatar_url": avatar_path.as_uri() if avatar_path else "",
user.user_id, PlatformUtils.get_platform(session), session.self_id
)
or "",
"sign_count": user.sign_count, "sign_count": user.sign_count,
"font_size": font_size, "font_size": font_size,
} }
@@ -227,8 +228,6 @@ async def _generate_html_card(
"current": impression, "current": impression,
"level": level, "level": level,
"level_text": f"{level} [{lik2relation.get(str(level), '未知')}]", "level_text": f"{level} [{lik2relation.get(str(level), '未知')}]",
"attitude": f"对你的态度: {level2attitude.get(str(level), '未知')}",
"relation": lik2relation.get(str(level), "未知"),
"heart2": [1 for _ in range(level)], "heart2": [1 for _ in range(level)],
"heart1": [1 for _ in range(len(lik2level) - level - 1)], "heart1": [1 for _ in range(len(lik2level) - level - 1)],
"next_level_at": next_impression, "next_level_at": next_impression,
@@ -238,7 +237,6 @@ async def _generate_html_card(
reward_info = None reward_info = None
rank = None rank = None
total_gold = None total_gold = None
last_sign_date_str = None
if is_card_view: if is_card_view:
value_list = ( value_list = (
@@ -249,12 +247,10 @@ async def _generate_html_card(
rank = value_list.index(user.user_id) + 1 if user.user_id in value_list else 0 rank = value_list.index(user.user_id) + 1 if user.user_id in value_list else 0
total_gold = user_console.gold if user_console else 0 total_gold = user_console.gold if user_console else 0
last_sign_date_str = ""
reward_info = { reward_info = {
"impression": f"好感度排名第 {rank} 位", "impression_added": 0,
"gold": f"总金币:{total_gold}", "gold_added": 0,
"gift": "", "gift_received": "",
"is_double": False, "is_double": False,
} }
@@ -285,7 +281,6 @@ async def _generate_html_card(
"progress": progress, "progress": progress,
"rank": rank, "rank": rank,
"total_gold": total_gold, "total_gold": total_gold,
"last_sign_date_str": last_sign_date_str,
} }
image_bytes = await ui.render_template("pages/builtin/sign", data=card_data) image_bytes = await ui.render_template("pages/builtin/sign", data=card_data)
+2 -2
View File
@@ -77,7 +77,7 @@ class PluginInfo(Model):
返回: 返回:
Self | None: 插件 Self | None: 插件
""" """
if filter_parent: if not kwargs.get("plugin_type") and filter_parent:
return await cls.get_or_none( return await cls.get_or_none(
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
) )
@@ -96,7 +96,7 @@ class PluginInfo(Model):
返回: 返回:
list[Self]: 插件列表 list[Self]: 插件列表
""" """
if filter_parent: if not kwargs.get("plugin_type") and filter_parent:
return await cls.filter( return await cls.filter(
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
).all() ).all()
+2
View File
@@ -18,6 +18,7 @@ require("nonebot_plugin_htmlrender")
require("nonebot_plugin_uninfo") require("nonebot_plugin_uninfo")
require("nonebot_plugin_waiter") require("nonebot_plugin_waiter")
from .avatar_service import avatar_service
from .db_context import Model, disconnect, with_db_timeout from .db_context import Model, disconnect, with_db_timeout
from .llm import ( from .llm import (
AI, AI,
@@ -57,6 +58,7 @@ __all__ = [
"Model", "Model",
"PluginInit", "PluginInit",
"PluginInitManager", "PluginInitManager",
"avatar_service",
"chat", "chat",
"clear_model_cache", "clear_model_cache",
"code", "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()
+1 -1
View File
@@ -87,7 +87,7 @@ class PluginInitManager:
@classmethod @classmethod
async def remove(cls, module_path: str): async def remove(cls, module_path: str):
"""运行指定插件安装方法""" """运行指定插件移除方法"""
if model := cls.plugins.get(module_path): if model := cls.plugins.get(module_path):
if model.remove: if model.remove:
class_ = model.class_() class_ = model.class_()
+145 -110
View File
@@ -56,6 +56,138 @@ class Theme(BaseModel):
default_assets_dir: Path default_assets_dir: Path
class ResourceResolver:
"""
一个独立的、用于解析组件和主题资源的类。
封装了所有复杂的路径查找和回退逻辑。
资源解析遵循以下回退顺序,以支持强大的主题覆盖和组件化:
1. **相对路径 (`./`)**: 对于在模板中使用 `asset('./style.css')` 的情况,
这是组件内部的资源。
a. **皮肤资源**: 首先在当前组件的皮肤目录中查找
(`.../skins/{variant_name}/assets/`)。
这允许皮肤完全覆盖其组件的默认资源。
b. **当前主题组件资源**: 接着在当前激活主题的组件根目录中查找
(`.../{theme_name}/.../assets/`)。
c. **默认主题组件资源**: 如果仍未找到,最后回退到 `default` 主题中
对应的组件目录
(`.../default/.../assets/`) 查找。这是核心的回退逻辑。
2. **全局路径**: 对于使用 `asset('js/script.js')` 的情况,这是主题的全局资源。
a. **当前主题全局资源**: 在当前激活主题的根 `assets` 目录中查找
(`themes/{theme_name}/assets/`)。
b. **默认主题全局资源**: 如果找不到,则回退到 `default` 主题的根 `assets` 目录
(`themes/default/assets/`)。
"""
def __init__(self, theme_manager: "ThemeManager"):
self.theme_manager = theme_manager
def _find_component_root(self, start_path: Path) -> Path:
"""从给定路径向上查找,找到包含 manifest.json 的组件根目录。"""
current_path = start_path.parent
themes_root_parts = len(THEMES_PATH.parts)
for _ in range(len(current_path.parts) - themes_root_parts):
if (current_path / "manifest.json").exists():
return current_path
if current_path.parent == current_path:
break
current_path = current_path.parent
return start_path.parent
def _search_paths_for_relative_asset(
self, asset_path: str, parent_template_name: str
) -> list[tuple[str, Path]]:
"""为相对路径的资源生成所有可能的查找路径元组 (描述, 路径)。"""
if not self.theme_manager.current_theme:
return []
paths_to_check: list[tuple[str, Path]] = []
current_theme_name = self.theme_manager.current_theme.name
current_theme_root = self.theme_manager.current_theme.assets_dir.parent
default_theme_root = self.theme_manager.current_theme.default_assets_dir.parent
if not self.theme_manager.jinja_env.loader:
return []
source_info = self.theme_manager.jinja_env.loader.get_source(
self.theme_manager.jinja_env, parent_template_name
)
if not source_info[1]:
return []
parent_template_abs_path = Path(source_info[1])
component_logical_root = Path(parent_template_name).parent
if (
"/skins/" in parent_template_abs_path.as_posix()
or "\\skins\\" in parent_template_abs_path.as_posix()
):
skin_dir = parent_template_abs_path.parent
paths_to_check.append(
(
f"'{current_theme_name}' 主题皮肤资源",
skin_dir / "assets" / asset_path,
)
)
paths_to_check.append(
(
f"'{current_theme_name}' 主题组件资源",
current_theme_root / component_logical_root / "assets" / asset_path,
)
)
if current_theme_name != "default":
paths_to_check.append(
(
"'default' 主题组件资源 (回退)",
default_theme_root / component_logical_root / "assets" / asset_path,
)
)
return paths_to_check
def resolve_asset_uri(self, asset_path: str, current_template_name: str) -> str:
"""解析资源路径,实现完整的回退逻辑,并返回可用的URI。"""
if not self.theme_manager.current_theme:
return ""
search_paths: list[tuple[str, Path]] = []
if asset_path.startswith("./"):
search_paths.extend(
self._search_paths_for_relative_asset(
asset_path[2:], current_template_name
)
)
else:
search_paths.append(
(
f"'{self.theme_manager.current_theme.name}' 主题全局资源",
self.theme_manager.current_theme.assets_dir / asset_path,
)
)
if self.theme_manager.current_theme.name != "default":
search_paths.append(
(
"'default' 主题全局资源 (回退)",
self.theme_manager.current_theme.default_assets_dir
/ asset_path,
)
)
for source_desc, path in search_paths:
if path.exists():
logger.debug(f"解析资源 '{asset_path}' -> 找到 {source_desc}: '{path}'")
return path.absolute().as_uri()
logger.warning(
f"资源文件未找到: '{asset_path}' (在模板 '{current_template_name}' 中引用)"
)
return ""
class ThemeManager: class ThemeManager:
def __init__(self, env: Environment): def __init__(self, env: Environment):
""" """
@@ -83,120 +215,31 @@ class ThemeManager:
return [] return []
return [d.name for d in THEMES_PATH.iterdir() if d.is_dir()] return [d.name for d in THEMES_PATH.iterdir() if d.is_dir()]
def _find_component_root(self, start_path: Path) -> Path: def _create_asset_loader(self) -> Callable[..., str]:
""" """
从给定的起始路径向上查找,直到找到包含 manifest.json 的目录。 创建一个闭包函数 (Jinja2中的 `asset()` 函数),使用
这被认为是组件的根目录。如果找不到,则返回起始路径的目录。 ResourceResolver 进行路径解析。
"""
current_path = start_path.parent
for _ in range(len(current_path.parts)):
if (current_path / "manifest.json").exists():
return current_path
if current_path.parent == current_path:
break
current_path = current_path.parent
return start_path.parent
def _create_asset_loader(
self, local_base_path: Path | None = None
) -> Callable[..., str]:
"""
创建并返回一个用于解析静态资源的闭包函数 (Jinja2中的 `asset()` 函数)。
该函数实现了强大的资源解析回退逻辑,查找顺序如下:
1. **相对路径 (`./`)**: 优先查找相对于当前模板的 `assets` 目录。
- 这支持组件皮肤 (`skins/`) 对其资源的覆盖。
2. **当前主题**: 在当前激活主题的 `assets` 目录中查找。
3. **默认主题**: 如果当前主题未找到,则回退到 `default` 主题的 `assets` 目录。
参数:
local_base_path: (可选) 当渲染独立模板时,提供模板所在的目录。
""" """
resolver = ResourceResolver(self)
@pass_context @pass_context
def asset_loader(ctx, asset_path: str) -> str: def asset_loader(ctx, asset_path: str) -> str:
if asset_path.startswith("./"): if not ctx.name:
parent_template_name = ctx.environment.get_template(ctx.name).name logger.warning("Jinja2 上下文缺少模板名称,无法进行资源解析。")
parent_template_abs_path = Path( return resolver.resolve_asset_uri(asset_path, "unknown_template")
ctx.environment.loader.get_source( parent_template_name = ctx.name
ctx.environment, parent_template_name return resolver.resolve_asset_uri(asset_path, parent_template_name)
)[1]
)
if (
"/skins/" in parent_template_abs_path.as_posix()
or "\\skins\\" in parent_template_abs_path.as_posix()
):
skin_dir = parent_template_abs_path.parent
skin_asset_path = skin_dir / "assets" / asset_path[2:]
if skin_asset_path.exists():
logger.debug(f"找到皮肤本地资源: '{skin_asset_path}'")
return skin_asset_path.absolute().as_uri()
logger.debug(
f"皮肤本地资源未找到: '{skin_asset_path}',将回退到组件公共资源"
)
component_root = self._find_component_root(parent_template_abs_path)
local_asset = component_root / "assets" / asset_path[2:]
if local_asset.exists():
logger.debug(f"找到组件公共资源: '{local_asset}'")
return local_asset.absolute().as_uri()
logger.warning(
f"组件相对资源未找到: '{asset_path}'。已在皮肤和组件根目录中查找。"
)
return ""
assert self.current_theme is not None
current_theme_asset = self.current_theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.absolute().as_uri()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.absolute().as_uri()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: "
f"{asset_path}"
)
return ""
return asset_loader return asset_loader
def _create_standalone_asset_loader( def _create_standalone_asset_loader(
self, local_base_path: Path self, local_base_path: Path
) -> Callable[[str], str]: ) -> Callable[[str], str]:
""" """为独立模板创建一个专用的 asset loader。"""
[新增] 为独立模板创建一个专用的、更简单的 asset loader。 resolver = ResourceResolver(self)
"""
def asset_loader(asset_path: str) -> str: def asset_loader(asset_path: str) -> str:
if asset_path.startswith("./"): return resolver.resolve_asset_uri(asset_path, str(local_base_path))
local_file = local_base_path / "assets" / asset_path[2:]
if local_file.exists():
return local_file.absolute().as_uri()
logger.warning(
f"独立模板本地资源 '{asset_path}' 在 "
f"'{local_base_path / 'assets'}' 中未找到。"
)
return ""
assert self.current_theme is not None
current_theme_asset = self.current_theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.absolute().as_uri()
default_theme_asset = self.current_theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.absolute().as_uri()
logger.warning(
f"资源文件在主题 '{self.current_theme.name}' 和 'default' 中均未找到: "
f"{asset_path}"
)
return ""
return asset_loader return asset_loader
@@ -278,15 +321,7 @@ class ThemeManager:
new_theme_loader = FileSystemLoader( new_theme_loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")] [str(theme_dir), str(THEMES_PATH / "default")]
) )
self.jinja_env.loader = ChoiceLoader([prefix_loader, new_theme_loader]) self.jinja_env.loader.loaders = [prefix_loader, new_theme_loader]
else:
self.jinja_env.loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
else:
self.jinja_env.loader = FileSystemLoader(
[str(theme_dir), str(THEMES_PATH / "default")]
)
palette_path = theme_dir / "palette.json" palette_path = theme_dir / "palette.json"
palette = ( palette = (
+36 -5
View File
@@ -1,6 +1,13 @@
from typing import Literal from pathlib import Path
from typing import Any, Literal
from ...models.core.table import TableCell, TableData from ...models.core.table import (
BaseCell,
ImageCell,
TableCell,
TableData,
TextCell,
)
from ..base import BaseBuilder from ..base import BaseBuilder
__all__ = ["TableBuilder"] __all__ = ["TableBuilder"]
@@ -13,6 +20,28 @@ class TableBuilder(BaseBuilder[TableData]):
data_model = TableData(title=title, tip=tip, headers=[], rows=[]) data_model = TableData(title=title, tip=tip, headers=[], rows=[])
super().__init__(data_model, template_name="components/core/table") 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": def set_headers(self, headers: list[str]) -> "TableBuilder":
""" """
设置表格的表头。 设置表格的表头。
@@ -57,12 +86,13 @@ class TableBuilder(BaseBuilder[TableData]):
返回: 返回:
TableBuilder: 当前构建器实例,以支持链式调用。 TableBuilder: 当前构建器实例,以支持链式调用。
""" """
self._data.rows.append(row) normalized_row = [self._normalize_cell(cell) for cell in row]
self._data.rows.append(normalized_row)
return self return self
def add_rows(self, rows: list[list[TableCell]]) -> "TableBuilder": def add_rows(self, rows: list[list[TableCell]]) -> "TableBuilder":
""" """
向表格中批量添加多行数据。 向表格中批量添加多行数据, 并自动转换原生类型。
参数: 参数:
rows: 一个包含多行数据的列表。 rows: 一个包含多行数据的列表。
@@ -70,5 +100,6 @@ class TableBuilder(BaseBuilder[TableData]):
返回: 返回:
TableBuilder: 当前构建器实例,以支持链式调用。 TableBuilder: 当前构建器实例,以支持链式调用。
""" """
self._data.rows.extend(rows) for row in rows:
self.add_row(row)
return self return self
+6
View File
@@ -12,6 +12,7 @@ from .components import (
from .core import ( from .core import (
BaseCell, BaseCell,
CodeElement, CodeElement,
ComponentCell,
HeadingElement, HeadingElement,
ImageCell, ImageCell,
ImageElement, ImageElement,
@@ -26,12 +27,14 @@ from .core import (
QuoteElement, QuoteElement,
RawHtmlElement, RawHtmlElement,
RenderableComponent, RenderableComponent,
RichTextCell,
StatusBadgeCell, StatusBadgeCell,
TableCell, TableCell,
TableData, TableData,
TableElement, TableElement,
TextCell, TextCell,
TextElement, TextElement,
TextSpan,
) )
from .presets import ( from .presets import (
HelpCategory, HelpCategory,
@@ -47,6 +50,7 @@ __all__ = [
"BaseCell", "BaseCell",
"BaseChartData", "BaseChartData",
"CodeElement", "CodeElement",
"ComponentCell",
"Divider", "Divider",
"EChartsData", "EChartsData",
"HeadingElement", "HeadingElement",
@@ -71,11 +75,13 @@ __all__ = [
"RawHtmlElement", "RawHtmlElement",
"Rectangle", "Rectangle",
"RenderableComponent", "RenderableComponent",
"RichTextCell",
"StatusBadgeCell", "StatusBadgeCell",
"TableCell", "TableCell",
"TableData", "TableData",
"TableElement", "TableElement",
"TextCell", "TextCell",
"TextElement", "TextElement",
"TextSpan",
"UserInfoBlock", "UserInfoBlock",
] ]
+53 -13
View File
@@ -11,44 +11,68 @@ from .core.base import RenderableComponent
class EChartsTitle(BaseModel): class EChartsTitle(BaseModel):
text: str text: str
"""图表主标题"""
left: Literal["left", "center", "right"] = "center" left: Literal["left", "center", "right"] = "center"
"""标题水平对齐方式"""
class EChartsAxis(BaseModel): class EChartsAxis(BaseModel):
type: Literal["category", "value", "time", "log"] type: Literal["category", "value", "time", "log"]
"""坐标轴类型"""
data: list[Any] | None = None data: list[Any] | None = None
"""类目数据"""
show: bool = True show: bool = True
"""是否显示坐标轴"""
class EChartsSeries(BaseModel): class EChartsSeries(BaseModel):
type: str type: str
"""系列类型 (e.g., 'bar', 'line', 'pie')"""
data: list[Any] data: list[Any]
"""系列数据"""
name: str | None = None name: str | None = None
"""系列名称,用于 tooltip 的显示"""
label: dict[str, Any] | None = None label: dict[str, Any] | None = None
"""图形上的文本标签"""
itemStyle: dict[str, Any] | None = None itemStyle: dict[str, Any] | None = None
"""图形样式"""
barMaxWidth: int | None = None barMaxWidth: int | None = None
"""柱条的最大宽度"""
smooth: bool | None = None smooth: bool | None = None
"""是否平滑显示折线"""
class EChartsTooltip(BaseModel): class EChartsTooltip(BaseModel):
trigger: Literal["item", "axis", "none"] = "item" trigger: Literal["item", "axis", "none"] = Field("item", description="触发类型")
"""触发类型"""
class EChartsGrid(BaseModel): class EChartsGrid(BaseModel):
left: str | None = None left: str | None = None
"""grid 组件离容器左侧的距离"""
right: str | None = None right: str | None = None
"""grid 组件离容器右侧的距离"""
top: str | None = None top: str | None = None
"""grid 组件离容器上侧的距离"""
bottom: str | None = None bottom: str | None = None
"""grid 组件离容器下侧的距离"""
containLabel: bool = True containLabel: bool = True
"""grid 区域是否包含坐标轴的刻度标签"""
class BaseChartData(RenderableComponent, ABC): class BaseChartData(RenderableComponent, ABC):
"""所有图表数据模型的基类""" """所有图表数据模型的基类"""
style_name: str | None = None style_name: str | None = None
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_options: dict[str, Any] | None = None
"""原始ECharts选项,用于高级自定义"""
@abstractmethod @abstractmethod
def build_option(self) -> dict[str, Any]: def build_option(self) -> dict[str, Any]:
@@ -70,21 +94,37 @@ class BaseChartData(RenderableComponent, ABC):
class EChartsData(BaseChartData): class EChartsData(BaseChartData):
"""统一的 ECharts 图表数据模型""" """统一的 ECharts 图表数据模型"""
template_path: str = Field(..., exclude=True) template_path: str = Field(..., exclude=True, description="图表组件的模板路径")
title_model: EChartsTitle | None = Field(None, alias="title") """图表组件的模板路径"""
grid_model: EChartsGrid | None = Field(None, alias="grid") title_model: EChartsTitle | None = Field(
tooltip_model: EChartsTooltip | None = Field(None, alias="tooltip") None, alias="title", description="标题组件"
x_axis_model: EChartsAxis | None = Field(None, alias="xAxis") )
y_axis_model: EChartsAxis | None = Field(None, alias="yAxis") """标题组件"""
series_models: list[EChartsSeries] = Field(default_factory=list, alias="series") grid_model: EChartsGrid | None = Field(None, alias="grid", description="网格组件")
legend_model: dict[str, Any] | None = Field(default_factory=dict, alias="legend") """网格组件"""
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( raw_options: dict[str, Any] = Field(
default_factory=dict, description="用于 set_option 的原始覆盖选项" default_factory=dict, description="用于 set_option 的原始覆盖选项"
) )
"""用于 set_option 的原始覆盖选项"""
background_image: str | None = Field( background_image: str | None = Field(None, description="用于横向柱状图的背景图片")
None, description="【兼容】用于横向柱状图的背景图片" """用于横向柱状图的背景图片"""
)
def build_option(self) -> dict[str, Any]: def build_option(self) -> dict[str, Any]:
"""将 Pydantic 模型序列化为 ECharts 的 option 字典。""" """将 Pydantic 模型序列化为 ECharts 的 option 字典。"""
+4
View File
@@ -14,9 +14,13 @@ class Alert(RenderableComponent):
type: Literal["info", "success", "warning", "error"] = Field( type: Literal["info", "success", "warning", "error"] = Field(
default="info", description="提示框的类型,决定了颜色和图标" default="info", description="提示框的类型,决定了颜色和图标"
) )
"""提示框的类型,决定了颜色和图标"""
title: str = Field(..., description="提示框的标题") title: str = Field(..., description="提示框的标题")
"""提示框的标题"""
content: str = Field(..., description="提示框的主要内容") content: str = Field(..., description="提示框的主要内容")
"""提示框的主要内容"""
show_icon: bool = Field(default=True, description="是否显示与类型匹配的图标") show_icon: bool = Field(default=True, description="是否显示与类型匹配的图标")
"""是否显示与类型匹配的图标"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+6
View File
@@ -12,8 +12,11 @@ class Avatar(RenderableComponent):
component_type: Literal["avatar"] = "avatar" component_type: Literal["avatar"] = "avatar"
src: str = Field(..., description="头像的URL或Base64数据URI") src: str = Field(..., description="头像的URL或Base64数据URI")
"""头像的URL或Base64数据URI"""
shape: Literal["circle", "square"] = Field("circle", description="头像形状") shape: Literal["circle", "square"] = Field("circle", description="头像形状")
"""头像形状"""
size: int = Field(50, description="头像尺寸(像素)") size: int = Field(50, description="头像尺寸(像素)")
"""头像尺寸(像素)"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
@@ -25,10 +28,13 @@ class AvatarGroup(RenderableComponent):
component_type: Literal["avatar_group"] = "avatar_group" component_type: Literal["avatar_group"] = "avatar_group"
avatars: list[Avatar] = Field(default_factory=list, description="头像列表") avatars: list[Avatar] = Field(default_factory=list, description="头像列表")
"""头像列表"""
spacing: int = Field(-15, description="头像间的间距(负数表示重叠)") spacing: int = Field(-15, description="头像间的间距(负数表示重叠)")
"""头像间的间距(负数表示重叠)"""
max_count: int | None = Field( max_count: int | None = Field(
None, description="最多显示的头像数量,超出部分会显示为'+N'" None, description="最多显示的头像数量,超出部分会显示为'+N'"
) )
"""最多显示的头像数量,超出部分会显示为'+N'"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+2
View File
@@ -12,10 +12,12 @@ class Badge(RenderableComponent):
component_type: Literal["badge"] = "badge" component_type: Literal["badge"] = "badge"
text: str = Field(..., description="徽章上显示的文本") text: str = Field(..., description="徽章上显示的文本")
"""徽章上显示的文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field( color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="info", default="info",
description="预设的颜色方案", description="预设的颜色方案",
) )
"""预设的颜色方案"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+8
View File
@@ -12,9 +12,13 @@ class Divider(RenderableComponent):
component_type: Literal["divider"] = "divider" component_type: Literal["divider"] = "divider"
margin: str = Field("2em 0", description="CSS margin属性,控制分割线上下的间距") margin: str = Field("2em 0", description="CSS margin属性,控制分割线上下的间距")
"""CSS margin属性,控制分割线上下的间距"""
color: str = Field("#f7889c", description="分割线颜色") color: str = Field("#f7889c", description="分割线颜色")
"""分割线颜色"""
style: Literal["solid", "dashed", "dotted"] = Field("solid", description="线条样式") style: Literal["solid", "dashed", "dotted"] = Field("solid", description="线条样式")
"""线条样式"""
thickness: str = Field("1px", description="线条粗细") thickness: str = Field("1px", description="线条粗细")
"""线条粗细"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
@@ -26,9 +30,13 @@ class Rectangle(RenderableComponent):
component_type: Literal["rectangle"] = "rectangle" component_type: Literal["rectangle"] = "rectangle"
height: str = Field("50px", description="矩形的高度 (CSS value)") height: str = Field("50px", description="矩形的高度 (CSS value)")
"""矩形的高度 (CSS value)"""
background_color: str = Field("#fdf1f5", description="背景颜色") background_color: str = Field("#fdf1f5", description="背景颜色")
"""背景颜色"""
border: str = Field("1px solid #fce4ec", description="CSS border属性") border: str = Field("1px solid #fce4ec", description="CSS border属性")
"""CSS border属性"""
border_radius: str = Field("8px", description="CSS border-radius属性") border_radius: str = Field("8px", description="CSS border-radius属性")
"""CSS border-radius属性"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+6
View File
@@ -12,17 +12,23 @@ class KpiCard(RenderableComponent):
component_type: Literal["kpi_card"] = "kpi_card" component_type: Literal["kpi_card"] = "kpi_card"
label: str = Field(..., description="指标的标签或名称") label: str = Field(..., description="指标的标签或名称")
"""指标的标签或名称"""
value: Any = Field(..., description="指标的主要数值") value: Any = Field(..., description="指标的主要数值")
"""指标的主要数值"""
unit: str | None = Field(default=None, description="数值的单位,可选") unit: str | None = Field(default=None, description="数值的单位,可选")
"""数值的单位,可选"""
change: str | None = Field( change: str | None = Field(
default=None, description="与上一周期的变化,例如 '+15%' 或 '-100'" default=None, description="与上一周期的变化,例如 '+15%' 或 '-100'"
) )
"""与上一周期的变化,例如 '+15%' 或 '-100'"""
change_type: Literal["positive", "negative", "neutral"] = Field( change_type: Literal["positive", "negative", "neutral"] = Field(
default="neutral", description="变化的类型,用于决定颜色" default="neutral", description="变化的类型,用于决定颜色"
) )
"""变化的类型,用于决定颜色"""
icon_svg: str | None = Field( icon_svg: str | None = Field(
default=None, description="卡片中显示的可选图标 (SVG path data)" default=None, description="卡片中显示的可选图标 (SVG path data)"
) )
"""卡片中显示的可选图标 (SVG path data)"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
@@ -12,12 +12,16 @@ class ProgressBar(RenderableComponent):
component_type: Literal["progress_bar"] = "progress_bar" component_type: Literal["progress_bar"] = "progress_bar"
progress: float = Field(..., ge=0, le=100, description="进度百分比 (0-100)") progress: float = Field(..., ge=0, le=100, description="进度百分比 (0-100)")
"""进度百分比 (0-100)"""
label: str | None = Field(default=None, description="显示在进度条上的可选文本") label: str | None = Field(default=None, description="显示在进度条上的可选文本")
"""显示在进度条上的可选文本"""
color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field( color_scheme: Literal["primary", "success", "warning", "error", "info"] = Field(
default="primary", default="primary",
description="预设的颜色方案", description="预设的颜色方案",
) )
"""预设的颜色方案"""
animated: bool = Field(default=False, description="是否显示动画效果") animated: bool = Field(default=False, description="是否显示动画效果")
"""是否显示动画效果"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+6
View File
@@ -11,10 +11,15 @@ class TimelineItem(BaseModel):
"""时间轴中的单个事件点。""" """时间轴中的单个事件点。"""
timestamp: str = Field(..., description="显示在时间点旁边的时间或标签") timestamp: str = Field(..., description="显示在时间点旁边的时间或标签")
"""显示在时间点旁边的时间或标签"""
title: str = Field(..., description="事件的标题") title: str = Field(..., description="事件的标题")
"""事件的标题"""
content: str = Field(..., description="事件的详细描述") content: str = Field(..., description="事件的详细描述")
"""事件的详细描述"""
icon: str | None = Field(default=None, description="可选的自定义图标SVG路径") icon: str | None = Field(default=None, description="可选的自定义图标SVG路径")
"""可选的自定义图标SVG路径"""
color: str | None = Field(default=None, description="可选的自定义颜色,覆盖默认") color: str | None = Field(default=None, description="可选的自定义颜色,覆盖默认")
"""可选的自定义颜色,覆盖默认"""
class Timeline(RenderableComponent): class Timeline(RenderableComponent):
@@ -24,6 +29,7 @@ class Timeline(RenderableComponent):
items: list[TimelineItem] = Field( items: list[TimelineItem] = Field(
default_factory=list, description="时间轴项目列表" default_factory=list, description="时间轴项目列表"
) )
"""时间轴项目列表"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
@@ -12,11 +12,15 @@ class UserInfoBlock(RenderableComponent):
component_type: Literal["user_info_block"] = "user_info_block" component_type: Literal["user_info_block"] = "user_info_block"
avatar_url: str = Field(..., description="用户头像的URL") avatar_url: str = Field(..., description="用户头像的URL")
"""用户头像的URL"""
name: str = Field(..., description="用户的名称") name: str = Field(..., description="用户的名称")
"""用户的名称"""
subtitle: str | None = Field( subtitle: str | None = Field(
default=None, description="显示在名称下方的副标题 (如UID或角色)" default=None, description="显示在名称下方的副标题 (如UID或角色)"
) )
"""显示在名称下方的副标题 (如UID或角色)"""
tags: list[str] = Field(default_factory=list, description="附加的标签列表") tags: list[str] = Field(default_factory=list, description="附加的标签列表")
"""附加的标签列表"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+12 -1
View File
@@ -22,7 +22,16 @@ from .markdown import (
TextElement, TextElement,
) )
from .notebook import NotebookData, NotebookElement from .notebook import NotebookData, NotebookElement
from .table import BaseCell, ImageCell, StatusBadgeCell, TableCell, TableData, TextCell from .table import (
BaseCell,
ComponentCell,
ImageCell,
RichTextCell,
StatusBadgeCell,
TableCell,
TableData,
TextCell,
)
from .template import TemplateComponent from .template import TemplateComponent
from .text import TextData, TextSpan from .text import TextData, TextSpan
@@ -30,6 +39,7 @@ __all__ = [
"BaseCell", "BaseCell",
"CardData", "CardData",
"CodeElement", "CodeElement",
"ComponentCell",
"DetailsData", "DetailsData",
"DetailsItem", "DetailsItem",
"HeadingElement", "HeadingElement",
@@ -48,6 +58,7 @@ __all__ = [
"QuoteElement", "QuoteElement",
"RawHtmlElement", "RawHtmlElement",
"RenderableComponent", "RenderableComponent",
"RichTextCell",
"StatusBadgeCell", "StatusBadgeCell",
"TableCell", "TableCell",
"TableData", "TableData",
+5
View File
@@ -20,10 +20,15 @@ class RenderableComponent(BaseModel, Renderable):
""" """
_is_standalone_template: bool = False _is_standalone_template: bool = False
"""标记此组件是否为独立模板"""
inline_style: dict[str, str] | None = None inline_style: dict[str, str] | None = None
"""应用于组件根元素的内联CSS样式"""
component_css: str | None = None component_css: str | None = None
"""注入到页面的额外CSS字符串"""
extra_classes: list[str] | None = None extra_classes: list[str] | None = None
"""应用于组件根元素的额外CSS类名列表"""
variant: str | None = None variant: str | None = None
"""组件的变体/皮肤名称"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+3
View File
@@ -7,8 +7,11 @@ class CardData(ContainerComponent):
"""通用卡片的数据模型,可以包含头部、内容和尾部""" """通用卡片的数据模型,可以包含头部、内容和尾部"""
header: RenderableComponent | None = None header: RenderableComponent | None = None
"""卡片的头部内容组件"""
content: RenderableComponent content: RenderableComponent
"""卡片的主要内容组件"""
footer: RenderableComponent | None = None footer: RenderableComponent | None = None
"""卡片的尾部内容组件"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+4
View File
@@ -9,14 +9,18 @@ class DetailsItem(BaseModel):
"""描述列表中的单个项目""" """描述列表中的单个项目"""
label: str = Field(..., description="项目的标签/键") label: str = Field(..., description="项目的标签/键")
"""项目的标签/键"""
value: Any = Field(..., description="项目的值") value: Any = Field(..., description="项目的值")
"""项目的值"""
class DetailsData(RenderableComponent): class DetailsData(RenderableComponent):
"""描述列表(键值对)的数据模型""" """描述列表(键值对)的数据模型"""
title: str | None = Field(None, description="列表的可选标题") title: str | None = Field(None, description="列表的可选标题")
"""列表的可选标题"""
items: list[DetailsItem] = Field(default_factory=list, description="键值对项目列表") items: list[DetailsItem] = Field(default_factory=list, description="键值对项目列表")
"""键值对项目列表"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+6
View File
@@ -12,20 +12,26 @@ class LayoutItem(BaseModel):
"""布局中的单个项目,现在持有可渲染组件的数据模型""" """布局中的单个项目,现在持有可渲染组件的数据模型"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型") component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
"""要渲染的组件的数据模型"""
metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据") metadata: dict[str, Any] | None = Field(None, description="传递给模板的额外元数据")
"""传递给模板的额外元数据"""
class LayoutData(ContainerComponent): class LayoutData(ContainerComponent):
"""布局构建器的数据模型""" """布局构建器的数据模型"""
style_name: str | None = None style_name: str | None = None
"""应用于布局容器的样式名称"""
layout_type: str = "column" layout_type: str = "column"
"""布局类型 (如 'column', 'row', 'grid')"""
children: list[LayoutItem] = Field( children: list[LayoutItem] = Field(
default_factory=list, description="要布局的项目列表" default_factory=list, description="要布局的项目列表"
) )
"""要布局的项目列表"""
options: dict[str, Any] = Field( options: dict[str, Any] = Field(
default_factory=dict, description="传递给模板的选项" default_factory=dict, description="传递给模板的选项"
) )
"""传递给模板的选项"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+3
View File
@@ -12,6 +12,7 @@ class ListItem(BaseModel):
"""列表中的单个项目,其内容可以是任何可渲染组件。""" """列表中的单个项目,其内容可以是任何可渲染组件。"""
component: RenderableComponent = Field(..., description="要渲染的组件的数据模型") component: RenderableComponent = Field(..., description="要渲染的组件的数据模型")
"""要渲染的组件的数据模型"""
class ListData(ContainerComponent): class ListData(ContainerComponent):
@@ -19,7 +20,9 @@ class ListData(ContainerComponent):
component_type: Literal["list"] = "list" component_type: Literal["list"] = "list"
items: list[ListItem] = Field(default_factory=list, description="列表项目") items: list[ListItem] = Field(default_factory=list, description="列表项目")
"""列表项目"""
ordered: bool = Field(default=False, description="是否为有序列表") ordered: bool = Field(default=False, description="是否为有序列表")
"""是否为有序列表"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+24 -4
View File
@@ -44,7 +44,9 @@ class TextElement(MarkdownElement):
class HeadingElement(MarkdownElement): class HeadingElement(MarkdownElement):
type: Literal["heading"] = "heading" type: Literal["heading"] = "heading"
text: str 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: def to_markdown(self) -> str:
return f"{'#' * self.level} {self.text}" return f"{'#' * self.level} {self.text}"
@@ -53,7 +55,9 @@ class HeadingElement(MarkdownElement):
class ImageElement(MarkdownElement): class ImageElement(MarkdownElement):
type: Literal["image"] = "image" type: Literal["image"] = "image"
src: str src: str
"""图片来源 (URL或data URI)"""
alt: str = "image" alt: str = "image"
"""图片的替代文本"""
def to_markdown(self) -> str: def to_markdown(self) -> str:
return f"![{self.alt}]({self.src})" return f"![{self.alt}]({self.src})"
@@ -62,7 +66,9 @@ class ImageElement(MarkdownElement):
class CodeElement(MarkdownElement): class CodeElement(MarkdownElement):
type: Literal["code"] = "code" type: Literal["code"] = "code"
code: str code: str
"""代码字符串"""
language: str = "" language: str = ""
"""代码语言,用于语法高亮"""
def to_markdown(self) -> str: def to_markdown(self) -> str:
return f"```{self.language}\n{self.code}\n```" return f"```{self.language}\n{self.code}\n```"
@@ -71,6 +77,7 @@ class CodeElement(MarkdownElement):
class RawHtmlElement(MarkdownElement): class RawHtmlElement(MarkdownElement):
type: Literal["raw_html"] = "raw_html" type: Literal["raw_html"] = "raw_html"
html: str html: str
"""原始HTML字符串"""
def to_markdown(self) -> str: def to_markdown(self) -> str:
return self.html return self.html
@@ -79,8 +86,11 @@ class RawHtmlElement(MarkdownElement):
class TableElement(MarkdownElement): class TableElement(MarkdownElement):
type: Literal["table"] = "table" type: Literal["table"] = "table"
headers: list[str] headers: list[str]
"""表格的表头列表"""
rows: list[list[str]] rows: list[list[str]]
"""表格的数据行列表"""
alignments: list[Literal["left", "center", "right"]] | None = None alignments: list[Literal["left", "center", "right"]] | None = None
"""每列的对齐方式"""
def to_markdown(self) -> str: def to_markdown(self) -> str:
header_row = "| " + " | ".join(self.headers) + " |" header_row = "| " + " | ".join(self.headers) + " |"
@@ -102,7 +112,10 @@ class TableElement(MarkdownElement):
class ContainerElement(MarkdownElement): class ContainerElement(MarkdownElement):
content: list[MarkdownElement] = Field(default_factory=list) content: list[MarkdownElement] = Field(
default_factory=list, description="容器内包含的Markdown元素列表"
)
"""容器内包含的Markdown元素列表"""
class QuoteElement(ContainerElement): class QuoteElement(ContainerElement):
@@ -121,6 +134,7 @@ class ListItemElement(ContainerElement):
class ListElement(ContainerElement): class ListElement(ContainerElement):
type: Literal["list"] = "list" type: Literal["list"] = "list"
ordered: bool = False ordered: bool = False
"""是否为有序列表 (例如 1., 2.)"""
def to_markdown(self) -> str: def to_markdown(self) -> str:
lines = [] lines = []
@@ -137,6 +151,7 @@ class ComponentElement(MarkdownElement):
type: Literal["component"] = "component" type: Literal["component"] = "component"
component: RenderableComponent component: RenderableComponent
"""嵌入在Markdown中的可渲染组件"""
def to_markdown(self) -> str: def to_markdown(self) -> str:
return "" return ""
@@ -146,9 +161,15 @@ class MarkdownData(ContainerComponent):
"""Markdown转图片的数据模型""" """Markdown转图片的数据模型"""
style_name: str | None = None style_name: str | None = None
elements: list[MarkdownElement] = Field(default_factory=list) """Markdown内容的样式名称"""
elements: list[MarkdownElement] = Field(
default_factory=list, description="构成Markdown文档的元素列表"
)
"""构成Markdown文档的元素列表"""
width: int = 800 width: int = 800
"""最终渲染图片的宽度"""
css_path: str | None = None css_path: str | None = None
"""自定义CSS文件的绝对路径"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
@@ -180,7 +201,6 @@ class MarkdownData(ContainerComponent):
logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}") logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}")
else: else:
style_name = self.style_name or "light" style_name = self.style_name or "light"
# 使用上下文对象来解析路径
css_path = await context.theme_manager.resolve_markdown_style_path( css_path = await context.theme_manager.resolve_markdown_style_path(
style_name, context style_name, context
) )
+11
View File
@@ -22,21 +22,32 @@ class NotebookElement(BaseModel):
"component", "component",
] ]
text: str | None = None text: str | None = None
"""元素的文本内容 (用于标题、段落、引用)"""
level: int | None = None level: int | None = None
"""标题的级别 (1-4)"""
src: str | None = None src: str | None = None
"""图片的来源 (URL或data URI)"""
caption: str | None = None caption: str | None = None
"""图片的说明文字"""
code: str | None = None code: str | None = None
"""代码块的内容"""
language: str | None = None language: str | None = None
"""代码块的语言"""
data: list[str] | None = None data: list[str] | None = None
"""列表项的内容列表"""
ordered: bool | None = None ordered: bool | None = None
"""是否为有序列表"""
component: RenderableComponent | None = None component: RenderableComponent | None = None
"""嵌入的自定义可渲染组件"""
class NotebookData(ContainerComponent): class NotebookData(ContainerComponent):
"""Notebook转图片的数据模型""" """Notebook转图片的数据模型"""
style_name: str | None = None style_name: str | None = None
"""Notebook的样式名称"""
elements: list[NotebookElement] elements: list[NotebookElement]
"""构成Notebook页面的元素列表"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+39 -1
View File
@@ -4,11 +4,14 @@ from pydantic import BaseModel, Field
from ...models.components.progress_bar import ProgressBar from ...models.components.progress_bar import ProgressBar
from .base import RenderableComponent from .base import RenderableComponent
from .text import TextSpan
__all__ = [ __all__ = [
"BaseCell", "BaseCell",
"ComponentCell",
"ImageCell", "ImageCell",
"ProgressBarCell", "ProgressBarCell",
"RichTextCell",
"StatusBadgeCell", "StatusBadgeCell",
"TableCell", "TableCell",
"TableData", "TableData",
@@ -56,8 +59,36 @@ class ProgressBarCell(BaseCell, ProgressBar):
type: Literal["progress_bar"] = "progress_bar" # type: ignore type: Literal["progress_bar"] = "progress_bar" # type: ignore
class RichTextCell(BaseCell):
"""富文本单元格,支持多个带样式的文本片段"""
type: Literal["rich_text"] = "rich_text" # type: ignore
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
"""文本片段列表"""
direction: Literal["column", "row"] = Field("column", description="片段排列方向")
"""片段排列方向"""
gap: str = Field("4px", description="片段之间的间距")
"""片段之间的间距"""
class ComponentCell(BaseCell):
"""一个通用的单元格,可以容纳任何可渲染的组件。"""
type: str = "component"
component: RenderableComponent
TableCell = ( TableCell = (
TextCell | ImageCell | StatusBadgeCell | ProgressBarCell | str | int | float | None TextCell
| ImageCell
| StatusBadgeCell
| ProgressBarCell
| RichTextCell
| ComponentCell
| str
| int
| float
| None
) )
@@ -65,16 +96,23 @@ class TableData(RenderableComponent):
"""通用表格的数据模型""" """通用表格的数据模型"""
style_name: str | None = None style_name: str | None = None
"""应用于表格容器的样式名称"""
title: str = Field(..., description="表格主标题") title: str = Field(..., description="表格主标题")
"""表格主标题"""
tip: str | None = Field(None, description="表格下方的提示信息") tip: str | None = Field(None, description="表格下方的提示信息")
"""表格下方的提示信息"""
headers: list[str] = Field(default_factory=list, description="表头列表") headers: list[str] = Field(default_factory=list, description="表头列表")
"""表头列表"""
rows: list[list[TableCell]] = Field(default_factory=list, description="数据行列表") rows: list[list[TableCell]] = Field(default_factory=list, description="数据行列表")
"""数据行列表"""
column_alignments: list[Literal["left", "center", "right"]] | None = Field( column_alignments: list[Literal["left", "center", "right"]] | None = Field(
default=None, description="每列的对齐方式" default=None, description="每列的对齐方式"
) )
"""每列的对齐方式"""
column_widths: list[str | int] | None = Field( column_widths: list[str | int] | None = Field(
default=None, description="每列的宽度 (e.g., ['50px', 'auto', 100])" default=None, description="每列的宽度 (e.g., ['50px', 'auto', 100])"
) )
"""每列的宽度 (e.g., ['50px', 'auto', 100])"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+7 -2
View File
@@ -1,6 +1,8 @@
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from pydantic import Field
from .base import RenderableComponent from .base import RenderableComponent
__all__ = ["TemplateComponent"] __all__ = ["TemplateComponent"]
@@ -10,8 +12,11 @@ class TemplateComponent(RenderableComponent):
"""基于独立模板文件的UI组件""" """基于独立模板文件的UI组件"""
_is_standalone_template: bool = True _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 @property
def template_name(self) -> str: def template_name(self) -> str:
+2
View File
@@ -23,9 +23,11 @@ class TextData(RenderableComponent):
"""轻量级富文本组件的数据模型""" """轻量级富文本组件的数据模型"""
spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表") spans: list[TextSpan] = Field(default_factory=list, description="文本片段列表")
"""文本片段列表"""
align: Literal["left", "right", "center"] = Field( align: Literal["left", "right", "center"] = Field(
"left", description="整体文本对齐方式" "left", description="整体文本对齐方式"
) )
"""整体文本对齐方式"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
@@ -13,25 +13,35 @@ class HelpItem(BaseModel):
"""帮助菜单中的单个功能项""" """帮助菜单中的单个功能项"""
name: str name: str
"""功能名称"""
description: str description: str
"""功能描述"""
usage: str usage: str
"""功能用法说明"""
class HelpCategory(BaseModel): class HelpCategory(BaseModel):
"""帮助菜单中的一个功能类别""" """帮助菜单中的一个功能类别"""
title: str title: str
"""分类标题"""
icon_svg_path: str icon_svg_path: str
"""分类图标的SVG路径数据"""
items: list[HelpItem] items: list[HelpItem]
"""该分类下的功能项列表"""
class PluginHelpPageData(RenderableComponent): class PluginHelpPageData(RenderableComponent):
"""通用插件帮助页面的数据模型""" """通用插件帮助页面的数据模型"""
style_name: str | None = None style_name: str | None = None
"""页面样式名称"""
bot_nickname: str bot_nickname: str
"""机器人昵称"""
page_title: str page_title: str
"""页面主标题"""
categories: list[HelpCategory] categories: list[HelpCategory]
"""帮助分类列表"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+16 -2
View File
@@ -13,29 +13,43 @@ class PluginMenuItem(BaseModel):
"""插件菜单中的单个插件项""" """插件菜单中的单个插件项"""
id: str id: str
"""插件的唯一ID"""
name: str name: str
"""插件名称"""
status: bool status: bool
"""插件在当前群组的开关状态"""
has_superuser_help: bool has_superuser_help: bool
commands: list[str] = Field(default_factory=list) """插件是否有超级用户专属帮助"""
commands: list[str] = Field(default_factory=list, description="插件的主要命令列表")
"""插件的主要命令列表"""
class PluginMenuCategory(BaseModel): class PluginMenuCategory(BaseModel):
"""插件菜单中的一个分类""" """插件菜单中的一个分类"""
name: str name: str
items: list[PluginMenuItem] """插件分类名称"""
items: list[PluginMenuItem] = Field(..., description="该分类下的插件项列表")
"""该分类下的插件项列表"""
class PluginMenuData(RenderableComponent): class PluginMenuData(RenderableComponent):
"""通用插件帮助菜单的数据模型""" """通用插件帮助菜单的数据模型"""
style_name: str | None = None style_name: str | None = None
"""页面样式名称"""
bot_name: str bot_name: str
"""机器人名称"""
bot_avatar_url: str bot_avatar_url: str
"""机器人头像URL"""
is_detail: bool is_detail: bool
"""是否为详细菜单模式"""
plugin_count: int plugin_count: int
"""总插件数量"""
active_count: int active_count: int
"""已启用插件数量"""
categories: list[PluginMenuCategory] categories: list[PluginMenuCategory]
"""插件分类列表"""
@property @property
def template_name(self) -> str: def template_name(self) -> str:
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from nonebot_plugin_alconna import UniMessage 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 playwright.async_api import Page
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
+1 -1
View File
@@ -247,7 +247,7 @@ class PlatformUtils:
if platform != "qq": if platform != "qq":
return None return None
if user_id.isdigit(): if user_id.isdigit():
return f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=160" return f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640"
else: else:
return f"https://q.qlogo.cn/qqapp/{appid}/{user_id}/640" return f"https://q.qlogo.cn/qqapp/{appid}/{user_id}/640"
+2 -2
View File
@@ -326,7 +326,7 @@ class RepoFileManager:
# 获取仓库树信息 # 获取仓库树信息
strategy = GitHubStrategy() strategy = GitHubStrategy()
strategy.body = await GitHubStrategy.parse_repo_info(repo_info) strategy.body = await strategy.parse_repo_info(repo_info)
# 处理目录路径,确保格式正确 # 处理目录路径,确保格式正确
if directory_path and not directory_path.endswith("/") and recursive: if directory_path and not directory_path.endswith("/") and recursive:
@@ -480,7 +480,7 @@ class RepoFileManager:
target_dir: Path | None = None, target_dir: Path | None = None,
) -> FileDownloadResult: ) -> FileDownloadResult:
""" """
下载单个文件 下载多个文件
参数: 参数:
repo_url: 仓库URL repo_url: 仓库URL
+57 -51
View File
@@ -7,6 +7,7 @@ import base64
from pathlib import Path from pathlib import Path
import re import re
import shutil import shutil
import tempfile
from zhenxun.services.log import logger from zhenxun.services.log import logger
@@ -145,80 +146,85 @@ async def sparse_checkout_clone(
target_dir: Path, target_dir: Path,
) -> None: ) -> None:
""" """
使用 git 稀疏检出克隆指定路径到目标目录(完全独立于主项目 git)。 使用 git 稀疏检出克隆指定路径到目标目录(在临时目录中操作)。
关键保障: 关键保障:
- 在 target_dir 下检测/初始化 .git,所有 git 操作均以 cwd=target_dir 执行 - 在临时目录中执行所有 git 操作,避免影响 target_dir 中的现有内容
- 强制拉取与工作区覆盖: fetch --force、checkout -B、reset --hard、clean -xdf - 只操作 target_dir/sparse_path 路径,不影响 target_dir 其他内容
- 反复设置 sparse-checkout 路径,确保路径更新生效
""" """
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
if not await check_git(): if not await check_git():
raise GitUnavailableError() raise GitUnavailableError()
git_dir = target_dir / ".git" # 在临时目录中进行 git 操作
if not git_dir.exists(): with tempfile.TemporaryDirectory() as temp_dir:
success, out, err = await run_git_command("init", target_dir) temp_path = Path(temp_dir)
# 初始化临时目录为 git 仓库
success, out, err = await run_git_command("init", temp_path)
if not success: if not success:
raise RuntimeError(f"git init 失败: {err or out}") raise RuntimeError(f"git init 失败: {err or out}")
success, out, err = await run_git_command( success, out, err = await run_git_command(
f"remote add origin {repo_url}", target_dir f"remote add origin {repo_url}", temp_path
) )
if not success: if not success:
raise RuntimeError(f"添加远程失败: {err or out}") raise RuntimeError(f"添加远程失败: {err or out}")
else:
# 启用稀疏检出(使用 --no-cone 模式以获得更精确的控制)
await run_git_command("config core.sparseCheckout true", temp_path)
await run_git_command("sparse-checkout init --no-cone", temp_path)
# 设置需要检出的路径(每次都覆盖配置)
if not sparse_path:
raise RuntimeError("sparse-checkout 路径不能为空")
# 使用 --no-cone 模式,直接指定要检出的具体路径
success, out, err = await run_git_command( success, out, err = await run_git_command(
f"remote set-url origin {repo_url}", target_dir f"sparse-checkout set {sparse_path}/", temp_path
) )
if not success: if not success:
# 兜底尝试添加 raise RuntimeError(f"配置稀疏路径失败: {err or out}")
await run_git_command(f"remote add origin {repo_url}", target_dir)
# 启用稀疏检出(使用 --no-cone 模式以获得更精确的控制) # 强制拉取并同步到远端
await run_git_command("config core.sparseCheckout true", target_dir) success, out, err = await run_git_command(
await run_git_command("sparse-checkout init --no-cone", target_dir) f"fetch --force --depth 1 origin {branch}", temp_path
)
if not success:
raise RuntimeError(f"fetch 失败: {err or out}")
# 设置需要检出的路径(每次都覆盖配置) # 使用远端强制更新本地分支并覆盖工作区
if not sparse_path: success, out, err = await run_git_command(
raise RuntimeError("sparse-checkout 路径不能为空") f"checkout -B {branch} origin/{branch}", temp_path
)
if not success:
# 回退方案
success2, out2, err2 = await run_git_command(
f"checkout {branch}", temp_path
)
if not success2:
raise RuntimeError(f"checkout 失败: {(err or out) or (err2 or out2)}")
# 使用 --no-cone 模式,直接指定要检出的具体路径 # 强制对齐工作区
# 例如:sparse_path="plugins/mahiro" -> 只检出 plugins/mahiro/ 下的内容 await run_git_command(f"reset --hard origin/{branch}", temp_path)
success, out, err = await run_git_command( await run_git_command("clean -xdf", temp_path)
f"sparse-checkout set {sparse_path}/", target_dir
)
if not success:
raise RuntimeError(f"配置稀疏路径失败: {err or out}")
# 强制拉取并同步到远端 # 将检出的文件移动到目标位置
success, out, err = await run_git_command( source_path = temp_path / sparse_path
f"fetch --force --depth 1 origin {branch}", target_dir if source_path.exists():
) # 确保目标路径存在
if not success: target_path = target_dir / sparse_path
raise RuntimeError(f"fetch 失败: {err or out}") target_path.parent.mkdir(parents=True, exist_ok=True)
# 使用远端强制更新本地分支并覆盖工作区 # 如果目标路径已存在,先清理
success, out, err = await run_git_command( if target_path.exists():
f"checkout -B {branch} origin/{branch}", target_dir if target_path.is_dir():
) shutil.rmtree(target_path)
if not success: else:
# 回退方案 target_path.unlink()
success2, out2, err2 = await run_git_command(f"checkout {branch}", target_dir)
if not success2:
raise RuntimeError(f"checkout 失败: {(err or out) or (err2 or out2)}")
# 强制对齐工作区 # 移动整个目录结构到目标位置
await run_git_command(f"reset --hard origin/{branch}", target_dir) shutil.move(str(source_path), str(target_path))
await run_git_command("clean -xdf", target_dir)
dir_path = target_dir / Path(sparse_path)
for f in dir_path.iterdir():
shutil.move(f, target_dir / f.name)
dir_name = sparse_path.split("/")[0]
rm_path = target_dir / dir_name
if rm_path.exists():
shutil.rmtree(rm_path)
def prepare_aliyun_url(repo_url: str) -> str: def prepare_aliyun_url(repo_url: str) -> str: