mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-10 22:30:02 +08:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e08d89350f | ||
|
|
07be73c1b7 | ||
|
|
7e6896fa01 | ||
|
|
3cc882b116 | ||
|
|
ee699fb345 | ||
|
|
631e66d54f | ||
|
|
c7ef6fdb17 | ||
|
|
fb0a9813e1 | ||
|
|
6940c2f37b | ||
|
|
74ce848127 | ||
|
|
9e5c4aa3e7 | ||
|
|
7f460296dd | ||
|
|
b505307f2f | ||
|
|
4ab9382205 | ||
|
|
1e2aa99207 | ||
|
|
7472cabd48 | ||
|
|
d9e65057cf | ||
|
|
b12168b6b9 | ||
|
|
a63f26c3b6 | ||
|
|
095a123c3c | ||
|
|
f9a38a26b2 | ||
|
|
6124e217d0 | ||
|
|
11524bcb04 | ||
|
|
d5e5fac02d | ||
|
|
55da0046a2 | ||
|
|
977f0b13b3 | ||
|
|
2fed781350 | ||
|
|
6d1789bbee |
@@ -32,6 +32,7 @@ MANIFEST
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
!resources.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__: v0.2.4-3deffcb
|
||||
__version__: v0.2.4-da6d5b4
|
||||
|
||||
Generated
+1036
-941
File diff suppressed because it is too large
Load Diff
@@ -14,21 +14,21 @@ priority = "primary"
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
playwright = "^1.41.1"
|
||||
nonebot-adapter-onebot = "^2.3.1"
|
||||
nonebot-adapter-onebot = ">=2.3.1"
|
||||
nonebot-plugin-apscheduler = "^0.5"
|
||||
tortoise-orm = { extras = ["asyncpg"], version = "^0.20.0" }
|
||||
tortoise-orm = "^0.20.0"
|
||||
cattrs = "^23.2.3"
|
||||
ruamel-yaml = "^0.18.5"
|
||||
strenum = "^0.4.15"
|
||||
nonebot-plugin-session = "^0.2.3"
|
||||
ujson = "^5.9.0"
|
||||
nb-cli = "^1.3.0"
|
||||
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
|
||||
nonebot-plugin-session = "^0.3.2"
|
||||
ujson = ">=5.9.0"
|
||||
nb-cli = ">=1.3.0"
|
||||
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
|
||||
pillow = "^10.0.0"
|
||||
retrying = "^1.3.4"
|
||||
aiofiles = "^23.2.1"
|
||||
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
|
||||
pypinyin = "^0.51.0"
|
||||
pypinyin = ">=0.51.0"
|
||||
beautifulsoup4 = "^4.12.3"
|
||||
lxml = "^5.1.0"
|
||||
psutil = "^5.9.8"
|
||||
@@ -36,15 +36,20 @@ feedparser = "^6.0.11"
|
||||
imagehash = "^4.3.1"
|
||||
cn2an = "^0.5.22"
|
||||
dateparser = "^1.2.0"
|
||||
bilireq = "0.2.3post0"
|
||||
bilireq = ">=0.2.10"
|
||||
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
|
||||
python-multipart = "^0.0.9"
|
||||
aiocache = "^0.12.2"
|
||||
aiocache = {extras = ["redis"], version = "^0.12.3"}
|
||||
py-cpuinfo = "^9.0.0"
|
||||
nonebot-plugin-alconna = "^0.54.0"
|
||||
nonebot-plugin-alconna = ">=0.56.0"
|
||||
tenacity = "^9.0.0"
|
||||
nonebot-plugin-uninfo = ">0.4.1"
|
||||
pydantic = "1.10.18"
|
||||
nonebot-plugin-uninfo = ">=0.7.3"
|
||||
nonebot-plugin-waiter = "^0.8.1"
|
||||
multidict = ">=6.0.0,!=6.3.2"
|
||||
pydantic = ">=1.0.0, <2.0.0"
|
||||
|
||||
redis = { version = ">=5", optional = true }
|
||||
asyncpg = { version = ">=0.20.0", optional = true }
|
||||
alibabacloud-devops20210625 = "^5.0.2"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
@@ -57,7 +62,6 @@ respx = "^0.21.1"
|
||||
ruff = "^0.8.0"
|
||||
pre-commit = "^4.0.0"
|
||||
|
||||
|
||||
[tool.nonebot]
|
||||
plugins = [
|
||||
"nonebot_plugin_apscheduler",
|
||||
|
||||
Generated
+1112
-1004
File diff suppressed because it is too large
Load Diff
@@ -14,21 +14,21 @@ priority = "primary"
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
playwright = "^1.41.1"
|
||||
nonebot-adapter-onebot = "^2.3.1"
|
||||
nonebot-adapter-onebot = ">=2.3.1"
|
||||
nonebot-plugin-apscheduler = "^0.5"
|
||||
tortoise-orm = { extras = ["asyncpg"], version = "^0.20.0" }
|
||||
tortoise-orm = "^0.20.0"
|
||||
cattrs = "^23.2.3"
|
||||
ruamel-yaml = "^0.18.5"
|
||||
strenum = "^0.4.15"
|
||||
nonebot-plugin-session = "^0.2.3"
|
||||
ujson = "^5.9.0"
|
||||
nb-cli = "^1.3.0"
|
||||
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
|
||||
nonebot-plugin-session = "^0.3.2"
|
||||
ujson = ">=5.9.0"
|
||||
nb-cli = ">=1.3.0"
|
||||
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
|
||||
pillow = "^10.0.0"
|
||||
retrying = "^1.3.4"
|
||||
aiofiles = "^23.2.1"
|
||||
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
|
||||
pypinyin = "^0.51.0"
|
||||
pypinyin = ">=0.51.0"
|
||||
beautifulsoup4 = "^4.12.3"
|
||||
lxml = "^5.1.0"
|
||||
psutil = "^5.9.8"
|
||||
@@ -36,15 +36,20 @@ feedparser = "^6.0.11"
|
||||
imagehash = "^4.3.1"
|
||||
cn2an = "^0.5.22"
|
||||
dateparser = "^1.2.0"
|
||||
bilireq = "0.2.3post0"
|
||||
bilireq = ">=0.2.10"
|
||||
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
|
||||
python-multipart = "^0.0.9"
|
||||
aiocache = "^0.12.2"
|
||||
aiocache = {extras = ["redis"], version = "^0.12.3"}
|
||||
py-cpuinfo = "^9.0.0"
|
||||
nonebot-plugin-alconna = "^0.54.0"
|
||||
nonebot-plugin-alconna = ">=0.56.0"
|
||||
tenacity = "^9.0.0"
|
||||
nonebot-plugin-uninfo = ">0.4.1"
|
||||
pydantic = "2.10.6"
|
||||
nonebot-plugin-uninfo = ">=0.7.3"
|
||||
nonebot-plugin-waiter = "^0.8.1"
|
||||
multidict = ">=6.0.0,!=6.3.2"
|
||||
pydantic = ">=2.0.0, <3.0.0"
|
||||
|
||||
redis = { version = ">=5", optional = true }
|
||||
asyncpg = { version = ">=0.20.0", optional = true }
|
||||
alibabacloud-devops20210625 = "^5.0.2"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
Generated
+1156
-921
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -14,21 +14,21 @@ priority = "primary"
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
playwright = "^1.41.1"
|
||||
nonebot-adapter-onebot = "^2.3.1"
|
||||
nonebot-adapter-onebot = ">=2.3.1"
|
||||
nonebot-plugin-apscheduler = "^0.5"
|
||||
tortoise-orm = "^0.20.0"
|
||||
cattrs = "^23.2.3"
|
||||
ruamel-yaml = "^0.18.5"
|
||||
strenum = "^0.4.15"
|
||||
nonebot-plugin-session = "^0.2.3"
|
||||
ujson = "^5.9.0"
|
||||
nb-cli = "^1.3.0"
|
||||
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
|
||||
nonebot-plugin-session = "^0.3.2"
|
||||
ujson = ">=5.9.0"
|
||||
nb-cli = ">=1.3.0"
|
||||
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
|
||||
pillow = "^10.0.0"
|
||||
retrying = "^1.3.4"
|
||||
aiofiles = "^23.2.1"
|
||||
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
|
||||
pypinyin = "^0.51.0"
|
||||
pypinyin = ">=0.51.0"
|
||||
beautifulsoup4 = "^4.12.3"
|
||||
lxml = "^5.1.0"
|
||||
psutil = "^5.9.8"
|
||||
@@ -36,14 +36,14 @@ feedparser = "^6.0.11"
|
||||
imagehash = "^4.3.1"
|
||||
cn2an = "^0.5.22"
|
||||
dateparser = "^1.2.0"
|
||||
bilireq = "0.2.3post0"
|
||||
bilireq = ">=0.2.10"
|
||||
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
|
||||
python-multipart = "^0.0.9"
|
||||
aiocache = {extras = ["redis"], version = "^0.12.3"}
|
||||
py-cpuinfo = "^9.0.0"
|
||||
nonebot-plugin-alconna = "^0.54.0"
|
||||
nonebot-plugin-alconna = ">=0.56.0"
|
||||
tenacity = "^9.0.0"
|
||||
nonebot-plugin-uninfo = ">0.4.1"
|
||||
nonebot-plugin-uninfo = ">=0.7.3"
|
||||
nonebot-plugin-waiter = "^0.8.1"
|
||||
multidict = ">=6.0.0,!=6.3.2"
|
||||
|
||||
@@ -145,4 +145,4 @@ asyncio_default_fixture_loop_scope = "session"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
+37
-131
@@ -1,131 +1,37 @@
|
||||
aiocache==0.12.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
aiofiles==23.2.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
aiosqlite==0.17.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
alibabacloud-devops20210625==5.0.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
anyio==4.8.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
apscheduler==3.11.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
arclet-alconna-tools==0.7.10 ; python_version >= "3.10" and python_version < "4.0"
|
||||
arclet-alconna==1.8.35 ; python_version >= "3.10" and python_version < "4.0"
|
||||
arrow==1.3.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
async-timeout==5.0.1 ; python_version == "3.10"
|
||||
asyncpg==0.30.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
attrs==25.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
beautifulsoup4==4.13.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
bilireq==0.2.3.post0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
binaryornot==0.4.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
cashews==7.4.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
cattrs==23.2.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
certifi==2025.1.31 ; python_version >= "3.10" and python_version < "4.0"
|
||||
cffi==1.17.1 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
|
||||
chardet==5.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
charset-normalizer==3.4.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
click==8.1.8 ; python_version >= "3.10" and python_version < "4.0"
|
||||
cn2an==0.5.23 ; python_version >= "3.10" and python_version < "4.0"
|
||||
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and (platform_system == "Windows" or sys_platform == "win32")
|
||||
cookiecutter==2.6.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
cryptography==44.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
dateparser==1.2.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
distlib==0.3.9 ; python_version >= "3.10" and python_version < "4.0"
|
||||
ecdsa==0.19.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
fastapi==0.115.8 ; python_version >= "3.10" and python_version < "4.0"
|
||||
feedparser==6.0.11 ; python_version >= "3.10" and python_version < "4.0"
|
||||
filelock==3.17.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
greenlet==3.1.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
grpcio==1.70.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
h11==0.14.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
httpcore==0.16.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
httptools==0.6.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
httpx==0.23.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
idna==3.10 ; python_version >= "3.10" and python_version < "4.0"
|
||||
imagehash==4.3.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
importlib-metadata==8.6.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
iso8601==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
jinja2==3.1.5 ; python_version >= "3.10" and python_version < "4.0"
|
||||
loguru==0.7.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
lxml==5.3.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markdown-it-py==3.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markdown==3.7 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markupsafe==3.0.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mdurl==0.1.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
msgpack==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
multidict==6.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nb-cli==1.4.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nepattern==0.7.7 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nonebot-adapter-onebot==2.4.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nonebot-plugin-alconna==0.54.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nonebot-plugin-apscheduler==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nonebot-plugin-htmlrender==0.6.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nonebot-plugin-session==0.2.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nonebot-plugin-uninfo==0.6.8 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nonebot-plugin-waiter==0.8.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nonebot2==2.4.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
nonebot2[fastapi]==2.4.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
noneprompt==0.1.9 ; python_version >= "3.10" and python_version < "4.0"
|
||||
numpy==2.2.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pillow==10.4.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
platformdirs==4.3.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
playwright==1.50.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
proces==0.1.7 ; python_version >= "3.10" and python_version < "4.0"
|
||||
prompt-toolkit==3.0.50 ; python_version >= "3.10" and python_version < "4.0"
|
||||
propcache==0.2.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
protobuf==4.25.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
psutil==5.9.8 ; python_version >= "3.10" and python_version < "4.0"
|
||||
py-cpuinfo==9.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pyasn1==0.6.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pycparser==2.22 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
|
||||
pydantic-core==2.27.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pydantic==2.10.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pyee==12.1.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pyfiglet==1.0.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pygments==2.19.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pygtrie==2.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pymdown-extensions==10.14.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pypika-tortoise==0.1.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pypinyin==0.51.0 ; python_version >= "3.10" and python_version < "4"
|
||||
python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
python-dotenv==1.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
python-jose[cryptography]==3.3.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
python-markdown-math==0.8 ; python_version >= "3.10" and python_version < "4.0"
|
||||
python-multipart==0.0.9 ; python_version >= "3.10" and python_version < "4.0"
|
||||
python-slugify==8.0.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pytz==2025.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pywavelets==1.8.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pyyaml==6.0.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
regex==2024.11.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
retrying==1.3.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
rfc3986[idna2008]==1.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
rich==13.9.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
rsa==4.9 ; python_version >= "3.10" and python_version < "4"
|
||||
ruamel-yaml-clib==0.2.12 ; platform_python_implementation == "CPython" and python_version < "3.13" and python_version >= "3.10"
|
||||
ruamel-yaml==0.18.10 ; python_version >= "3.10" and python_version < "4.0"
|
||||
scipy==1.15.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
sgmllib3k==1.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
six==1.17.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
sniffio==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
soupsieve==2.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
starlette==0.45.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
strenum==0.4.15 ; python_version >= "3.10" and python_version < "4.0"
|
||||
tarina==0.6.8 ; python_version >= "3.10" and python_version < "4.0"
|
||||
tenacity==9.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
text-unidecode==1.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
tomli==2.2.1 ; python_version == "3.10"
|
||||
tomlkit==0.13.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
tortoise-orm[asyncpg]==0.20.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
types-python-dateutil==2.9.0.20241206 ; python_version >= "3.10" and python_version < "4.0"
|
||||
typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
tzdata==2025.1 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
|
||||
tzlocal==5.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
ujson==5.10.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
urllib3==2.3.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
uvicorn[standard]==0.34.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
uvloop==0.21.0 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.10" and python_version < "4.0"
|
||||
virtualenv==20.29.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
watchfiles==0.24.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
wcwidth==0.2.13 ; python_version >= "3.10" and python_version < "4.0"
|
||||
websockets==14.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
win32-setctime==1.2.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
|
||||
yarl==1.18.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
zipp==3.21.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
playwright>=1.41.1,<2.0.0
|
||||
nonebot-adapter-onebot>=2.3.1
|
||||
nonebot-plugin-apscheduler>=0.5,<0.6
|
||||
tortoise-orm>=0.20.0,<0.21.0
|
||||
cattrs>=23.2.3,<24.0.0
|
||||
ruamel.yaml>=0.18.5,<0.19.0
|
||||
strenum>=0.4.15,<0.5.0
|
||||
nonebot-plugin-session>=0.3.2,<0.4.0
|
||||
ujson>=5.9.0
|
||||
nb-cli>=1.3.0
|
||||
nonebot2[fastapi]>=2.3.3
|
||||
Pillow>=10.0.0,<11.0.0
|
||||
retrying>=1.3.4,<2.0.0
|
||||
aiofiles>=23.2.1,<24.0.0
|
||||
nonebot-plugin-htmlrender>=0.6.0,<1.0.0
|
||||
pypinyin>=0.51.0
|
||||
beautifulsoup4>=4.12.3,<5.0.0
|
||||
lxml>=5.1.0,<6.0.0
|
||||
psutil>=5.9.8,<6.0.0
|
||||
feedparser>=6.0.11,<7.0.0
|
||||
ImageHash>=4.3.1,<5.0.0
|
||||
cn2an>=0.5.22,<0.6.0
|
||||
dateparser>=1.2.0,<2.0.0
|
||||
bilireq>=0.2.10
|
||||
python-jose[cryptography]>=3.3.0,<4.0.0
|
||||
python-multipart>=0.0.9,<0.1.0
|
||||
aiocache[redis]>=0.12.3,<0.13.0
|
||||
py-cpuinfo>=9.0.0,<10.0.0
|
||||
nonebot-plugin-alconna>=0.56.0
|
||||
tenacity>=9.0.0,<10.0.0
|
||||
nonebot-plugin-uninfo>=0.7.3
|
||||
nonebot-plugin-waiter>=0.8.1,<0.9.0
|
||||
multidict>=6.0.0,<7.0.0,!=6.3.2
|
||||
alibabacloud-devops20210625>=5.0.2,<6.0.0
|
||||
|
||||
redis>=5
|
||||
asyncpg>=0.20.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
require_resources_version: ">=1.0.0"
|
||||
@@ -225,7 +225,7 @@ def init_mocker_path(mocker: MockerFixture, tmp_path: Path):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip("不会修")
|
||||
@pytest.mark.xfail
|
||||
async def test_check_update_release(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -322,7 +322,7 @@ async def test_check_update_release(
|
||||
assert (mock_backup_path / folder).exists()
|
||||
|
||||
|
||||
@pytest.mark.skip("不会修")
|
||||
@pytest.mark.xfail
|
||||
async def test_check_update_main(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import cast
|
||||
from nonebot.adapters.onebot.v11 import Bot
|
||||
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
|
||||
from nonebug import App
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from tests.config import BotId, GroupId, MessageId, UserId
|
||||
@@ -64,9 +65,11 @@ def init_mocker(mocker: MockerFixture, tmp_path: Path):
|
||||
mock_platform = mocker.patch("zhenxun.builtin_plugins.check.data_source.platform")
|
||||
mock_platform.uname.return_value = platform_uname
|
||||
|
||||
mock_template_to_pic = mocker.patch("zhenxun.builtin_plugins.check.template_to_pic")
|
||||
mock_template_to_pic_return = mocker.AsyncMock()
|
||||
mock_template_to_pic.return_value = mock_template_to_pic_return
|
||||
mock_render_service = mocker.patch(
|
||||
"zhenxun.builtin_plugins.check.renderer_service.render"
|
||||
)
|
||||
mock_render_service_return = mocker.AsyncMock()
|
||||
mock_render_service.return_value = mock_render_service_return
|
||||
|
||||
mock_build_message = mocker.patch(
|
||||
"zhenxun.builtin_plugins.check.MessageUtils.build_message"
|
||||
@@ -74,22 +77,18 @@ def init_mocker(mocker: MockerFixture, tmp_path: Path):
|
||||
mock_build_message_return = mocker.AsyncMock()
|
||||
mock_build_message.return_value = mock_build_message_return
|
||||
|
||||
mock_template_path_new = tmp_path / "resources" / "template"
|
||||
mocker.patch(
|
||||
"zhenxun.builtin_plugins.check.TEMPLATE_PATH", new=mock_template_path_new
|
||||
)
|
||||
return (
|
||||
mock_psutil,
|
||||
mock_cpuinfo,
|
||||
mock_platform,
|
||||
mock_template_to_pic,
|
||||
mock_template_to_pic_return,
|
||||
mock_render_service,
|
||||
mock_render_service_return,
|
||||
mock_build_message,
|
||||
mock_build_message_return,
|
||||
mock_template_path_new,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_check(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -105,11 +104,10 @@ async def test_check(
|
||||
mock_psutil,
|
||||
mock_cpuinfo,
|
||||
mock_platform,
|
||||
mock_template_to_pic,
|
||||
mock_template_to_pic_return,
|
||||
mock_render_service,
|
||||
mock_render_service_return,
|
||||
mock_build_message,
|
||||
mock_build_message_return,
|
||||
mock_template_path_new,
|
||||
) = init_mocker(mocker, tmp_path)
|
||||
async with app.test_matcher(_self_check_matcher) as ctx:
|
||||
bot = create_bot(ctx)
|
||||
@@ -126,11 +124,12 @@ async def test_check(
|
||||
ctx.receive_event(bot=bot, event=event)
|
||||
ctx.should_ignore_rule(_self_check_matcher)
|
||||
|
||||
mock_template_to_pic.assert_awaited_once()
|
||||
mock_build_message.assert_called_once_with(mock_template_to_pic_return)
|
||||
mock_render_service.assert_awaited_once()
|
||||
mock_build_message.assert_called_once_with(mock_render_service_return)
|
||||
mock_build_message_return.send.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_check_arm(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -161,11 +160,10 @@ async def test_check_arm(
|
||||
mock_psutil,
|
||||
mock_cpuinfo,
|
||||
mock_platform,
|
||||
mock_template_to_pic,
|
||||
mock_template_to_pic_return,
|
||||
mock_render_service,
|
||||
mock_render_service_return,
|
||||
mock_build_message,
|
||||
mock_build_message_return,
|
||||
mock_template_path_new,
|
||||
) = init_mocker(mocker, tmp_path)
|
||||
|
||||
mock_platform.uname.return_value = platform_uname_arm
|
||||
@@ -199,6 +197,6 @@ async def test_check_arm(
|
||||
mocker.call().decode().split().__getitem__().__float__(),
|
||||
] # type: ignore
|
||||
)
|
||||
mock_template_to_pic.assert_awaited_once()
|
||||
mock_build_message.assert_called_once_with(mock_template_to_pic_return)
|
||||
mock_render_service.assert_awaited_once()
|
||||
mock_build_message.assert_called_once_with(mock_render_service_return)
|
||||
mock_build_message_return.send.assert_awaited_once()
|
||||
|
||||
@@ -6,6 +6,7 @@ from nonebot.adapters.onebot.v11 import Bot
|
||||
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
|
||||
from nonebot.adapters.onebot.v11.message import Message
|
||||
from nonebug import App
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from tests.config import BotId, GroupId, MessageId, UserId
|
||||
@@ -14,6 +15,7 @@ from tests.utils import _v11_group_message_event
|
||||
test_path = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_add_plugin_basic(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -60,6 +62,7 @@ async def test_add_plugin_basic(
|
||||
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_add_plugin_basic_commit_version(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -106,6 +109,7 @@ async def test_add_plugin_basic_commit_version(
|
||||
assert (mock_base_path / "plugins" / "bilibili_sub" / "__init__.py").is_file()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_add_plugin_basic_is_not_dir(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -152,6 +156,7 @@ async def test_add_plugin_basic_is_not_dir(
|
||||
assert (mock_base_path / "plugins" / "jitang.py").is_file()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_add_plugin_extra(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -198,6 +203,7 @@ async def test_add_plugin_extra(
|
||||
assert (mock_base_path / "plugins" / "github_sub" / "__init__.py").is_file()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_plugin_not_exist_add(
|
||||
app: App,
|
||||
create_bot: Callable,
|
||||
@@ -236,6 +242,7 @@ async def test_plugin_not_exist_add(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_add_plugin_exist(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -8,12 +8,14 @@ from nonebot.adapters.onebot.v11 import Bot
|
||||
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
|
||||
from nonebot.adapters.onebot.v11.message import Message
|
||||
from nonebug import App
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from tests.config import BotId, GroupId, MessageId, UserId
|
||||
from tests.utils import _v11_group_message_event
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_remove_plugin(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -60,6 +62,7 @@ async def test_remove_plugin(
|
||||
assert not (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_plugin_not_exist_remove(
|
||||
app: App,
|
||||
create_bot: Callable,
|
||||
@@ -92,6 +95,7 @@ async def test_plugin_not_exist_remove(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_remove_plugin_not_install(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -5,12 +5,14 @@ from nonebot.adapters.onebot.v11 import Bot
|
||||
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
|
||||
from nonebot.adapters.onebot.v11.message import Message
|
||||
from nonebug import App
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from tests.config import BotId, GroupId, MessageId, UserId
|
||||
from tests.utils import _v11_group_message_event
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_search_plugin_name(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -52,6 +54,7 @@ async def test_search_plugin_name(
|
||||
mock_build_message_return.send.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_search_plugin_author(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -93,6 +96,7 @@ async def test_search_plugin_author(
|
||||
mock_build_message_return.send.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_plugin_not_exist_search(
|
||||
app: App,
|
||||
create_bot: Callable,
|
||||
|
||||
@@ -6,12 +6,14 @@ from nonebot.adapters.onebot.v11 import Bot
|
||||
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
|
||||
from nonebot.adapters.onebot.v11.message import Message
|
||||
from nonebug import App
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from tests.config import BotId, GroupId, MessageId, UserId
|
||||
from tests.utils import _v11_group_message_event
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_update_all_plugin_basic_need_update(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -62,6 +64,7 @@ async def test_update_all_plugin_basic_need_update(
|
||||
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_update_all_plugin_basic_is_new(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -6,13 +6,14 @@ from nonebot.adapters.onebot.v11 import Bot
|
||||
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
|
||||
from nonebot.adapters.onebot.v11.message import Message
|
||||
from nonebug import App
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from respx import MockRouter
|
||||
|
||||
from tests.config import BotId, GroupId, MessageId, UserId
|
||||
from tests.utils import _v11_group_message_event
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_update_plugin_basic_need_update(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -63,6 +64,7 @@ async def test_update_plugin_basic_need_update(
|
||||
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_update_plugin_basic_is_new(
|
||||
app: App,
|
||||
mocker: MockerFixture,
|
||||
@@ -112,6 +114,7 @@ async def test_update_plugin_basic_is_new(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_plugin_not_exist_update(
|
||||
app: App,
|
||||
create_bot: Callable,
|
||||
@@ -150,9 +153,9 @@ async def test_plugin_not_exist_update(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
async def test_update_plugin_not_install(
|
||||
app: App,
|
||||
mocked_api: MockRouter,
|
||||
create_bot: Callable,
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
+17
-25
@@ -2,18 +2,14 @@ from nonebot.plugin import PluginMetadata
|
||||
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
|
||||
from nonebot_plugin_session import EventSession
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||
from zhenxun.configs.utils import PluginExtraData
|
||||
from zhenxun.services.help_service import create_plugin_help_image
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.exception import EmptyError
|
||||
from zhenxun.utils.message import MessageUtils
|
||||
from zhenxun.utils.rules import admin_check, ensure_group
|
||||
|
||||
from .config import ADMIN_HELP_IMAGE
|
||||
from .html_help import build_html_help
|
||||
from .normal_help import build_help
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="群组管理员帮助",
|
||||
description="管理员帮助列表",
|
||||
@@ -30,17 +26,19 @@ __plugin_meta__ = PluginMetadata(
|
||||
precautions=[
|
||||
"只有群主/群管理 才能使用哦,群主拥有6级权限,管理员拥有5级权限!"
|
||||
],
|
||||
configs=[
|
||||
RegisterConfig(
|
||||
key="type",
|
||||
value="zhenxun",
|
||||
help="管理员帮助样式,normal, zhenxun",
|
||||
default_value="zhenxun",
|
||||
)
|
||||
],
|
||||
configs=[],
|
||||
).to_dict(),
|
||||
)
|
||||
|
||||
|
||||
async def build_html_help() -> bytes:
|
||||
"""构建管理员帮助图片"""
|
||||
return await create_plugin_help_image(
|
||||
plugin_types=[PluginType.ADMIN, PluginType.SUPER_AND_ADMIN],
|
||||
page_title="群管理员帮助手册",
|
||||
)
|
||||
|
||||
|
||||
_matcher = on_alconna(
|
||||
Alconna("管理员帮助"),
|
||||
rule=admin_check(1) & ensure_group,
|
||||
@@ -54,15 +52,9 @@ async def _(
|
||||
session: EventSession,
|
||||
arparma: Arparma,
|
||||
):
|
||||
if not ADMIN_HELP_IMAGE.exists():
|
||||
try:
|
||||
if Config.get_config("admin_help", "type") == "zhenxun":
|
||||
await build_html_help()
|
||||
else:
|
||||
await build_help()
|
||||
except EmptyError:
|
||||
await MessageUtils.build_message("当前管理员帮助为空...").finish(
|
||||
reply_to=True
|
||||
)
|
||||
await MessageUtils.build_message(ADMIN_HELP_IMAGE).send()
|
||||
try:
|
||||
image_bytes = await build_html_help()
|
||||
await MessageUtils.build_message(image_bytes).send()
|
||||
except EmptyError:
|
||||
await MessageUtils.build_message("当前管理员帮助为空...").finish(reply_to=True)
|
||||
logger.info("查看管理员帮助", arparma.header_result, session=session)
|
||||
@@ -1,23 +0,0 @@
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from pydantic import BaseModel
|
||||
|
||||
from zhenxun.configs.path_config import IMAGE_PATH
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
|
||||
ADMIN_HELP_IMAGE = IMAGE_PATH / "ADMIN_HELP.png"
|
||||
if ADMIN_HELP_IMAGE.exists():
|
||||
ADMIN_HELP_IMAGE.unlink()
|
||||
|
||||
|
||||
class PluginData(BaseModel):
|
||||
"""
|
||||
插件信息
|
||||
"""
|
||||
|
||||
plugin: PluginInfo
|
||||
"""插件信息"""
|
||||
metadata: PluginMetadata
|
||||
"""元数据"""
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
@@ -1,57 +0,0 @@
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
|
||||
from zhenxun.builtin_plugins.admin.admin_help.config import ADMIN_HELP_IMAGE
|
||||
from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.configs.path_config import TEMPLATE_PATH
|
||||
from zhenxun.models.task_info import TaskInfo
|
||||
from zhenxun.utils._build_image import BuildImage
|
||||
|
||||
from .utils import get_plugins
|
||||
|
||||
|
||||
async def get_task() -> dict[str, str] | None:
|
||||
"""获取被动技能帮助"""
|
||||
if task_list := await TaskInfo.all():
|
||||
return {
|
||||
"name": "被动技能",
|
||||
"description": "控制群组中的被动技能状态",
|
||||
"usage": "通过 开启/关闭群被动 来控制群被动 <br>"
|
||||
+ " 示例:开启/关闭群被动早晚安 <br> 示例:开启/关闭全部群被动"
|
||||
+ " <br> ---------- <br> "
|
||||
+ "<br>".join([task.name for task in task_list]),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
async def build_html_help():
|
||||
"""构建帮助图片"""
|
||||
plugins = await get_plugins()
|
||||
plugin_list = [
|
||||
{
|
||||
"name": data.plugin.name,
|
||||
"description": data.metadata.description.replace("\n", "<br>"),
|
||||
"usage": data.metadata.usage.replace("\n", "<br>"),
|
||||
}
|
||||
for data in plugins
|
||||
]
|
||||
if task := await get_task():
|
||||
plugin_list.append(task)
|
||||
plugin_list.sort(key=lambda p: len(p["description"]) + len(p["usage"]))
|
||||
pic = await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "help").absolute()),
|
||||
template_name="main.html",
|
||||
templates={
|
||||
"data": {
|
||||
"plugin_list": plugin_list,
|
||||
"nickname": BotConfig.self_nickname,
|
||||
"help_name": "群管理员",
|
||||
}
|
||||
},
|
||||
pages={
|
||||
"viewport": {"width": 824, "height": 10},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
result = await BuildImage.open(pic).resize(0.5)
|
||||
await result.save(ADMIN_HELP_IMAGE)
|
||||
@@ -1,127 +0,0 @@
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from PIL.ImageFont import FreeTypeFont
|
||||
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.models.task_info import TaskInfo
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils._build_image import BuildImage
|
||||
from zhenxun.utils.image_utils import build_sort_image, group_image, text2image
|
||||
|
||||
from .config import ADMIN_HELP_IMAGE
|
||||
from .utils import get_plugins
|
||||
|
||||
|
||||
async def build_usage_des_image(
|
||||
metadata: PluginMetadata,
|
||||
) -> tuple[BuildImage | None, BuildImage | None]:
|
||||
"""构建用法和描述图片
|
||||
|
||||
参数:
|
||||
metadata: PluginMetadata
|
||||
|
||||
返回:
|
||||
tuple[BuildImage | None, BuildImage | None]: 用法和描述图片
|
||||
"""
|
||||
usage = None
|
||||
description = None
|
||||
if metadata.usage:
|
||||
usage = await text2image(
|
||||
metadata.usage,
|
||||
padding=5,
|
||||
color=(255, 255, 255),
|
||||
font_color=(0, 0, 0),
|
||||
)
|
||||
if metadata.description:
|
||||
description = await text2image(
|
||||
metadata.description,
|
||||
padding=5,
|
||||
color=(255, 255, 255),
|
||||
font_color=(0, 0, 0),
|
||||
)
|
||||
return usage, description
|
||||
|
||||
|
||||
async def build_image(
|
||||
plugin: PluginInfo, metadata: PluginMetadata, font: FreeTypeFont
|
||||
) -> BuildImage:
|
||||
"""构建帮助图片
|
||||
|
||||
参数:
|
||||
plugin: PluginInfo
|
||||
metadata: PluginMetadata
|
||||
font: FreeTypeFont
|
||||
|
||||
返回:
|
||||
BuildImage: 帮助图片
|
||||
|
||||
"""
|
||||
usage, description = await build_usage_des_image(metadata)
|
||||
width = 0
|
||||
height = 100
|
||||
if usage:
|
||||
width = usage.width
|
||||
height += usage.height
|
||||
if description and description.width > width:
|
||||
width = description.width
|
||||
height += description.height
|
||||
font_width, _ = BuildImage.get_text_size(f"{plugin.name}[{plugin.level}]", font)
|
||||
if font_width > width:
|
||||
width = font_width
|
||||
A = BuildImage(width + 30, height + 120, "#EAEDF2")
|
||||
await A.text((15, 10), f"{plugin.name}[{plugin.level}]")
|
||||
await A.text((15, 70), "简介:")
|
||||
if not description:
|
||||
description = BuildImage(A.width - 30, 30, (255, 255, 255))
|
||||
await description.circle_corner(10)
|
||||
await A.paste(description, (15, 100))
|
||||
if not usage:
|
||||
usage = BuildImage(A.width - 30, 30, (255, 255, 255))
|
||||
await usage.circle_corner(10)
|
||||
await A.text((15, description.height + 115), "用法:")
|
||||
await A.paste(usage, (15, description.height + 145))
|
||||
await A.circle_corner(10)
|
||||
return A
|
||||
|
||||
|
||||
async def build_help():
|
||||
"""构造管理员帮助图片
|
||||
|
||||
返回:
|
||||
BuildImage: 管理员帮助图片
|
||||
"""
|
||||
font = BuildImage.load_font("HYWenHei-85W.ttf", 20)
|
||||
image_list = []
|
||||
for data in await get_plugins():
|
||||
plugin = data.plugin
|
||||
metadata = data.metadata
|
||||
try:
|
||||
A = await build_image(plugin, metadata, font)
|
||||
image_list.append(A)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"获取群管理员插件 {plugin.module}: {plugin.name} 设置失败...",
|
||||
"管理员帮助",
|
||||
e=e,
|
||||
)
|
||||
if task_list := await TaskInfo.all():
|
||||
task_str = "\n".join([task.name for task in task_list])
|
||||
task_str = "通过 开启/关闭群被动 来控制群被动\n----------\n" + task_str
|
||||
task_image = await text2image(task_str, padding=5, color=(255, 255, 255))
|
||||
await task_image.circle_corner(10)
|
||||
A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2")
|
||||
await A.text((25, 10), "被动技能")
|
||||
await A.paste(task_image, (25, 50))
|
||||
await A.circle_corner(10)
|
||||
image_list.append(A)
|
||||
image_group, _ = group_image(image_list)
|
||||
A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160)
|
||||
text = await BuildImage.build_text_image(
|
||||
"群管理员帮助",
|
||||
size=40,
|
||||
)
|
||||
tip = await BuildImage.build_text_image(
|
||||
"注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red"
|
||||
)
|
||||
await A.paste(text, (50, 30))
|
||||
await A.paste(tip, (50, 90))
|
||||
await A.save(ADMIN_HELP_IMAGE)
|
||||
@@ -1,22 +0,0 @@
|
||||
import nonebot
|
||||
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.exception import EmptyError
|
||||
|
||||
from .config import PluginData
|
||||
|
||||
|
||||
async def get_plugins() -> list[PluginData]:
|
||||
"""获取插件数据"""
|
||||
plugin_list = await PluginInfo.filter(
|
||||
plugin_type__in=[PluginType.ADMIN, PluginType.SUPER_AND_ADMIN]
|
||||
).all()
|
||||
data_list = []
|
||||
for plugin in plugin_list:
|
||||
if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path):
|
||||
if _plugin.metadata:
|
||||
data_list.append(PluginData(plugin=plugin, metadata=_plugin.metadata))
|
||||
if not data_list:
|
||||
raise EmptyError()
|
||||
return data_list
|
||||
@@ -9,7 +9,7 @@ from zhenxun.services.log import logger
|
||||
from zhenxun.utils.enum import BlockType, PluginType
|
||||
from zhenxun.utils.message import MessageUtils
|
||||
|
||||
from ._data_source import PluginManager, build_plugin, build_task, delete_help_image
|
||||
from ._data_source import PluginManager, build_plugin, build_task
|
||||
from .command import _group_status_matcher, _status_matcher
|
||||
|
||||
base_config = Config.get("plugin_switch")
|
||||
@@ -154,7 +154,6 @@ async def _(
|
||||
else:
|
||||
result = await PluginManager.unblock_group_plugin(name, group_id)
|
||||
logger.info(f"开启功能 {name}", arparma.header_result, session=session)
|
||||
delete_help_image(group_id)
|
||||
await MessageUtils.build_message(result).finish(reply_to=True)
|
||||
elif session.user.id in bot.config.superusers:
|
||||
"""私聊"""
|
||||
@@ -218,7 +217,6 @@ async def _(
|
||||
session=session,
|
||||
target=group_id,
|
||||
)
|
||||
delete_help_image()
|
||||
await MessageUtils.build_message(result).finish(reply_to=True)
|
||||
|
||||
|
||||
@@ -266,7 +264,6 @@ async def _(
|
||||
else:
|
||||
result = await PluginManager.block_group_plugin(name, group_id)
|
||||
logger.info(f"关闭功能 {name}", arparma.header_result, session=session)
|
||||
delete_help_image(group_id)
|
||||
await MessageUtils.build_message(result).finish(reply_to=True)
|
||||
elif session.user.id in bot.config.superusers:
|
||||
group_id = group.result if group.available else None
|
||||
@@ -338,7 +335,6 @@ async def _(
|
||||
session=session,
|
||||
target=group_id,
|
||||
)
|
||||
delete_help_image()
|
||||
await MessageUtils.build_message(result).finish(reply_to=True)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import os
|
||||
from typing import cast
|
||||
|
||||
from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH
|
||||
from zhenxun.models.group_console import GroupConsole
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.models.task_info import TaskInfo
|
||||
@@ -11,23 +9,6 @@ from zhenxun.utils.enum import BlockType, CacheType, PluginType
|
||||
from zhenxun.utils.exception import GroupInfoNotFound
|
||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
|
||||
|
||||
HELP_FILE = IMAGE_PATH / "SIMPLE_HELP.png"
|
||||
|
||||
GROUP_HELP_PATH = DATA_PATH / "group_help"
|
||||
|
||||
|
||||
def delete_help_image(gid: str | None = None):
|
||||
"""删除帮助图片"""
|
||||
if gid:
|
||||
for file in os.listdir(GROUP_HELP_PATH):
|
||||
if file.startswith(f"{gid}"):
|
||||
os.remove(GROUP_HELP_PATH / file)
|
||||
else:
|
||||
if HELP_FILE.exists():
|
||||
HELP_FILE.unlink()
|
||||
for file in GROUP_HELP_PATH.iterdir():
|
||||
file.unlink()
|
||||
|
||||
|
||||
def plugin_row_style(column: str, text: str) -> RowStyle:
|
||||
"""被动技能文本风格
|
||||
|
||||
@@ -84,13 +84,16 @@ async def _(
|
||||
):
|
||||
result = ""
|
||||
await MessageUtils.build_message("正在进行检查更新...").send(reply_to=True)
|
||||
|
||||
if not ver_type.available:
|
||||
result += await UpdateManager.check_version()
|
||||
logger.info("查看当前版本...", "检查更新", session=session)
|
||||
await MessageUtils.build_message(result).finish()
|
||||
return
|
||||
|
||||
ver_type_str = ver_type.result
|
||||
source_str = source.result
|
||||
if ver_type_str in {"main", "release"}:
|
||||
if not ver_type.available:
|
||||
result += await UpdateManager.check_version()
|
||||
logger.info("查看当前版本...", "检查更新", session=session)
|
||||
await MessageUtils.build_message(result).finish()
|
||||
try:
|
||||
result += await UpdateManager.update_zhenxun(
|
||||
bot,
|
||||
|
||||
@@ -1,37 +1,135 @@
|
||||
import asyncio
|
||||
from typing import Literal
|
||||
|
||||
from nonebot.adapters import Bot
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.http_utils import AsyncHttpx
|
||||
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
|
||||
from zhenxun.utils.manager.zhenxun_repo_manager import (
|
||||
ZhenxunRepoConfig,
|
||||
ZhenxunRepoManager,
|
||||
)
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
from zhenxun.utils.repo_utils import RepoFileManager
|
||||
|
||||
LOG_COMMAND = "AutoUpdate"
|
||||
|
||||
|
||||
class UpdateManager:
|
||||
@staticmethod
|
||||
async def _get_latest_commit_date(owner: str, repo: str, path: str) -> str:
|
||||
"""获取文件最新 commit 日期"""
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits"
|
||||
params = {"path": path, "page": 1, "per_page": 1}
|
||||
try:
|
||||
data = await AsyncHttpx.get_json(api_url, params=params)
|
||||
if data and isinstance(data, list) and data[0]:
|
||||
date_str = data[0]["commit"]["committer"]["date"]
|
||||
return date_str.split("T")[0]
|
||||
except Exception as e:
|
||||
logger.warning(f"获取 {owner}/{repo}/{path} 的 commit 日期失败", e=e)
|
||||
return "获取失败"
|
||||
|
||||
@classmethod
|
||||
async def check_version(cls) -> str:
|
||||
"""检查更新版本
|
||||
"""检查真寻和资源的版本"""
|
||||
bot_cur_version = cls.__get_version()
|
||||
|
||||
返回:
|
||||
str: 更新信息
|
||||
"""
|
||||
cur_version = cls.__get_version()
|
||||
release_data = await ZhenxunRepoManager.zhenxun_get_latest_releases_data()
|
||||
if not release_data:
|
||||
return "检查更新获取版本失败..."
|
||||
return (
|
||||
"检测到当前版本更新\n"
|
||||
f"当前版本:{cur_version}\n"
|
||||
f"最新版本:{release_data.get('name')}\n"
|
||||
f"创建日期:{release_data.get('created_at')}\n"
|
||||
f"更新内容:\n{release_data.get('body')}"
|
||||
release_task = ZhenxunRepoManager.zhenxun_get_latest_releases_data()
|
||||
dev_version_task = RepoFileManager.get_file_content(
|
||||
ZhenxunRepoConfig.ZHENXUN_BOT_GITHUB_URL, "__version__"
|
||||
)
|
||||
bot_commit_date_task = cls._get_latest_commit_date(
|
||||
"HibiKier", "zhenxun_bot", "__version__"
|
||||
)
|
||||
res_commit_date_task = cls._get_latest_commit_date(
|
||||
"zhenxun-org", "zhenxun-bot-resources", "__version__"
|
||||
)
|
||||
|
||||
(
|
||||
release_data,
|
||||
dev_version_text,
|
||||
bot_commit_date,
|
||||
res_commit_date,
|
||||
) = await asyncio.gather(
|
||||
release_task,
|
||||
dev_version_task,
|
||||
bot_commit_date_task,
|
||||
res_commit_date_task,
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
if isinstance(release_data, dict):
|
||||
bot_release_version = release_data.get("name", "获取失败")
|
||||
bot_release_date = release_data.get("created_at", "").split("T")[0]
|
||||
else:
|
||||
bot_release_version = "获取失败"
|
||||
bot_release_date = "获取失败"
|
||||
logger.warning(f"获取 Bot release 信息失败: {release_data}")
|
||||
|
||||
if isinstance(dev_version_text, str):
|
||||
bot_dev_version = dev_version_text.split(":")[-1].strip()
|
||||
else:
|
||||
bot_dev_version = "获取失败"
|
||||
bot_commit_date = "获取失败"
|
||||
logger.warning(f"获取 Bot dev 版本信息失败: {dev_version_text}")
|
||||
|
||||
bot_update_hint = ""
|
||||
try:
|
||||
cur_base_v = bot_cur_version.split("-")[0].lstrip("v")
|
||||
dev_base_v = bot_dev_version.split("-")[0].lstrip("v")
|
||||
|
||||
if Version(cur_base_v) < Version(dev_base_v):
|
||||
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
|
||||
elif (
|
||||
Version(cur_base_v) == Version(dev_base_v)
|
||||
and bot_cur_version != bot_dev_version
|
||||
):
|
||||
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
|
||||
except (InvalidVersion, TypeError, IndexError):
|
||||
if bot_cur_version != bot_dev_version and bot_dev_version != "获取失败":
|
||||
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
|
||||
|
||||
bot_update_info = (
|
||||
f"当前版本: {bot_cur_version}\n"
|
||||
f"最新开发版: {bot_dev_version} (更新于: {bot_commit_date})\n"
|
||||
f"最新正式版: {bot_release_version} (发布于: {bot_release_date})"
|
||||
f"{bot_update_hint}"
|
||||
)
|
||||
|
||||
res_version_file = ZhenxunRepoConfig.RESOURCE_PATH / "__version__"
|
||||
res_cur_version = "未找到"
|
||||
if res_version_file.exists():
|
||||
if text := res_version_file.open(encoding="utf8").readline():
|
||||
res_cur_version = text.split(":")[-1].strip()
|
||||
|
||||
res_latest_version = "获取失败"
|
||||
try:
|
||||
res_latest_version_text = await RepoFileManager.get_file_content(
|
||||
ZhenxunRepoConfig.RESOURCE_GITHUB_URL, "__version__"
|
||||
)
|
||||
res_latest_version = res_latest_version_text.split(":")[-1].strip()
|
||||
except Exception as e:
|
||||
res_commit_date = "获取失败"
|
||||
logger.warning(f"获取资源版本信息失败: {e}")
|
||||
|
||||
res_update_hint = ""
|
||||
try:
|
||||
if Version(res_cur_version) < Version(res_latest_version):
|
||||
res_update_hint = "\n-> 发现新资源版本, 可用 `检查更新 resource` 更新"
|
||||
except (InvalidVersion, TypeError):
|
||||
pass
|
||||
|
||||
res_update_info = (
|
||||
f"当前版本: {res_cur_version}\n"
|
||||
f"最新版本: {res_latest_version} (更新于: {res_commit_date})"
|
||||
f"{res_update_hint}"
|
||||
)
|
||||
|
||||
return f"『绪山真寻 Bot』\n{bot_update_info}\n\n『真寻资源』\n{res_update_info}"
|
||||
|
||||
@classmethod
|
||||
async def update_webui(
|
||||
@@ -125,6 +223,7 @@ class UpdateManager:
|
||||
f"检测真寻已更新,当前版本:{cur_version}\n开始更新...",
|
||||
user_id,
|
||||
)
|
||||
result_message = ""
|
||||
if zip:
|
||||
new_version = await ZhenxunRepoManager.zhenxun_zip_update(version_type)
|
||||
await PlatformUtils.send_superuser(
|
||||
@@ -133,7 +232,7 @@ class UpdateManager:
|
||||
await VirtualEnvPackageManager.install_requirement(
|
||||
ZhenxunRepoConfig.REQUIREMENTS_FILE
|
||||
)
|
||||
return (
|
||||
result_message = (
|
||||
f"版本更新完成!\n版本: {cur_version} -> {new_version}\n"
|
||||
"请重新启动真寻以完成更新!"
|
||||
)
|
||||
@@ -155,13 +254,54 @@ class UpdateManager:
|
||||
await VirtualEnvPackageManager.install_requirement(
|
||||
ZhenxunRepoConfig.REQUIREMENTS_FILE
|
||||
)
|
||||
return (
|
||||
result_message = (
|
||||
f"版本更新完成!\n"
|
||||
f"版本: {cur_version} -> {result.new_version}\n"
|
||||
f"变更文件个数: {len(result.changed_files)}"
|
||||
f"{'' if source == 'git' else '(阿里云更新不支持查看变更文件)'}\n"
|
||||
"请重新启动真寻以完成更新!"
|
||||
)
|
||||
resource_warning = ""
|
||||
if version_type == "main":
|
||||
try:
|
||||
spec_content = await RepoFileManager.get_file_content(
|
||||
ZhenxunRepoConfig.ZHENXUN_BOT_GITHUB_URL, "resources.spec"
|
||||
)
|
||||
required_spec_str = None
|
||||
for line in spec_content.splitlines():
|
||||
if line.startswith("require_resources_version:"):
|
||||
required_spec_str = line.split(":", 1)[1].strip().strip("\"'")
|
||||
break
|
||||
if required_spec_str:
|
||||
res_version_file = ZhenxunRepoConfig.RESOURCE_PATH / "__version__"
|
||||
local_res_version_str = "0.0.0"
|
||||
if res_version_file.exists():
|
||||
if text := res_version_file.open(encoding="utf8").readline():
|
||||
local_res_version_str = text.split(":")[-1].strip()
|
||||
|
||||
spec = SpecifierSet(required_spec_str)
|
||||
local_ver = Version(local_res_version_str)
|
||||
if not spec.contains(local_ver):
|
||||
warning_header = (
|
||||
f"⚠️ **资源版本不兼容!**\n"
|
||||
f"当前代码需要资源版本: `{required_spec_str}`\n"
|
||||
f"您当前的资源版本是: `{local_res_version_str}`\n"
|
||||
"**将自动为您更新资源文件...**"
|
||||
)
|
||||
await PlatformUtils.send_superuser(bot, warning_header, user_id)
|
||||
resource_update_source = None if zip else source
|
||||
resource_update_result = await cls.update_resources(
|
||||
source=resource_update_source, force=force
|
||||
)
|
||||
resource_warning = (
|
||||
f"\n\n{warning_header}\n{resource_update_result}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"检查资源版本兼容性时出错: {e}", LOG_COMMAND, e=e)
|
||||
resource_warning = (
|
||||
"\n\n⚠️ 检查资源版本兼容性时出错,建议手动运行 `检查更新 resource`"
|
||||
)
|
||||
return result_message + resource_warning
|
||||
|
||||
@classmethod
|
||||
def __get_version(cls) -> str:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from io import BytesIO
|
||||
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot_plugin_alconna import (
|
||||
@@ -15,15 +14,17 @@ from nonebot_plugin_alconna import (
|
||||
from nonebot_plugin_session import EventSession
|
||||
import pytz
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
|
||||
from zhenxun.models.chat_history import ChatHistory
|
||||
from zhenxun.models.group_member_info import GroupInfoUser
|
||||
from zhenxun.services import avatar_service
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.ui.builders import TableBuilder
|
||||
from zhenxun.ui.models import ImageCell, TextCell
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
|
||||
from zhenxun.utils.message import MessageUtils
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="消息统计",
|
||||
@@ -123,64 +124,63 @@ async def _(
|
||||
if rank_data := await ChatHistory.get_group_msg_rank(
|
||||
group_id, fetch_count, "DES" if arparma.find("des") else "DESC", date_scope
|
||||
):
|
||||
idx = 1
|
||||
data_list = []
|
||||
rows_data = []
|
||||
platform = "qq"
|
||||
|
||||
for uid, num in rank_data:
|
||||
if len(data_list) >= count.result:
|
||||
user_ids_in_rank = [str(uid) for uid, _ in rank_data]
|
||||
users_in_group_query = GroupInfoUser.filter(
|
||||
user_id__in=user_ids_in_rank, group_id=group_id
|
||||
)
|
||||
users_in_group = {u.user_id: u for u in await users_in_group_query}
|
||||
|
||||
for idx, (uid, num) in enumerate(rank_data):
|
||||
if len(rows_data) >= count.result:
|
||||
break
|
||||
|
||||
user_in_group = await GroupInfoUser.filter(
|
||||
user_id=uid, group_id=group_id
|
||||
).first()
|
||||
uid_str = str(uid)
|
||||
user_in_group = users_in_group.get(uid_str)
|
||||
|
||||
if not user_in_group and not show_quit_member:
|
||||
continue
|
||||
|
||||
if user_in_group:
|
||||
user_name = user_in_group.user_name
|
||||
else:
|
||||
user_name = f"{uid}(已退群)"
|
||||
user_name = (
|
||||
user_in_group.user_name if user_in_group else f"{uid_str}(已退群)"
|
||||
)
|
||||
|
||||
avatar_size = 40
|
||||
try:
|
||||
avatar_bytes = await PlatformUtils.get_user_avatar(str(uid), "qq")
|
||||
if avatar_bytes:
|
||||
avatar_img = BuildImage(
|
||||
avatar_size, avatar_size, background=BytesIO(avatar_bytes)
|
||||
)
|
||||
await avatar_img.circle()
|
||||
avatar_tuple = (avatar_img, avatar_size, avatar_size)
|
||||
else:
|
||||
avatar_img = BuildImage(avatar_size, avatar_size, color="#CCCCCC")
|
||||
await avatar_img.circle()
|
||||
avatar_tuple = (avatar_img, avatar_size, avatar_size)
|
||||
except Exception as e:
|
||||
logger.warning(f"获取用户头像失败: {e}", "chat_history")
|
||||
avatar_img = BuildImage(avatar_size, avatar_size, color="#CCCCCC")
|
||||
await avatar_img.circle()
|
||||
avatar_tuple = (avatar_img, avatar_size, avatar_size)
|
||||
avatar_path = await avatar_service.get_avatar_path(platform, uid_str)
|
||||
|
||||
data_list.append([idx, avatar_tuple, user_name, num])
|
||||
idx += 1
|
||||
rows_data.append(
|
||||
[
|
||||
TextCell(content=str(len(rows_data) + 1)),
|
||||
ImageCell(
|
||||
src=avatar_path.as_uri() if avatar_path else "", shape="circle"
|
||||
),
|
||||
TextCell(content=user_name),
|
||||
TextCell(content=str(num), bold=True),
|
||||
]
|
||||
)
|
||||
if not date_scope:
|
||||
if date_scope := await ChatHistory.get_group_first_msg_datetime(group_id):
|
||||
date_scope = date_scope.astimezone(
|
||||
first_msg_time = await ChatHistory.get_group_first_msg_datetime(group_id)
|
||||
if first_msg_time:
|
||||
date_scope_start = first_msg_time.astimezone(
|
||||
pytz.timezone("Asia/Shanghai")
|
||||
).replace(microsecond=0)
|
||||
date_str = f"{str(date_scope_start).split('+')[0]} - 至今"
|
||||
else:
|
||||
date_scope = time_now.replace(microsecond=0)
|
||||
date_str = f"{str(date_scope).split('+')[0]} - 至今"
|
||||
date_str = f"{time_now.replace(microsecond=0)} - 至今"
|
||||
else:
|
||||
date_str = (
|
||||
f"{date_scope[0].replace(microsecond=0)} - "
|
||||
f"{date_scope[1].replace(microsecond=0)}"
|
||||
)
|
||||
A = await ImageTemplate.table_page(
|
||||
f"消息排行({count.result})", date_str, column_name, data_list
|
||||
)
|
||||
|
||||
builder = TableBuilder(f"消息排行({count.result})", date_str)
|
||||
builder.set_headers(column_name).add_rows(rows_data)
|
||||
|
||||
image_bytes = await ui.render(builder.build())
|
||||
|
||||
logger.info(
|
||||
f"查看消息排行 数量={count.result}", arparma.header_result, session=session
|
||||
)
|
||||
await MessageUtils.build_message(A).finish(reply_to=True)
|
||||
await MessageUtils.build_message(image_bytes).finish(reply_to=True)
|
||||
await MessageUtils.build_message("群组消息记录为空...").finish()
|
||||
|
||||
@@ -4,10 +4,9 @@ from nonebot.permission import SUPERUSER
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot.rule import Rule, to_me
|
||||
from nonebot_plugin_alconna import Alconna, on_alconna
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.path_config import TEMPLATE_PATH
|
||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.enum import PluginType
|
||||
@@ -67,18 +66,14 @@ _self_check_poke_matcher = on_notice(
|
||||
|
||||
async def handle_self_check():
|
||||
try:
|
||||
data = await get_status_info()
|
||||
image = await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "check").absolute()),
|
||||
template_name="main.html",
|
||||
templates={"data": data},
|
||||
pages={
|
||||
"viewport": {"width": 195, "height": 750},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
data_dict = await get_status_info()
|
||||
|
||||
image_bytes = await ui.render_template(
|
||||
"pages/builtin/check",
|
||||
data=data_dict,
|
||||
)
|
||||
await MessageUtils.build_message(image).send()
|
||||
|
||||
await MessageUtils.build_message(image_bytes).send()
|
||||
logger.info("自检成功", "自检")
|
||||
except Exception as e:
|
||||
await MessageUtils.build_message(f"自检失败: {e}").send()
|
||||
|
||||
@@ -13,11 +13,6 @@ from nonebot_plugin_alconna import (
|
||||
)
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
|
||||
from zhenxun.builtin_plugins.help._config import (
|
||||
GROUP_HELP_PATH,
|
||||
SIMPLE_DETAIL_HELP_IMAGE,
|
||||
SIMPLE_HELP_IMAGE,
|
||||
)
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||
from zhenxun.services.log import logger
|
||||
@@ -36,18 +31,6 @@ __plugin_meta__ = PluginMetadata(
|
||||
plugin_type=PluginType.DEPENDANT,
|
||||
is_show=False,
|
||||
configs=[
|
||||
RegisterConfig(
|
||||
key="type",
|
||||
value="zhenxun",
|
||||
help="帮助图片样式 [normal, HTML, zhenxun]",
|
||||
default_value="zhenxun",
|
||||
),
|
||||
RegisterConfig(
|
||||
key="detail_type",
|
||||
value="zhenxun",
|
||||
help="帮助详情图片样式 ['normal', 'zhenxun']",
|
||||
default_value="zhenxun",
|
||||
),
|
||||
RegisterConfig(
|
||||
key="ENABLE_LLM_HELPER",
|
||||
value=False,
|
||||
@@ -76,6 +59,13 @@ __plugin_meta__ = PluginMetadata(
|
||||
default_value=100,
|
||||
type=int,
|
||||
),
|
||||
RegisterConfig(
|
||||
key="HELP_STYLE",
|
||||
value="default",
|
||||
help="帮助页面的显示样式 (可选值: 'default', 'simple')",
|
||||
default_value="default",
|
||||
type=str,
|
||||
),
|
||||
],
|
||||
).to_dict(),
|
||||
)
|
||||
@@ -88,12 +78,18 @@ _matcher = on_alconna(
|
||||
Option("-s|--superuser", action=store_true, help_text="超级用户帮助"),
|
||||
Option("-d|--detail", action=store_true, help_text="详细帮助"),
|
||||
),
|
||||
aliases={"help", "帮助", "菜单"},
|
||||
aliases={"help", "菜单"},
|
||||
rule=to_me(),
|
||||
priority=1,
|
||||
block=True,
|
||||
)
|
||||
|
||||
_matcher.shortcut(
|
||||
r"帮助(?P<name>.*?)",
|
||||
command="功能",
|
||||
arguments=["{name}"],
|
||||
prefix=True,
|
||||
)
|
||||
|
||||
_matcher.shortcut(
|
||||
r"详细帮助",
|
||||
@@ -119,8 +115,11 @@ async def _(
|
||||
)
|
||||
|
||||
if name.available:
|
||||
help_style = Config.get_config("help", "HELP_STYLE")
|
||||
variant = help_style if help_style != "default" else None
|
||||
|
||||
traditional_help_result = await get_plugin_help(
|
||||
session.user.id, name.result, _is_superuser
|
||||
session.user.id, name.result, _is_superuser, variant=variant
|
||||
)
|
||||
|
||||
is_plugin_found = not (
|
||||
@@ -144,15 +143,8 @@ async def _(
|
||||
f"查看帮助详情失败,未找到: {name.result}", "帮助", session=session
|
||||
)
|
||||
elif session.group and (gid := session.group.id):
|
||||
_image_path = GROUP_HELP_PATH / f"{gid}_{is_detail.result}.png"
|
||||
if not _image_path.exists():
|
||||
await create_help_img(session, gid, is_detail.result)
|
||||
await MessageUtils.build_message(_image_path).finish()
|
||||
image_bytes = await create_help_img(session, gid, is_detail.result)
|
||||
await MessageUtils.build_message(image_bytes).finish()
|
||||
else:
|
||||
if is_detail.result:
|
||||
_image_path = SIMPLE_DETAIL_HELP_IMAGE
|
||||
else:
|
||||
_image_path = SIMPLE_HELP_IMAGE
|
||||
if not _image_path.exists():
|
||||
await create_help_img(session, None, is_detail.result)
|
||||
await MessageUtils.build_message(_image_path).finish()
|
||||
image_bytes = await create_help_img(session, None, is_detail.result)
|
||||
await MessageUtils.build_message(image_bytes).finish()
|
||||
|
||||
@@ -1,74 +1,126 @@
|
||||
from pathlib import Path
|
||||
|
||||
import nonebot
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH
|
||||
from zhenxun import ui
|
||||
from zhenxun.configs.config import BotConfig, Config
|
||||
from zhenxun.configs.path_config import IMAGE_PATH
|
||||
from zhenxun.configs.utils import PluginExtraData
|
||||
from zhenxun.models.bot_console import BotConsole
|
||||
from zhenxun.models.group_console import GroupConsole
|
||||
from zhenxun.models.level_user import LevelUser
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.models.statistics import Statistics
|
||||
from zhenxun.services import (
|
||||
LLMException,
|
||||
LLMMessage,
|
||||
avatar_service,
|
||||
generate,
|
||||
)
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils._image_template import Markdown
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
|
||||
|
||||
from ._config import (
|
||||
GROUP_HELP_PATH,
|
||||
SIMPLE_DETAIL_HELP_IMAGE,
|
||||
SIMPLE_HELP_IMAGE,
|
||||
base_config,
|
||||
from zhenxun.ui.builders import (
|
||||
NotebookBuilder,
|
||||
PluginMenuBuilder,
|
||||
)
|
||||
from .html_help import build_html_image
|
||||
from .normal_help import build_normal_image
|
||||
from .zhenxun_help import build_zhenxun_image
|
||||
from zhenxun.ui.models import PluginMenuCategory
|
||||
from zhenxun.utils.common_utils import format_usage_for_markdown
|
||||
from zhenxun.utils.enum import BlockType, PluginType
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
from ._utils import classify_plugin
|
||||
|
||||
random_bk_path = IMAGE_PATH / "background" / "help" / "simple_help"
|
||||
|
||||
background = IMAGE_PATH / "background" / "0.png"
|
||||
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
|
||||
def _create_plugin_menu_item(
|
||||
bot: BotConsole | None,
|
||||
plugin: PluginInfo,
|
||||
group: GroupConsole | None,
|
||||
is_detail: bool,
|
||||
) -> dict:
|
||||
"""为插件菜单构造一个插件菜单项数据字典"""
|
||||
status = True
|
||||
has_superuser_help = False
|
||||
nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
|
||||
if nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra:
|
||||
extra_data = PluginExtraData(**nb_plugin.metadata.extra)
|
||||
if extra_data.superuser_help:
|
||||
has_superuser_help = True
|
||||
|
||||
if not plugin.status:
|
||||
if plugin.block_type == BlockType.ALL:
|
||||
status = False
|
||||
elif group and plugin.block_type == BlockType.GROUP:
|
||||
status = False
|
||||
elif not group and plugin.block_type == BlockType.PRIVATE:
|
||||
status = False
|
||||
elif group and f"{plugin.module}," in group.block_plugin:
|
||||
status = False
|
||||
elif bot and f"{plugin.module}," in bot.block_plugins:
|
||||
status = False
|
||||
|
||||
commands = []
|
||||
if is_detail and nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra:
|
||||
extra_data = PluginExtraData(**nb_plugin.metadata.extra)
|
||||
commands = [cmd.command for cmd in extra_data.commands]
|
||||
|
||||
return {
|
||||
"id": str(plugin.id),
|
||||
"name": plugin.name,
|
||||
"status": status,
|
||||
"has_superuser_help": has_superuser_help,
|
||||
"commands": commands,
|
||||
}
|
||||
|
||||
|
||||
async def create_help_img(
|
||||
session: Uninfo, group_id: str | None, is_detail: bool
|
||||
) -> Path:
|
||||
"""生成帮助图片
|
||||
) -> bytes:
|
||||
"""使用渲染服务生成帮助图片"""
|
||||
classified_data = await classify_plugin(
|
||||
session, group_id, is_detail, _create_plugin_menu_item
|
||||
)
|
||||
|
||||
参数:
|
||||
session: Uninfo
|
||||
group_id: 群号
|
||||
"""
|
||||
help_type = base_config.get("type", "").strip().lower()
|
||||
sorted_categories = dict(
|
||||
sorted(classified_data.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
)
|
||||
categories_for_model = []
|
||||
plugin_count = 0
|
||||
active_count = 0
|
||||
|
||||
match help_type:
|
||||
case "html":
|
||||
result = BuildImage.open(
|
||||
await build_html_image(session, group_id, is_detail)
|
||||
)
|
||||
case "zhenxun":
|
||||
result = BuildImage.open(
|
||||
await build_zhenxun_image(session, group_id, is_detail)
|
||||
)
|
||||
case _:
|
||||
result = await build_normal_image(group_id, is_detail)
|
||||
if group_id:
|
||||
save_path = GROUP_HELP_PATH / f"{group_id}_{is_detail}.png"
|
||||
elif is_detail:
|
||||
save_path = SIMPLE_DETAIL_HELP_IMAGE
|
||||
else:
|
||||
save_path = SIMPLE_HELP_IMAGE
|
||||
await result.save(save_path)
|
||||
return save_path
|
||||
if sorted_categories:
|
||||
menu_key = next(iter(sorted_categories.keys()))
|
||||
max_data = sorted_categories.pop(menu_key)
|
||||
main_category_name = "主要功能" if menu_key in ["normal", "功能"] else menu_key
|
||||
categories_for_model.append({"name": main_category_name, "items": max_data})
|
||||
plugin_count += len(max_data)
|
||||
active_count += sum(1 for item in max_data if item["status"])
|
||||
|
||||
for menu, value in sorted_categories.items():
|
||||
category_name = "主要功能" if menu in ["normal", "功能"] else menu
|
||||
categories_for_model.append({"name": category_name, "items": value})
|
||||
plugin_count += len(value)
|
||||
active_count += sum(1 for item in value if item["status"])
|
||||
|
||||
platform = PlatformUtils.get_platform(session)
|
||||
bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id
|
||||
bot_avatar_path = await avatar_service.get_avatar_path(platform, bot_id)
|
||||
bot_avatar_url = bot_avatar_path.as_uri() if bot_avatar_path else ""
|
||||
|
||||
builder = PluginMenuBuilder(
|
||||
bot_name=BotConfig.self_nickname,
|
||||
bot_avatar_url=bot_avatar_url,
|
||||
is_detail=is_detail,
|
||||
)
|
||||
|
||||
for category in categories_for_model:
|
||||
builder.add_category(
|
||||
PluginMenuCategory(name=category["name"], items=category["items"])
|
||||
)
|
||||
|
||||
return await ui.render(builder.build())
|
||||
|
||||
|
||||
async def get_user_allow_help(user_id: str) -> list[PluginType]:
|
||||
@@ -92,36 +144,6 @@ async def get_user_allow_help(user_id: str) -> list[PluginType]:
|
||||
return type_list
|
||||
|
||||
|
||||
async def get_normal_help(
|
||||
metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool
|
||||
) -> str | bytes:
|
||||
"""构建默认帮助详情
|
||||
|
||||
参数:
|
||||
metadata: PluginMetadata
|
||||
extra: PluginExtraData
|
||||
is_superuser: 是否超级用户帮助
|
||||
|
||||
返回:
|
||||
str | bytes: 返回信息
|
||||
"""
|
||||
items = None
|
||||
if is_superuser:
|
||||
if usage := extra.superuser_help:
|
||||
items = {
|
||||
"简介": metadata.description,
|
||||
"用法": usage,
|
||||
}
|
||||
else:
|
||||
items = {
|
||||
"简介": metadata.description,
|
||||
"用法": metadata.usage,
|
||||
}
|
||||
if items:
|
||||
return (await ImageTemplate.hl_page(metadata.name, items)).pic2bytes()
|
||||
return "该功能没有帮助信息"
|
||||
|
||||
|
||||
def min_leading_spaces(str_list: list[str]) -> int:
|
||||
min_spaces = 9999
|
||||
|
||||
@@ -142,52 +164,16 @@ def split_text(text: str):
|
||||
return [s.replace(" ", " ") for s in split_text]
|
||||
|
||||
|
||||
async def get_zhenxun_help(
|
||||
module: str, metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool
|
||||
async def get_plugin_help(
|
||||
user_id: str, name: str, is_superuser: bool, variant: str | None = None
|
||||
) -> str | bytes:
|
||||
"""构建ZhenXun帮助详情
|
||||
|
||||
参数:
|
||||
module: 模块名
|
||||
metadata: PluginMetadata
|
||||
extra: PluginExtraData
|
||||
is_superuser: 是否超级用户帮助
|
||||
|
||||
返回:
|
||||
str | bytes: 返回信息
|
||||
"""
|
||||
call_count = await Statistics.filter(plugin_name=module).count()
|
||||
usage = metadata.usage
|
||||
if is_superuser:
|
||||
if not extra.superuser_help:
|
||||
return "该功能没有超级用户帮助信息"
|
||||
usage = extra.superuser_help
|
||||
return await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "help_detail").absolute()),
|
||||
template_name="main.html",
|
||||
templates={
|
||||
"title": metadata.name,
|
||||
"author": extra.author,
|
||||
"version": extra.version,
|
||||
"call_count": call_count,
|
||||
"descriptions": split_text(metadata.description),
|
||||
"usages": split_text(usage),
|
||||
},
|
||||
pages={
|
||||
"viewport": {"width": 824, "height": 590},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
|
||||
|
||||
async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str | bytes:
|
||||
"""获取功能的帮助信息
|
||||
|
||||
参数:
|
||||
user_id: 用户id
|
||||
name: 插件名称或id
|
||||
is_superuser: 是否为超级用户
|
||||
variant: 使用的皮肤/变体名称
|
||||
"""
|
||||
type_list = await get_user_allow_help(user_id)
|
||||
if name.isdigit():
|
||||
@@ -196,16 +182,45 @@ async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str |
|
||||
plugin = await PluginInfo.get_or_none(
|
||||
name__iexact=name, load_status=True, plugin_type__in=type_list
|
||||
)
|
||||
|
||||
if plugin:
|
||||
_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
|
||||
if _plugin and _plugin.metadata:
|
||||
extra_data = PluginExtraData(**_plugin.metadata.extra)
|
||||
if Config.get_config("help", "detail_type") == "zhenxun":
|
||||
return await get_zhenxun_help(
|
||||
plugin.module, _plugin.metadata, extra_data, is_superuser
|
||||
)
|
||||
else:
|
||||
return await get_normal_help(_plugin.metadata, extra_data, is_superuser)
|
||||
|
||||
call_count = await Statistics.filter(plugin_name=plugin.module).count()
|
||||
usage = _plugin.metadata.usage
|
||||
if is_superuser:
|
||||
if not extra_data.superuser_help:
|
||||
return "该功能没有超级用户帮助信息"
|
||||
usage = extra_data.superuser_help
|
||||
|
||||
metadata_items = [
|
||||
{"label": "作者", "value": extra_data.author or "未知"},
|
||||
{"label": "版本", "value": extra_data.version or "未知"},
|
||||
{"label": "调用次数", "value": call_count},
|
||||
]
|
||||
|
||||
processed_description = format_usage_for_markdown(
|
||||
_plugin.metadata.description.strip()
|
||||
)
|
||||
processed_usage = format_usage_for_markdown(usage.strip())
|
||||
|
||||
sections = [
|
||||
{"title": "简介", "content": [processed_description]},
|
||||
{"title": "使用方法", "content": [processed_usage]},
|
||||
]
|
||||
|
||||
page_data = {
|
||||
"title": _plugin.metadata.name,
|
||||
"metadata": metadata_items,
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
component = ui.template("pages/builtin/help", data=page_data)
|
||||
if variant:
|
||||
component.variant = variant
|
||||
return await ui.render(component, use_cache=True, device_scale_factor=2)
|
||||
return "糟糕! 该功能没有帮助喔..."
|
||||
return "没有查找到这个功能噢..."
|
||||
|
||||
@@ -282,10 +297,12 @@ async def get_llm_help(question: str, user_id: str) -> str | bytes:
|
||||
|
||||
reply_text = response.text if response else "抱歉,我暂时无法回答这个问题。"
|
||||
threshold = Config.get_config("help", "LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD", 50)
|
||||
|
||||
if len(reply_text) > threshold:
|
||||
markdown = Markdown()
|
||||
markdown.text(reply_text)
|
||||
return await markdown.build()
|
||||
builder = NotebookBuilder()
|
||||
builder.text(reply_text)
|
||||
return await ui.render(builder.build())
|
||||
|
||||
return reply_text
|
||||
|
||||
except LLMException as e:
|
||||
|
||||
@@ -53,5 +53,5 @@ async def classify_plugin(
|
||||
classify[menu] = []
|
||||
classify[menu].append(handle(bot, plugin, group, is_detail))
|
||||
for value in classify.values():
|
||||
value.sort(key=lambda x: x.id)
|
||||
value.sort(key=lambda x: int(x["id"]))
|
||||
return classify
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import os
|
||||
import random
|
||||
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
from pydantic import BaseModel
|
||||
|
||||
from zhenxun.configs.path_config import TEMPLATE_PATH
|
||||
from zhenxun.models.bot_console import BotConsole
|
||||
from zhenxun.models.group_console import GroupConsole
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.utils.enum import BlockType
|
||||
|
||||
from ._utils import classify_plugin
|
||||
|
||||
LOGO_PATH = TEMPLATE_PATH / "menu" / "res" / "logo"
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
plugin_name: str
|
||||
"""插件名称"""
|
||||
sta: int
|
||||
"""插件状态"""
|
||||
id: int
|
||||
"""插件id"""
|
||||
|
||||
|
||||
class PluginList(BaseModel):
|
||||
plugin_type: str
|
||||
"""菜单名称"""
|
||||
icon: str
|
||||
"""图标"""
|
||||
logo: str
|
||||
"""logo"""
|
||||
items: list[Item]
|
||||
"""插件列表"""
|
||||
|
||||
|
||||
ICON2STR = {
|
||||
"normal": "fa fa-cog",
|
||||
"原神相关": "fa fa-circle-o",
|
||||
"常规插件": "fa fa-cubes",
|
||||
"联系管理员": "fa fa-envelope-o",
|
||||
"抽卡相关": "fa fa-credit-card-alt",
|
||||
"来点好康的": "fa fa-picture-o",
|
||||
"数据统计": "fa fa-bar-chart",
|
||||
"一些工具": "fa fa-shopping-cart",
|
||||
"商店": "fa fa-shopping-cart",
|
||||
"其它": "fa fa-tags",
|
||||
"群内小游戏": "fa fa-gamepad",
|
||||
}
|
||||
|
||||
|
||||
def __handle_item(
|
||||
bot: BotConsole, plugin: PluginInfo, group: GroupConsole | None, is_detail: bool
|
||||
) -> Item:
|
||||
"""构造Item
|
||||
|
||||
参数:
|
||||
bot: BotConsole
|
||||
plugin: PluginInfo
|
||||
group: 群组
|
||||
is_detail: 是否详细
|
||||
|
||||
返回:
|
||||
Item: Item
|
||||
"""
|
||||
sta = 0
|
||||
if not plugin.status:
|
||||
if group and plugin.block_type in [
|
||||
BlockType.ALL,
|
||||
BlockType.GROUP,
|
||||
]:
|
||||
sta = 2
|
||||
if not group and plugin.block_type in [
|
||||
BlockType.ALL,
|
||||
BlockType.PRIVATE,
|
||||
]:
|
||||
sta = 2
|
||||
if group:
|
||||
if f"{plugin.module}," in group.superuser_block_plugin:
|
||||
sta = 2
|
||||
if f"{plugin.module}," in group.block_plugin:
|
||||
sta = 1
|
||||
if bot and f"{plugin.module}," in bot.block_plugins:
|
||||
sta = 2
|
||||
return Item(plugin_name=plugin.name, sta=sta, id=plugin.id)
|
||||
|
||||
|
||||
def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]:
|
||||
"""构建前端插件数据
|
||||
|
||||
参数:
|
||||
classify: 插件数据
|
||||
|
||||
返回:
|
||||
list[dict[str, str]]: 前端插件数据
|
||||
"""
|
||||
lengths = [len(classify[c]) for c in classify]
|
||||
index = lengths.index(max(lengths))
|
||||
menu_key = list(classify.keys())[index]
|
||||
max_data = classify[menu_key]
|
||||
del classify[menu_key]
|
||||
plugin_list = []
|
||||
for menu_type in classify:
|
||||
icon = "fa fa-pencil-square-o"
|
||||
if menu_type in ICON2STR.keys():
|
||||
icon = ICON2STR[menu_type]
|
||||
logo = LOGO_PATH / random.choice(os.listdir(LOGO_PATH))
|
||||
data = {
|
||||
"name": menu_type if menu_type != "normal" else "功能",
|
||||
"items": classify[menu_type],
|
||||
"icon": icon,
|
||||
"logo": str(logo.absolute()),
|
||||
}
|
||||
plugin_list.append(data)
|
||||
plugin_list.insert(
|
||||
0,
|
||||
{
|
||||
"name": menu_key if menu_key != "normal" else "功能",
|
||||
"items": max_data,
|
||||
"icon": "fa fa-pencil-square-o",
|
||||
"logo": str((LOGO_PATH / random.choice(os.listdir(LOGO_PATH))).absolute()),
|
||||
},
|
||||
)
|
||||
return plugin_list
|
||||
|
||||
|
||||
async def build_html_image(
|
||||
session: Uninfo, group_id: str | None, is_detail: bool
|
||||
) -> bytes:
|
||||
"""构造HTML帮助图片
|
||||
|
||||
参数:
|
||||
session: Uninfo
|
||||
group_id: 群号
|
||||
is_detail: 是否详细帮助
|
||||
"""
|
||||
classify = await classify_plugin(session, group_id, is_detail, __handle_item)
|
||||
plugin_list = build_plugin_data(classify)
|
||||
return await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "menu").absolute()),
|
||||
template_name="zhenxun_menu.html",
|
||||
templates={"plugin_list": plugin_list},
|
||||
pages={
|
||||
"viewport": {"width": 1903, "height": 10},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
@@ -1,100 +0,0 @@
|
||||
from zhenxun.configs.path_config import IMAGE_PATH
|
||||
from zhenxun.models.group_console import GroupConsole
|
||||
from zhenxun.utils._build_image import BuildImage
|
||||
from zhenxun.utils.enum import BlockType
|
||||
from zhenxun.utils.image_utils import build_sort_image, group_image
|
||||
|
||||
from ._utils import sort_type
|
||||
|
||||
BACKGROUND_PATH = IMAGE_PATH / "background" / "help" / "simple_help"
|
||||
|
||||
|
||||
async def build_normal_image(group_id: str | None, is_detail: bool) -> BuildImage:
|
||||
"""构造PIL帮助图片
|
||||
|
||||
参数:
|
||||
group_id: 群号
|
||||
is_detail: 详细帮助
|
||||
"""
|
||||
image_list = []
|
||||
font_size = 24
|
||||
font = BuildImage.load_font("HYWenHei-85W.ttf", 20)
|
||||
sort_data = await sort_type()
|
||||
for idx, menu_type in enumerate(sort_data):
|
||||
plugin_list = sort_data[menu_type]
|
||||
"""拿到最大宽度和结算高度"""
|
||||
wh_list = [
|
||||
BuildImage.get_text_size(f"{x.id}.{x.name}", font) for x in plugin_list
|
||||
]
|
||||
wh_list.append(BuildImage.get_text_size(menu_type, font))
|
||||
sum_height = (font_size + 6) * len(plugin_list) + 10
|
||||
max_width = max(x[0] for x in wh_list) + 30
|
||||
bk = BuildImage(
|
||||
max_width + 40,
|
||||
sum_height + 50,
|
||||
font_size=30,
|
||||
color="#a7d1fc",
|
||||
font="CJGaoDeGuo.otf",
|
||||
)
|
||||
title_size = bk.getsize(menu_type)
|
||||
max_width = max_width if max_width > title_size[0] else title_size[0]
|
||||
row = BuildImage(
|
||||
max_width + 40,
|
||||
sum_height,
|
||||
font_size=font_size,
|
||||
color="black" if idx % 2 else "white",
|
||||
)
|
||||
curr_h = 10
|
||||
group = await GroupConsole.get_group(group_id=group_id) if group_id else None
|
||||
for _, plugin in enumerate(plugin_list):
|
||||
text_color = (255, 255, 255) if idx % 2 else (0, 0, 0)
|
||||
if group and f"{plugin.module}," in group.block_plugin:
|
||||
text_color = (252, 75, 13)
|
||||
pos = None
|
||||
# 禁用状态划线
|
||||
if plugin.block_type in [BlockType.ALL, BlockType.GROUP] or (
|
||||
group and f"super:{plugin.module}," in group.block_plugin
|
||||
):
|
||||
w = curr_h + int(row.getsize(plugin.name)[1] / 2) + 2
|
||||
line_width = row.getsize(plugin.name)[0] + 35
|
||||
pos = (7, w, line_width, w)
|
||||
await row.text((10, curr_h), f"{plugin.id}.{plugin.name}", text_color)
|
||||
if pos:
|
||||
await row.line(pos, (236, 66, 7), 3)
|
||||
curr_h += font_size + 5
|
||||
await bk.text((0, 14), menu_type, center_type="width")
|
||||
await bk.paste(row, (0, 50))
|
||||
await bk.transparent(2)
|
||||
image_list.append(bk)
|
||||
image_group, h = group_image(image_list)
|
||||
|
||||
async def _a(image: BuildImage):
|
||||
await image.filter("GaussianBlur", 5)
|
||||
|
||||
result = await build_sort_image(
|
||||
image_group,
|
||||
h,
|
||||
background_path=BACKGROUND_PATH,
|
||||
background_handle=_a,
|
||||
)
|
||||
width, height = 10, 10
|
||||
for s in [
|
||||
"目前支持的功能列表:",
|
||||
"可以通过 '帮助 [功能名称或功能Id]' 来获取对应功能的使用方法",
|
||||
]:
|
||||
text = await BuildImage.build_text_image(s, "HYWenHei-85W.ttf", 24)
|
||||
await result.paste(text, (width, height))
|
||||
height += 50
|
||||
if s == "目前支持的功能列表:":
|
||||
width += 50
|
||||
text = await BuildImage.build_text_image(
|
||||
"注: 红字代表功能被群管理员禁用,红线代表功能正在维护",
|
||||
"HYWenHei-85W.ttf",
|
||||
24,
|
||||
(231, 74, 57),
|
||||
)
|
||||
await result.paste(
|
||||
text,
|
||||
(300, 10),
|
||||
)
|
||||
return result
|
||||
@@ -1,143 +0,0 @@
|
||||
import nonebot
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
from pydantic import BaseModel
|
||||
|
||||
from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.configs.path_config import TEMPLATE_PATH
|
||||
from zhenxun.configs.utils import PluginExtraData
|
||||
from zhenxun.models.bot_console import BotConsole
|
||||
from zhenxun.models.group_console import GroupConsole
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.utils.enum import BlockType
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
from ._utils import classify_plugin
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
plugin_name: str
|
||||
"""插件名称"""
|
||||
commands: list[str]
|
||||
"""插件命令"""
|
||||
id: str
|
||||
"""插件id"""
|
||||
status: bool
|
||||
"""插件状态"""
|
||||
has_superuser_help: bool
|
||||
"""插件是否拥有超级用户帮助"""
|
||||
|
||||
|
||||
def __handle_item(
|
||||
bot: BotConsole | None,
|
||||
plugin: PluginInfo,
|
||||
group: GroupConsole | None,
|
||||
is_detail: bool,
|
||||
):
|
||||
"""构造Item
|
||||
|
||||
参数:
|
||||
bot: BotConsole
|
||||
plugin: PluginInfo
|
||||
group: 群组
|
||||
is_detail: 是否为详细
|
||||
|
||||
返回:
|
||||
Item: Item
|
||||
"""
|
||||
status = True
|
||||
has_superuser_help = False
|
||||
nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
|
||||
if nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra:
|
||||
extra_data = PluginExtraData(**nb_plugin.metadata.extra)
|
||||
if extra_data.superuser_help:
|
||||
has_superuser_help = True
|
||||
if not plugin.status:
|
||||
if plugin.block_type == BlockType.ALL:
|
||||
status = False
|
||||
elif group and plugin.block_type == BlockType.GROUP:
|
||||
status = False
|
||||
elif not group and plugin.block_type == BlockType.PRIVATE:
|
||||
status = False
|
||||
elif group and f"{plugin.module}," in group.block_plugin:
|
||||
status = False
|
||||
elif bot and f"{plugin.module}," in bot.block_plugins:
|
||||
status = False
|
||||
commands = []
|
||||
nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
|
||||
if is_detail and nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra:
|
||||
extra_data = PluginExtraData(**nb_plugin.metadata.extra)
|
||||
commands = [cmd.command for cmd in extra_data.commands]
|
||||
return Item(
|
||||
plugin_name=plugin.name,
|
||||
commands=commands,
|
||||
id=str(plugin.id),
|
||||
status=status,
|
||||
has_superuser_help=has_superuser_help,
|
||||
)
|
||||
|
||||
|
||||
def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]:
|
||||
"""构建前端插件数据
|
||||
|
||||
参数:
|
||||
classify: 插件数据
|
||||
|
||||
返回:
|
||||
list[dict[str, str]]: 前端插件数据
|
||||
"""
|
||||
classify = dict(sorted(classify.items(), key=lambda x: len(x[1]), reverse=True))
|
||||
menu_key = next(iter(classify.keys()))
|
||||
max_data = classify[menu_key]
|
||||
del classify[menu_key]
|
||||
plugin_list = [
|
||||
{
|
||||
"name": "主要功能" if menu in ["normal", "功能"] else menu,
|
||||
"items": value,
|
||||
}
|
||||
for menu, value in classify.items()
|
||||
]
|
||||
plugin_list.insert(0, {"name": menu_key, "items": max_data})
|
||||
for plugin in plugin_list:
|
||||
plugin["items"].sort(key=lambda x: x.id)
|
||||
return plugin_list
|
||||
|
||||
|
||||
async def build_zhenxun_image(
|
||||
session: Uninfo, group_id: str | None, is_detail: bool
|
||||
) -> bytes:
|
||||
"""构造真寻帮助图片
|
||||
|
||||
参数:
|
||||
bot_id: bot_id
|
||||
group_id: 群号
|
||||
is_detail: 是否详细帮助
|
||||
"""
|
||||
classify = await classify_plugin(session, group_id, is_detail, __handle_item)
|
||||
plugin_list = build_plugin_data(classify)
|
||||
platform = PlatformUtils.get_platform(session)
|
||||
bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id
|
||||
bot_ava = PlatformUtils.get_user_avatar_url(bot_id, platform)
|
||||
width = int(637 * 1.5) if is_detail else 637
|
||||
title_font = int(53 * 1.5) if is_detail else 53
|
||||
tip_font = int(19 * 1.5) if is_detail else 19
|
||||
plugin_count = sum(len(plugin["items"]) for plugin in plugin_list)
|
||||
return await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "ss_menu").absolute()),
|
||||
template_name="main.html",
|
||||
templates={
|
||||
"data": {
|
||||
"plugin_list": plugin_list,
|
||||
"ava": bot_ava,
|
||||
"width": width,
|
||||
"font_size": (title_font, tip_font),
|
||||
"is_detail": is_detail,
|
||||
"plugin_count": plugin_count,
|
||||
}
|
||||
},
|
||||
pages={
|
||||
"viewport": {"width": width, "height": 10},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from nonebot.adapters import Bot, Message
|
||||
from nonebot.adapters.onebot.v11 import MessageSegment
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.models.bot_message_store import BotMessageStore
|
||||
@@ -40,6 +41,35 @@ def replace_message(message: Message) -> str:
|
||||
return result
|
||||
|
||||
|
||||
def format_message_for_log(message: Message) -> str:
|
||||
"""
|
||||
将消息对象转换为适合日志记录的字符串,对base64等长内容进行摘要处理。
|
||||
"""
|
||||
if not isinstance(message, Message):
|
||||
return str(message)
|
||||
|
||||
log_parts = []
|
||||
for seg in message:
|
||||
seg: MessageSegment
|
||||
if seg.type == "text":
|
||||
log_parts.append(seg.data.get("text", ""))
|
||||
elif seg.type in ("image", "record", "video"):
|
||||
file_info = seg.data.get("file", "")
|
||||
if isinstance(file_info, str) and file_info.startswith("base64://"):
|
||||
b64_data = file_info[9:]
|
||||
data_size_bytes = (len(b64_data) * 3) / 4 - b64_data.count("=", -2)
|
||||
log_parts.append(
|
||||
f"[{seg.type}: base64, size={data_size_bytes / 1024:.2f}KB]"
|
||||
)
|
||||
else:
|
||||
log_parts.append(f"[{seg.type}]")
|
||||
elif seg.type == "at":
|
||||
log_parts.append(f"[@{seg.data.get('qq', 'unknown')}]")
|
||||
else:
|
||||
log_parts.append(f"[{seg.type}]")
|
||||
return "".join(log_parts)
|
||||
|
||||
|
||||
@Bot.on_called_api
|
||||
async def handle_api_result(
|
||||
bot: Bot, exception: Exception | None, api: str, data: dict[str, Any], result: Any
|
||||
@@ -78,7 +108,7 @@ async def handle_api_result(
|
||||
else replace_message(message),
|
||||
platform=PlatformUtils.get_platform(bot),
|
||||
)
|
||||
logger.debug(f"消息发送记录,message: {message}")
|
||||
logger.debug(f"消息发送记录,message: {format_message_for_log(message)}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"消息发送记录发生错误...data: {data}, result: {result}",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
from tortoise.expressions import RawSQL
|
||||
from tortoise.functions import Count
|
||||
|
||||
from zhenxun.configs.path_config import TEMPLATE_PATH
|
||||
from zhenxun import ui
|
||||
from zhenxun.models.chat_history import ChatHistory
|
||||
from zhenxun.models.level_user import LevelUser
|
||||
from zhenxun.models.sign_user import SignUser
|
||||
from zhenxun.models.statistics import Statistics
|
||||
from zhenxun.models.user_console import UserConsole
|
||||
from zhenxun.services import avatar_service
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
RACE = [
|
||||
@@ -90,7 +90,7 @@ def get_level(impression: float) -> int:
|
||||
|
||||
async def get_chat_history(
|
||||
user_id: str, group_id: str | None
|
||||
) -> tuple[list[str], list[str]]:
|
||||
) -> tuple[list[str], list[int]]:
|
||||
"""获取用户聊天记录
|
||||
|
||||
参数:
|
||||
@@ -98,11 +98,11 @@ async def get_chat_history(
|
||||
group_id: 群id
|
||||
|
||||
返回:
|
||||
tuple[list[str], list[str]]: 日期列表, 次数列表
|
||||
tuple[list[str], list[int]]: 日期列表, 次数列表
|
||||
|
||||
"""
|
||||
now = datetime.now()
|
||||
filter_date = now - timedelta(days=7, hours=now.hour, minutes=now.minute)
|
||||
filter_date = now - timedelta(days=7)
|
||||
date_list = (
|
||||
await ChatHistory.filter(
|
||||
user_id=user_id, group_id=group_id, create_time__gte=filter_date
|
||||
@@ -111,19 +111,15 @@ async def get_chat_history(
|
||||
.group_by("date")
|
||||
.values("date", "count")
|
||||
)
|
||||
chart_date = []
|
||||
count_list = []
|
||||
date2cnt = {str(date["date"]): date["count"] for date in date_list}
|
||||
date = now.date()
|
||||
chart_date: list[str] = []
|
||||
count_list: list[int] = []
|
||||
date2cnt = {str(item["date"]): item["count"] for item in date_list}
|
||||
current_date = now.date()
|
||||
for _ in range(7):
|
||||
if str(date) in date2cnt:
|
||||
count_list.append(date2cnt[str(date)])
|
||||
else:
|
||||
count_list.append(0)
|
||||
chart_date.append(str(date))
|
||||
date -= timedelta(days=1)
|
||||
for c in chart_date:
|
||||
chart_date[chart_date.index(c)] = c[5:]
|
||||
date_str = str(current_date)
|
||||
count_list.append(date2cnt.get(date_str, 0))
|
||||
chart_date.append(date_str[5:])
|
||||
current_date -= timedelta(days=1)
|
||||
chart_date.reverse()
|
||||
count_list.reverse()
|
||||
return chart_date, count_list
|
||||
@@ -136,7 +132,6 @@ async def get_user_info(
|
||||
|
||||
参数:
|
||||
session: Uninfo
|
||||
bot: Bot
|
||||
user_id: 用户id
|
||||
group_id: 群id
|
||||
nickname: 用户昵称
|
||||
@@ -145,50 +140,62 @@ async def get_user_info(
|
||||
bytes: 图片数据
|
||||
"""
|
||||
platform = PlatformUtils.get_platform(session) or "qq"
|
||||
ava_url = PlatformUtils.get_user_avatar_url(user_id, platform, session.self_id)
|
||||
avatar_path = await avatar_service.get_avatar_path(platform, user_id)
|
||||
avatar_url = avatar_path.as_uri() if avatar_path else ""
|
||||
|
||||
user = await UserConsole.get_user(user_id, platform)
|
||||
level = await LevelUser.get_user_level(user_id, group_id)
|
||||
permission_level = await LevelUser.get_user_level(user_id, group_id)
|
||||
|
||||
sign_level = 0
|
||||
if sign_user := await SignUser.get_or_none(user_id=user_id):
|
||||
sign_level = get_level(float(sign_user.impression))
|
||||
|
||||
chat_count = await ChatHistory.filter(user_id=user_id, group_id=group_id).count()
|
||||
stat_count = await Statistics.filter(user_id=user_id, group_id=group_id).count()
|
||||
select_index = ["" for _ in range(9)]
|
||||
select_index[sign_level] = "select"
|
||||
|
||||
selected_indices = [""] * 9
|
||||
selected_indices[sign_level] = "select"
|
||||
|
||||
uid = f"{user.uid}".rjust(8, "0")
|
||||
uid = f"{uid[:4]} {uid[4:]}"
|
||||
uid_formatted = f"{uid[:4]} {uid[4:]}"
|
||||
|
||||
now = datetime.now()
|
||||
weather = "moon" if now.hour < 6 or now.hour > 19 else "sun"
|
||||
chart_date, count_list = await get_chat_history(user_id, group_id)
|
||||
data = {
|
||||
"date": now.date(),
|
||||
"weather": weather,
|
||||
"ava_url": ava_url,
|
||||
"nickname": nickname,
|
||||
"title": "勇 者",
|
||||
"race": random.choice(RACE),
|
||||
"sex": random.choice(SEX),
|
||||
"occ": random.choice(OCC),
|
||||
"uid": uid,
|
||||
"description": "这是一个传奇的故事,"
|
||||
"人类的赞歌是勇气的赞歌,人类的伟大是勇气的伟译。",
|
||||
"sign_level": sign_level,
|
||||
"level": level,
|
||||
"gold": user.gold,
|
||||
"prop": len(user.props),
|
||||
"call": stat_count,
|
||||
"say": chat_count,
|
||||
"select_index": select_index,
|
||||
"chart_date": chart_date,
|
||||
"count_list": count_list,
|
||||
}
|
||||
return await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "my_info").absolute()),
|
||||
template_name="main.html",
|
||||
templates={"data": data},
|
||||
pages={
|
||||
"viewport": {"width": 1754, "height": 1240},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
weather_icon_name = "moon" if now.hour < 6 or now.hour > 19 else "sun"
|
||||
|
||||
chart_labels, chart_data = await get_chat_history(user_id, group_id)
|
||||
|
||||
profile_data = {
|
||||
"page": {
|
||||
"date": str(now.date()),
|
||||
"weather_icon_name": weather_icon_name,
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
"info": {
|
||||
"avatar_url": avatar_url,
|
||||
"nickname": nickname,
|
||||
"title": "勇 者",
|
||||
"race": random.choice(RACE),
|
||||
"sex": random.choice(SEX),
|
||||
"occupation": random.choice(OCC),
|
||||
"uid": uid_formatted,
|
||||
"description": (
|
||||
"这是一个传奇的故事,人类的赞歌是勇气的赞歌,人类的伟大是勇气的伟大"
|
||||
),
|
||||
},
|
||||
"stats": {
|
||||
"gold": user.gold,
|
||||
"prop_count": len(user.props),
|
||||
"call_count": stat_count,
|
||||
"chat_count": chat_count,
|
||||
},
|
||||
"favorability": {
|
||||
"level": sign_level,
|
||||
"selected_indices": selected_indices,
|
||||
},
|
||||
"permission_level": permission_level,
|
||||
"chart": {
|
||||
"labels": chart_labels,
|
||||
"data": chart_data,
|
||||
},
|
||||
}
|
||||
|
||||
return await ui.render_template("pages/builtin/my_info", data=profile_data)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from zhenxun.services import renderer_service
|
||||
from zhenxun.services.llm.core import KeyStatus
|
||||
from zhenxun.services.llm.types import ModelModality
|
||||
from zhenxun.utils._build_image import BuildImage
|
||||
from zhenxun.utils._image_template import ImageTemplate, Markdown, RowStyle
|
||||
from zhenxun.ui.builders import MarkdownBuilder, TableBuilder
|
||||
from zhenxun.ui.models import StatusBadgeCell, TextCell
|
||||
|
||||
|
||||
def _format_seconds(seconds: int) -> str:
|
||||
@@ -27,35 +28,40 @@ class Presenters:
|
||||
@staticmethod
|
||||
async def format_model_list_as_image(
|
||||
models: list[dict[str, Any]], show_all: bool
|
||||
) -> BuildImage:
|
||||
) -> bytes:
|
||||
"""将模型列表格式化为表格图片"""
|
||||
title = "📋 LLM模型列表" + (" (所有已配置模型)" if show_all else " (仅可用)")
|
||||
title = "LLM模型列表" + (" (所有已配置模型)" if show_all else " (仅可用)")
|
||||
|
||||
if not models:
|
||||
return await BuildImage.build_text_image(
|
||||
f"{title}\n\n当前没有配置任何LLM模型。"
|
||||
)
|
||||
builder = TableBuilder(
|
||||
title=title, tip="当前没有配置任何LLM模型。"
|
||||
).set_headers(["提供商", "模型名称", "API类型", "状态"])
|
||||
return await renderer_service.render(builder.build())
|
||||
|
||||
column_name = ["提供商", "模型名称", "API类型", "状态"]
|
||||
data_list = []
|
||||
rows_data = []
|
||||
for model in models:
|
||||
status_text = "✅ 可用" if model.get("is_available", True) else "❌ 不可用"
|
||||
is_available = model.get("is_available", True)
|
||||
embed_tag = " (Embed)" if model.get("is_embedding_model", False) else ""
|
||||
data_list.append(
|
||||
rows_data.append(
|
||||
[
|
||||
model.get("provider_name", "N/A"),
|
||||
f"{model.get('model_name', 'N/A')}{embed_tag}",
|
||||
model.get("api_type", "N/A"),
|
||||
status_text,
|
||||
TextCell(content=model.get("provider_name", "N/A")),
|
||||
TextCell(content=f"{model.get('model_name', 'N/A')}{embed_tag}"),
|
||||
TextCell(content=model.get("api_type", "N/A")),
|
||||
StatusBadgeCell(
|
||||
text="可用" if is_available else "不可用",
|
||||
status_type="ok" if is_available else "error",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
return await ImageTemplate.table_page(
|
||||
head_text=title,
|
||||
tip_text="使用 `llm info <Provider/ModelName>` 查看详情",
|
||||
column_name=column_name,
|
||||
data_list=data_list,
|
||||
builder = TableBuilder(
|
||||
title=title, tip="使用 `llm info <Provider/ModelName>` 查看详情"
|
||||
)
|
||||
builder.set_headers(column_name)
|
||||
builder.set_column_alignments(["left", "left", "left", "center"])
|
||||
builder.add_rows(rows_data)
|
||||
return await renderer_service.render(builder.build(), use_cache=True)
|
||||
|
||||
@staticmethod
|
||||
async def format_model_details_as_markdown_image(details: dict[str, Any]) -> bytes:
|
||||
@@ -76,77 +82,33 @@ class Presenters:
|
||||
if caps.is_embedding_model:
|
||||
cap_list.append("文本嵌入")
|
||||
|
||||
md = Markdown()
|
||||
md.head(f"🔎 模型详情: {provider.name}/{model.model_name}", level=1)
|
||||
md.text("---")
|
||||
md.head("提供商信息", level=2)
|
||||
md.list(
|
||||
[
|
||||
f"**名称**: {provider.name}",
|
||||
f"**API 类型**: {provider.api_type}",
|
||||
f"**API Base**: {provider.api_base or '默认'}",
|
||||
]
|
||||
)
|
||||
md.head("模型详情", level=2)
|
||||
builder = MarkdownBuilder()
|
||||
builder.head(f"🔎 模型详情: {provider.name}/{model.model_name}", 1)
|
||||
builder.text("---")
|
||||
builder.head("提供商信息", 2)
|
||||
builder.text(f"- **名称**: {provider.name}")
|
||||
builder.text(f"- **API 类型**: {provider.api_type}")
|
||||
builder.text(f"- **API Base**: {provider.api_base or '默认'}")
|
||||
|
||||
builder.head("模型详情", 2)
|
||||
|
||||
temp_value = model.temperature or provider.temperature or "未设置"
|
||||
token_value = model.max_tokens or provider.max_tokens or "未设置"
|
||||
|
||||
md.list(
|
||||
[
|
||||
f"**名称**: {model.model_name}",
|
||||
f"**默认温度**: {temp_value}",
|
||||
f"**最大Token**: {token_value}",
|
||||
f"**核心能力**: {', '.join(cap_list) or '纯文本'}",
|
||||
]
|
||||
)
|
||||
builder.text(f"- **名称**: {model.model_name}")
|
||||
builder.text(f"- **默认温度**: {temp_value}")
|
||||
builder.text(f"- **最大Token**: {token_value}")
|
||||
builder.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}")
|
||||
|
||||
return await md.build()
|
||||
return await renderer_service.render(builder.with_style("light").build())
|
||||
|
||||
@staticmethod
|
||||
async def format_key_status_as_image(
|
||||
provider_name: str, sorted_stats: list[dict[str, Any]]
|
||||
) -> BuildImage:
|
||||
) -> bytes:
|
||||
"""将已排序的、详细的API Key状态格式化为表格图片"""
|
||||
title = f"🔑 '{provider_name}' API Key 状态"
|
||||
|
||||
if not sorted_stats:
|
||||
return await BuildImage.build_text_image(
|
||||
f"{title}\n\n该提供商没有配置API Keys。"
|
||||
)
|
||||
|
||||
def _status_row_style(column: str, text: str) -> RowStyle:
|
||||
style = RowStyle()
|
||||
if column == "状态":
|
||||
if "✅ 健康" in text:
|
||||
style.font_color = "#67C23A"
|
||||
elif "⚠️ 告警" in text:
|
||||
style.font_color = "#E6A23C"
|
||||
elif "❌ 错误" in text or "🚫" in text:
|
||||
style.font_color = "#F56C6C"
|
||||
elif "❄️ 冷却中" in text:
|
||||
style.font_color = "#409EFF"
|
||||
elif column == "成功率":
|
||||
try:
|
||||
if text != "N/A":
|
||||
rate = float(text.replace("%", ""))
|
||||
if rate < 80:
|
||||
style.font_color = "#F56C6C"
|
||||
elif rate < 95:
|
||||
style.font_color = "#E6A23C"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return style
|
||||
|
||||
column_name = [
|
||||
"Key (部分)",
|
||||
"状态",
|
||||
"总调用",
|
||||
"成功率",
|
||||
"平均延迟(s)",
|
||||
"上次错误",
|
||||
"建议操作",
|
||||
]
|
||||
data_list = []
|
||||
|
||||
for key_info in sorted_stats:
|
||||
@@ -155,15 +117,19 @@ class Presenters:
|
||||
if status_enum == KeyStatus.COOLDOWN:
|
||||
cooldown_seconds = int(key_info["cooldown_seconds_left"])
|
||||
formatted_time = _format_seconds(cooldown_seconds)
|
||||
status_text = f"❄️ 冷却中({formatted_time})"
|
||||
status_cell = StatusBadgeCell(
|
||||
text=f"冷却中({formatted_time})", status_type="info"
|
||||
)
|
||||
else:
|
||||
status_text = {
|
||||
KeyStatus.DISABLED: "🚫 永久禁用",
|
||||
KeyStatus.ERROR: "❌ 错误",
|
||||
KeyStatus.WARNING: "⚠️ 告警",
|
||||
KeyStatus.HEALTHY: "✅ 健康",
|
||||
KeyStatus.UNUSED: "⚪️ 未使用",
|
||||
}.get(status_enum, "❔ 未知")
|
||||
status_map = {
|
||||
KeyStatus.DISABLED: ("永久禁用", "error"),
|
||||
KeyStatus.ERROR: ("错误", "error"),
|
||||
KeyStatus.WARNING: ("告警", "warning"),
|
||||
KeyStatus.HEALTHY: ("健康", "ok"),
|
||||
KeyStatus.UNUSED: ("未使用", "info"),
|
||||
}
|
||||
text, status_type = status_map.get(status_enum, ("未知", "info"))
|
||||
status_cell = StatusBadgeCell(text=text, status_type=status_type) # type: ignore
|
||||
|
||||
total_calls = key_info["total_calls"]
|
||||
total_calls_text = (
|
||||
@@ -174,6 +140,13 @@ class Presenters:
|
||||
|
||||
success_rate = key_info["success_rate"]
|
||||
success_rate_text = f"{success_rate:.1f}%" if total_calls > 0 else "N/A"
|
||||
rate_color = None
|
||||
if total_calls > 0:
|
||||
if success_rate < 80:
|
||||
rate_color = "#F56C6C"
|
||||
elif success_rate < 95:
|
||||
rate_color = "#E6A23C"
|
||||
success_rate_cell = TextCell(content=success_rate_text, color=rate_color)
|
||||
|
||||
avg_latency = key_info["avg_latency"]
|
||||
avg_latency_text = f"{avg_latency / 1000:.2f}" if avg_latency > 0 else "N/A"
|
||||
@@ -184,21 +157,29 @@ class Presenters:
|
||||
|
||||
data_list.append(
|
||||
[
|
||||
key_info["key_id"],
|
||||
status_text,
|
||||
total_calls_text,
|
||||
success_rate_text,
|
||||
avg_latency_text,
|
||||
last_error,
|
||||
key_info["suggested_action"],
|
||||
TextCell(content=key_info["key_id"]),
|
||||
status_cell,
|
||||
TextCell(content=total_calls_text),
|
||||
success_rate_cell,
|
||||
TextCell(content=avg_latency_text),
|
||||
TextCell(content=last_error),
|
||||
TextCell(content=key_info["suggested_action"]),
|
||||
]
|
||||
)
|
||||
|
||||
return await ImageTemplate.table_page(
|
||||
head_text=title,
|
||||
tip_text="使用 `llm reset-key <Provider>` 重置Key状态",
|
||||
column_name=column_name,
|
||||
data_list=data_list,
|
||||
text_style=_status_row_style,
|
||||
column_space=15,
|
||||
builder = TableBuilder(
|
||||
title=title, tip="使用 `llm reset-key <Provider>` 重置Key状态"
|
||||
)
|
||||
builder.set_headers(
|
||||
[
|
||||
"Key (部分)",
|
||||
"状态",
|
||||
"总调用",
|
||||
"成功率",
|
||||
"平均延迟(s)",
|
||||
"上次错误",
|
||||
"建议操作",
|
||||
]
|
||||
)
|
||||
builder.add_rows(data_list)
|
||||
return await renderer_service.render(builder.build(), use_cache=False)
|
||||
|
||||
@@ -6,6 +6,7 @@ from nonebot_plugin_apscheduler import scheduler
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
from nonebot_plugin_waiter import prompt_until
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.depends import UserName
|
||||
@@ -188,15 +189,33 @@ async def _(session: Uninfo, arparma: Arparma, amount: Match[int]):
|
||||
|
||||
@_matcher.assign("user-info")
|
||||
async def _(session: Uninfo, arparma: Arparma, uname: str = UserName()):
|
||||
result = await BankManager.get_user_info(session, uname)
|
||||
await MessageUtils.build_message(result).send()
|
||||
user_payload = await BankManager.get_user_info_data(session, uname)
|
||||
|
||||
render_data = {"page_type": "user", "payload": user_payload}
|
||||
|
||||
image_bytes = await ui.render_template(
|
||||
"pages/builtin/mahiro_bank",
|
||||
data=render_data,
|
||||
viewport={"width": 386, "height": 10},
|
||||
)
|
||||
|
||||
await MessageUtils.build_message(image_bytes).send()
|
||||
logger.info("查看银行个人信息", arparma.header_result, session=session)
|
||||
|
||||
|
||||
@_matcher.assign("bank-info")
|
||||
async def _(session: Uninfo, arparma: Arparma):
|
||||
result = await BankManager.get_bank_info()
|
||||
await MessageUtils.build_message(result).send()
|
||||
overview_payload = await BankManager.get_bank_info_data()
|
||||
|
||||
render_data = {"page_type": "overview", "payload": overview_payload}
|
||||
|
||||
image_bytes = await ui.render_template(
|
||||
"pages/builtin/mahiro_bank",
|
||||
data=render_data,
|
||||
viewport={"width": 450, "height": 10},
|
||||
)
|
||||
|
||||
await MessageUtils.build_message(image_bytes).send()
|
||||
logger.info("查看银行信息", arparma.header_result, session=session)
|
||||
|
||||
|
||||
|
||||
@@ -2,17 +2,16 @@ import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
from tortoise.expressions import RawSQL
|
||||
from tortoise.functions import Count, Sum
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.path_config import TEMPLATE_PATH
|
||||
from zhenxun.models.mahiro_bank import MahiroBank
|
||||
from zhenxun.models.mahiro_bank_log import MahiroBankLog
|
||||
from zhenxun.models.sign_user import SignUser
|
||||
from zhenxun.models.user_console import UserConsole
|
||||
from zhenxun.services import avatar_service
|
||||
from zhenxun.utils.enum import BankHandleType, GoldHandle
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
@@ -158,15 +157,15 @@ class BankManager:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_user_info(cls, session: Uninfo, uname: str) -> bytes:
|
||||
"""获取用户数据
|
||||
async def get_user_info_data(cls, session: Uninfo, uname: str) -> dict:
|
||||
"""获取用户数据(返回字典)
|
||||
|
||||
参数:
|
||||
session: Uninfo
|
||||
uname: 用户id
|
||||
|
||||
返回:
|
||||
bytes: 图片数据
|
||||
dict: 用户银行数据字典
|
||||
"""
|
||||
user_id = session.user.id
|
||||
user = await cls.get_user(user_id=user_id)
|
||||
@@ -199,9 +198,9 @@ class BankManager:
|
||||
deposit_list = [
|
||||
{
|
||||
"id": deposit.id,
|
||||
"date": now.date(),
|
||||
"date": str(now.date()),
|
||||
"start_time": str(deposit.create_time).split(".")[0],
|
||||
"end_time": end_time.replace(microsecond=0),
|
||||
"end_time": str(end_time.replace(microsecond=0)),
|
||||
"amount": deposit.amount,
|
||||
"rate": f"{deposit.rate * 100:.2f}",
|
||||
"projected_revenue": int(
|
||||
@@ -212,12 +211,12 @@ class BankManager:
|
||||
for deposit in user_today_deposit
|
||||
]
|
||||
platform = PlatformUtils.get_platform(session)
|
||||
data = {
|
||||
avatar_path = await avatar_service.get_avatar_path(platform, user_id)
|
||||
avatar_url = avatar_path.as_uri() if avatar_path else ""
|
||||
return {
|
||||
"name": uname,
|
||||
"rank": rank + 1,
|
||||
"avatar_url": PlatformUtils.get_user_avatar_url(
|
||||
user_id, platform, session.self_id
|
||||
),
|
||||
"avatar_url": avatar_url or "",
|
||||
"amount": user.amount,
|
||||
"deposit_count": deposit_count,
|
||||
"today_deposit_count": len(user_today_deposit),
|
||||
@@ -225,21 +224,16 @@ class BankManager:
|
||||
"projected_revenue": projected_revenue,
|
||||
"today_deposit_amount": today_deposit_amount,
|
||||
"deposit_list": deposit_list,
|
||||
"create_time": now.replace(microsecond=0),
|
||||
"create_time": str(now.replace(microsecond=0)),
|
||||
}
|
||||
return await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "mahiro_bank").absolute()),
|
||||
template_name="user.html",
|
||||
templates={"data": data},
|
||||
pages={
|
||||
"viewport": {"width": 386, "height": 700},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_bank_info(cls) -> bytes:
|
||||
async def get_bank_info_data(cls) -> dict:
|
||||
"""获取银行总览数据(返回字典)
|
||||
|
||||
返回:
|
||||
dict: 银行总览数据字典
|
||||
"""
|
||||
now = datetime.now()
|
||||
now_start = now - timedelta(
|
||||
hours=now.hour, minutes=now.minute, seconds=now.second
|
||||
@@ -293,27 +287,17 @@ class BankManager:
|
||||
if lasted_log:
|
||||
date = now.date() - lasted_log.create_time.date()
|
||||
date = (date.days or 1) + 1
|
||||
data = {
|
||||
"amount_sum": bank_data[0]["amount_sum"],
|
||||
"user_count": bank_data[0]["user_count"],
|
||||
return {
|
||||
"amount_sum": bank_data[0]["amount_sum"] or 0,
|
||||
"user_count": bank_data[0]["user_count"] or 0,
|
||||
"today_count": today_count,
|
||||
"day_amount": int(bank_data[0]["amount_sum"] / date),
|
||||
"day_amount": int((bank_data[0]["amount_sum"] or 0) / date),
|
||||
"interest_amount": interest_amount[0]["amount_sum"] or 0,
|
||||
"active_user_count": active_user_count[0]["count"] or 0,
|
||||
"e_data": e_date,
|
||||
"e_amount": e_amount,
|
||||
"create_time": now.replace(microsecond=0),
|
||||
"create_time": str(now.replace(microsecond=0)),
|
||||
}
|
||||
return await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "mahiro_bank").absolute()),
|
||||
template_name="bank.html",
|
||||
templates={"data": data},
|
||||
pages={
|
||||
"viewport": {"width": 450, "height": 750},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def deposit(
|
||||
@@ -406,7 +390,6 @@ class BankManager:
|
||||
bank_data[log.user_id].append(log)
|
||||
log_create_list = []
|
||||
log_update_list = []
|
||||
# 计算每日默认金币
|
||||
for bank_user in bank_user_list:
|
||||
if user := user_data.get(bank_user.user_id):
|
||||
amount = bank_user.amount
|
||||
@@ -414,7 +397,6 @@ class BankManager:
|
||||
amount -= sum(log.amount for log in logs)
|
||||
if not amount:
|
||||
continue
|
||||
# 计算每日默认金币
|
||||
gold = int(amount * bank_user.rate)
|
||||
user.gold += gold
|
||||
log_create_list.append(
|
||||
@@ -426,7 +408,6 @@ class BankManager:
|
||||
is_completed=True,
|
||||
)
|
||||
)
|
||||
# 计算每日存款金币
|
||||
for user_id, logs in bank_data.items():
|
||||
if user := user_data.get(user_id):
|
||||
for log in logs:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from nonebot.permission import SUPERUSER
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot_plugin_alconna import Alconna, Args, Subcommand, on_alconna
|
||||
from nonebot_plugin_alconna import Alconna, Args, Match, Option, Subcommand, on_alconna
|
||||
from nonebot_plugin_session import EventSession
|
||||
|
||||
from zhenxun.configs.utils import PluginExtraData
|
||||
@@ -16,11 +16,16 @@ __plugin_meta__ = PluginMetadata(
|
||||
description="插件商店",
|
||||
usage="""
|
||||
插件商店 : 查看当前的插件商店
|
||||
添加插件 id or module : 添加插件
|
||||
移除插件 id or module : 移除插件
|
||||
搜索插件 name or author : 搜索插件
|
||||
更新插件 id or module : 更新插件
|
||||
添加插件 id或module或插件名称 ?[-s [git, ali]]: 添加插件
|
||||
使用-s时指定源,git为github,ali为阿里云
|
||||
移除插件 id或module: 移除插件
|
||||
搜索插件 name或author: 搜索插件
|
||||
更新插件 id或module: 更新插件
|
||||
更新全部插件 : 更新全部插件
|
||||
|
||||
示例:
|
||||
添加插件 pix
|
||||
添加插件 真寻日报 -s git
|
||||
""".strip(),
|
||||
extra=PluginExtraData(
|
||||
author="HibiKier",
|
||||
@@ -32,7 +37,11 @@ __plugin_meta__ = PluginMetadata(
|
||||
_matcher = on_alconna(
|
||||
Alconna(
|
||||
"插件商店",
|
||||
Subcommand("add", Args["plugin_id", str]),
|
||||
Subcommand(
|
||||
"add",
|
||||
Args["plugin_id", str],
|
||||
Option("-s", Args["source", str]),
|
||||
),
|
||||
Subcommand("remove", Args["plugin_id", str]),
|
||||
Subcommand("search", Args["plugin_name_or_author", str]),
|
||||
Subcommand("update", Args["plugin_id", str]),
|
||||
@@ -91,13 +100,20 @@ async def _(session: EventSession):
|
||||
|
||||
|
||||
@_matcher.assign("add")
|
||||
async def _(session: EventSession, plugin_id: str):
|
||||
async def _(session: EventSession, plugin_id: str, source: Match[str]):
|
||||
if is_number(plugin_id):
|
||||
await MessageUtils.build_message(f"正在添加插件 Id: {plugin_id}").send()
|
||||
else:
|
||||
await MessageUtils.build_message(
|
||||
f"正在添加插件 Module/名称: {plugin_id}"
|
||||
).send()
|
||||
source_str = source.result if source.available else None
|
||||
if source_str and source_str not in ["ali", "git"]:
|
||||
await MessageUtils.build_message(
|
||||
f"源类型错误: {source_str} 请使用 ali 或 git"
|
||||
).finish()
|
||||
try:
|
||||
if is_number(plugin_id):
|
||||
await MessageUtils.build_message(f"正在添加插件 Id: {plugin_id}").send()
|
||||
else:
|
||||
await MessageUtils.build_message(f"正在添加插件 Module: {plugin_id}").send()
|
||||
result = await StoreManager.add_plugin(plugin_id)
|
||||
result = await StoreManager.add_plugin(plugin_id, source_str)
|
||||
except Exception as e:
|
||||
logger.error(f"添加插件 Id: {plugin_id}失败", "插件商店", session=session, e=e)
|
||||
await MessageUtils.build_message(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
import shutil
|
||||
@@ -10,11 +11,12 @@ from zhenxun.configs.path_config import TEMP_PATH
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.services.plugin_init import PluginInitManager
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
|
||||
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
|
||||
from zhenxun.utils.repo_utils import RepoFileManager
|
||||
from zhenxun.utils.repo_utils.models import RepoFileInfo, RepoType
|
||||
from zhenxun.utils.utils import is_number
|
||||
from zhenxun.utils.utils import is_number, win_on_rm_error
|
||||
|
||||
from .config import (
|
||||
BASE_PATH,
|
||||
@@ -163,13 +165,17 @@ class StoreManager:
|
||||
|
||||
@classmethod
|
||||
async def get_plugin_by_value(
|
||||
cls, index_or_module: str, is_update: bool = False
|
||||
cls,
|
||||
index_or_module: str,
|
||||
is_update: bool = False,
|
||||
is_remove: bool = False,
|
||||
) -> tuple[StorePluginInfo, bool]:
|
||||
"""获取插件信息
|
||||
|
||||
参数:
|
||||
index_or_module: 插件索引或模块名
|
||||
is_update: 是否是更新插件
|
||||
is_remove: 是否是移除插件
|
||||
|
||||
异常:
|
||||
PluginStoreException: 插件不存在
|
||||
@@ -179,6 +185,8 @@ class StoreManager:
|
||||
StorePluginInfo: 插件信息
|
||||
bool: 是否是外部插件
|
||||
"""
|
||||
plugin_list: list[StorePluginInfo]
|
||||
extra_plugin_list: list[StorePluginInfo]
|
||||
plugin_list, extra_plugin_list = await cls.get_data()
|
||||
plugin_info = None
|
||||
is_external = False
|
||||
@@ -196,14 +204,32 @@ class StoreManager:
|
||||
break
|
||||
if not plugin_info:
|
||||
raise PluginStoreException(f"插件不存在: {plugin_key}")
|
||||
if not is_update and plugin_info.module in [p[0] for p in db_plugin_list]:
|
||||
|
||||
modules = [p[0] for p in db_plugin_list]
|
||||
|
||||
if is_remove:
|
||||
if plugin_info.module not in modules:
|
||||
raise PluginStoreException(f"插件 {plugin_info.name} 未安装,无法移除")
|
||||
if plugin_obj := await PluginInfo.get_plugin(
|
||||
module=plugin_info.module, plugin_type=PluginType.PARENT
|
||||
):
|
||||
plugin_info.module_path = plugin_obj.module_path
|
||||
elif plugin_obj := await PluginInfo.get_plugin(module=plugin_info.module):
|
||||
plugin_info.module_path = plugin_obj.module_path
|
||||
return plugin_info, is_external
|
||||
|
||||
if is_update:
|
||||
if plugin_info.module not in modules:
|
||||
raise PluginStoreException(f"插件 {plugin_info.name} 未安装,无法更新")
|
||||
return plugin_info, is_external
|
||||
|
||||
if plugin_info.module in modules:
|
||||
raise PluginStoreException(f"插件 {plugin_info.name} 已安装,无需重复安装")
|
||||
if plugin_info.module not in [p[0] for p in db_plugin_list] and is_update:
|
||||
raise PluginStoreException(f"插件 {plugin_info.name} 未安装,无法更新")
|
||||
|
||||
return plugin_info, is_external
|
||||
|
||||
@classmethod
|
||||
async def add_plugin(cls, index_or_module: str) -> str:
|
||||
async def add_plugin(cls, index_or_module: str, source: str | None = None) -> str:
|
||||
"""添加插件
|
||||
|
||||
参数:
|
||||
@@ -221,20 +247,18 @@ class StoreManager:
|
||||
plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}"
|
||||
logger.info(f"正在安装插件 {plugin_info.name}...", LOG_COMMAND)
|
||||
await cls.install_plugin_with_repo(
|
||||
plugin_info.github_url,
|
||||
plugin_info.module_path,
|
||||
plugin_info.is_dir,
|
||||
plugin_info,
|
||||
is_external,
|
||||
source,
|
||||
)
|
||||
return f"插件 {plugin_info.name} 安装成功! 重启后生效"
|
||||
|
||||
@classmethod
|
||||
async def install_plugin_with_repo(
|
||||
cls,
|
||||
github_url: str,
|
||||
module_path: str,
|
||||
is_dir: bool,
|
||||
plugin_info: StorePluginInfo,
|
||||
is_external: bool = False,
|
||||
source: str | None = None,
|
||||
):
|
||||
"""安装插件
|
||||
|
||||
@@ -245,19 +269,39 @@ class StoreManager:
|
||||
is_external: 是否是外部仓库
|
||||
"""
|
||||
repo_type = RepoType.GITHUB if is_external else None
|
||||
replace_module_path = module_path.replace(".", "/")
|
||||
if source == "ali":
|
||||
repo_type = RepoType.ALIYUN
|
||||
elif source == "git":
|
||||
repo_type = RepoType.GITHUB
|
||||
module_path = plugin_info.module_path
|
||||
is_dir = plugin_info.is_dir
|
||||
github_url = plugin_info.github_url
|
||||
assert github_url
|
||||
replace_module_path = module_path.replace(".", "/").lstrip("/")
|
||||
plugin_name = module_path.split(".")[-1] or plugin_info.module
|
||||
if is_dir:
|
||||
files = await RepoFileManager.list_directory_files(
|
||||
github_url, replace_module_path, repo_type=repo_type
|
||||
)
|
||||
else:
|
||||
files = [RepoFileInfo(path=f"{replace_module_path}.py", is_dir=False)]
|
||||
local_path = BASE_PATH / "plugins" if is_external else BASE_PATH
|
||||
if not is_external:
|
||||
target_dir = BASE_PATH
|
||||
elif is_dir and module_path == ".":
|
||||
target_dir = BASE_PATH / "plugins" / plugin_name
|
||||
else:
|
||||
target_dir = BASE_PATH / "plugins"
|
||||
files = [file for file in files if not file.is_dir]
|
||||
download_files = [(file.path, local_path / file.path) for file in files]
|
||||
await RepoFileManager.download_files(
|
||||
github_url, download_files, repo_type=repo_type
|
||||
download_files = [(file.path, target_dir / file.path) for file in files]
|
||||
result = await RepoFileManager.download_files(
|
||||
github_url,
|
||||
download_files,
|
||||
repo_type=repo_type,
|
||||
sparse_path=replace_module_path,
|
||||
target_dir=target_dir,
|
||||
)
|
||||
if not result.success:
|
||||
raise PluginStoreException(result.error_message)
|
||||
|
||||
requirement_paths = [
|
||||
file
|
||||
@@ -268,7 +312,7 @@ class StoreManager:
|
||||
|
||||
is_install_req = False
|
||||
for requirement_path in requirement_paths:
|
||||
requirement_file = local_path / requirement_path.path
|
||||
requirement_file = target_dir / requirement_path.path
|
||||
if requirement_file.exists():
|
||||
is_install_req = True
|
||||
await VirtualEnvPackageManager.install_requirement(requirement_file)
|
||||
@@ -310,22 +354,21 @@ class StoreManager:
|
||||
返回:
|
||||
str: 返回消息
|
||||
"""
|
||||
plugin_info, _ = await cls.get_plugin_by_value(index_or_module)
|
||||
path = BASE_PATH
|
||||
if plugin_info.github_url:
|
||||
path = BASE_PATH / "plugins"
|
||||
for p in plugin_info.module_path.split("."):
|
||||
path = path / p
|
||||
plugin_info, _ = await cls.get_plugin_by_value(index_or_module, is_remove=True)
|
||||
module_path = plugin_info.module_path
|
||||
module = module_path.split(".")[-1]
|
||||
path = BASE_PATH.parent / Path(module_path.replace(".", os.sep))
|
||||
if not plugin_info.is_dir:
|
||||
path = Path(f"{path}.py")
|
||||
path = path.parent / f"{module}.py"
|
||||
if not path.exists():
|
||||
return f"插件 {plugin_info.name} 不存在..."
|
||||
logger.debug(f"尝试移除插件 {plugin_info.name} 文件: {path}", LOG_COMMAND)
|
||||
if plugin_info.is_dir:
|
||||
shutil.rmtree(path)
|
||||
# 处理 Windows 下 .git 等目录内只读文件导致的 WinError 5
|
||||
shutil.rmtree(path, onerror=win_on_rm_error)
|
||||
else:
|
||||
path.unlink()
|
||||
await PluginInitManager.remove(f"zhenxun.{plugin_info.module_path}")
|
||||
await PluginInitManager.remove(module_path)
|
||||
return f"插件 {plugin_info.name} 移除成功! 重启后生效"
|
||||
|
||||
@classmethod
|
||||
@@ -392,9 +435,7 @@ class StoreManager:
|
||||
if plugin_info.github_url is None:
|
||||
plugin_info.github_url = DEFAULT_GITHUB_URL
|
||||
await cls.install_plugin_with_repo(
|
||||
plugin_info.github_url,
|
||||
plugin_info.module_path,
|
||||
plugin_info.is_dir,
|
||||
plugin_info,
|
||||
is_external,
|
||||
)
|
||||
return f"插件 {plugin_info.name} 更新成功! 重启后生效"
|
||||
@@ -416,7 +457,7 @@ class StoreManager:
|
||||
update_success_list = []
|
||||
result = "--已更新{}个插件 {}个失败 {}个成功--"
|
||||
logger.info(f"尝试更新全部插件 {plugin_name_list}", LOG_COMMAND)
|
||||
for plugin_info in plugin_list:
|
||||
for plugin_info in all_plugin_list:
|
||||
try:
|
||||
db_plugin_list = await cls.get_loaded_plugins("module", "version")
|
||||
suc_plugin = {p[0]: (p[1] or "Unknown") for p in db_plugin_list}
|
||||
@@ -442,9 +483,7 @@ class StoreManager:
|
||||
plugin_info.github_url = DEFAULT_GITHUB_URL
|
||||
is_external = False
|
||||
await cls.install_plugin_with_repo(
|
||||
plugin_info.github_url,
|
||||
plugin_info.module_path,
|
||||
plugin_info.is_dir,
|
||||
plugin_info,
|
||||
is_external,
|
||||
)
|
||||
update_success_list.append(plugin_info.name)
|
||||
@@ -496,11 +535,11 @@ class StoreManager:
|
||||
raise PluginStoreException("插件ID不存在...")
|
||||
return all_plugin_list[idx].module
|
||||
elif isinstance(plugin_id, str):
|
||||
result = (
|
||||
None
|
||||
if plugin_id not in [v.module for v in all_plugin_list]
|
||||
else plugin_id
|
||||
) or next(v for v in all_plugin_list if v.name == plugin_id).module
|
||||
if not result:
|
||||
raise PluginStoreException("插件 Module / 名称 不存在...")
|
||||
return result
|
||||
if plugin_id in [v.module for v in all_plugin_list]:
|
||||
return plugin_id
|
||||
|
||||
for plugin_info in all_plugin_list:
|
||||
if plugin_info.name.lower() == plugin_id.lower():
|
||||
return plugin_info.module
|
||||
|
||||
raise PluginStoreException("插件 Module / 名称 不存在...")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import random
|
||||
import time
|
||||
|
||||
from nonebot import on_message, on_request
|
||||
from nonebot.adapters.onebot.v11 import (
|
||||
@@ -12,7 +11,6 @@ from nonebot.adapters.onebot.v11 import (
|
||||
from nonebot.adapters.onebot.v11 import Bot as v11Bot
|
||||
from nonebot.adapters.onebot.v12 import Bot as v12Bot
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot_plugin_apscheduler import scheduler
|
||||
from nonebot_plugin_session import EventSession
|
||||
|
||||
from zhenxun.configs.config import BotConfig, Config
|
||||
@@ -66,19 +64,6 @@ __plugin_meta__ = PluginMetadata(
|
||||
)
|
||||
|
||||
|
||||
class Timer:
|
||||
data: dict[str, float] = {} # noqa: RUF012
|
||||
|
||||
@classmethod
|
||||
def check(cls, uid: int | str):
|
||||
return True if uid not in cls.data else time.time() - cls.data[uid] > 5 * 60
|
||||
|
||||
@classmethod
|
||||
def clear(cls):
|
||||
now = time.time()
|
||||
cls.data = {k: v for k, v in cls.data.items() if v - now < 5 * 60}
|
||||
|
||||
|
||||
# TODO: 其他平台请求
|
||||
|
||||
friend_req = on_request(priority=5, block=True)
|
||||
@@ -86,68 +71,70 @@ group_req = on_request(priority=5, block=True)
|
||||
_t = on_message(priority=999, block=False, rule=lambda: False)
|
||||
|
||||
|
||||
cache = CacheRoot.cache_dict(
|
||||
"REQUEST_CACHE", (base_config.get("TIP_MESSAGE_LIMIT") or 360) * 60, str
|
||||
)
|
||||
cache = CacheRoot.cache_dict("REQUEST_CACHE", 60, str)
|
||||
|
||||
|
||||
@friend_req.handle()
|
||||
async def _(bot: v12Bot | v11Bot, event: FriendRequestEvent, session: EventSession):
|
||||
if event.user_id and Timer.check(event.user_id):
|
||||
logger.debug("收录好友请求...", "好友请求", target=event.user_id)
|
||||
user = await bot.get_stranger_info(user_id=event.user_id)
|
||||
nickname = user["nickname"]
|
||||
# sex = user["sex"]
|
||||
# age = str(user["age"])
|
||||
comment = event.comment
|
||||
if base_config.get("AUTO_ADD_FRIEND"):
|
||||
logger.debug(
|
||||
"已开启好友请求自动同意,成功通过该请求",
|
||||
"好友请求",
|
||||
target=event.user_id,
|
||||
)
|
||||
await asyncio.sleep(random.randint(1, 10))
|
||||
await bot.set_friend_add_request(flag=event.flag, approve=True)
|
||||
await FriendUser.create(
|
||||
user_id=str(user["user_id"]), user_name=user["nickname"]
|
||||
)
|
||||
else:
|
||||
# 旧请求全部设置为过期
|
||||
await FgRequest.filter(
|
||||
request_type=RequestType.FRIEND,
|
||||
user_id=str(event.user_id),
|
||||
handle_type__isnull=True,
|
||||
).update(handle_type=RequestHandleType.EXPIRE)
|
||||
f = await FgRequest.create(
|
||||
request_type=RequestType.FRIEND,
|
||||
platform=session.platform,
|
||||
bot_id=bot.self_id,
|
||||
flag=event.flag,
|
||||
user_id=event.user_id,
|
||||
nickname=nickname,
|
||||
comment=comment,
|
||||
)
|
||||
cache_key = str(event.user_id)
|
||||
if not cache.get(cache_key):
|
||||
cache.set(cache_key, "1")
|
||||
results = await PlatformUtils.send_superuser(
|
||||
bot,
|
||||
f"*****一份好友申请*****\n"
|
||||
f"ID: {f.id}\n"
|
||||
f"昵称:{nickname}({event.user_id})\n"
|
||||
f"自动同意:{'√' if base_config.get('AUTO_ADD_FRIEND') else '×'}\n"
|
||||
f"日期:{datetime.now().replace(microsecond=0)}\n"
|
||||
f"备注:{event.comment}",
|
||||
)
|
||||
if message_ids := [
|
||||
str(r[1].msg_ids[0]["message_id"])
|
||||
for r in results
|
||||
if r[1] and r[1].msg_ids
|
||||
]:
|
||||
f.message_ids = ",".join(message_ids)
|
||||
await f.save(update_fields=["message_ids"])
|
||||
logger.debug("收录好友请求...", "好友请求", target=event.user_id)
|
||||
user = await bot.get_stranger_info(user_id=event.user_id)
|
||||
nickname = user["nickname"]
|
||||
# sex = user["sex"]
|
||||
# age = str(user["age"])
|
||||
comment = event.comment
|
||||
if base_config.get("AUTO_ADD_FRIEND"):
|
||||
logger.debug(
|
||||
"已开启好友请求自动同意,成功通过该请求",
|
||||
"好友请求",
|
||||
target=event.user_id,
|
||||
)
|
||||
await asyncio.sleep(random.randint(1, 10))
|
||||
await bot.set_friend_add_request(flag=event.flag, approve=True)
|
||||
await FriendUser.create(
|
||||
user_id=str(user["user_id"]), user_name=user["nickname"]
|
||||
)
|
||||
else:
|
||||
logger.debug("好友请求五分钟内重复, 已忽略", "好友请求", target=event.user_id)
|
||||
# 旧请求全部设置为过期
|
||||
await FgRequest.filter(
|
||||
request_type=RequestType.FRIEND,
|
||||
user_id=str(event.user_id),
|
||||
handle_type__isnull=True,
|
||||
).update(handle_type=RequestHandleType.EXPIRE)
|
||||
f = await FgRequest.create(
|
||||
request_type=RequestType.FRIEND,
|
||||
platform=session.platform,
|
||||
bot_id=bot.self_id,
|
||||
flag=event.flag,
|
||||
user_id=event.user_id,
|
||||
nickname=nickname,
|
||||
comment=comment,
|
||||
)
|
||||
cache_key = str(event.user_id)
|
||||
if not cache.get(cache_key):
|
||||
cache.set(cache_key, "1")
|
||||
results = await PlatformUtils.send_superuser(
|
||||
bot,
|
||||
f"*****一份好友申请*****\n"
|
||||
f"ID: {f.id}\n"
|
||||
f"昵称:{nickname}({event.user_id})\n"
|
||||
f"自动同意:{'√' if base_config.get('AUTO_ADD_FRIEND') else '×'}\n"
|
||||
f"日期:{datetime.now().replace(microsecond=0)}\n"
|
||||
f"备注:{event.comment}",
|
||||
)
|
||||
if message_ids := [
|
||||
str(r[1].msg_ids[0]["message_id"])
|
||||
for r in results
|
||||
if r[1] and r[1].msg_ids
|
||||
]:
|
||||
f.message_ids = ",".join(message_ids)
|
||||
await f.save(update_fields=["message_ids"])
|
||||
else:
|
||||
tip_limit = base_config.get("TIP_MESSAGE_LIMIT") or 360
|
||||
logger.debug(
|
||||
f"好友请求{tip_limit}分钟内重复, 已忽略",
|
||||
"好友请求",
|
||||
target=cache_key,
|
||||
)
|
||||
|
||||
|
||||
@group_req.handle()
|
||||
@@ -227,7 +214,7 @@ async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSessio
|
||||
"\n在群组中 群组管理员与群主 允许使用管理员帮助"
|
||||
"(包括ban与功能开关等)\n请在群组中发送 '管理员帮助'",
|
||||
)
|
||||
elif cache.get(f"{event.group_id}"):
|
||||
elif not cache.get(f"{event.group_id}"):
|
||||
cache.set(f"{event.group_id}", "1")
|
||||
logger.debug(
|
||||
f"收录 用户[{event.user_id}] 群聊[{event.group_id}] 群聊请求",
|
||||
@@ -284,15 +271,3 @@ async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSessio
|
||||
"群聊请求",
|
||||
target=f"{event.user_id}:{event.group_id}",
|
||||
)
|
||||
|
||||
|
||||
@scheduler.scheduled_job(
|
||||
"interval",
|
||||
minutes=5,
|
||||
)
|
||||
async def _():
|
||||
Timer.clear()
|
||||
|
||||
|
||||
async def _():
|
||||
Timer.clear()
|
||||
|
||||
@@ -76,7 +76,9 @@ async def handle_view(
|
||||
await schedule_cmd.finish("没有找到任何相关的定时任务。")
|
||||
|
||||
img = await presenters.format_schedule_list_as_image(
|
||||
schedules=schedules, title=title, current_page=page.result
|
||||
schedules=schedules,
|
||||
title=title,
|
||||
current_page=page.result if page.available else 1,
|
||||
)
|
||||
await MessageUtils.build_message(img).send(reply_to=True)
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.models.scheduled_job import ScheduledJob
|
||||
from zhenxun.services.scheduler import scheduler_manager
|
||||
from zhenxun.utils._image_template import ImageTemplate, RowStyle
|
||||
from zhenxun.ui.builders import TableBuilder
|
||||
from zhenxun.ui.models import StatusBadgeCell, TextCell
|
||||
from zhenxun.utils.pydantic_compat import model_json_schema
|
||||
|
||||
|
||||
@@ -118,19 +120,6 @@ def format_update_success(schedule_info: ScheduledJob) -> str:
|
||||
return _format_operation_result_card("🔄️ 成功更新定时任务配置!", schedule_info)
|
||||
|
||||
|
||||
def _status_row_style(column: str, text: str) -> RowStyle:
|
||||
"""为状态列设置颜色"""
|
||||
style = RowStyle()
|
||||
if column == "状态":
|
||||
if text == "启用":
|
||||
style.font_color = "#67C23A"
|
||||
elif text == "暂停":
|
||||
style.font_color = "#F56C6C"
|
||||
elif text == "运行中":
|
||||
style.font_color = "#409EFF"
|
||||
return style
|
||||
|
||||
|
||||
def _format_params(schedule_status: dict) -> str:
|
||||
"""将任务参数格式化为人类可读的字符串"""
|
||||
if kwargs := schedule_status.get("job_kwargs"):
|
||||
@@ -157,36 +146,47 @@ async def format_schedule_list_as_image(
|
||||
]
|
||||
all_statuses = await asyncio.gather(*status_tasks)
|
||||
|
||||
def get_status_text(status_value):
|
||||
if isinstance(status_value, bool):
|
||||
return "启用" if status_value else "暂停"
|
||||
return str(status_value)
|
||||
data_list = []
|
||||
for s in all_statuses:
|
||||
if not s:
|
||||
continue
|
||||
|
||||
data_list = [
|
||||
[
|
||||
s["id"],
|
||||
s["plugin_name"],
|
||||
s.get("bot_id") or "N/A",
|
||||
s["group_id"] or "全局",
|
||||
s["next_run_time"],
|
||||
_format_trigger_info(s),
|
||||
_format_params(s),
|
||||
get_status_text(s["is_enabled"]),
|
||||
]
|
||||
for s in all_statuses
|
||||
if s
|
||||
]
|
||||
status_value = s["is_enabled"]
|
||||
if status_value == "运行中":
|
||||
status_cell = StatusBadgeCell(text="运行中", status_type="info")
|
||||
else:
|
||||
is_enabled = status_value == "启用"
|
||||
status_cell = StatusBadgeCell(
|
||||
text="启用" if is_enabled else "暂停",
|
||||
status_type="ok" if is_enabled else "error",
|
||||
)
|
||||
|
||||
data_list.append(
|
||||
[
|
||||
TextCell(content=str(s["id"])),
|
||||
TextCell(content=s["plugin_name"]),
|
||||
TextCell(content=s.get("bot_id") or "N/A"),
|
||||
TextCell(content=s["group_id"] or "全局"),
|
||||
TextCell(content=s["next_run_time"]),
|
||||
TextCell(content=_format_trigger_info(s)),
|
||||
TextCell(content=_format_params(s)),
|
||||
status_cell,
|
||||
]
|
||||
)
|
||||
|
||||
if not data_list:
|
||||
return "没有找到任何相关的定时任务。"
|
||||
|
||||
return await ImageTemplate.table_page(
|
||||
head_text=title,
|
||||
tip_text=f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务",
|
||||
column_name=["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"],
|
||||
data_list=data_list,
|
||||
column_space=20,
|
||||
text_style=_status_row_style,
|
||||
builder = TableBuilder(
|
||||
title, f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务"
|
||||
)
|
||||
builder.set_headers(
|
||||
["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"]
|
||||
).add_rows(data_list)
|
||||
return await ui.render(
|
||||
builder.build(),
|
||||
viewport={"width": 1400, "height": 10},
|
||||
device_scale_factor=2,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ async def _(session: Uninfo, arparma: Arparma, nickname: str = UserName()):
|
||||
nickname,
|
||||
PlatformUtils.get_platform(session),
|
||||
):
|
||||
await MessageUtils.build_message(image.pic2bytes()).finish(reply_to=True)
|
||||
await MessageUtils.build_message(image).finish(reply_to=True) # type: ignore
|
||||
return await MessageUtils.build_message("你的道具为空捏...").send(reply_to=True)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
import inspect
|
||||
@@ -7,26 +8,27 @@ from types import MappingProxyType
|
||||
from typing import Any, Literal
|
||||
|
||||
from nonebot.adapters import Bot, Event
|
||||
from nonebot.compat import model_dump
|
||||
from nonebot_plugin_alconna import At, UniMessage, UniMsg
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
from tortoise.expressions import Q
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.models.friend_user import FriendUser
|
||||
from zhenxun.models.goods_info import GoodsInfo
|
||||
from zhenxun.models.group_member_info import GroupInfoUser
|
||||
from zhenxun.models.user_console import UserConsole
|
||||
from zhenxun.models.user_gold_log import UserGoldLog
|
||||
from zhenxun.models.user_props_log import UserPropsLog
|
||||
from zhenxun.services import avatar_service
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.ui.models import ImageCell, TextCell
|
||||
from zhenxun.utils.enum import GoldHandle, PropHandle
|
||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
from .config import ICON_PATH, PLATFORM_PATH, base_config
|
||||
from .html_image import html_image
|
||||
from .normal_image import normal_image
|
||||
from .config import ICON_PATH, PLATFORM_PATH
|
||||
|
||||
|
||||
class Goods(BaseModel):
|
||||
@@ -91,9 +93,7 @@ class ShopParam(BaseModel):
|
||||
return model_dump(self, **kwargs)
|
||||
|
||||
|
||||
async def gold_rank(
|
||||
session: Uninfo, group_id: str | None, num: int
|
||||
) -> BuildImage | str:
|
||||
async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes | str:
|
||||
query = UserConsole
|
||||
if group_id:
|
||||
uid_list = await GroupInfoUser.filter(group_id=group_id).values_list(
|
||||
@@ -124,16 +124,20 @@ async def gold_rank(
|
||||
data_list = []
|
||||
platform = PlatformUtils.get_platform(session)
|
||||
for i, user in enumerate(user_list):
|
||||
ava_bytes = await PlatformUtils.get_user_avatar(
|
||||
user[0], platform, session.self_id
|
||||
)
|
||||
avatar_path = await avatar_service.get_avatar_path(platform, user[0])
|
||||
data_list.append(
|
||||
[
|
||||
f"{i + 1}",
|
||||
(ava_bytes, 30, 30) if platform == "qq" else "",
|
||||
uid2name.get(user[0]),
|
||||
user[1],
|
||||
(PLATFORM_PATH.get(platform), 30, 30),
|
||||
TextCell(content=f"{i + 1}"),
|
||||
ImageCell(
|
||||
src=avatar_path.as_uri() if avatar_path else "", shape="circle"
|
||||
)
|
||||
if avatar_path
|
||||
else TextCell(content=""),
|
||||
TextCell(content=uid2name.get(user[0]) or user[0]),
|
||||
TextCell(content=str(user[1]), bold=True),
|
||||
ImageCell(src=platform_path.resolve().as_uri())
|
||||
if (platform_path := PLATFORM_PATH.get(platform))
|
||||
else TextCell(content=""),
|
||||
]
|
||||
)
|
||||
if group_id:
|
||||
@@ -142,7 +146,11 @@ async def gold_rank(
|
||||
else:
|
||||
title = "金币全局排行"
|
||||
tip = f"你的排名在全局第 {index} 位哦!"
|
||||
return await ImageTemplate.table_page(title, tip, column_name, data_list)
|
||||
from zhenxun.ui.builders import TableBuilder
|
||||
|
||||
builder = TableBuilder(title, tip)
|
||||
builder.set_headers(column_name).add_rows(data_list)
|
||||
return await ui.render(builder.build())
|
||||
|
||||
|
||||
class ShopManage:
|
||||
@@ -150,9 +158,7 @@ class ShopManage:
|
||||
|
||||
@classmethod
|
||||
async def get_shop_image(cls) -> bytes:
|
||||
if base_config.get("style") == "zhenxun":
|
||||
return await html_image()
|
||||
return await normal_image()
|
||||
return await prepare_shop_data()
|
||||
|
||||
@classmethod
|
||||
def __build_params(
|
||||
@@ -494,7 +500,7 @@ class ShopManage:
|
||||
@classmethod
|
||||
async def my_props(
|
||||
cls, user_id: str, name: str, platform: str | None = None
|
||||
) -> BuildImage | None:
|
||||
) -> bytes | None:
|
||||
"""获取道具背包
|
||||
|
||||
参数:
|
||||
@@ -526,10 +532,10 @@ class ShopManage:
|
||||
if not prop:
|
||||
continue
|
||||
|
||||
icon = ""
|
||||
icon = None
|
||||
if prop.icon:
|
||||
icon_path = ICON_PATH / prop.icon
|
||||
icon = (icon_path, 33, 33) if icon_path.exists() else ""
|
||||
icon = icon_path if icon_path.exists() else None
|
||||
|
||||
table_rows.append(
|
||||
[
|
||||
@@ -545,12 +551,11 @@ class ShopManage:
|
||||
return None
|
||||
|
||||
column_name = ["-", "使用ID", "名称", "数量", "简介"]
|
||||
return await ImageTemplate.table_page(
|
||||
f"{name}的道具仓库",
|
||||
"通过 使用道具[ID/名称] 令道具生效",
|
||||
column_name,
|
||||
table_rows,
|
||||
)
|
||||
from zhenxun.ui.builders import TableBuilder
|
||||
|
||||
builder = TableBuilder(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
|
||||
builder.set_headers(column_name).add_rows(table_rows)
|
||||
return await ui.render(builder.build())
|
||||
|
||||
@classmethod
|
||||
async def my_cost(cls, user_id: str, platform: str | None = None) -> int:
|
||||
@@ -565,3 +570,62 @@ class ShopManage:
|
||||
"""
|
||||
user = await UserConsole.get_user(user_id, platform)
|
||||
return user.gold
|
||||
|
||||
|
||||
def get_limit_time(end_time: int) -> str | None:
|
||||
now = int(time.time())
|
||||
if now > end_time or end_time == 0:
|
||||
return None
|
||||
time_difference = datetime.fromtimestamp(end_time) - datetime.fromtimestamp(now)
|
||||
total_seconds = time_difference.total_seconds()
|
||||
hours = int(total_seconds // 3600)
|
||||
minutes = int((total_seconds % 3600) // 60)
|
||||
return f"{hours}:{minutes:02d}"
|
||||
|
||||
|
||||
def get_discount(price: int, discount: float) -> int | None:
|
||||
return None if discount == 1.0 else int(price * discount)
|
||||
|
||||
|
||||
async def prepare_shop_data() -> bytes:
|
||||
"""准备商店数据并调用渲染服务"""
|
||||
goods_list = (
|
||||
await GoodsInfo.filter(
|
||||
Q(goods_limit_time__gte=time.time()) | Q(goods_limit_time=0)
|
||||
)
|
||||
.annotate()
|
||||
.order_by("id")
|
||||
.all()
|
||||
)
|
||||
|
||||
partition_dict: dict[str, list[dict]] = defaultdict(list)
|
||||
for idx, goods in enumerate(goods_list):
|
||||
partition_name = goods.partition or "默认分区"
|
||||
|
||||
icon_asset_path = None
|
||||
if goods.icon and (ICON_PATH / goods.icon).exists():
|
||||
icon_asset_path = f"image/shop_icon/{goods.icon}"
|
||||
|
||||
goods_item = {
|
||||
"id": idx + 1,
|
||||
"name": goods.goods_name,
|
||||
"description": goods.goods_description,
|
||||
"price": goods.goods_price,
|
||||
"discount_price": get_discount(goods.goods_price, goods.goods_discount),
|
||||
"limit_time": get_limit_time(goods.goods_limit_time),
|
||||
"daily_limit": goods.daily_limit or "∞",
|
||||
"icon_url": icon_asset_path,
|
||||
}
|
||||
partition_dict[partition_name].append(goods_item)
|
||||
|
||||
categories = [
|
||||
{"partition_title": partition, "goods_list": items}
|
||||
for partition, items in partition_dict.items()
|
||||
]
|
||||
|
||||
shop_data = {
|
||||
"bot_nickname": BotConfig.self_nickname,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
return await ui.render_template("pages/builtin/shop", data=shop_data)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH
|
||||
from zhenxun.configs.path_config import IMAGE_PATH, THEMES_PATH
|
||||
|
||||
base_config = Config.get("shop")
|
||||
|
||||
@@ -17,4 +17,4 @@ PLATFORM_PATH = {
|
||||
|
||||
LEFT_RIGHT_IMAGE = ["1.png", "2.png", "qq.png"]
|
||||
|
||||
LEFT_RIGHT_PATH = TEMPLATE_PATH / "shop" / "res" / "img"
|
||||
LEFT_RIGHT_PATH = THEMES_PATH / "default" / "assets" / "shop" / "img"
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
from pydantic import BaseModel
|
||||
from tortoise.expressions import Q
|
||||
|
||||
from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.configs.path_config import TEMPLATE_PATH
|
||||
from zhenxun.models.goods_info import GoodsInfo
|
||||
from zhenxun.utils._build_image import BuildImage
|
||||
|
||||
from .config import ICON_PATH
|
||||
|
||||
|
||||
class GoodsItem(BaseModel):
|
||||
goods_list: list[dict]
|
||||
"""商品列表"""
|
||||
partition: str
|
||||
"""分区名称"""
|
||||
|
||||
|
||||
def get_limit_time(end_time: int):
|
||||
now = int(time.time())
|
||||
if now > end_time:
|
||||
return None
|
||||
current_datetime = datetime.fromtimestamp(now)
|
||||
end_datetime = datetime.fromtimestamp(end_time)
|
||||
time_difference = end_datetime - current_datetime
|
||||
total_seconds = time_difference.total_seconds()
|
||||
hours = int(total_seconds // 3600)
|
||||
minutes = int((total_seconds % 3600) // 60)
|
||||
return f"{hours}:{minutes}"
|
||||
|
||||
|
||||
def get_discount(price: int, discount: float):
|
||||
return None if discount == 1.0 else int(price * discount)
|
||||
|
||||
|
||||
async def html_image() -> bytes:
|
||||
"""构建图片"""
|
||||
goods_list = (
|
||||
await GoodsInfo.filter(
|
||||
Q(goods_limit_time__gte=time.time()) | Q(goods_limit_time=0)
|
||||
)
|
||||
.annotate()
|
||||
.order_by("id")
|
||||
.all()
|
||||
)
|
||||
partition_dict: dict[str, list[dict]] = {}
|
||||
for idx, goods in enumerate(goods_list):
|
||||
if not goods.partition:
|
||||
goods.partition = "默认分区"
|
||||
if goods.partition not in partition_dict:
|
||||
partition_dict[goods.partition] = []
|
||||
icon = None
|
||||
if goods.icon:
|
||||
path = ICON_PATH / goods.icon
|
||||
if path.exists():
|
||||
icon = (
|
||||
"data:image/png;base64,"
|
||||
f"{BuildImage.open(ICON_PATH / goods.icon).pic2bs4()[9:]}"
|
||||
)
|
||||
partition_dict[goods.partition].append(
|
||||
{
|
||||
"id": idx + 1,
|
||||
"price": goods.goods_price,
|
||||
"discount_price": get_discount(goods.goods_price, goods.goods_discount),
|
||||
"limit_time": get_limit_time(goods.goods_limit_time),
|
||||
"daily_limit": goods.daily_limit or "∞",
|
||||
"name": goods.goods_name,
|
||||
"icon": icon,
|
||||
"description": goods.goods_description,
|
||||
}
|
||||
)
|
||||
data_list = [
|
||||
GoodsItem(goods_list=value, partition=partition)
|
||||
for partition, value in partition_dict.items()
|
||||
]
|
||||
return await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "shop").absolute()),
|
||||
template_name="main.html",
|
||||
templates={"name": BotConfig.self_nickname, "data_list": data_list},
|
||||
pages={
|
||||
"viewport": {"width": 850, "height": 1024},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
@@ -1,207 +0,0 @@
|
||||
import time
|
||||
|
||||
from tortoise.expressions import Q
|
||||
|
||||
from zhenxun.configs.path_config import IMAGE_PATH
|
||||
from zhenxun.models.goods_info import GoodsInfo
|
||||
from zhenxun.utils._build_image import BuildImage
|
||||
from zhenxun.utils.image_utils import text2image
|
||||
|
||||
from .config import ICON_PATH
|
||||
|
||||
|
||||
async def normal_image() -> bytes:
|
||||
"""制作商店图片
|
||||
|
||||
返回:
|
||||
BuildImage: 商店图片
|
||||
"""
|
||||
h = 10
|
||||
goods_list = (
|
||||
await GoodsInfo.filter(
|
||||
Q(goods_limit_time__gte=time.time()) | Q(goods_limit_time=0)
|
||||
)
|
||||
.annotate()
|
||||
.order_by("id")
|
||||
.all()
|
||||
)
|
||||
# A = BuildImage(1100, h, color="#f9f6f2")
|
||||
total_n = 0
|
||||
image_list = []
|
||||
for idx, goods in enumerate(goods_list):
|
||||
name_image = BuildImage(
|
||||
580, 40, font_size=25, color="#e67b6b", font="CJGaoDeGuo.otf"
|
||||
)
|
||||
await name_image.text(
|
||||
(15, 0), f"{idx + 1}.{goods.goods_name}", center_type="height"
|
||||
)
|
||||
await name_image.line((380, -5, 280, 45), "#a29ad6", 5)
|
||||
await name_image.text((390, 0), "售价:", center_type="height")
|
||||
if goods.goods_discount != 1:
|
||||
discount_price = int(goods.goods_discount * goods.goods_price)
|
||||
old_price_image = await BuildImage.build_text_image(
|
||||
str(goods.goods_price), font_color=(194, 194, 194), size=15
|
||||
)
|
||||
await old_price_image.line(
|
||||
(
|
||||
0,
|
||||
int(old_price_image.height / 2),
|
||||
old_price_image.width + 1,
|
||||
int(old_price_image.height / 2),
|
||||
),
|
||||
(0, 0, 0),
|
||||
)
|
||||
await name_image.paste(old_price_image, (440, 0))
|
||||
await name_image.text((440, 15), str(discount_price), (255, 255, 255))
|
||||
else:
|
||||
await name_image.text(
|
||||
(440, 0),
|
||||
str(goods.goods_price),
|
||||
(255, 255, 255),
|
||||
center_type="height",
|
||||
)
|
||||
_tmp = await BuildImage.build_text_image(str(goods.goods_price), size=25)
|
||||
await name_image.text(
|
||||
(
|
||||
440 + _tmp.width,
|
||||
0,
|
||||
),
|
||||
" 金币",
|
||||
center_type="height",
|
||||
)
|
||||
des_image = None
|
||||
font_img = BuildImage(600, 80, font_size=20, color="#a29ad6")
|
||||
p = font_img.getsize("简介:")[0] + 20
|
||||
if goods.goods_description:
|
||||
des_list = goods.goods_description.split("\n")
|
||||
desc = ""
|
||||
for des in des_list:
|
||||
if font_img.getsize(des)[0] > font_img.width - p - 20:
|
||||
msg = ""
|
||||
tmp = ""
|
||||
for i in range(len(des)):
|
||||
if font_img.getsize(tmp)[0] < font_img.width - p - 20:
|
||||
tmp += des[i]
|
||||
else:
|
||||
msg += tmp + "\n"
|
||||
tmp = des[i]
|
||||
desc += msg
|
||||
if tmp:
|
||||
desc += tmp
|
||||
else:
|
||||
desc += des + "\n"
|
||||
if desc[-1] == "\n":
|
||||
desc = desc[:-1]
|
||||
des_image = await text2image(desc, color="#a29ad6")
|
||||
goods_image = BuildImage(
|
||||
600,
|
||||
(50 + des_image.height) if des_image else 50,
|
||||
font_size=20,
|
||||
color="#a29ad6",
|
||||
font="CJGaoDeGuo.otf",
|
||||
)
|
||||
if des_image:
|
||||
await goods_image.text((15, 50), "简介:")
|
||||
await goods_image.paste(des_image, (p, 50))
|
||||
await name_image.circle_corner(5)
|
||||
await goods_image.paste(name_image, (0, 5), center_type="width")
|
||||
await goods_image.circle_corner(20)
|
||||
bk = BuildImage(
|
||||
1180,
|
||||
(50 + des_image.height) if des_image else 50,
|
||||
font_size=15,
|
||||
color="#f9f6f2",
|
||||
font="CJGaoDeGuo.otf",
|
||||
)
|
||||
if goods.icon and (ICON_PATH / goods.icon).exists():
|
||||
icon = BuildImage(70, 70, background=ICON_PATH / goods.icon)
|
||||
await bk.paste(icon)
|
||||
await bk.paste(goods_image, (70, 0))
|
||||
n = 0
|
||||
_w = 650
|
||||
# 添加限时图标和时间
|
||||
if goods.goods_limit_time > 0:
|
||||
n += 140
|
||||
_limit_time_logo = BuildImage(
|
||||
40, 40, background=f"{IMAGE_PATH}/other/time.png"
|
||||
)
|
||||
await bk.paste(_limit_time_logo, (_w + 50, 0))
|
||||
_time_img = await BuildImage.build_text_image("限时!", size=23)
|
||||
await bk.paste(
|
||||
_time_img,
|
||||
(_w + 90, 10),
|
||||
)
|
||||
limit_time = time.strftime(
|
||||
"%Y-%m-%d %H:%M", time.localtime(goods.goods_limit_time)
|
||||
).split()
|
||||
y_m_d = limit_time[0]
|
||||
_h_m = limit_time[1].split(":")
|
||||
h_m = f"{_h_m[0]}时 {_h_m[1]}分"
|
||||
await bk.text((_w + 55, 38), str(y_m_d))
|
||||
await bk.text((_w + 65, 57), str(h_m))
|
||||
_w += 140
|
||||
if goods.goods_discount != 1:
|
||||
n += 140
|
||||
_discount_logo = BuildImage(
|
||||
30, 30, background=f"{IMAGE_PATH}/other/discount.png"
|
||||
)
|
||||
await bk.paste(_discount_logo, (_w + 50, 10))
|
||||
_tmp = await BuildImage.build_text_image("折扣!", size=23)
|
||||
await bk.paste(_tmp, (_w + 90, 15))
|
||||
_tmp = await BuildImage.build_text_image(
|
||||
f"{10 * goods.goods_discount:.1f} 折",
|
||||
size=30,
|
||||
font_color=(85, 156, 75),
|
||||
)
|
||||
await bk.paste(_tmp, (_w + 50, 44))
|
||||
_w += 140
|
||||
if goods.daily_limit != 0:
|
||||
n += 140
|
||||
_daily_limit_logo = BuildImage(
|
||||
35, 35, background=f"{IMAGE_PATH}/other/daily_limit.png"
|
||||
)
|
||||
await bk.paste(_daily_limit_logo, (_w + 50, 10))
|
||||
_tmp = await BuildImage.build_text_image(
|
||||
"限购!",
|
||||
size=23,
|
||||
)
|
||||
await bk.paste(_tmp, (_w + 90, 20))
|
||||
_tmp = await BuildImage.build_text_image(f"{goods.daily_limit}", size=30)
|
||||
await bk.paste(_tmp, (_w + 72, 45))
|
||||
total_n = max(total_n, n)
|
||||
if n:
|
||||
await bk.line((650, -1, 650 + n, -1), "#a29ad6", 5)
|
||||
# await bk.aline((650, 80, 650 + n, 80), "#a29ad6", 5)
|
||||
|
||||
# 添加限时图标和时间
|
||||
image_list.append(bk)
|
||||
# await A.apaste(bk, (0, current_h), True)
|
||||
# current_h += 90
|
||||
current_h = 0
|
||||
h = sum(img.height + 10 for img in image_list) or 400
|
||||
A = BuildImage(1100, h, color="#f9f6f2")
|
||||
for img in image_list:
|
||||
await A.paste(img, (0, current_h))
|
||||
current_h += img.height + 10
|
||||
w = 950
|
||||
if total_n:
|
||||
w += total_n
|
||||
h = A.height + 230 + 100
|
||||
h = max(h, 1000)
|
||||
shop_logo = BuildImage(100, 100, background=f"{IMAGE_PATH}/other/shop_text.png")
|
||||
shop = BuildImage(w, h, font_size=20, color="#f9f6f2")
|
||||
await shop.paste(A, (20, 230))
|
||||
await shop.paste(shop_logo, (450, 30))
|
||||
tip = "注【通过 购买道具 序号 或者 商品名称 购买】"
|
||||
await shop.text(
|
||||
(
|
||||
int((1000 - shop.getsize(tip)[0]) / 2),
|
||||
170,
|
||||
),
|
||||
"注【通过 序号 或者 商品名称 购买】",
|
||||
)
|
||||
await shop.text(
|
||||
(20, h - 100),
|
||||
"神秘药水\t\t售价:9999999金币\n\t\t鬼知道会有什么效果~",
|
||||
)
|
||||
return shop.pic2bytes()
|
||||
@@ -84,12 +84,6 @@ __plugin_meta__ = PluginMetadata(
|
||||
default_value=0.05,
|
||||
type=float,
|
||||
),
|
||||
RegisterConfig(
|
||||
key="IMAGE_STYLE",
|
||||
value="zhenxun",
|
||||
help="签到图片样式, [normal, zhenxun]",
|
||||
default_value="zhenxun",
|
||||
),
|
||||
],
|
||||
limits=[PluginCdBlock()],
|
||||
).to_dict(),
|
||||
|
||||
@@ -6,14 +6,16 @@ import secrets
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
import pytz
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.configs.path_config import IMAGE_PATH
|
||||
from zhenxun.models.friend_user import FriendUser
|
||||
from zhenxun.models.group_member_info import GroupInfoUser
|
||||
from zhenxun.models.sign_log import SignLog
|
||||
from zhenxun.models.sign_user import SignUser
|
||||
from zhenxun.models.user_console import UserConsole
|
||||
from zhenxun.services.avatar_service import avatar_service
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
|
||||
from zhenxun.ui.models import ImageCell, TextCell
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
from ._random_event import random_event
|
||||
@@ -33,7 +35,7 @@ class SignManage:
|
||||
@classmethod
|
||||
async def rank(
|
||||
cls, session: Uninfo, num: int, group_id: str | None = None
|
||||
) -> BuildImage | str: # sourcery skip: avoid-builtin-shadow
|
||||
) -> bytes | str:
|
||||
"""好感度排行
|
||||
|
||||
参数:
|
||||
@@ -42,7 +44,7 @@ class SignManage:
|
||||
group_id: 群组id
|
||||
|
||||
返回:
|
||||
BuildImage: 构造图片
|
||||
bytes: 构造图片
|
||||
"""
|
||||
query = SignUser
|
||||
if group_id:
|
||||
@@ -78,17 +80,23 @@ class SignManage:
|
||||
data_list = []
|
||||
platform = PlatformUtils.get_platform(session)
|
||||
for i, user in enumerate(user_list):
|
||||
bytes = await PlatformUtils.get_user_avatar(
|
||||
user[0], platform, session.self_id
|
||||
avatar_path = await avatar_service.get_avatar_path(
|
||||
platform=user[3] or "qq", identifier=user[0]
|
||||
)
|
||||
data_list.append(
|
||||
[
|
||||
f"{i + 1}",
|
||||
(bytes, 30, 30) if user[3] == "qq" else "",
|
||||
uid2name.get(user[0]),
|
||||
user[1],
|
||||
user[2],
|
||||
(PLATFORM_PATH.get(user[3]), 30, 30),
|
||||
TextCell(content=f"{i + 1}"),
|
||||
ImageCell(
|
||||
src=avatar_path.as_uri() if avatar_path else "", shape="circle"
|
||||
)
|
||||
if avatar_path
|
||||
else TextCell(content=""),
|
||||
TextCell(content=uid2name.get(user[0]) or user[0]),
|
||||
TextCell(content=str(user[1]), bold=True),
|
||||
TextCell(content=str(user[2])),
|
||||
ImageCell(src=platform_path.resolve().as_uri())
|
||||
if (platform_path := PLATFORM_PATH.get(platform))
|
||||
else TextCell(content=""),
|
||||
]
|
||||
)
|
||||
if group_id:
|
||||
@@ -97,7 +105,11 @@ class SignManage:
|
||||
else:
|
||||
title = "好感度全局排行"
|
||||
tip = f"你的排名在全局第 {index} 位哦!"
|
||||
return await ImageTemplate.table_page(title, tip, column_name, data_list)
|
||||
from zhenxun.ui.builders import TableBuilder
|
||||
|
||||
builder = TableBuilder(title, tip)
|
||||
builder.set_headers(column_name).add_rows(data_list)
|
||||
return await ui.render(builder.build())
|
||||
|
||||
@classmethod
|
||||
async def sign(
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
from zhenxun.configs.path_config import IMAGE_PATH
|
||||
|
||||
SIGN_RESOURCE_PATH = IMAGE_PATH / "sign" / "sign_res"
|
||||
SIGN_TODAY_CARD_PATH = IMAGE_PATH / "sign" / "today_card"
|
||||
SIGN_BORDER_PATH = SIGN_RESOURCE_PATH / "border"
|
||||
SIGN_BACKGROUND_PATH = SIGN_RESOURCE_PATH / "background"
|
||||
|
||||
SIGN_BORDER_PATH.mkdir(exist_ok=True, parents=True)
|
||||
SIGN_BACKGROUND_PATH.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
|
||||
lik2relation = {
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
|
||||
import aiofiles
|
||||
import nonebot
|
||||
from nonebot.drivers import Driver
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
from nonebot_plugin_uninfo import Uninfo
|
||||
import pytz
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.configs.config import BotConfig, Config
|
||||
from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH
|
||||
from zhenxun.models.sign_log import SignLog
|
||||
from zhenxun.models.sign_user import SignUser
|
||||
from zhenxun.utils.http_utils import AsyncHttpx
|
||||
from zhenxun.utils.image_utils import BuildImage
|
||||
from zhenxun.services import avatar_service
|
||||
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
from .config import (
|
||||
SIGN_BACKGROUND_PATH,
|
||||
SIGN_BORDER_PATH,
|
||||
SIGN_RESOURCE_PATH,
|
||||
SIGN_TODAY_CARD_PATH,
|
||||
level2attitude,
|
||||
lik2level,
|
||||
@@ -57,9 +50,7 @@ LG_MESSAGE = [
|
||||
|
||||
@PriorityLifecycle.on_startup(priority=5)
|
||||
async def init_image():
|
||||
SIGN_RESOURCE_PATH.mkdir(parents=True, exist_ok=True)
|
||||
SIGN_TODAY_CARD_PATH.mkdir(exist_ok=True, parents=True)
|
||||
# await generate_progress_bar_pic()
|
||||
clear_sign_data_pic()
|
||||
|
||||
|
||||
@@ -88,290 +79,54 @@ async def get_card(
|
||||
返回:
|
||||
Path: 卡片路径
|
||||
"""
|
||||
await generate_progress_bar_pic()
|
||||
user_id = user.user_id
|
||||
date = datetime.now().date()
|
||||
_type = "view" if is_card_view else "sign"
|
||||
file_name = f"{user_id}_{_type}_{date}.png"
|
||||
view_name = f"{user_id}_view_{date}.png"
|
||||
card_file = Path(SIGN_TODAY_CARD_PATH) / file_name
|
||||
card_file = SIGN_TODAY_CARD_PATH / file_name
|
||||
|
||||
if card_file.exists():
|
||||
return IMAGE_PATH / "sign" / "today_card" / file_name
|
||||
return card_file
|
||||
|
||||
if add_impression == -1:
|
||||
card_file = Path(SIGN_TODAY_CARD_PATH) / view_name
|
||||
if card_file.exists():
|
||||
return card_file
|
||||
view_name = f"{user_id}_view_{date}.png"
|
||||
view_card_file = SIGN_TODAY_CARD_PATH / view_name
|
||||
if view_card_file.exists():
|
||||
return view_card_file
|
||||
is_card_view = True
|
||||
return (
|
||||
await _generate_html_card(
|
||||
user, session, nickname, add_impression, gold, gift, is_double, is_card_view
|
||||
)
|
||||
if base_config.get("IMAGE_STYLE") == "zhenxun"
|
||||
else await _generate_card(
|
||||
user, session, nickname, add_impression, gold, gift, is_double, is_card_view
|
||||
)
|
||||
|
||||
return await _generate_html_card(
|
||||
user, session, nickname, add_impression, gold, gift, is_double, is_card_view
|
||||
)
|
||||
|
||||
|
||||
async def _generate_card(
|
||||
user: SignUser,
|
||||
session: Uninfo,
|
||||
nickname: str,
|
||||
add_impression: float,
|
||||
gold: int | None,
|
||||
gift: str,
|
||||
is_double: bool = False,
|
||||
is_card_view: bool = False,
|
||||
) -> Path:
|
||||
"""生成签到卡片
|
||||
|
||||
参数:
|
||||
user: SignUser
|
||||
session: Uninfo
|
||||
nickname: 用户昵称
|
||||
add_impression: 新增的好感度
|
||||
gold: 金币
|
||||
gift: 礼物
|
||||
is_double: 是否触发双倍.
|
||||
is_card_view: 是否展示好感度卡片.
|
||||
|
||||
返回:
|
||||
Path: 卡片路径
|
||||
"""
|
||||
ava_bk = BuildImage(140, 140, (255, 255, 255, 0))
|
||||
ava_border = BuildImage(
|
||||
140,
|
||||
140,
|
||||
background=SIGN_BORDER_PATH / "ava_border_01.png",
|
||||
)
|
||||
if session.user.avatar and (
|
||||
byt := await AsyncHttpx.get_content(session.user.avatar)
|
||||
):
|
||||
ava = BuildImage(107, 107, background=BytesIO(byt))
|
||||
else:
|
||||
ava = BuildImage(107, 107, (0, 0, 0))
|
||||
await ava.circle()
|
||||
await ava_bk.paste(ava, (19, 18))
|
||||
await ava_bk.paste(ava_border, center_type="center")
|
||||
impression = float(user.impression)
|
||||
info_img = BuildImage(250, 150, color=(255, 255, 255, 0), font_size=15)
|
||||
level, next_impression, previous_impression = get_level_and_next_impression(
|
||||
impression
|
||||
)
|
||||
interpolation = next_impression - impression
|
||||
await info_img.text((0, 0), f"· 好感度等级:{level} [{lik2relation[level]}]")
|
||||
await info_img.text(
|
||||
(0, 20), f"· {BotConfig.self_nickname}对你的态度:{level2attitude[level]}"
|
||||
)
|
||||
await info_img.text((0, 40), f"· 距离升级还差 {interpolation:.2f} 好感度")
|
||||
|
||||
bar_bk = BuildImage(220, 20, background=SIGN_RESOURCE_PATH / "bar_white.png")
|
||||
bar = BuildImage(220, 20, background=SIGN_RESOURCE_PATH / "bar.png")
|
||||
ratio = 1 - (next_impression - impression) / (next_impression - previous_impression)
|
||||
if next_impression == 0:
|
||||
ratio = 0
|
||||
await bar.resize(width=int(bar.width * ratio) or 1, height=bar.height)
|
||||
await bar_bk.paste(bar)
|
||||
font_size = 20 if "好感度双倍加持卡" in gift else 30
|
||||
gift_border = BuildImage(
|
||||
270,
|
||||
100,
|
||||
background=SIGN_BORDER_PATH / "gift_border_02.png",
|
||||
font_size=font_size,
|
||||
)
|
||||
await gift_border.text((0, 0), gift, center_type="center")
|
||||
|
||||
bk = BuildImage(
|
||||
876,
|
||||
424,
|
||||
background=SIGN_BACKGROUND_PATH
|
||||
/ random.choice(os.listdir(SIGN_BACKGROUND_PATH)),
|
||||
font_size=25,
|
||||
)
|
||||
A = BuildImage(876, 274, background=SIGN_RESOURCE_PATH / "white.png")
|
||||
line = BuildImage(2, 180, color="black")
|
||||
await A.transparent(2)
|
||||
await A.paste(ava_bk, (25, 80))
|
||||
await A.paste(line, (200, 70))
|
||||
nickname_img = await BuildImage.build_text_image(
|
||||
nickname, size=50, font_color=(255, 255, 255)
|
||||
)
|
||||
user_console = await user.user_console
|
||||
if user_console and user_console.uid is not None:
|
||||
uid = f"{user_console.uid}".rjust(12, "0")
|
||||
uid = f"{uid[:4]} {uid[4:8]} {uid[8:]}"
|
||||
else:
|
||||
uid = "XXXX XXXX XXXX"
|
||||
uid_img = await BuildImage.build_text_image(
|
||||
f"UID: {uid}", size=30, font_color=(255, 255, 255)
|
||||
)
|
||||
image1 = await bk.build_text_image("Accumulative check-in for", bk.font, size=30)
|
||||
image2 = await bk.build_text_image("days", bk.font, size=30)
|
||||
sign_day_img = await BuildImage.build_text_image(
|
||||
f"{user.sign_count}", size=40, font_color=(211, 64, 33)
|
||||
)
|
||||
tip_width = image1.width + image2.width + sign_day_img.width + 60
|
||||
tip_height = max([image1.height, image2.height, sign_day_img.height])
|
||||
tip_image = BuildImage(tip_width, tip_height, (255, 255, 255, 0))
|
||||
await tip_image.paste(image1, (0, 7))
|
||||
await tip_image.paste(sign_day_img, (image1.width + 7, 0))
|
||||
await tip_image.paste(image2, (image1.width + sign_day_img.width + 15, 7))
|
||||
|
||||
lik_text1_img = await BuildImage.build_text_image("当前", size=20)
|
||||
lik_text2_img = await BuildImage.build_text_image(
|
||||
f"好感度:{user.impression:.2f}", size=30
|
||||
)
|
||||
watermark = await BuildImage.build_text_image(
|
||||
f"{BotConfig.self_nickname}@{datetime.now().year}",
|
||||
size=15,
|
||||
font_color=(155, 155, 155),
|
||||
)
|
||||
today_data = BuildImage(300, 300, color=(255, 255, 255, 0), font_size=20)
|
||||
if is_card_view:
|
||||
today_sign_text_img = await BuildImage.build_text_image("", size=30)
|
||||
value_list = (
|
||||
await SignUser.annotate()
|
||||
.order_by("-impression")
|
||||
.values_list("user_id", flat=True)
|
||||
)
|
||||
index = value_list.index(user.user_id) + 1 # type: ignore
|
||||
rank_img = await BuildImage.build_text_image(
|
||||
f"* 好感度排名第 {index} 位", size=30
|
||||
)
|
||||
await A.paste(rank_img, ((A.width - rank_img.width - 32), 20))
|
||||
last_log = (
|
||||
await SignLog.filter(user_id=user.user_id).order_by("create_time").first()
|
||||
)
|
||||
last_date = "从未"
|
||||
if last_log:
|
||||
last_date = last_log.create_time.astimezone(
|
||||
pytz.timezone("Asia/Shanghai")
|
||||
).date()
|
||||
await today_data.text(
|
||||
(0, 0),
|
||||
f"上次签到日期:{last_date}",
|
||||
)
|
||||
await today_data.text((0, 25), f"总金币:{gold}")
|
||||
default_setu_prob = (
|
||||
Config.get_config("send_setu", "INITIAL_SETU_PROBABILITY") * 100 # type: ignore
|
||||
)
|
||||
setu_prob = (
|
||||
default_setu_prob + float(user.impression) if user.impression < 100 else 100
|
||||
)
|
||||
await today_data.text(
|
||||
(0, 50),
|
||||
f"色图概率:{setu_prob:.2f}%",
|
||||
)
|
||||
await today_data.text((0, 75), f"开箱次数:{(20 + int(user.impression / 3))}")
|
||||
_type = "view"
|
||||
else:
|
||||
await A.paste(gift_border, (570, 140))
|
||||
today_sign_text_img = await BuildImage.build_text_image("今日签到", size=30)
|
||||
if is_double:
|
||||
await today_data.text((0, 0), f"好感度 + {add_impression / 2:.2f} × 2")
|
||||
else:
|
||||
await today_data.text((0, 0), f"好感度 + {add_impression:.2f}")
|
||||
await today_data.text((0, 25), f"金币 + {gold}")
|
||||
_type = "sign"
|
||||
current_date = datetime.now()
|
||||
current_datetime_str = current_date.strftime("%Y-%m-%d %a %H:%M:%S")
|
||||
date = current_date.date()
|
||||
date_img = await BuildImage.build_text_image(
|
||||
f"时间:{current_datetime_str}", size=20
|
||||
)
|
||||
await bk.paste(nickname_img, (30, 15))
|
||||
await bk.paste(uid_img, (30, 85))
|
||||
await bk.paste(A, (0, 150))
|
||||
await bk.paste(tip_image, (10, 167))
|
||||
await bk.paste(date_img, (220, 370))
|
||||
await bk.paste(lik_text1_img, (220, 240))
|
||||
await bk.paste(lik_text2_img, (262, 234))
|
||||
await bk.paste(bar_bk, (225, 275))
|
||||
await bk.paste(info_img, (220, 305))
|
||||
await bk.paste(today_sign_text_img, (550, 180))
|
||||
await bk.paste(today_data, (580, 220))
|
||||
await bk.paste(watermark, (15, 400))
|
||||
await bk.save(SIGN_TODAY_CARD_PATH / f"{user.user_id}_{_type}_{date}.png")
|
||||
return IMAGE_PATH / "sign" / "today_card" / f"{user.user_id}_{_type}_{date}.png"
|
||||
|
||||
|
||||
async def generate_progress_bar_pic():
|
||||
"""
|
||||
初始化进度条图片
|
||||
"""
|
||||
bar_white_file = SIGN_RESOURCE_PATH / "bar_white.png"
|
||||
if bar_white_file.exists():
|
||||
return
|
||||
|
||||
bg_2 = (254, 1, 254)
|
||||
bg_1 = (0, 245, 246)
|
||||
|
||||
bk = BuildImage(1000, 50)
|
||||
img_x = BuildImage(50, 50, color=bg_2)
|
||||
await img_x.circle()
|
||||
await img_x.crop((25, 0, 50, 50))
|
||||
img_y = BuildImage(50, 50, color=bg_1)
|
||||
await img_y.circle()
|
||||
await img_y.crop((0, 0, 25, 50))
|
||||
A = BuildImage(950, 50)
|
||||
width, height = A.size
|
||||
|
||||
step_r = (bg_2[0] - bg_1[0]) / width
|
||||
step_g = (bg_2[1] - bg_1[1]) / width
|
||||
step_b = (bg_2[2] - bg_1[2]) / width
|
||||
|
||||
for y in range(width):
|
||||
bg_r = round(bg_1[0] + step_r * y)
|
||||
bg_g = round(bg_1[1] + step_g * y)
|
||||
bg_b = round(bg_1[2] + step_b * y)
|
||||
for x in range(height):
|
||||
await A.point((y, x), fill=(bg_r, bg_g, bg_b))
|
||||
await bk.paste(img_y, (0, 0))
|
||||
await bk.paste(A, (25, 0))
|
||||
await bk.paste(img_x, (975, 0))
|
||||
await bk.save(SIGN_RESOURCE_PATH / "bar.png")
|
||||
|
||||
A = BuildImage(950, 50)
|
||||
bk = BuildImage(1000, 50)
|
||||
img_x = BuildImage(50, 50)
|
||||
await img_x.circle()
|
||||
await img_x.crop((25, 0, 50, 50))
|
||||
img_y = BuildImage(50, 50)
|
||||
await img_y.circle()
|
||||
await img_y.crop((0, 0, 25, 50))
|
||||
await bk.paste(img_y, (0, 0))
|
||||
await bk.paste(A, (25, 0))
|
||||
await bk.paste(img_x, (975, 0))
|
||||
await bk.save(bar_white_file)
|
||||
|
||||
|
||||
def get_level_and_next_impression(impression: float) -> tuple[str, int | float, int]:
|
||||
def get_level_and_next_impression(impression: float) -> tuple[int, int | float, int]:
|
||||
"""获取当前好感等级与下一等级的差距
|
||||
|
||||
参数:
|
||||
impression: 好感度
|
||||
|
||||
返回:
|
||||
tuple[str, int, int]: 好感度等级,下一等级好感度要求,已达到的好感度要求
|
||||
tuple[int, int, int]: 好感度等级,下一等级好感度要求,已达到的好感度要求
|
||||
"""
|
||||
|
||||
keys = list(lik2level.keys())
|
||||
level, next_impression, previous_impression = (
|
||||
lik2level[keys[-1]],
|
||||
level_int, next_impression, previous_impression = (
|
||||
int(lik2level[keys[-1]]),
|
||||
keys[-2],
|
||||
keys[-1],
|
||||
)
|
||||
for i in range(len(keys)):
|
||||
if impression >= keys[i]:
|
||||
level, next_impression, previous_impression = (
|
||||
lik2level[keys[i]],
|
||||
level_int, next_impression, previous_impression = (
|
||||
int(lik2level[keys[i]]),
|
||||
keys[i - 1],
|
||||
keys[i],
|
||||
)
|
||||
if i == 0:
|
||||
next_impression = impression
|
||||
break
|
||||
return level, next_impression, previous_impression
|
||||
return level_int, next_impression, previous_impression
|
||||
|
||||
|
||||
def clear_sign_data_pic():
|
||||
@@ -394,7 +149,7 @@ async def _generate_html_card(
|
||||
is_double: bool = False,
|
||||
is_card_view: bool = False,
|
||||
) -> Path:
|
||||
"""生成签到卡片
|
||||
"""使用渲染服务生成签到卡片
|
||||
|
||||
参数:
|
||||
user: SignUser
|
||||
@@ -404,79 +159,133 @@ async def _generate_html_card(
|
||||
gold: 金币
|
||||
gift: 礼物
|
||||
is_double: 是否触发双倍.
|
||||
is_card_view: 是否展示好感度卡片.
|
||||
is_card_view: 是否为卡片视图.
|
||||
|
||||
返回:
|
||||
Path: 卡片路径
|
||||
"""
|
||||
now = datetime.now()
|
||||
date = now.date()
|
||||
_type = "view" if is_card_view else "sign"
|
||||
file_name = f"{user.user_id}_{_type}_{date}.png"
|
||||
card_file = SIGN_TODAY_CARD_PATH / file_name
|
||||
|
||||
if card_file.exists():
|
||||
return card_file
|
||||
|
||||
impression = float(user.impression)
|
||||
user_console = await user.user_console
|
||||
if user_console and user_console.uid is not None:
|
||||
uid = f"{user_console.uid}".rjust(12, "0")
|
||||
uid = f"{uid[:4]} {uid[4:8]} {uid[8:]}"
|
||||
uid_formatted = f"{uid[:4]} {uid[4:8]} {uid[8:]}"
|
||||
else:
|
||||
uid = "XXXX XXXX XXXX"
|
||||
uid_formatted = "XXXX XXXX XXXX"
|
||||
|
||||
level, next_impression, previous_impression = get_level_and_next_impression(
|
||||
impression
|
||||
)
|
||||
interpolation = next_impression - impression
|
||||
message = f"{BotConfig.self_nickname}希望你开心!"
|
||||
hour = datetime.now().hour
|
||||
if hour > 6 and hour < 10:
|
||||
message = random.choice(MORNING_MESSAGE)
|
||||
elif hour >= 0 and hour < 6:
|
||||
message = random.choice(LG_MESSAGE)
|
||||
_impression = f"{add_impression}(×2)" if is_double else add_impression
|
||||
process = 1 - (next_impression - impression) / (
|
||||
next_impression - previous_impression
|
||||
|
||||
attitude = f"对你的态度: {level2attitude.get(str(level), '未知')}"
|
||||
interpolation_val = max(0, next_impression - impression)
|
||||
interpolation = f"{interpolation_val:.2f}"
|
||||
|
||||
denominator = next_impression - previous_impression
|
||||
progress = (
|
||||
100.0
|
||||
if denominator == 0
|
||||
else min(100.0, ((impression - previous_impression) / denominator) * 100)
|
||||
)
|
||||
now = datetime.now()
|
||||
data = {
|
||||
"ava_url": PlatformUtils.get_user_avatar_url(
|
||||
user.user_id, PlatformUtils.get_platform(session), session.self_id
|
||||
),
|
||||
"name": nickname,
|
||||
"uid": uid,
|
||||
"sign_count": f"{user.sign_count}",
|
||||
"message": f"{BotConfig.self_nickname}说: {message}",
|
||||
"cur_impression": f"{impression:.2f}",
|
||||
"impression": f"好感度+{_impression}",
|
||||
"gold": f"金币+{gold}",
|
||||
"gift": gift,
|
||||
"level": f"{level} [{lik2relation[level]}]",
|
||||
"attitude": f"对你的态度: {level2attitude[level]}",
|
||||
"interpolation": f"{interpolation:.2f}",
|
||||
"heart2": [1 for _ in range(int(level))],
|
||||
"heart1": [1 for _ in range(len(lik2level) - int(level) - 1)],
|
||||
"process": process * 100,
|
||||
"date": str(now.replace(microsecond=0)),
|
||||
"font_size": 45,
|
||||
}
|
||||
|
||||
hour = now.hour
|
||||
if 6 < hour < 10:
|
||||
message = random.choice(MORNING_MESSAGE)
|
||||
elif 0 <= hour < 6:
|
||||
message = random.choice(LG_MESSAGE)
|
||||
else:
|
||||
message = f"{BotConfig.self_nickname}希望你开心!"
|
||||
bot_message = f"{BotConfig.self_nickname}说: {message}"
|
||||
|
||||
temperature = random.randint(1, 40)
|
||||
weather_icon_name = f"{random.randint(0, 11)}.png"
|
||||
tag_icon_name = f"{random.randint(0, 5)}.png"
|
||||
|
||||
font_size = 45
|
||||
if len(nickname) > 6:
|
||||
data["font_size"] = 27
|
||||
_type = "sign"
|
||||
font_size = 27
|
||||
|
||||
avatar_path = await avatar_service.get_avatar_path(
|
||||
PlatformUtils.get_platform(session), user.user_id
|
||||
)
|
||||
user_info = {
|
||||
"nickname": nickname,
|
||||
"uid_str": uid_formatted,
|
||||
"avatar_url": avatar_path.as_uri() if avatar_path else "",
|
||||
"sign_count": user.sign_count,
|
||||
"font_size": font_size,
|
||||
}
|
||||
|
||||
favorability_info = {
|
||||
"current": impression,
|
||||
"level": level,
|
||||
"level_text": f"{level} [{lik2relation.get(str(level), '未知')}]",
|
||||
"heart2": [1 for _ in range(level)],
|
||||
"heart1": [1 for _ in range(len(lik2level) - level - 1)],
|
||||
"next_level_at": next_impression,
|
||||
"previous_level_at": previous_impression,
|
||||
}
|
||||
|
||||
reward_info = None
|
||||
rank = None
|
||||
total_gold = None
|
||||
|
||||
if is_card_view:
|
||||
_type = "view"
|
||||
value_list = (
|
||||
await SignUser.annotate()
|
||||
.order_by("-impression")
|
||||
.values_list("user_id", flat=True)
|
||||
)
|
||||
index = value_list.index(user.user_id) + 1 # type: ignore
|
||||
data["impression"] = f"好感度排名第 {index} 位"
|
||||
data["gold"] = f"总金币:{gold}"
|
||||
data["gift"] = ""
|
||||
pic = await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "sign").absolute()),
|
||||
template_name="main.html",
|
||||
templates={"data": data},
|
||||
pages={
|
||||
"viewport": {"width": 465, "height": 926},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
image = BuildImage.open(pic)
|
||||
date = now.date()
|
||||
await image.save(SIGN_TODAY_CARD_PATH / f"{user.user_id}_{_type}_{date}.png")
|
||||
return IMAGE_PATH / "sign" / "today_card" / f"{user.user_id}_{_type}_{date}.png"
|
||||
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
|
||||
|
||||
reward_info = {
|
||||
"impression_added": 0,
|
||||
"gold_added": 0,
|
||||
"gift_received": "",
|
||||
"is_double": False,
|
||||
}
|
||||
|
||||
else:
|
||||
reward_info = {
|
||||
"impression_added": add_impression,
|
||||
"gold_added": gold or 0,
|
||||
"gift_received": gift,
|
||||
"is_double": is_double,
|
||||
}
|
||||
|
||||
page_info = {
|
||||
"date_str": str(now.replace(microsecond=0)),
|
||||
"weather_icon_name": weather_icon_name,
|
||||
"temperature": temperature,
|
||||
"tag_icon_name": tag_icon_name,
|
||||
}
|
||||
|
||||
card_data = {
|
||||
"is_card_view": is_card_view,
|
||||
"user": user_info,
|
||||
"favorability": favorability_info,
|
||||
"reward": reward_info,
|
||||
"page": page_info,
|
||||
"bot_message": bot_message,
|
||||
"attitude": attitude,
|
||||
"interpolation": interpolation,
|
||||
"progress": progress,
|
||||
"rank": rank,
|
||||
"total_gold": total_gold,
|
||||
}
|
||||
|
||||
image_bytes = await ui.render_template("pages/builtin/sign", data=card_data)
|
||||
|
||||
async with aiofiles.open(card_file, "wb") as f:
|
||||
await f.write(image_bytes)
|
||||
|
||||
return card_file
|
||||
|
||||
@@ -7,7 +7,6 @@ from zhenxun.models.statistics import Statistics
|
||||
from zhenxun.utils.echart_utils import ChartUtils
|
||||
from zhenxun.utils.echart_utils.models import Barh
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.image_utils import BuildImage
|
||||
from zhenxun.utils.time_utils import TimeUtils
|
||||
|
||||
|
||||
@@ -60,7 +59,7 @@ class StatisticsManage:
|
||||
@classmethod
|
||||
async def get_global_statistics(
|
||||
cls, plugin_name: str | None, day: int | None, title: str
|
||||
) -> BuildImage | str:
|
||||
) -> bytes | str:
|
||||
query = Statistics
|
||||
if plugin_name:
|
||||
query = query.filter(plugin_name=plugin_name)
|
||||
@@ -114,7 +113,7 @@ class StatisticsManage:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def __build_image(cls, data_list: list[tuple[str, int]], title: str):
|
||||
async def __build_image(cls, data_list: list[tuple[str, int]], title: str) -> bytes:
|
||||
module2count = {x[0]: x[1] for x in data_list}
|
||||
plugin_info = await PluginInfo.filter(
|
||||
module__in=module2count.keys(),
|
||||
|
||||
+18
-25
@@ -3,17 +3,13 @@ from nonebot.plugin import PluginMetadata
|
||||
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
|
||||
from nonebot_plugin_session import EventSession
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||
from zhenxun.configs.utils import PluginExtraData
|
||||
from zhenxun.services.help_service import create_plugin_help_image
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.exception import EmptyError
|
||||
from zhenxun.utils.message import MessageUtils
|
||||
|
||||
from .config import SUPERUSER_HELP_IMAGE
|
||||
from .normal_help import build_help
|
||||
from .zhenxun_help import build_html_help
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="超级用户帮助",
|
||||
description="超级用户帮助",
|
||||
@@ -24,17 +20,18 @@ __plugin_meta__ = PluginMetadata(
|
||||
author="HibiKier",
|
||||
version="0.1",
|
||||
plugin_type=PluginType.SUPERUSER,
|
||||
configs=[
|
||||
RegisterConfig(
|
||||
key="type",
|
||||
value="zhenxun",
|
||||
help="超级用户帮助样式,normal, zhenxun",
|
||||
default_value="zhenxun",
|
||||
)
|
||||
],
|
||||
).to_dict(),
|
||||
)
|
||||
|
||||
|
||||
async def build_html_help() -> bytes:
|
||||
"""构建超级用户帮助图片"""
|
||||
return await create_plugin_help_image(
|
||||
plugin_types=[PluginType.SUPERUSER, PluginType.SUPER_AND_ADMIN],
|
||||
page_title="超级用户帮助手册",
|
||||
)
|
||||
|
||||
|
||||
_matcher = on_alconna(
|
||||
Alconna("超级用户帮助"),
|
||||
permission=SUPERUSER,
|
||||
@@ -45,15 +42,11 @@ _matcher = on_alconna(
|
||||
|
||||
@_matcher.handle()
|
||||
async def _(session: EventSession, arparma: Arparma):
|
||||
if not SUPERUSER_HELP_IMAGE.exists():
|
||||
try:
|
||||
if Config.get_config("admin_help", "type") == "zhenxun":
|
||||
await build_html_help()
|
||||
else:
|
||||
await build_help()
|
||||
except EmptyError:
|
||||
await MessageUtils.build_message("当前超级用户帮助为空...").finish(
|
||||
reply_to=True
|
||||
)
|
||||
await MessageUtils.build_message(SUPERUSER_HELP_IMAGE).send()
|
||||
try:
|
||||
image_bytes = await build_html_help()
|
||||
await MessageUtils.build_message(image_bytes).send()
|
||||
except EmptyError:
|
||||
await MessageUtils.build_message("当前超级用户帮助为空...").finish(
|
||||
reply_to=True
|
||||
)
|
||||
logger.info("查看超级用户帮助", arparma.header_result, session=session)
|
||||
@@ -1,23 +0,0 @@
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from pydantic import BaseModel
|
||||
|
||||
from zhenxun.configs.path_config import IMAGE_PATH
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
|
||||
SUPERUSER_HELP_IMAGE = IMAGE_PATH / "SUPERUSER_HELP.png"
|
||||
if SUPERUSER_HELP_IMAGE.exists():
|
||||
SUPERUSER_HELP_IMAGE.unlink()
|
||||
|
||||
|
||||
class PluginData(BaseModel):
|
||||
"""
|
||||
插件信息
|
||||
"""
|
||||
|
||||
plugin: PluginInfo
|
||||
"""插件信息"""
|
||||
metadata: PluginMetadata
|
||||
"""元数据"""
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
@@ -1,127 +0,0 @@
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from PIL.ImageFont import FreeTypeFont
|
||||
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.models.task_info import TaskInfo
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils._build_image import BuildImage
|
||||
from zhenxun.utils.image_utils import build_sort_image, group_image, text2image
|
||||
|
||||
from .config import SUPERUSER_HELP_IMAGE
|
||||
from .utils import get_plugins
|
||||
|
||||
|
||||
async def build_usage_des_image(
|
||||
metadata: PluginMetadata,
|
||||
) -> tuple[BuildImage | None, BuildImage | None]:
|
||||
"""构建用法和描述图片
|
||||
|
||||
参数:
|
||||
metadata: PluginMetadata
|
||||
|
||||
返回:
|
||||
tuple[BuildImage | None, BuildImage | None]: 用法和描述图片
|
||||
"""
|
||||
usage = None
|
||||
description = None
|
||||
if metadata.usage:
|
||||
usage = await text2image(
|
||||
metadata.usage,
|
||||
padding=5,
|
||||
color=(255, 255, 255),
|
||||
font_color=(0, 0, 0),
|
||||
)
|
||||
if metadata.description:
|
||||
description = await text2image(
|
||||
metadata.description,
|
||||
padding=5,
|
||||
color=(255, 255, 255),
|
||||
font_color=(0, 0, 0),
|
||||
)
|
||||
return usage, description
|
||||
|
||||
|
||||
async def build_image(
|
||||
plugin: PluginInfo, metadata: PluginMetadata, font: FreeTypeFont
|
||||
) -> BuildImage:
|
||||
"""构建帮助图片
|
||||
|
||||
参数:
|
||||
plugin: PluginInfo
|
||||
metadata: PluginMetadata
|
||||
font: FreeTypeFont
|
||||
|
||||
返回:
|
||||
BuildImage: 帮助图片
|
||||
|
||||
"""
|
||||
usage, description = await build_usage_des_image(metadata)
|
||||
width = 0
|
||||
height = 100
|
||||
if usage:
|
||||
width = usage.width
|
||||
height += usage.height
|
||||
if description and description.width > width:
|
||||
width = description.width
|
||||
height += description.height
|
||||
font_width, _ = BuildImage.get_text_size(f"{plugin.name}[{plugin.level}]", font)
|
||||
if font_width > width:
|
||||
width = font_width
|
||||
A = BuildImage(width + 30, height + 120, "#EAEDF2")
|
||||
await A.text((15, 10), f"{plugin.name}[{plugin.level}]")
|
||||
await A.text((15, 70), "简介:")
|
||||
if not description:
|
||||
description = BuildImage(A.width - 30, 30, (255, 255, 255))
|
||||
await description.circle_corner(10)
|
||||
await A.paste(description, (15, 100))
|
||||
if not usage:
|
||||
usage = BuildImage(A.width - 30, 30, (255, 255, 255))
|
||||
await usage.circle_corner(10)
|
||||
await A.text((15, description.height + 115), "用法:")
|
||||
await A.paste(usage, (15, description.height + 145))
|
||||
await A.circle_corner(10)
|
||||
return A
|
||||
|
||||
|
||||
async def build_help():
|
||||
"""构造超级用户帮助图片
|
||||
|
||||
返回:
|
||||
BuildImage: 超级用户帮助图片
|
||||
"""
|
||||
font = BuildImage.load_font("HYWenHei-85W.ttf", 20)
|
||||
image_list = []
|
||||
for data in await get_plugins():
|
||||
plugin = data.plugin
|
||||
metadata = data.metadata
|
||||
try:
|
||||
A = await build_image(plugin, metadata, font)
|
||||
image_list.append(A)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"获取群超级用户插件 {plugin.module}: {plugin.name} 设置失败...",
|
||||
"超级用户帮助",
|
||||
e=e,
|
||||
)
|
||||
if task_list := await TaskInfo.all():
|
||||
task_str = "\n".join([task.name for task in task_list])
|
||||
task_str = "通过 开启/关闭群被动 来控制群被动\n----------\n" + task_str
|
||||
task_image = await text2image(task_str, padding=5, color=(255, 255, 255))
|
||||
await task_image.circle_corner(10)
|
||||
A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2")
|
||||
await A.text((25, 10), "被动技能")
|
||||
await A.paste(task_image, (25, 50))
|
||||
await A.circle_corner(10)
|
||||
image_list.append(A)
|
||||
image_group, _ = group_image(image_list)
|
||||
A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160)
|
||||
text = await BuildImage.build_text_image(
|
||||
"群超级用户帮助",
|
||||
size=40,
|
||||
)
|
||||
tip = await BuildImage.build_text_image(
|
||||
"注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red"
|
||||
)
|
||||
await A.paste(text, (50, 30))
|
||||
await A.paste(tip, (50, 90))
|
||||
await A.save(SUPERUSER_HELP_IMAGE)
|
||||
@@ -1,22 +0,0 @@
|
||||
import nonebot
|
||||
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.exception import EmptyError
|
||||
|
||||
from .config import PluginData
|
||||
|
||||
|
||||
async def get_plugins() -> list[PluginData]:
|
||||
"""获取插件数据"""
|
||||
plugin_list = await PluginInfo.filter(
|
||||
plugin_type__in=[PluginType.SUPERUSER, PluginType.SUPER_AND_ADMIN]
|
||||
).all()
|
||||
data_list = []
|
||||
for plugin in plugin_list:
|
||||
if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path):
|
||||
if _plugin.metadata:
|
||||
data_list.append(PluginData(plugin=plugin, metadata=_plugin.metadata))
|
||||
if not data_list:
|
||||
raise EmptyError()
|
||||
return data_list
|
||||
@@ -1,60 +0,0 @@
|
||||
from nonebot_plugin_htmlrender import template_to_pic
|
||||
|
||||
from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.configs.path_config import TEMPLATE_PATH
|
||||
from zhenxun.models.task_info import TaskInfo
|
||||
from zhenxun.utils._build_image import BuildImage
|
||||
|
||||
from .config import SUPERUSER_HELP_IMAGE
|
||||
from .utils import get_plugins
|
||||
|
||||
|
||||
async def get_task() -> dict[str, str] | None:
|
||||
"""获取被动技能帮助"""
|
||||
if task_list := await TaskInfo.all():
|
||||
return {
|
||||
"name": "被动技能",
|
||||
"description": "控制群组中的被动技能状态",
|
||||
"usage": "通过 开启/关闭群被动 来控制群被动 <br>"
|
||||
+ " 示例:开启/关闭群被动早晚安 <br> ---------- <br> "
|
||||
+ "<br>".join([task.name for task in task_list]),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
async def build_html_help():
|
||||
"""构建帮助图片"""
|
||||
plugins = await get_plugins()
|
||||
plugin_list = []
|
||||
for data in plugins:
|
||||
if data.metadata.extra:
|
||||
if superuser_help := data.metadata.extra.get("superuser_help"):
|
||||
data.metadata.usage += f"<br>以下为超级用户额外命令<br>{superuser_help}"
|
||||
plugin_list.append(
|
||||
{
|
||||
"name": data.plugin.name,
|
||||
"description": data.metadata.description.replace("\n", "<br>"),
|
||||
"usage": data.metadata.usage.replace("\n", "<br>"),
|
||||
}
|
||||
)
|
||||
if task := await get_task():
|
||||
plugin_list.append(task)
|
||||
plugin_list.sort(key=lambda p: len(p["description"]) + len(p["usage"]))
|
||||
pic = await template_to_pic(
|
||||
template_path=str((TEMPLATE_PATH / "help").absolute()),
|
||||
template_name="main.html",
|
||||
templates={
|
||||
"data": {
|
||||
"plugin_list": plugin_list,
|
||||
"nickname": BotConfig.self_nickname,
|
||||
"help_name": "超级用户",
|
||||
}
|
||||
},
|
||||
pages={
|
||||
"viewport": {"width": 824, "height": 10},
|
||||
"base_url": f"file://{TEMPLATE_PATH}",
|
||||
},
|
||||
wait=2,
|
||||
)
|
||||
result = await BuildImage.open(pic).resize(0.5)
|
||||
await result.save(SUPERUSER_HELP_IMAGE)
|
||||
@@ -0,0 +1,142 @@
|
||||
from nonebot.permission import SUPERUSER
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot.rule import to_me
|
||||
from nonebot_plugin_alconna import (
|
||||
Alconna,
|
||||
AlconnaMatch,
|
||||
Args,
|
||||
Arparma,
|
||||
Match,
|
||||
Subcommand,
|
||||
on_alconna,
|
||||
)
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||
from zhenxun.services import renderer_service
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.enum import PluginType
|
||||
from zhenxun.utils.message import MessageUtils
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="UI管理",
|
||||
description="管理UI、主题和渲染服务的相关配置",
|
||||
usage="""
|
||||
指令:
|
||||
ui reload / 重载主题: 重新加载当前主题的配置和资源。
|
||||
ui theme / 主题列表: 显示所有可用的主题,并高亮显示当前主题。
|
||||
ui theme [主题名称] / 切换主题 [主题名称]: 将UI主题切换为指定主题。
|
||||
""".strip(),
|
||||
extra=PluginExtraData(
|
||||
author="HibiKier",
|
||||
version="0.1",
|
||||
plugin_type=PluginType.SUPERUSER,
|
||||
configs=[
|
||||
RegisterConfig(
|
||||
module="UI",
|
||||
key="THEME",
|
||||
value="default",
|
||||
help="设置渲染服务使用的全局主题名称(对应 resources/themes/下的目录名)",
|
||||
default_value="default",
|
||||
type=str,
|
||||
),
|
||||
RegisterConfig(
|
||||
module="UI",
|
||||
key="CACHE",
|
||||
value=True,
|
||||
help="是否为渲染服务生成的图片启用文件缓存",
|
||||
default_value=True,
|
||||
type=bool,
|
||||
),
|
||||
RegisterConfig(
|
||||
module="UI",
|
||||
key="DEBUG_MODE",
|
||||
value=False,
|
||||
help="是否在日志中输出渲染组件的完整HTML源码,用于调试",
|
||||
default_value=False,
|
||||
type=bool,
|
||||
),
|
||||
],
|
||||
).to_dict(),
|
||||
)
|
||||
|
||||
|
||||
ui_matcher = on_alconna(
|
||||
Alconna(
|
||||
"ui",
|
||||
Subcommand("reload", help_text="重载当前主题"),
|
||||
Subcommand("theme", Args["theme_name?", str], help_text="查看或切换主题"),
|
||||
),
|
||||
aliases={"主题管理"},
|
||||
rule=to_me(),
|
||||
permission=SUPERUSER,
|
||||
priority=1,
|
||||
block=True,
|
||||
)
|
||||
|
||||
ui_matcher.shortcut("重载主题", command="ui reload")
|
||||
ui_matcher.shortcut("主题列表", command="ui theme")
|
||||
ui_matcher.shortcut("切换主题", command="ui theme", arguments=["{%0}"])
|
||||
|
||||
|
||||
@ui_matcher.assign("reload")
|
||||
async def handle_reload(arparma: Arparma):
|
||||
theme_name = await renderer_service.reload_theme()
|
||||
logger.info(
|
||||
f"UI主题已重载为: {theme_name}", "UI管理器", session=arparma.header_result
|
||||
)
|
||||
await MessageUtils.build_message(f"UI主题已成功重载为 '{theme_name}'!").send(
|
||||
reply_to=True
|
||||
)
|
||||
|
||||
|
||||
@ui_matcher.assign("theme")
|
||||
async def handle_theme(
|
||||
arparma: Arparma, theme_name_match: Match[str] = AlconnaMatch("theme_name")
|
||||
):
|
||||
if theme_name_match.available:
|
||||
new_theme_name = theme_name_match.result
|
||||
try:
|
||||
await renderer_service.switch_theme(new_theme_name)
|
||||
logger.info(
|
||||
f"UI主题已切换为: {new_theme_name}",
|
||||
"UI管理器",
|
||||
session=arparma.header_result,
|
||||
)
|
||||
await MessageUtils.build_message(
|
||||
f"🎨 主题已成功切换为 '{new_theme_name}'!"
|
||||
).send(reply_to=True)
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(
|
||||
f"尝试切换到不存在的主题: {new_theme_name}",
|
||||
"UI管理器",
|
||||
session=arparma.header_result,
|
||||
)
|
||||
await MessageUtils.build_message(str(e)).send(reply_to=True)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"切换主题时发生错误: {e}",
|
||||
"UI管理器",
|
||||
session=arparma.header_result,
|
||||
e=e,
|
||||
)
|
||||
await MessageUtils.build_message(f"切换主题失败: {e}").send(reply_to=True)
|
||||
else:
|
||||
try:
|
||||
available_themes = renderer_service.list_available_themes()
|
||||
current_theme = Config.get_config("UI", "THEME", "default")
|
||||
|
||||
theme_list_str = "\n".join(
|
||||
f" - {theme}{' <- 当前' if theme == current_theme else ''}"
|
||||
for theme in sorted(available_themes)
|
||||
)
|
||||
response = f"🎨 可用主题列表:\n{theme_list_str}"
|
||||
await MessageUtils.build_message(response).send(reply_to=True)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"获取主题列表时发生错误: {e}",
|
||||
"UI管理器",
|
||||
session=arparma.header_result,
|
||||
e=e,
|
||||
)
|
||||
await MessageUtils.build_message("获取主题列表失败。").send(reply_to=True)
|
||||
@@ -16,7 +16,7 @@ from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
from ....base_model import Result
|
||||
from ....config import QueryDateType
|
||||
from ....utils import authentication, clear_help_image, get_system_status
|
||||
from ....utils import authentication, get_system_status
|
||||
from .data_source import ApiDataSource
|
||||
from .model import (
|
||||
ActiveGroup,
|
||||
@@ -234,7 +234,6 @@ async def _(param: BotManageUpdateParam):
|
||||
bot_data.block_plugins = CommonUtils.convert_module_format(param.block_plugins)
|
||||
bot_data.block_tasks = CommonUtils.convert_module_format(param.block_tasks)
|
||||
await bot_data.save(update_fields=["block_plugins", "block_tasks"])
|
||||
clear_help_image()
|
||||
return Result.ok()
|
||||
except Exception as e:
|
||||
logger.error(f"{router.prefix}/update_bot_manage 调用错误", "WebUi", e=e)
|
||||
|
||||
@@ -7,7 +7,7 @@ from zhenxun.utils.enum import BlockType, PluginType
|
||||
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
|
||||
|
||||
from ....base_model import Result
|
||||
from ....utils import authentication, clear_help_image
|
||||
from ....utils import authentication
|
||||
from .data_source import ApiDataSource
|
||||
from .model import (
|
||||
BatchUpdatePlugins,
|
||||
@@ -82,7 +82,6 @@ async def _() -> Result[PluginCount]:
|
||||
async def _(param: UpdatePlugin) -> Result:
|
||||
try:
|
||||
await ApiDataSource.update_plugin(param)
|
||||
clear_help_image()
|
||||
return Result.ok(info="已经帮你写好啦!")
|
||||
except (ValueError, KeyError):
|
||||
return Result.fail("插件数据不存在...")
|
||||
@@ -110,7 +109,6 @@ async def _(param: PluginSwitch) -> Result:
|
||||
db_plugin.block_type = None
|
||||
db_plugin.status = True
|
||||
await db_plugin.save()
|
||||
clear_help_image()
|
||||
return Result.ok(info="成功改变了开关状态!")
|
||||
except Exception as e:
|
||||
logger.error(f"{router.prefix}/change_switch 调用错误", "WebUi", e=e)
|
||||
@@ -177,7 +175,6 @@ async def _(
|
||||
updated_count=result_dict["updated_count"],
|
||||
errors=result_dict["errors"],
|
||||
)
|
||||
clear_help_image()
|
||||
return Result.ok(result_model, "插件配置更新完成")
|
||||
except Exception as e:
|
||||
logger.error(f"{router.prefix}/plugins/batch_update 调用错误", "WebUi", e=e)
|
||||
@@ -197,7 +194,6 @@ async def _(payload: RenameMenuTypePayload) -> Result[str]:
|
||||
old_name=payload.old_name, new_name=payload.new_name
|
||||
)
|
||||
if result.get("success"):
|
||||
clear_help_image()
|
||||
return Result.ok(
|
||||
info=result.get(
|
||||
"info",
|
||||
|
||||
@@ -12,7 +12,7 @@ import psutil
|
||||
import ujson as json
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH
|
||||
from zhenxun.configs.path_config import DATA_PATH
|
||||
|
||||
from .base_model import SystemFolderSize, SystemStatus, User
|
||||
|
||||
@@ -68,22 +68,6 @@ def validate_path(path_str: str | None) -> tuple[Path | None, str | None]:
|
||||
return None, f"路径验证失败: {e!s}"
|
||||
|
||||
|
||||
GROUP_HELP_PATH = DATA_PATH / "group_help"
|
||||
SIMPLE_HELP_IMAGE = IMAGE_PATH / "SIMPLE_HELP.png"
|
||||
SIMPLE_DETAIL_HELP_IMAGE = IMAGE_PATH / "SIMPLE_DETAIL_HELP.png"
|
||||
|
||||
|
||||
def clear_help_image():
|
||||
"""清理帮助图片"""
|
||||
if SIMPLE_HELP_IMAGE.exists():
|
||||
SIMPLE_HELP_IMAGE.unlink()
|
||||
if SIMPLE_DETAIL_HELP_IMAGE.exists():
|
||||
SIMPLE_DETAIL_HELP_IMAGE.unlink()
|
||||
for file in GROUP_HELP_PATH.iterdir():
|
||||
if file.is_file():
|
||||
file.unlink()
|
||||
|
||||
|
||||
def get_user(uname: str) -> User | None:
|
||||
"""获取账号密码
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ DATA_PATH = Path() / "data"
|
||||
# 临时数据路径
|
||||
TEMP_PATH = Path() / "resources" / "temp"
|
||||
# 网页模板路径
|
||||
TEMPLATE_PATH = Path() / "resources" / "template"
|
||||
THEMES_PATH = Path() / "resources" / "themes"
|
||||
# [新增] UI渲染服务的统一缓存路径
|
||||
UI_CACHE_PATH = TEMP_PATH / "ui_cache"
|
||||
|
||||
|
||||
IMAGE_PATH.mkdir(parents=True, exist_ok=True)
|
||||
@@ -25,3 +27,4 @@ LOG_PATH.mkdir(parents=True, exist_ok=True)
|
||||
FONT_PATH.mkdir(parents=True, exist_ok=True)
|
||||
DATA_PATH.mkdir(parents=True, exist_ok=True)
|
||||
TEMP_PATH.mkdir(parents=True, exist_ok=True)
|
||||
UI_CACHE_PATH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing_extensions import Self
|
||||
|
||||
from tortoise import fields
|
||||
|
||||
from zhenxun.services.data_access import DataAccess
|
||||
from zhenxun.services.db_context import Model
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.enum import CacheType, DbLockType
|
||||
@@ -57,14 +58,15 @@ class BanConsole(Model):
|
||||
"""
|
||||
if not user_id and not group_id:
|
||||
raise UserAndGroupIsNone()
|
||||
dao = DataAccess(cls)
|
||||
if user_id:
|
||||
return (
|
||||
await cls.safe_get_or_none(user_id=user_id, group_id=group_id)
|
||||
await dao.safe_get_or_none(user_id=user_id, group_id=group_id)
|
||||
if group_id
|
||||
else await cls.safe_get_or_none(user_id=user_id, group_id__isnull=True)
|
||||
else await dao.safe_get_or_none(user_id=user_id, group_id__isnull=True)
|
||||
)
|
||||
else:
|
||||
return await cls.safe_get_or_none(user_id="", group_id=group_id)
|
||||
return await dao.safe_get_or_none(user_id="", group_id=group_id)
|
||||
|
||||
@classmethod
|
||||
async def check_ban_level(
|
||||
|
||||
@@ -7,6 +7,7 @@ from tortoise.backends.base.client import BaseDBAsyncClient
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.models.task_info import TaskInfo
|
||||
from zhenxun.services.cache import CacheRoot
|
||||
from zhenxun.services.data_access import DataAccess
|
||||
from zhenxun.services.db_context import Model
|
||||
from zhenxun.utils.enum import CacheType, DbLockType, PluginType
|
||||
|
||||
@@ -254,13 +255,14 @@ class GroupConsole(Model):
|
||||
返回:
|
||||
Self: GroupConsole
|
||||
"""
|
||||
dao = DataAccess(cls)
|
||||
if channel_id:
|
||||
return await cls.safe_get_or_none(
|
||||
return await dao.safe_get_or_none(
|
||||
group_id=group_id,
|
||||
channel_id=channel_id,
|
||||
clean_duplicates=clean_duplicates,
|
||||
)
|
||||
return await cls.safe_get_or_none(
|
||||
return await dao.safe_get_or_none(
|
||||
group_id=group_id,
|
||||
channel_id__isnull=True,
|
||||
clean_duplicates=clean_duplicates,
|
||||
|
||||
@@ -77,7 +77,7 @@ class PluginInfo(Model):
|
||||
返回:
|
||||
Self | None: 插件
|
||||
"""
|
||||
if filter_parent:
|
||||
if not kwargs.get("plugin_type") and filter_parent:
|
||||
return await cls.get_or_none(
|
||||
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
|
||||
)
|
||||
@@ -96,7 +96,7 @@ class PluginInfo(Model):
|
||||
返回:
|
||||
list[Self]: 插件列表
|
||||
"""
|
||||
if filter_parent:
|
||||
if not kwargs.get("plugin_type") and filter_parent:
|
||||
return await cls.filter(
|
||||
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
|
||||
).all()
|
||||
|
||||
@@ -18,6 +18,7 @@ require("nonebot_plugin_htmlrender")
|
||||
require("nonebot_plugin_uninfo")
|
||||
require("nonebot_plugin_waiter")
|
||||
|
||||
from .avatar_service import avatar_service
|
||||
from .db_context import Model, disconnect, with_db_timeout
|
||||
from .llm import (
|
||||
AI,
|
||||
@@ -43,6 +44,7 @@ from .llm import (
|
||||
)
|
||||
from .log import logger
|
||||
from .plugin_init import PluginInit, PluginInitManager
|
||||
from .renderer import renderer_service
|
||||
from .scheduler import scheduler_manager
|
||||
|
||||
__all__ = [
|
||||
@@ -56,6 +58,7 @@ __all__ = [
|
||||
"Model",
|
||||
"PluginInit",
|
||||
"PluginInitManager",
|
||||
"avatar_service",
|
||||
"chat",
|
||||
"clear_model_cache",
|
||||
"code",
|
||||
@@ -69,6 +72,7 @@ __all__ = [
|
||||
"list_available_models",
|
||||
"list_embedding_models",
|
||||
"logger",
|
||||
"renderer_service",
|
||||
"scheduler_manager",
|
||||
"search",
|
||||
"set_global_default_model_name",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
头像缓存服务
|
||||
|
||||
提供一个统一的、带缓存的头像获取服务,支持多平台和可配置的过期策略。
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from nonebot_plugin_apscheduler import scheduler
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.path_config import DATA_PATH
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.http_utils import AsyncHttpx
|
||||
from zhenxun.utils.platform import PlatformUtils
|
||||
|
||||
Config.add_plugin_config(
|
||||
"avatar_cache",
|
||||
"ENABLED",
|
||||
True,
|
||||
help="是否启用头像缓存功能",
|
||||
default_value=True,
|
||||
type=bool,
|
||||
)
|
||||
Config.add_plugin_config(
|
||||
"avatar_cache",
|
||||
"TTL_DAYS",
|
||||
7,
|
||||
help="头像缓存的有效期(天)",
|
||||
default_value=7,
|
||||
type=int,
|
||||
)
|
||||
Config.add_plugin_config(
|
||||
"avatar_cache",
|
||||
"CLEANUP_INTERVAL_HOURS",
|
||||
24,
|
||||
help="后台清理过期缓存的间隔时间(小时)",
|
||||
default_value=24,
|
||||
type=int,
|
||||
)
|
||||
|
||||
|
||||
class AvatarService:
|
||||
"""
|
||||
一个集中式的头像缓存服务,提供L1(内存)和L2(文件)两级缓存。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache_path = (DATA_PATH / "cache" / "avatars").resolve()
|
||||
self.cache_path.mkdir(parents=True, exist_ok=True)
|
||||
self._memory_cache: dict[str, Path] = {}
|
||||
|
||||
def _get_cache_path(self, platform: str, identifier: str) -> Path:
|
||||
"""
|
||||
根据平台和ID生成存储的文件路径。
|
||||
例如: data/cache/avatars/qq/123456789.png
|
||||
"""
|
||||
identifier = str(identifier)
|
||||
return self.cache_path / platform / f"{identifier}.png"
|
||||
|
||||
async def get_avatar_path(
|
||||
self, platform: str, identifier: str, force_refresh: bool = False
|
||||
) -> Path | None:
|
||||
"""
|
||||
获取用户或群组的头像本地路径。
|
||||
|
||||
参数:
|
||||
platform: 平台名称 (e.g., 'qq')
|
||||
identifier: 用户ID或群组ID
|
||||
force_refresh: 是否强制刷新缓存
|
||||
|
||||
返回:
|
||||
Path | None: 头像的本地文件路径,如果获取失败则返回None。
|
||||
"""
|
||||
if not Config.get_config("avatar_cache", "ENABLED"):
|
||||
return None
|
||||
|
||||
cache_key = f"{platform}-{identifier}"
|
||||
if not force_refresh and cache_key in self._memory_cache:
|
||||
if self._memory_cache[cache_key].exists():
|
||||
return self._memory_cache[cache_key]
|
||||
|
||||
local_path = self._get_cache_path(platform, identifier)
|
||||
ttl_seconds = Config.get_config("avatar_cache", "TTL_DAYS", 7) * 86400
|
||||
|
||||
if not force_refresh and local_path.exists():
|
||||
try:
|
||||
file_mtime = os.path.getmtime(local_path)
|
||||
if time.time() - file_mtime < ttl_seconds:
|
||||
self._memory_cache[cache_key] = local_path
|
||||
return local_path
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
avatar_url = PlatformUtils.get_user_avatar_url(identifier, platform)
|
||||
if not avatar_url:
|
||||
return None
|
||||
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if await AsyncHttpx.download_file(avatar_url, local_path):
|
||||
self._memory_cache[cache_key] = local_path
|
||||
return local_path
|
||||
else:
|
||||
logger.warning(f"下载头像失败: {avatar_url}", "AvatarService")
|
||||
return None
|
||||
|
||||
async def _cleanup_cache(self):
|
||||
"""后台定时清理过期的缓存文件"""
|
||||
if not Config.get_config("avatar_cache", "ENABLED"):
|
||||
return
|
||||
|
||||
logger.info("开始执行头像缓存清理任务...", "AvatarService")
|
||||
ttl_seconds = Config.get_config("avatar_cache", "TTL_DAYS", 7) * 86400
|
||||
now = time.time()
|
||||
deleted_count = 0
|
||||
for root, _, files in os.walk(self.cache_path):
|
||||
for name in files:
|
||||
file_path = Path(root) / name
|
||||
try:
|
||||
if now - os.path.getmtime(file_path) > ttl_seconds:
|
||||
file_path.unlink()
|
||||
deleted_count += 1
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"头像缓存清理完成,共删除 {deleted_count} 个过期文件。", "AvatarService"
|
||||
)
|
||||
|
||||
|
||||
avatar_service = AvatarService()
|
||||
|
||||
|
||||
@scheduler.scheduled_job(
|
||||
"interval", hours=Config.get_config("avatar_cache", "CLEANUP_INTERVAL_HOURS", 24)
|
||||
)
|
||||
async def _run_avatar_cache_cleanup():
|
||||
await avatar_service._cleanup_cache()
|
||||
+12
-15
@@ -37,7 +37,7 @@ class CacheDict(Generic[T]):
|
||||
return 0
|
||||
return data.expire_time
|
||||
|
||||
def __getitem__(self, key: str) -> T | None:
|
||||
def __getitem__(self, key: str) -> T:
|
||||
"""获取字典项
|
||||
|
||||
参数:
|
||||
@@ -47,8 +47,10 @@ class CacheDict(Generic[T]):
|
||||
T: 字典值
|
||||
"""
|
||||
if value := self._data.get(key):
|
||||
return value.value if self.expire_time(key) else None
|
||||
return None
|
||||
if self.expire_time(key):
|
||||
raise KeyError(f"键 {key} 已过期")
|
||||
return value.value
|
||||
raise KeyError(f"键 {key} 不存在")
|
||||
|
||||
def __setitem__(self, key: str, value: T) -> None:
|
||||
"""设置字典项
|
||||
@@ -78,16 +80,7 @@ class CacheDict(Generic[T]):
|
||||
返回:
|
||||
bool: 是否存在
|
||||
"""
|
||||
if key not in self._data:
|
||||
return False
|
||||
|
||||
# 检查是否过期
|
||||
data = self._data[key]
|
||||
if data.expire_time > 0 and data.expire_time < time.time():
|
||||
del self._data[key]
|
||||
return False
|
||||
|
||||
return True
|
||||
return False if key not in self._data else bool(self.expire_time(key))
|
||||
|
||||
def get(self, key: str, default: Any = None) -> T | None:
|
||||
"""获取字典项,如果不存在返回默认值
|
||||
@@ -99,8 +92,12 @@ class CacheDict(Generic[T]):
|
||||
返回:
|
||||
Any: 字典值或默认值
|
||||
"""
|
||||
value = self[key]
|
||||
return default if value is None else value
|
||||
if value := self._data.get(key):
|
||||
if self.expire_time(key):
|
||||
return default
|
||||
if not value:
|
||||
return default
|
||||
return default if value.value is None else value.value
|
||||
|
||||
def set(self, key: str, value: Any, expire: int | None = None):
|
||||
"""设置字典项
|
||||
|
||||
@@ -7,6 +7,8 @@ from zhenxun.services.log import logger
|
||||
|
||||
T = TypeVar("T", bound=Model)
|
||||
|
||||
cache = CacheRoot.cache_dict("DB_TEST_BAN", 10, int)
|
||||
|
||||
|
||||
class DataAccess(Generic[T]):
|
||||
"""数据访问层,根据配置决定是否使用缓存
|
||||
@@ -167,6 +169,7 @@ class DataAccess(Generic[T]):
|
||||
return await with_db_timeout(
|
||||
db_query_func(*args, **kwargs),
|
||||
operation=f"{self.model_cls.__name__}.{db_query_func.__name__}",
|
||||
source="DataAccess",
|
||||
)
|
||||
|
||||
# 尝试从缓存获取
|
||||
@@ -179,9 +182,10 @@ class DataAccess(Generic[T]):
|
||||
if cache_key is not None:
|
||||
data = await self.cache.get(cache_key)
|
||||
logger.debug(
|
||||
f"{self.model_cls.__name__} self.cache.get(cache_key)"
|
||||
f"{self.model_cls.__name__} key: {cache_key}"
|
||||
f" 从缓存获取到的数据 {type(data)}: {data}"
|
||||
)
|
||||
|
||||
if data == self._NULL_RESULT:
|
||||
# 空结果缓存命中
|
||||
self._cache_stats[self.cache_type]["null_hits"] += 1
|
||||
|
||||
@@ -227,6 +227,7 @@ class Model(TortoiseModel):
|
||||
return await with_db_timeout(
|
||||
cls.get_or_none(*args, using_db=using_db, **kwargs),
|
||||
operation=f"{cls.__name__}.get_or_none",
|
||||
source="DataBaseModel",
|
||||
)
|
||||
except MultipleObjectsReturned:
|
||||
# 如果出现多个记录的情况,进行特殊处理
|
||||
@@ -239,6 +240,7 @@ class Model(TortoiseModel):
|
||||
records = await with_db_timeout(
|
||||
cls.filter(*args, **kwargs).all(),
|
||||
operation=f"{cls.__name__}.filter.all",
|
||||
source="DataBaseModel",
|
||||
)
|
||||
|
||||
if not records:
|
||||
@@ -255,6 +257,7 @@ class Model(TortoiseModel):
|
||||
await with_db_timeout(
|
||||
record.delete(),
|
||||
operation=f"{cls.__name__}.delete_duplicate",
|
||||
source="DataBaseModel",
|
||||
)
|
||||
logger.info(
|
||||
f"{cls.__name__} 删除重复记录:"
|
||||
@@ -269,11 +272,13 @@ class Model(TortoiseModel):
|
||||
return await with_db_timeout(
|
||||
cls.filter(*args, **kwargs).order_by("-id").first(),
|
||||
operation=f"{cls.__name__}.filter.order_by.first",
|
||||
source="DataBaseModel",
|
||||
)
|
||||
# 如果没有 id 字段,则返回第一个记录
|
||||
return await with_db_timeout(
|
||||
cls.filter(*args, **kwargs).first(),
|
||||
operation=f"{cls.__name__}.filter.first",
|
||||
source="DataBaseModel",
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
|
||||
@@ -11,11 +11,15 @@ from .config import (
|
||||
|
||||
|
||||
async def with_db_timeout(
|
||||
coro, timeout: float = DB_TIMEOUT_SECONDS, operation: str | None = None
|
||||
coro,
|
||||
timeout: float = DB_TIMEOUT_SECONDS,
|
||||
operation: str | None = None,
|
||||
source: str | None = None,
|
||||
):
|
||||
"""带超时控制的数据库操作"""
|
||||
start_time = time.time()
|
||||
try:
|
||||
logger.debug(f"开始执行数据库操作: {operation} 来源: {source}")
|
||||
result = await asyncio.wait_for(coro, timeout=timeout)
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > SLOW_QUERY_THRESHOLD and operation:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
from collections import defaultdict
|
||||
|
||||
import nonebot
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from pydantic import BaseModel
|
||||
|
||||
from zhenxun import ui
|
||||
from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.models.plugin_info import PluginInfo
|
||||
from zhenxun.models.task_info import TaskInfo
|
||||
from zhenxun.ui.builders import PluginHelpPageBuilder
|
||||
from zhenxun.ui.models import HelpCategory, HelpItem
|
||||
from zhenxun.utils.common_utils import format_usage_for_markdown
|
||||
from zhenxun.utils.enum import PluginType
|
||||
|
||||
|
||||
class PluginData(BaseModel):
|
||||
plugin: PluginInfo
|
||||
metadata: PluginMetadata
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
async def _get_plugins_by_types(plugin_types: list[PluginType]) -> list[PluginData]:
|
||||
"""根据指定的插件类型列表获取插件数据"""
|
||||
plugin_list = await PluginInfo.filter(plugin_type__in=plugin_types).all()
|
||||
data_list = []
|
||||
for plugin in plugin_list:
|
||||
if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path):
|
||||
if _plugin.metadata:
|
||||
data_list.append(PluginData(plugin=plugin, metadata=_plugin.metadata))
|
||||
return data_list
|
||||
|
||||
|
||||
async def _get_task_category() -> dict:
|
||||
"""获取被动技能帮助类别"""
|
||||
task_items = []
|
||||
if task_list := await TaskInfo.all():
|
||||
task_names = "\n".join([task.name for task in task_list])
|
||||
task_items.append(
|
||||
{
|
||||
"name": "被动技能",
|
||||
"description": "控制群组中的被动技能状态",
|
||||
"usage": "通过 开启/关闭群被动 来控制群被动\n"
|
||||
+ " 示例:开启/关闭群被动早晚安\n 示例:开启/关闭全部群被动"
|
||||
+ " \n ---------- \n "
|
||||
+ task_names,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"title": "被动技能管理",
|
||||
"icon_svg_path": "M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z",
|
||||
"items": task_items,
|
||||
}
|
||||
|
||||
|
||||
async def create_plugin_help_image(
|
||||
plugin_types: list[PluginType], page_title: str
|
||||
) -> bytes:
|
||||
"""
|
||||
一个通用的函数,用于创建插件帮助图片。
|
||||
|
||||
参数:
|
||||
plugin_types: 要包含在帮助中的插件类型列表。
|
||||
page_title: 生成图片的标题。
|
||||
|
||||
返回:
|
||||
bytes: 生成的图片字节流。
|
||||
"""
|
||||
plugins_data = await _get_plugins_by_types(plugin_types)
|
||||
|
||||
grouped_plugins = defaultdict(list)
|
||||
for data in plugins_data:
|
||||
menu_type = data.plugin.menu_type or "功能"
|
||||
grouped_plugins[menu_type].append(
|
||||
HelpItem(
|
||||
name=data.plugin.name,
|
||||
description=format_usage_for_markdown(data.metadata.description),
|
||||
usage=format_usage_for_markdown(data.metadata.usage),
|
||||
)
|
||||
)
|
||||
|
||||
builder = PluginHelpPageBuilder(
|
||||
bot_nickname=BotConfig.self_nickname, page_title=page_title
|
||||
)
|
||||
|
||||
for menu_type, items in grouped_plugins.items():
|
||||
builder.add_category(
|
||||
HelpCategory(
|
||||
title=menu_type,
|
||||
icon_svg_path="M12,2L15.09,8.26L22,9.27L17,14.14L18.18,21.02L12,17.77L5.82,21.02L7,14.14L2,9.27L8.91,8.26L12,2Z",
|
||||
items=sorted(items, key=lambda x: x.name),
|
||||
)
|
||||
)
|
||||
|
||||
task_category_data = await _get_task_category()
|
||||
if task_category_data["items"]:
|
||||
task_items = [HelpItem(**item) for item in task_category_data["items"]]
|
||||
builder.add_category(
|
||||
HelpCategory(
|
||||
title=task_category_data["title"],
|
||||
icon_svg_path=task_category_data["icon_svg_path"],
|
||||
items=task_items,
|
||||
)
|
||||
)
|
||||
|
||||
image_bytes = await ui.render(builder.build(), use_cache=True)
|
||||
|
||||
return image_bytes
|
||||
@@ -87,7 +87,7 @@ class PluginInitManager:
|
||||
|
||||
@classmethod
|
||||
async def remove(cls, module_path: str):
|
||||
"""运行指定插件安装方法"""
|
||||
"""运行指定插件移除方法"""
|
||||
if model := cls.plugins.get(module_path):
|
||||
if model.remove:
|
||||
class_ = model.class_()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
|
||||
|
||||
from .service import RendererService
|
||||
|
||||
renderer_service = RendererService()
|
||||
|
||||
|
||||
@PriorityLifecycle.on_startup(priority=10)
|
||||
async def _init_renderer_service():
|
||||
"""在Bot启动时初始化渲染服务及其依赖。"""
|
||||
await renderer_service.initialize()
|
||||
|
||||
|
||||
__all__ = ["renderer_service"]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
渲染器服务的共享配置和常量
|
||||
"""
|
||||
|
||||
RESERVED_TEMPLATE_KEYS: set[str] = {
|
||||
"data",
|
||||
"theme",
|
||||
"theme_css",
|
||||
"extra_css",
|
||||
"required_scripts",
|
||||
"required_styles",
|
||||
"frameless",
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
from pathlib import Path
|
||||
|
||||
from nonebot_plugin_htmlrender import html_to_pic
|
||||
|
||||
from .protocols import ScreenshotEngine
|
||||
|
||||
|
||||
class PlaywrightEngine(ScreenshotEngine):
|
||||
"""使用 nonebot-plugin-htmlrender 实现的截图引擎。"""
|
||||
|
||||
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
|
||||
base_url_for_browser = base_url_path.absolute().as_uri()
|
||||
if not base_url_for_browser.endswith("/"):
|
||||
base_url_for_browser += "/"
|
||||
|
||||
final_render_options = {
|
||||
"viewport": {"width": 800, "height": 10},
|
||||
**render_options,
|
||||
"base_url": base_url_for_browser,
|
||||
}
|
||||
|
||||
return await html_to_pic(
|
||||
html=html,
|
||||
template_path=base_url_for_browser,
|
||||
**final_render_options,
|
||||
)
|
||||
|
||||
|
||||
def get_screenshot_engine() -> ScreenshotEngine:
|
||||
"""
|
||||
截图引擎工厂函数。
|
||||
目前只返回 PlaywrightEngine, 未来可以根据配置返回不同的引擎。
|
||||
"""
|
||||
return PlaywrightEngine()
|
||||
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Theme(BaseModel):
|
||||
"""
|
||||
一个封装了所有主题相关信息的模型。
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="主题名称")
|
||||
palette: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="主题的调色板,用于定义CSS变量和Jinja2模板中的颜色常量",
|
||||
)
|
||||
style_css: str = Field("", description="用于HTML渲染的全局CSS内容")
|
||||
assets_dir: Path = Field(..., description="主题的资产目录路径")
|
||||
default_assets_dir: Path = Field(
|
||||
..., description="默认主题的资产目录路径,用于资源回退"
|
||||
)
|
||||
|
||||
|
||||
class TemplateManifest(BaseModel):
|
||||
"""
|
||||
模板清单模型,用于描述一个模板的元数据。
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="模板的人类可读名称")
|
||||
engine: Literal["html", "markdown"] = Field(
|
||||
"html", description="渲染此模板所需的引擎"
|
||||
)
|
||||
entrypoint: str = Field(
|
||||
..., description="模板的入口文件 (例如 'template.html' 或 'renderer.py')"
|
||||
)
|
||||
styles: list[str] | str | None = Field(
|
||||
None,
|
||||
description="此组件依赖的CSS文件路径列表(相对于此manifest文件所在的组件根目录)",
|
||||
)
|
||||
render_options: dict[str, Any] = Field(
|
||||
default_factory=dict, description="传递给渲染引擎的额外选项 (如viewport)"
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Renderable(ABC):
|
||||
"""
|
||||
一个协议,定义了任何可被渲染的UI组件必须具备的形态。
|
||||
|
||||
该协议确保了所有UI组件都能被 `RendererService` 以统一的方式处理。
|
||||
任何想要被渲染服务处理的UI数据模型都应直接或间接实现此协议。
|
||||
"""
|
||||
|
||||
component_css: str | None
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def template_name(self) -> str:
|
||||
"""
|
||||
返回用于渲染此组件的Jinja2模板的路径。
|
||||
这是一个抽象属性,所有子类都必须覆盖它。
|
||||
|
||||
返回:
|
||||
str: 指向模板文件的相对路径,例如 'components/core/table'。
|
||||
"""
|
||||
...
|
||||
|
||||
async def prepare(self) -> None:
|
||||
"""
|
||||
[可选] 一个生命周期钩子,用于在渲染前执行异步数据获取和预处理。
|
||||
|
||||
此方法会在组件的数据被传递给模板之前调用。
|
||||
适合用于执行数据库查询、网络请求等耗时操作,以准备最终的渲染数据。
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_children(self) -> Iterable["Renderable"]:
|
||||
"""
|
||||
[新增] 返回一个包含所有直接子组件的可迭代对象。
|
||||
|
||||
这使得渲染服务能够递归地遍历整个组件树,以执行依赖收集(CSS、JS)等任务。
|
||||
非容器组件应返回一个空列表。
|
||||
"""
|
||||
...
|
||||
|
||||
def get_required_scripts(self) -> list[str]:
|
||||
"""[可选] 返回此组件所需的JS脚本路径列表 (相对于主题的assets目录)。"""
|
||||
return []
|
||||
|
||||
def get_required_styles(self) -> list[str]:
|
||||
"""[可选] 返回此组件所需的CSS样式表路径列表 (相对于主题的assets目录)。"""
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
def get_render_data(self) -> dict[str, Any | Awaitable[Any]]:
|
||||
"""
|
||||
返回一个将传递给模板的数据字典。
|
||||
重要:字典的值可以是协程(Awaitable),渲染服务会自动解析它们。
|
||||
|
||||
返回:
|
||||
dict[str, Any | Awaitable[Any]]: 用于模板渲染的上下文数据。
|
||||
"""
|
||||
...
|
||||
|
||||
def get_extra_css(self, context: Any) -> str | Awaitable[str]:
|
||||
"""
|
||||
[可选] 一个生命周期钩子,让组件可以提供额外的CSS。
|
||||
可以返回 str 或 awaitable[str]。
|
||||
|
||||
参数:
|
||||
context: 当前的渲染上下文对象,可用于访问主题管理器等。
|
||||
|
||||
返回:
|
||||
str | Awaitable[str]: 注入到页面的额外CSS字符串。
|
||||
"""
|
||||
return ""
|
||||
|
||||
|
||||
class ScreenshotEngine(Protocol):
|
||||
"""
|
||||
一个协议,定义了截图引擎的核心能力。
|
||||
这允许系统在不同的截图后端(如Playwright, Pyppeteer)之间切换,
|
||||
而无需修改上层渲染服务的代码。
|
||||
"""
|
||||
|
||||
async def render(self, html: str, base_url_path: Path, **render_options) -> bytes:
|
||||
"""
|
||||
将HTML字符串截图为图片。
|
||||
|
||||
参数:
|
||||
html: 要渲染的HTML内容。
|
||||
base_url_path: 用于解析相对路径(如CSS, JS, 图片)的基础URL路径。
|
||||
**render_options: 传递给底层截图库的额外选项 (如 viewport)。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的图片字节数据。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class RenderResult(BaseModel):
|
||||
"""
|
||||
渲染服务的统一返回类型。
|
||||
封装了渲染过程可能产出的所有结果,主要用于调试和内部传递。
|
||||
"""
|
||||
|
||||
image_bytes: bytes | None = None
|
||||
html_content: str | None = None
|
||||
@@ -0,0 +1,32 @@
|
||||
# File: zhenxun/services/renderer/registry.py
|
||||
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
|
||||
class AssetRegistry:
|
||||
"""一个独立的、用于存储由插件动态注册的资源的单例服务。"""
|
||||
|
||||
_markdown_styles: ClassVar[dict[str, Path]] = {}
|
||||
|
||||
def register_markdown_style(self, name: str, path: Path):
|
||||
"""
|
||||
为 Markdown 渲染器注册一个具名样式。
|
||||
|
||||
参数:
|
||||
name (str): 样式的唯一名称。
|
||||
path (Path): 指向该样式的CSS文件路径。
|
||||
"""
|
||||
if name in self._markdown_styles:
|
||||
logger.warning(f"Markdown 样式 '{name}' 已被注册,将被覆盖。")
|
||||
self._markdown_styles[name] = path
|
||||
logger.debug(f"已注册 Markdown 样式 '{name}' -> '{path}'")
|
||||
|
||||
def resolve_markdown_style(self, name: str) -> Path | None:
|
||||
"""解析已注册的 Markdown 样式。"""
|
||||
return self._markdown_styles.get(name)
|
||||
|
||||
|
||||
asset_registry = AssetRegistry()
|
||||
@@ -0,0 +1,596 @@
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import aiofiles
|
||||
from jinja2 import (
|
||||
ChoiceLoader,
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
PrefixLoader,
|
||||
TemplateNotFound,
|
||||
select_autoescape,
|
||||
)
|
||||
from nonebot.utils import is_coroutine_callable
|
||||
import ujson as json
|
||||
|
||||
from zhenxun.configs.config import Config
|
||||
from zhenxun.configs.path_config import THEMES_PATH, UI_CACHE_PATH
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.exception import RenderingError
|
||||
from zhenxun.utils.pydantic_compat import _dump_pydantic_obj
|
||||
|
||||
from .config import RESERVED_TEMPLATE_KEYS
|
||||
from .engine import get_screenshot_engine
|
||||
from .protocols import Renderable, RenderResult, ScreenshotEngine
|
||||
from .registry import asset_registry
|
||||
from .theme import RelativePathEnvironment, ThemeManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderContext:
|
||||
"""单次渲染任务的上下文对象,用于状态传递和缓存。"""
|
||||
|
||||
renderer: "RendererService"
|
||||
theme_manager: ThemeManager
|
||||
screenshot_engine: ScreenshotEngine
|
||||
component: Renderable
|
||||
use_cache: bool
|
||||
render_options: dict[str, Any]
|
||||
resolved_template_paths: dict[str, str] = field(default_factory=dict)
|
||||
resolved_style_paths: dict[str, Path | None] = field(default_factory=dict)
|
||||
collected_asset_styles: set[str] = field(default_factory=set)
|
||||
collected_scripts: set[str] = field(default_factory=set)
|
||||
collected_inline_css: list[str] = field(default_factory=list)
|
||||
processed_components: set[int] = field(default_factory=set)
|
||||
|
||||
|
||||
class RendererService:
|
||||
"""
|
||||
图片渲染服务的统一门面。
|
||||
|
||||
作为UI渲染的中心枢纽,负责编排和调用底层服务,提供统一的渲染接口。
|
||||
主要职责包括:
|
||||
- 管理和加载UI主题 (通过 ThemeManager)。
|
||||
- 使用Jinja2引擎将组件数据模型 (`Renderable`) 渲染为HTML。
|
||||
- 调用截图引擎 (ScreenshotEngine) 将HTML转换为图片。
|
||||
- 处理插件注册的模板、过滤器和全局函数。
|
||||
- (可选) 管理渲染结果的缓存。
|
||||
"""
|
||||
|
||||
_plugin_template_paths: ClassVar[dict[str, Path]] = {}
|
||||
|
||||
def __init__(self):
|
||||
self._jinja_env: Environment | None = None
|
||||
self._theme_manager: ThemeManager | None = None
|
||||
self._screenshot_engine: ScreenshotEngine | None = None
|
||||
self._initialized = False
|
||||
self._init_lock = asyncio.Lock()
|
||||
self._custom_filters: dict[str, Callable] = {}
|
||||
self._custom_globals: dict[str, Callable] = {}
|
||||
|
||||
self.filter("dump_json")(self._pydantic_tojson_filter)
|
||||
|
||||
def _create_jinja_env(self) -> Environment:
|
||||
"""
|
||||
创建并配置 Jinja2 渲染环境。
|
||||
|
||||
构建一个完整的 Jinja2 环境,包含:
|
||||
- PrefixLoader:用于插件模板的命名空间加载
|
||||
- FileSystemLoader:用于主题模板的文件系统加载
|
||||
- RelativePathEnvironment:支持模板间相对路径引用的自定义环境
|
||||
|
||||
返回:
|
||||
Environment: 完全配置好的 Jinja2 环境实例,准备接收自定义过滤器和全局函数。
|
||||
"""
|
||||
prefix_loader = PrefixLoader(
|
||||
{
|
||||
namespace: FileSystemLoader(str(path.absolute()))
|
||||
for namespace, path in self._plugin_template_paths.items()
|
||||
}
|
||||
)
|
||||
theme_loader = FileSystemLoader(str(THEMES_PATH / "default"))
|
||||
final_loader = ChoiceLoader([prefix_loader, theme_loader])
|
||||
|
||||
env = RelativePathEnvironment(
|
||||
loader=final_loader,
|
||||
enable_async=True,
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
return env
|
||||
|
||||
def register_template_namespace(self, namespace: str, path: Path):
|
||||
"""
|
||||
为插件注册一个Jinja2模板命名空间。
|
||||
|
||||
这允许插件在自己的目录中维护模板,并通过
|
||||
`{% include '@namespace/template.html' %}` 的方式引用它们,
|
||||
避免了与核心或其他插件的模板命名冲突。
|
||||
|
||||
参数:
|
||||
namespace: 插件的唯一命名空间,例如插件名。
|
||||
path: 包含该插件模板的目录路径。
|
||||
"""
|
||||
if namespace in self._plugin_template_paths:
|
||||
logger.warning(f"模板命名空间 '{namespace}' 已被注册,将被覆盖。")
|
||||
if not path.is_dir():
|
||||
raise ValueError(f"提供的路径 '{path}' 不是一个有效的目录。")
|
||||
self._plugin_template_paths[namespace] = path
|
||||
|
||||
def register_markdown_style(self, name: str, path: Path):
|
||||
"""
|
||||
为 Markdown 渲染器注册一个具名样式 (委托给 AssetRegistry)。
|
||||
|
||||
参数:
|
||||
name (str): 样式的唯一名称,例如 'cyberpunk'。
|
||||
path (Path): 指向该样式的CSS文件路径。
|
||||
"""
|
||||
if not path.is_file():
|
||||
raise ValueError(f"提供的路径 '{path}' 不是一个有效的 CSS 文件。")
|
||||
asset_registry.register_markdown_style(name, path)
|
||||
|
||||
def filter(self, name: str) -> Callable:
|
||||
"""
|
||||
装饰器:注册一个自定义 Jinja2 过滤器。
|
||||
|
||||
参数:
|
||||
name: 过滤器在模板中的调用名称。
|
||||
|
||||
返回:
|
||||
Callable: 用于装饰过滤器函数的装饰器。
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
if name in self._custom_filters:
|
||||
logger.warning(f"Jinja2 过滤器 '{name}' 已被注册,将被覆盖。")
|
||||
self._custom_filters[name] = func
|
||||
logger.debug(f"已注册自定义 Jinja2 过滤器: '{name}'")
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def global_function(self, name: str) -> Callable:
|
||||
"""
|
||||
装饰器:注册一个自定义 Jinja2 全局函数。
|
||||
|
||||
参数:
|
||||
name: 函数在模板中的调用名称。
|
||||
|
||||
返回:
|
||||
Callable: 用于装饰全局函数的装饰器。
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
if name in self._custom_globals:
|
||||
logger.warning(f"Jinja2 全局函数 '{name}' 已被注册,将被覆盖。")
|
||||
self._custom_globals[name] = func
|
||||
logger.debug(f"已注册自定义 Jinja2 全局函数: '{name}'")
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
[新增] 延迟初始化方法,在 on_startup 钩子中调用。
|
||||
|
||||
负责初始化截图引擎和主题管理器,确保在首次渲染前所有依赖都已准备就绪。
|
||||
使用锁来防止并发初始化。
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
async with self._init_lock:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._jinja_env = self._create_jinja_env()
|
||||
|
||||
self._jinja_env.filters.update(self._custom_filters)
|
||||
self._jinja_env.globals.update(self._custom_globals)
|
||||
|
||||
self._screenshot_engine = get_screenshot_engine()
|
||||
|
||||
self._theme_manager = ThemeManager(self._jinja_env)
|
||||
|
||||
current_theme_name = Config.get_config("UI", "THEME", "default")
|
||||
await self._theme_manager.load_theme(current_theme_name)
|
||||
self._initialized = True
|
||||
|
||||
async def _collect_dependencies_recursive(
|
||||
self, component: Renderable, context: "RenderContext"
|
||||
):
|
||||
"""
|
||||
递归遍历组件树,收集所有依赖项(CSS, JS, 额外CSS)并存入上下文。
|
||||
|
||||
这是实现组件化样式和脚本管理的基础,确保即使是深层嵌套的组件
|
||||
所需的资源也能被正确加载到最终的HTML页面中。
|
||||
"""
|
||||
component_id = id(component)
|
||||
if component_id in context.processed_components:
|
||||
return
|
||||
context.processed_components.add(component_id)
|
||||
|
||||
component_path_base = str(component.template_name)
|
||||
manifest = await context.theme_manager.get_template_manifest(
|
||||
component_path_base
|
||||
)
|
||||
|
||||
style_paths_to_load = []
|
||||
if manifest and manifest.styles:
|
||||
styles = (
|
||||
[manifest.styles]
|
||||
if isinstance(manifest.styles, str)
|
||||
else manifest.styles
|
||||
)
|
||||
for style_path in styles:
|
||||
full_style_path = str(Path(component_path_base) / style_path).replace(
|
||||
"\\", "/"
|
||||
)
|
||||
style_paths_to_load.append(full_style_path)
|
||||
else:
|
||||
resolved_template_name = (
|
||||
await context.theme_manager._resolve_component_template(
|
||||
component, context
|
||||
)
|
||||
)
|
||||
conventional_style_path = str(
|
||||
Path(resolved_template_name).with_name("style.css")
|
||||
).replace("\\", "/")
|
||||
style_paths_to_load.append(conventional_style_path)
|
||||
|
||||
for css_template_path in style_paths_to_load:
|
||||
try:
|
||||
css_template = context.theme_manager.jinja_env.get_template(
|
||||
css_template_path
|
||||
)
|
||||
theme_context = {
|
||||
"theme": context.theme_manager.jinja_env.globals.get("theme", {})
|
||||
}
|
||||
css_content = await css_template.render_async(**theme_context)
|
||||
context.collected_inline_css.append(css_content)
|
||||
except TemplateNotFound:
|
||||
pass
|
||||
|
||||
context.collected_scripts.update(component.get_required_scripts())
|
||||
context.collected_asset_styles.update(component.get_required_styles())
|
||||
|
||||
if hasattr(component, "get_extra_css"):
|
||||
res = component.get_extra_css(context)
|
||||
css_str = await res if inspect.isawaitable(res) else str(res)
|
||||
if css_str:
|
||||
context.collected_inline_css.append(css_str)
|
||||
|
||||
for child in component.get_children():
|
||||
if child:
|
||||
await self._collect_dependencies_recursive(child, context)
|
||||
|
||||
async def _render_component(
|
||||
self,
|
||||
context: "RenderContext",
|
||||
) -> RenderResult:
|
||||
"""
|
||||
核心的私有渲染方法,执行完整的渲染流程。
|
||||
|
||||
执行步骤:
|
||||
1. **缓存检查**: 如果启用缓存,则根据组件模板名和渲染数据生成缓存键,
|
||||
并尝试从文件系统中读取缓存图片。
|
||||
2. **组件准备**: 调用 `component.prepare()` 生命周期钩子,允许组件执行
|
||||
异步数据加载。
|
||||
3. **依赖收集**: 调用 `_collect_dependencies_recursive` 遍历组件树,
|
||||
收集所有需要的CSS文件、JS文件和内联CSS。
|
||||
4. **HTML渲染**: 调用 `ThemeManager` 将组件数据模型渲染为HTML字符串。
|
||||
此步骤会处理独立模板和主题内模板两种情况。
|
||||
5. **截图**: 调用 `ScreenshotEngine` 将生成的HTML转换为图片字节。
|
||||
6. **缓存写入**: 如果缓存未命中且启用了缓存,将生成的图片写入文件系统。
|
||||
"""
|
||||
return await self._apply_caching_layer(self._render_component_core, context)
|
||||
|
||||
async def _apply_caching_layer(
|
||||
self,
|
||||
core_render_func: Callable[..., Awaitable[RenderResult]],
|
||||
context: "RenderContext",
|
||||
) -> RenderResult:
|
||||
"""
|
||||
一个高阶函数,为核心渲染逻辑提供缓存层。
|
||||
它负责处理缓存的读取和写入,而将实际的渲染工作委托给传入的函数。
|
||||
"""
|
||||
cache_path = None
|
||||
component = context.component
|
||||
|
||||
if Config.get_config("UI", "CACHE") and context.use_cache:
|
||||
try:
|
||||
template_name = component.template_name
|
||||
data_dict = component.get_render_data()
|
||||
resolved_data_dict = {}
|
||||
for key, value in data_dict.items():
|
||||
if is_coroutine_callable(value): # type: ignore
|
||||
resolved_data_dict[key] = await value
|
||||
else:
|
||||
resolved_data_dict[key] = value
|
||||
data_str = json.dumps(resolved_data_dict, sort_keys=True)
|
||||
cache_key_str = f"{template_name}:{data_str}"
|
||||
cache_filename = (
|
||||
f"{hashlib.sha256(cache_key_str.encode()).hexdigest()}.png"
|
||||
)
|
||||
cache_path = UI_CACHE_PATH / cache_filename
|
||||
|
||||
if cache_path.exists():
|
||||
logger.debug(f"UI缓存命中: {cache_path}")
|
||||
async with aiofiles.open(cache_path, "rb") as f:
|
||||
image_bytes = await f.read()
|
||||
return RenderResult(
|
||||
image_bytes=image_bytes, html_content="<!-- from cache -->"
|
||||
)
|
||||
logger.debug(f"UI缓存未命中: {cache_key_str[:100]}...")
|
||||
except Exception as e:
|
||||
logger.warning(f"UI缓存读取失败: {e}", e=e)
|
||||
cache_path = None
|
||||
|
||||
result = await core_render_func(context)
|
||||
|
||||
if (
|
||||
Config.get_config("UI", "CACHE")
|
||||
and context.use_cache
|
||||
and cache_path
|
||||
and result.image_bytes
|
||||
):
|
||||
try:
|
||||
async with aiofiles.open(cache_path, "wb") as f:
|
||||
await f.write(result.image_bytes)
|
||||
logger.debug(f"UI缓存写入成功: {cache_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"UI缓存写入失败: {e}", e=e)
|
||||
|
||||
return result
|
||||
|
||||
async def _render_component_core(self, context: "RenderContext") -> RenderResult:
|
||||
"""
|
||||
纯粹的核心渲染逻辑,不包含任何缓存处理。
|
||||
此方法负责从组件数据模型生成最终的图片字节和HTML。
|
||||
"""
|
||||
component = context.component
|
||||
|
||||
try:
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert context.theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert context.screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
|
||||
if (
|
||||
hasattr(component, "template_path")
|
||||
and isinstance(
|
||||
template_path := getattr(component, "template_path"),
|
||||
Path,
|
||||
)
|
||||
and template_path.is_absolute()
|
||||
):
|
||||
await component.prepare()
|
||||
|
||||
logger.debug(f"正在渲染独立模板: '{template_path}'", "RendererService")
|
||||
|
||||
template_dir = template_path.parent
|
||||
temp_loader = FileSystemLoader(str(template_dir))
|
||||
temp_env = Environment(
|
||||
loader=temp_loader,
|
||||
enable_async=True,
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
|
||||
temp_env.globals.update(context.theme_manager.jinja_env.globals)
|
||||
temp_env.globals["asset"] = (
|
||||
context.theme_manager._create_standalone_asset_loader(template_dir)
|
||||
)
|
||||
temp_env.filters["md"] = context.theme_manager._markdown_filter
|
||||
|
||||
data_dict = component.get_render_data()
|
||||
template = temp_env.get_template(template_path.name)
|
||||
|
||||
template_context = {
|
||||
"theme": context.theme_manager.jinja_env.globals.get("theme", {}),
|
||||
"data": data_dict,
|
||||
}
|
||||
for key, value in data_dict.items():
|
||||
if key in RESERVED_TEMPLATE_KEYS:
|
||||
logger.warning(
|
||||
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
|
||||
f"在模板 '{component.template_name}' 中请使用 "
|
||||
f"'data.{key}' 访问。"
|
||||
)
|
||||
else:
|
||||
template_context[key] = value
|
||||
html_content = await template.render_async(**template_context)
|
||||
|
||||
component_render_options = data_dict.get("render_options", {})
|
||||
if not isinstance(component_render_options, dict):
|
||||
component_render_options = {}
|
||||
|
||||
final_render_options = component_render_options.copy()
|
||||
final_render_options.update(context.render_options)
|
||||
|
||||
image_bytes = await context.screenshot_engine.render(
|
||||
html=html_content,
|
||||
base_url_path=template_dir,
|
||||
**final_render_options,
|
||||
)
|
||||
|
||||
return RenderResult(image_bytes=image_bytes, html_content=html_content)
|
||||
|
||||
else:
|
||||
await component.prepare()
|
||||
await self._collect_dependencies_recursive(component, context)
|
||||
|
||||
data_dict = component.get_render_data()
|
||||
component_render_options = data_dict.get("render_options", {})
|
||||
if not isinstance(component_render_options, dict):
|
||||
component_render_options = {}
|
||||
|
||||
manifest_options = {}
|
||||
if manifest := await context.theme_manager.get_template_manifest(
|
||||
component.template_name
|
||||
):
|
||||
manifest_options = manifest.render_options or {}
|
||||
|
||||
final_render_options = component_render_options.copy()
|
||||
final_render_options.update(manifest_options)
|
||||
final_render_options.update(context.render_options)
|
||||
|
||||
if not context.theme_manager.current_theme:
|
||||
raise RenderingError("渲染失败:主题未被正确加载。")
|
||||
|
||||
html_content = await context.theme_manager._render_component_to_html(
|
||||
context,
|
||||
**final_render_options,
|
||||
)
|
||||
|
||||
screenshot_options = final_render_options.copy()
|
||||
screenshot_options.pop("extra_css", None)
|
||||
screenshot_options.pop("frameless", None)
|
||||
|
||||
image_bytes = await context.screenshot_engine.render(
|
||||
html=html_content,
|
||||
base_url_path=THEMES_PATH.parent,
|
||||
**screenshot_options,
|
||||
)
|
||||
|
||||
return RenderResult(image_bytes=image_bytes, html_content=html_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"渲染组件 '{component.__class__.__name__}' 时发生错误",
|
||||
"RendererService",
|
||||
e=e,
|
||||
)
|
||||
raise RenderingError(
|
||||
f"渲染组件 '{component.__class__.__name__}' 失败"
|
||||
) from e
|
||||
|
||||
async def render(
|
||||
self,
|
||||
component: Renderable,
|
||||
use_cache: bool = False,
|
||||
**render_options,
|
||||
) -> bytes:
|
||||
"""
|
||||
统一的、多态的渲染入口,直接返回图片字节。
|
||||
|
||||
参数:
|
||||
component: 一个 `Renderable` 实例 (例如通过 `TableBuilder().build()` 创建)。
|
||||
use_cache: (可选) 是否启用渲染缓存,默认为 False。
|
||||
**render_options: 传递给底层截图引擎的额外参数,例如 `viewport`。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的PNG图片字节数据。
|
||||
|
||||
异常:
|
||||
RenderingError: 当渲染流程中任何步骤失败时抛出。
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
|
||||
context = RenderContext(
|
||||
renderer=self,
|
||||
theme_manager=self._theme_manager,
|
||||
screenshot_engine=self._screenshot_engine,
|
||||
component=component,
|
||||
use_cache=use_cache,
|
||||
render_options=render_options,
|
||||
)
|
||||
result = await self._render_component(context)
|
||||
if Config.get_config("UI", "DEBUG_MODE") and result.html_content:
|
||||
logger.info(
|
||||
f"--- [UI DEBUG] HTML for {component.__class__.__name__} ---\n"
|
||||
f"{result.html_content}\n"
|
||||
f"--- [UI DEBUG] End of HTML ---"
|
||||
)
|
||||
if result.image_bytes is None:
|
||||
raise RenderingError("渲染成功但未能生成图片字节数据。")
|
||||
return result.image_bytes
|
||||
|
||||
async def render_to_html(
|
||||
self, component: Renderable, frameless: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
调试方法:只执行到HTML生成步骤,不进行截图。
|
||||
|
||||
参数:
|
||||
component: 一个 `Renderable` 实例。
|
||||
frameless: 是否以无边框模式渲染(只渲染HTML片段)。
|
||||
|
||||
返回:
|
||||
str: 最终渲染出的完整HTML字符串。
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert self._screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
|
||||
context = RenderContext(
|
||||
renderer=self,
|
||||
theme_manager=self._theme_manager,
|
||||
screenshot_engine=self._screenshot_engine,
|
||||
component=component,
|
||||
use_cache=False,
|
||||
render_options={"frameless": frameless},
|
||||
)
|
||||
await self._collect_dependencies_recursive(component, context)
|
||||
return await self._theme_manager._render_component_to_html(
|
||||
context, frameless=frameless
|
||||
)
|
||||
|
||||
async def reload_theme(self) -> str:
|
||||
"""
|
||||
重新加载当前主题的配置和样式,并清除缓存的Jinja环境。
|
||||
这在开发主题时非常有用,可以热重载主题更改。
|
||||
|
||||
返回:
|
||||
str: 已成功加载的主题名称。
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None, "ThemeManager 未初始化"
|
||||
|
||||
current_theme_name = Config.get_config("UI", "THEME", "default")
|
||||
await self._theme_manager.load_theme(current_theme_name)
|
||||
logger.info(f"主题 '{current_theme_name}' 已成功重载。")
|
||||
return current_theme_name
|
||||
|
||||
def list_available_themes(self) -> list[str]:
|
||||
"""获取所有可用主题的列表。"""
|
||||
if not self._initialized or not self._theme_manager:
|
||||
raise RuntimeError("ThemeManager尚未初始化。")
|
||||
return self._theme_manager.list_available_themes()
|
||||
|
||||
async def switch_theme(self, theme_name: str) -> str:
|
||||
"""
|
||||
切换UI主题,加载新主题并持久化配置。
|
||||
|
||||
返回:
|
||||
str: 已成功切换到的主题名称。
|
||||
"""
|
||||
if not self._initialized or not self._theme_manager:
|
||||
await self.initialize()
|
||||
assert self._theme_manager is not None
|
||||
|
||||
available_themes = self._theme_manager.list_available_themes()
|
||||
if theme_name not in available_themes:
|
||||
raise FileNotFoundError(
|
||||
f"主题 '{theme_name}' 不存在。可用主题: {', '.join(available_themes)}"
|
||||
)
|
||||
|
||||
await self._theme_manager.load_theme(theme_name)
|
||||
Config.set_config("UI", "THEME", theme_name, auto_save=True)
|
||||
logger.info(f"UI主题已切换为: {theme_name}")
|
||||
return theme_name
|
||||
|
||||
@staticmethod
|
||||
def _pydantic_tojson_filter(obj: Any) -> str:
|
||||
"""一个能够递归处理Pydantic模型及其集合的 tojson 过滤器"""
|
||||
dumped_obj = _dump_pydantic_obj(obj)
|
||||
return json.dumps(dumped_obj, ensure_ascii=False)
|
||||
@@ -0,0 +1,547 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiofiles
|
||||
from jinja2 import (
|
||||
ChoiceLoader,
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
PrefixLoader,
|
||||
TemplateNotFound,
|
||||
pass_context,
|
||||
)
|
||||
import markdown
|
||||
from markupsafe import Markup
|
||||
from pydantic import BaseModel
|
||||
import ujson as json
|
||||
|
||||
from zhenxun.configs.path_config import THEMES_PATH
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.services.renderer.models import TemplateManifest
|
||||
from zhenxun.services.renderer.protocols import Renderable
|
||||
from zhenxun.services.renderer.registry import asset_registry
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .service import RenderContext
|
||||
|
||||
from .config import RESERVED_TEMPLATE_KEYS
|
||||
|
||||
|
||||
class RelativePathEnvironment(Environment):
|
||||
"""
|
||||
一个自定义的 Jinja2 环境,重写了 join_path 方法以支持模板间的相对路径引用。
|
||||
"""
|
||||
|
||||
def join_path(self, template: str, parent: str) -> str:
|
||||
"""
|
||||
如果模板路径以 './' 或 '../' 开头,则视为相对于父模板的路径进行解析。
|
||||
否则,使用默认的解析行为。
|
||||
"""
|
||||
if template.startswith("./") or template.startswith("../"):
|
||||
path = os.path.normpath(os.path.join(os.path.dirname(parent), template))
|
||||
return path.replace(os.path.sep, "/")
|
||||
return super().join_path(template, parent)
|
||||
|
||||
|
||||
class Theme(BaseModel):
|
||||
name: str
|
||||
palette: dict[str, Any]
|
||||
style_css: str = ""
|
||||
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:
|
||||
def __init__(self, env: Environment):
|
||||
"""
|
||||
主题管理器,负责UI主题的加载、解析和模板渲染。
|
||||
|
||||
主要职责:
|
||||
- 加载和管理UI主题,包括 `palette.json` (调色板) 和 `theme.css.jinja`(主题样式)
|
||||
- 配置和持有核心的 Jinja2 环境实例。
|
||||
- 向 Jinja2 环境注入全局函数,如 `asset()` 和 `render()`,供模板使用。
|
||||
- 实现`asset()`函数的资源解析逻辑,支持皮肤、组件、主题和默认主题之间的资源回退
|
||||
- 封装将 `Renderable` 组件渲染为最终HTML的复杂逻辑。
|
||||
"""
|
||||
self.jinja_env = env
|
||||
self.current_theme: Theme | None = None
|
||||
|
||||
self.jinja_env.globals["render"] = self._global_render_component
|
||||
self.jinja_env.globals["asset"] = self._create_asset_loader()
|
||||
self.jinja_env.globals["resolve_template"] = self._resolve_component_template
|
||||
|
||||
self.jinja_env.filters["md"] = self._markdown_filter
|
||||
|
||||
def list_available_themes(self) -> list[str]:
|
||||
"""扫描主题目录并返回所有可用的主题名称。"""
|
||||
if not THEMES_PATH.is_dir():
|
||||
return []
|
||||
return [d.name for d in THEMES_PATH.iterdir() if d.is_dir()]
|
||||
|
||||
def _create_asset_loader(self) -> Callable[..., str]:
|
||||
"""
|
||||
创建一个闭包函数 (Jinja2中的 `asset()` 函数),使用
|
||||
ResourceResolver 进行路径解析。
|
||||
"""
|
||||
resolver = ResourceResolver(self)
|
||||
|
||||
@pass_context
|
||||
def asset_loader(ctx, asset_path: str) -> str:
|
||||
if not ctx.name:
|
||||
logger.warning("Jinja2 上下文缺少模板名称,无法进行资源解析。")
|
||||
return resolver.resolve_asset_uri(asset_path, "unknown_template")
|
||||
parent_template_name = ctx.name
|
||||
return resolver.resolve_asset_uri(asset_path, parent_template_name)
|
||||
|
||||
return asset_loader
|
||||
|
||||
def _create_standalone_asset_loader(
|
||||
self, local_base_path: Path
|
||||
) -> Callable[[str], str]:
|
||||
"""为独立模板创建一个专用的 asset loader。"""
|
||||
resolver = ResourceResolver(self)
|
||||
|
||||
def asset_loader(asset_path: str) -> str:
|
||||
return resolver.resolve_asset_uri(asset_path, str(local_base_path))
|
||||
|
||||
return asset_loader
|
||||
|
||||
async def _global_render_component(self, component: Renderable | None) -> str:
|
||||
"""
|
||||
一个全局的Jinja2函数,用于在模板内部渲染子组件
|
||||
它封装了查找模板、设置上下文和渲染的逻辑。
|
||||
"""
|
||||
if not component:
|
||||
return ""
|
||||
try:
|
||||
|
||||
class MockContext:
|
||||
def __init__(self):
|
||||
self.resolved_template_paths = {}
|
||||
self.theme_manager = self
|
||||
|
||||
mock_context = MockContext()
|
||||
template_path = await self._resolve_component_template(
|
||||
component,
|
||||
mock_context, # type: ignore
|
||||
)
|
||||
template = self.jinja_env.get_template(template_path)
|
||||
|
||||
template_context = {
|
||||
"data": component,
|
||||
"frameless": True,
|
||||
}
|
||||
render_data = component.get_render_data()
|
||||
template_context.update(render_data)
|
||||
|
||||
return Markup(await template.render_async(**template_context))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"在全局 render 函数中渲染组件 '{component.__class__.__name__}' 失败",
|
||||
e=e,
|
||||
)
|
||||
return f"<!-- 组件渲染失败{component.__class__.__name__}: {e} -->"
|
||||
|
||||
@staticmethod
|
||||
def _markdown_filter(text: str) -> str:
|
||||
"""一个将 Markdown 文本转换为 HTML 的 Jinja2 过滤器。"""
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
return markdown.markdown(
|
||||
text,
|
||||
extensions=[
|
||||
"pymdownx.tasklist",
|
||||
"tables",
|
||||
"fenced_code",
|
||||
"codehilite",
|
||||
"mdx_math",
|
||||
"pymdownx.tilde",
|
||||
],
|
||||
extension_configs={"mdx_math": {"enable_dollar_delimiter": True}},
|
||||
)
|
||||
|
||||
async def load_theme(self, theme_name: str = "default"):
|
||||
theme_dir = THEMES_PATH / theme_name
|
||||
if not theme_dir.is_dir():
|
||||
logger.error(f"主题 '{theme_name}' 不存在,将回退到默认主题。")
|
||||
if theme_name == "default":
|
||||
raise FileNotFoundError("默认主题 'default' 未找到!")
|
||||
theme_name = "default"
|
||||
theme_dir = THEMES_PATH / "default"
|
||||
|
||||
default_palette_path = THEMES_PATH / "default" / "palette.json"
|
||||
default_palette = (
|
||||
json.loads(default_palette_path.read_text("utf-8"))
|
||||
if default_palette_path.exists()
|
||||
else {}
|
||||
)
|
||||
if self.jinja_env.loader and isinstance(self.jinja_env.loader, ChoiceLoader):
|
||||
current_loaders = list(self.jinja_env.loader.loaders)
|
||||
if len(current_loaders) > 1 and isinstance(
|
||||
current_loaders[0], PrefixLoader
|
||||
):
|
||||
prefix_loader = current_loaders[0]
|
||||
new_theme_loader = FileSystemLoader(
|
||||
[str(theme_dir), str(THEMES_PATH / "default")]
|
||||
)
|
||||
self.jinja_env.loader.loaders = [prefix_loader, new_theme_loader]
|
||||
|
||||
palette_path = theme_dir / "palette.json"
|
||||
palette = (
|
||||
json.loads(palette_path.read_text("utf-8")) if palette_path.exists() else {}
|
||||
)
|
||||
|
||||
self.current_theme = Theme(
|
||||
name=theme_name,
|
||||
palette=palette,
|
||||
assets_dir=theme_dir / "assets",
|
||||
default_assets_dir=THEMES_PATH / "default" / "assets",
|
||||
)
|
||||
theme_context_dict = {
|
||||
"name": theme_name,
|
||||
"palette": palette,
|
||||
"assets_dir": theme_dir / "assets",
|
||||
"default_assets_dir": THEMES_PATH / "default" / "assets",
|
||||
}
|
||||
self.jinja_env.globals["theme"] = theme_context_dict
|
||||
self.jinja_env.globals["default_theme_palette"] = default_palette
|
||||
logger.info(f"主题管理器已加载主题: {theme_name}")
|
||||
|
||||
async def _resolve_component_template(
|
||||
self, component: Renderable, context: "RenderContext"
|
||||
) -> str:
|
||||
"""
|
||||
智能解析组件模板的路径,支持简单组件和带皮肤(variant)的复杂组件。
|
||||
|
||||
查找顺序如下:
|
||||
1. **带皮肤的组件**: 如果组件定义了 `variant`,则在
|
||||
`components/{component_name}/skins/{variant_name}/` 目录下查找入口文件。
|
||||
2. **标准组件**: 在组件的根目录 `components/{component_name}/` 下查找入口文件。
|
||||
3. **兼容模式**: (作为最终回退)直接查找名为`components/{component_name}.html`
|
||||
的文件
|
||||
|
||||
入口文件名默认为 `main.html`,但可以被组件目录下的 `manifest.json` 文件中的
|
||||
`entrypoint` 字段覆盖。
|
||||
"""
|
||||
component_path_base = str(component.template_name)
|
||||
|
||||
variant = getattr(component, "variant", None)
|
||||
cache_key = f"{component_path_base}::{variant or 'default'}"
|
||||
if cached_path := context.resolved_template_paths.get(cache_key):
|
||||
logger.trace(f"模板路径缓存命中: '{cache_key}' -> '{cached_path}'")
|
||||
return cached_path
|
||||
|
||||
if Path(component_path_base).suffix:
|
||||
try:
|
||||
self.jinja_env.get_template(component_path_base)
|
||||
logger.debug(f"解析到直接模板路径: '{component_path_base}'")
|
||||
return component_path_base
|
||||
except TemplateNotFound as e:
|
||||
logger.error(f"指定的模板文件路径不存在: '{component_path_base}'", e=e)
|
||||
raise e
|
||||
|
||||
entrypoint_filename = "main.html"
|
||||
manifest = await self.get_template_manifest(component_path_base)
|
||||
if manifest and manifest.entrypoint:
|
||||
entrypoint_filename = manifest.entrypoint
|
||||
|
||||
potential_paths = []
|
||||
|
||||
if variant:
|
||||
potential_paths.append(
|
||||
f"{component_path_base}/skins/{variant}/{entrypoint_filename}"
|
||||
)
|
||||
|
||||
potential_paths.append(f"{component_path_base}/{entrypoint_filename}")
|
||||
|
||||
if entrypoint_filename == "main.html":
|
||||
potential_paths.append(f"{component_path_base}.html")
|
||||
|
||||
for path in potential_paths:
|
||||
try:
|
||||
self.jinja_env.get_template(path)
|
||||
logger.debug(f"解析到模板路径: '{path}'")
|
||||
context.resolved_template_paths[cache_key] = path
|
||||
return path
|
||||
except TemplateNotFound:
|
||||
continue
|
||||
|
||||
err_msg = (
|
||||
f"无法为组件 '{component_path_base}' 找到任何可用的模板。"
|
||||
f"检查路径: {potential_paths}"
|
||||
)
|
||||
logger.error(err_msg)
|
||||
raise TemplateNotFound(err_msg)
|
||||
|
||||
async def get_template_manifest(
|
||||
self, component_path: str
|
||||
) -> TemplateManifest | None:
|
||||
"""
|
||||
查找并解析组件的 manifest.json 文件。
|
||||
"""
|
||||
manifest_path_str = f"{component_path}/manifest.json"
|
||||
|
||||
if not self.jinja_env.loader:
|
||||
return None
|
||||
|
||||
try:
|
||||
_, full_path, _ = self.jinja_env.loader.get_source(
|
||||
self.jinja_env, manifest_path_str
|
||||
)
|
||||
if full_path and Path(full_path).exists():
|
||||
async with aiofiles.open(full_path, encoding="utf-8") as f:
|
||||
manifest_data = json.loads(await f.read())
|
||||
return TemplateManifest(**manifest_data)
|
||||
except TemplateNotFound:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def resolve_markdown_style_path(
|
||||
self, style_name: str, context: "RenderContext"
|
||||
) -> Path | None:
|
||||
"""
|
||||
按照 注册 -> 主题约定 -> 默认约定 的顺序解析 Markdown 样式路径。
|
||||
[新逻辑] 使用传入的上下文进行缓存。
|
||||
"""
|
||||
if cached_path := context.resolved_style_paths.get(style_name):
|
||||
logger.trace(f"Markdown样式路径缓存命中: '{style_name}'")
|
||||
return cached_path
|
||||
|
||||
resolved_path: Path | None = None
|
||||
if registered_path := asset_registry.resolve_markdown_style(style_name):
|
||||
logger.debug(f"找到已注册的 Markdown 样式: '{style_name}'")
|
||||
resolved_path = registered_path
|
||||
|
||||
elif self.current_theme:
|
||||
theme_style_path = (
|
||||
self.current_theme.assets_dir
|
||||
/ "css"
|
||||
/ "styles"
|
||||
/ "markdown"
|
||||
/ f"{style_name}.css"
|
||||
)
|
||||
if theme_style_path.exists():
|
||||
logger.debug(
|
||||
f"在主题 '{self.current_theme.name}' 中找到"
|
||||
f"Markdown 样式: '{style_name}'"
|
||||
)
|
||||
resolved_path = theme_style_path
|
||||
|
||||
default_style_path = (
|
||||
self.current_theme.default_assets_dir
|
||||
/ "css"
|
||||
/ "styles"
|
||||
/ "markdown"
|
||||
/ f"{style_name}.css"
|
||||
)
|
||||
if not resolved_path and default_style_path.exists():
|
||||
logger.debug(f"在 'default' 主题中找到 Markdown 样式: '{style_name}'")
|
||||
resolved_path = default_style_path
|
||||
|
||||
if resolved_path:
|
||||
context.resolved_style_paths[style_name] = resolved_path
|
||||
else:
|
||||
logger.warning(
|
||||
f"Markdown 样式 '{style_name}' 在注册表和主题目录中均未找到。"
|
||||
)
|
||||
|
||||
return resolved_path
|
||||
|
||||
async def _render_component_to_html(
|
||||
self,
|
||||
context: "RenderContext",
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""将 Renderable 组件渲染成 HTML 字符串,并处理异步数据。"""
|
||||
component = context.component
|
||||
assert self.current_theme is not None, "主题加载失败"
|
||||
|
||||
data_dict = component.get_render_data()
|
||||
|
||||
theme_context_dict = model_dump(self.current_theme)
|
||||
|
||||
theme_css_template = self.jinja_env.get_template("theme.css.jinja")
|
||||
theme_css_content = await theme_css_template.render_async(
|
||||
theme=theme_context_dict
|
||||
)
|
||||
|
||||
resolved_template_name = await self._resolve_component_template(
|
||||
component, context
|
||||
)
|
||||
logger.debug(
|
||||
f"正在渲染组件 '{component.template_name}' "
|
||||
f"(主题: {self.current_theme.name}),解析模板: '{resolved_template_name}'",
|
||||
"渲染服务",
|
||||
)
|
||||
template = self.jinja_env.get_template(resolved_template_name)
|
||||
|
||||
unpacked_data = {}
|
||||
for key, value in data_dict.items():
|
||||
if key in RESERVED_TEMPLATE_KEYS:
|
||||
logger.warning(
|
||||
f"模板数据键 '{key}' 与渲染器保留关键字冲突,"
|
||||
f"在模板 '{component.template_name}' 中请使用 'data.{key}' 访问。"
|
||||
)
|
||||
else:
|
||||
unpacked_data[key] = value
|
||||
|
||||
template_context = {
|
||||
"data": component,
|
||||
"theme": theme_context_dict,
|
||||
"frameless": kwargs.get("frameless", False),
|
||||
}
|
||||
template_context.update(unpacked_data)
|
||||
template_context.update(kwargs)
|
||||
|
||||
html_fragment = await template.render_async(**template_context)
|
||||
|
||||
if not kwargs.get("frameless", False):
|
||||
base_template = self.jinja_env.get_template("partials/_base.html")
|
||||
page_context = {
|
||||
"data": component,
|
||||
"theme_css": theme_css_content,
|
||||
"collected_inline_css": context.collected_inline_css,
|
||||
"required_scripts": list(context.collected_scripts),
|
||||
"collected_asset_styles": list(context.collected_asset_styles),
|
||||
"body_content": html_fragment,
|
||||
}
|
||||
return await base_template.render_async(**page_context)
|
||||
else:
|
||||
return html_fragment
|
||||
@@ -0,0 +1,226 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from zhenxun.services.renderer.protocols import Renderable
|
||||
|
||||
from . import builders
|
||||
from .builders.core.layout import LayoutBuilder
|
||||
from .models.core.base import RenderableComponent
|
||||
from .models.core.markdown import MarkdownData
|
||||
from .models.core.template import TemplateComponent
|
||||
|
||||
|
||||
def template(path: str | Path, data: dict[str, Any]) -> TemplateComponent:
|
||||
"""
|
||||
创建一个基于独立模板文件的UI组件。
|
||||
适用于不希望遵循标准主题结构,而是直接渲染单个HTML文件的场景。
|
||||
|
||||
参数:
|
||||
path: 指向HTML模板文件的绝对或相对路径。
|
||||
data: 传递给模板的上下文数据字典。
|
||||
|
||||
返回:
|
||||
TemplateComponent: 一个可被 `render()` 函数处理的组件实例。
|
||||
"""
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
|
||||
return TemplateComponent(template_path=path, data=data)
|
||||
|
||||
|
||||
def markdown(content: str, style: str | Path | None = "default") -> MarkdownData:
|
||||
"""
|
||||
创建一个基于Markdown内容的UI组件。
|
||||
|
||||
参数:
|
||||
content: 要渲染的Markdown字符串。
|
||||
style: (可选) Markdown的样式名称(如 'github-light')或一个指向
|
||||
自定义CSS文件的路径。
|
||||
|
||||
返回:
|
||||
MarkdownData: 一个可被 `render()` 函数处理的组件实例。
|
||||
"""
|
||||
builder = builders.MarkdownBuilder().text(content)
|
||||
component = builder.build()
|
||||
if isinstance(style, Path):
|
||||
component.css_path = str(style.absolute())
|
||||
else:
|
||||
component.style_name = style
|
||||
return component
|
||||
|
||||
|
||||
def vstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
|
||||
"""
|
||||
创建一个垂直布局组件。
|
||||
便捷函数,用于将多个组件垂直堆叠。
|
||||
|
||||
参数:
|
||||
children: 一个包含 `RenderableComponent` 实例的列表。
|
||||
**layout_options: 传递给布局模板的额外选项,如 `padding`, `gap`。
|
||||
|
||||
返回:
|
||||
LayoutBuilder: 一个配置好的垂直布局构建器。
|
||||
"""
|
||||
builder = LayoutBuilder.column(**layout_options)
|
||||
for child in children:
|
||||
builder.add_item(child)
|
||||
return builder
|
||||
|
||||
|
||||
def hstack(children: list[RenderableComponent], **layout_options) -> "LayoutBuilder":
|
||||
"""
|
||||
创建一个水平布局组件。
|
||||
便捷函数,用于将多个组件水平排列。
|
||||
|
||||
参数:
|
||||
children: 一个包含 `RenderableComponent` 实例的列表。
|
||||
**layout_options: 传递给布局模板的额外选项,如 `padding`, `gap`。
|
||||
|
||||
返回:
|
||||
LayoutBuilder: 一个配置好的水平布局构建器。
|
||||
"""
|
||||
builder = LayoutBuilder.row(**layout_options)
|
||||
for child in children:
|
||||
builder.add_item(child)
|
||||
return builder
|
||||
|
||||
|
||||
async def render(
|
||||
component_or_path: Renderable | str | Path,
|
||||
data: dict | None = None,
|
||||
*,
|
||||
use_cache: bool = False,
|
||||
**kwargs,
|
||||
) -> bytes:
|
||||
"""
|
||||
统一的UI渲染入口。
|
||||
这是第三方开发者最常用的函数,用于将任何可渲染对象转换为图片。
|
||||
|
||||
用法:
|
||||
1. 渲染一个已构建的UI组件: `render(my_builder.build())`
|
||||
2. 直接渲染一个模板文件: `render("path/to/template", data={...})`
|
||||
|
||||
参数:
|
||||
component_or_path: 一个 `Renderable` 实例,或一个指向模板文件的
|
||||
`str` 或 `Path` 对象。
|
||||
data: (可选) 当 `component_or_path` 是路径时,必须提供此数据字典。
|
||||
use_cache: (可选) 是否为此渲染启用文件缓存,默认为 `False`。
|
||||
**kwargs: 传递给底层截图引擎的额外参数,例如 `viewport`。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的PNG图片字节数据。
|
||||
"""
|
||||
from zhenxun.services import renderer_service
|
||||
|
||||
component: Renderable
|
||||
if isinstance(component_or_path, str | Path):
|
||||
if data is None:
|
||||
raise ValueError("使用模板路径渲染时必须提供 'data' 参数。")
|
||||
component = TemplateComponent(template_path=component_or_path, data=data)
|
||||
else:
|
||||
component = component_or_path
|
||||
|
||||
return await renderer_service.render(component, use_cache=use_cache, **kwargs)
|
||||
|
||||
|
||||
async def render_template(
|
||||
path: str | Path, data: dict, use_cache: bool = False, **kwargs
|
||||
) -> bytes:
|
||||
"""
|
||||
渲染一个独立的Jinja2模板文件。
|
||||
|
||||
这是一个便捷函数,封装了 render() 函数的调用,提供更简洁的模板渲染接口。
|
||||
|
||||
参数:
|
||||
path: 模板文件路径,相对于主题模板目录。
|
||||
data: 传递给模板的数据字典。
|
||||
use_cache: (可选) 是否启用渲染缓存,默认为 False。
|
||||
**kwargs: 传递给渲染服务的额外参数。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的图片数据。
|
||||
|
||||
异常:
|
||||
RenderingError: 渲染失败时抛出。
|
||||
"""
|
||||
return await render(path, data, use_cache=use_cache, **kwargs)
|
||||
|
||||
|
||||
async def render_markdown(
|
||||
md: str, style: str | Path | None = "default", use_cache: bool = False, **kwargs
|
||||
) -> bytes:
|
||||
"""
|
||||
将Markdown字符串渲染为图片。
|
||||
|
||||
这是一个便捷函数,封装了 render() 函数的调用,专门用于渲染Markdown内容。
|
||||
|
||||
参数:
|
||||
md: 要渲染的Markdown内容字符串。
|
||||
style: (可选) 样式名称或自定义CSS文件路径,默认为 "default"。
|
||||
use_cache: (可选) 是否启用渲染缓存,默认为 False。
|
||||
**kwargs: 传递给渲染服务的额外参数。
|
||||
|
||||
返回:
|
||||
bytes: 渲染后的图片数据。
|
||||
|
||||
异常:
|
||||
RenderingError: 渲染失败时抛出。
|
||||
"""
|
||||
builder = builders.MarkdownBuilder().text(md)
|
||||
component = builder.build()
|
||||
if isinstance(style, Path):
|
||||
component.css_path = str(style.absolute())
|
||||
else:
|
||||
component.style_name = style
|
||||
|
||||
return await render(component, use_cache=use_cache, **kwargs)
|
||||
|
||||
|
||||
from zhenxun.services.renderer.protocols import RenderResult
|
||||
|
||||
|
||||
async def render_full_result(
|
||||
component: Renderable, use_cache: bool = False, **kwargs
|
||||
) -> RenderResult:
|
||||
"""
|
||||
渲染组件并返回包含图片和HTML的完整结果对象。
|
||||
主要用于调试或需要同时访问图片和其源HTML的场景。
|
||||
|
||||
参数:
|
||||
component: 一个 `Renderable` 实例。
|
||||
use_cache: (可选) 是否为此渲染启用文件缓存,默认为 `False`。
|
||||
**kwargs: 传递给底层截图引擎的额外参数。
|
||||
|
||||
返回:
|
||||
RenderResult: 一个包含 `image_bytes` 和 `html_content` 的Pydantic模型。
|
||||
"""
|
||||
from zhenxun.services import renderer_service
|
||||
from zhenxun.services.renderer.service import RenderContext
|
||||
|
||||
if not renderer_service._initialized:
|
||||
await renderer_service.initialize()
|
||||
assert renderer_service._theme_manager is not None, "ThemeManager 未初始化"
|
||||
assert renderer_service._screenshot_engine is not None, "ScreenshotEngine 未初始化"
|
||||
|
||||
context = RenderContext(
|
||||
renderer=renderer_service,
|
||||
theme_manager=renderer_service._theme_manager,
|
||||
screenshot_engine=renderer_service._screenshot_engine,
|
||||
component=component,
|
||||
use_cache=use_cache,
|
||||
render_options=kwargs,
|
||||
)
|
||||
return await renderer_service._render_component(context)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"builders",
|
||||
"hstack",
|
||||
"markdown",
|
||||
"render",
|
||||
"render_full_result",
|
||||
"render_markdown",
|
||||
"render_template",
|
||||
"template",
|
||||
"vstack",
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
from .charts import EChartsBuilder
|
||||
from .components import (
|
||||
AlertBuilder,
|
||||
AvatarBuilder,
|
||||
AvatarGroupBuilder,
|
||||
BadgeBuilder,
|
||||
DividerBuilder,
|
||||
KpiCardBuilder,
|
||||
ProgressBarBuilder,
|
||||
TimelineBuilder,
|
||||
UserInfoBlockBuilder,
|
||||
)
|
||||
from .core import (
|
||||
CardBuilder,
|
||||
DetailsBuilder,
|
||||
LayoutBuilder,
|
||||
ListBuilder,
|
||||
MarkdownBuilder,
|
||||
NotebookBuilder,
|
||||
TableBuilder,
|
||||
TextBuilder,
|
||||
)
|
||||
from .presets import (
|
||||
PluginHelpPageBuilder,
|
||||
PluginMenuBuilder,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AlertBuilder",
|
||||
"AvatarBuilder",
|
||||
"AvatarGroupBuilder",
|
||||
"BadgeBuilder",
|
||||
"CardBuilder",
|
||||
"DetailsBuilder",
|
||||
"DividerBuilder",
|
||||
"EChartsBuilder",
|
||||
"KpiCardBuilder",
|
||||
"LayoutBuilder",
|
||||
"ListBuilder",
|
||||
"MarkdownBuilder",
|
||||
"NotebookBuilder",
|
||||
"PluginHelpPageBuilder",
|
||||
"PluginMenuBuilder",
|
||||
"ProgressBarBuilder",
|
||||
"TableBuilder",
|
||||
"TextBuilder",
|
||||
"TimelineBuilder",
|
||||
"UserInfoBlockBuilder",
|
||||
]
|
||||
@@ -0,0 +1,117 @@
|
||||
from typing import Generic, TypeVar
|
||||
from typing_extensions import Self
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
T_DataModel = TypeVar("T_DataModel", bound=BaseModel)
|
||||
|
||||
|
||||
class BaseBuilder(Generic[T_DataModel]):
|
||||
"""
|
||||
所有UI构建器的通用基类。
|
||||
|
||||
它实现了Builder设计模式,提供了一个流畅的、链式调用的API来创建和配置UI组件的数据模型。
|
||||
同时,它也提供了通用的样式化方法,如 `with_style`, `with_inline_style` 等。
|
||||
|
||||
参数:
|
||||
T_DataModel: 与此构建器关联的 Pydantic 数据模型类型。
|
||||
"""
|
||||
|
||||
def __init__(self, data_model: T_DataModel, template_name: str):
|
||||
self._data: T_DataModel = data_model
|
||||
self._style_name: str | None = None
|
||||
self._template_name = template_name
|
||||
self._inline_style: dict | None = None
|
||||
self._component_css: str | None = None
|
||||
self._variant: str | None = None
|
||||
self._extra_classes: list[str] = []
|
||||
|
||||
@property
|
||||
def data(self) -> T_DataModel:
|
||||
return self._data
|
||||
|
||||
def with_style(self, style_name: str) -> Self:
|
||||
"""
|
||||
为组件应用一个特定的样式。
|
||||
|
||||
参数:
|
||||
style_name: 在主题的CSS中定义的样式类名。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._style_name = style_name
|
||||
return self
|
||||
|
||||
def with_inline_style(self, style: dict[str, str]) -> Self:
|
||||
"""
|
||||
为组件的根元素应用动态的内联样式。
|
||||
|
||||
参数:
|
||||
style: 一个CSS样式字典,例如
|
||||
`{"background-color":"#fff","font-size":"16px"}`。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._inline_style = style
|
||||
return self
|
||||
|
||||
def with_variant(self, variant_name: str) -> Self:
|
||||
"""
|
||||
为组件应用一个特定的变体/皮肤。
|
||||
|
||||
参数:
|
||||
variant_name: 在组件的 `skins/` 目录下定义的变体名称。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._variant = variant_name
|
||||
return self
|
||||
|
||||
def with_component_css(self, css: str) -> Self:
|
||||
"""
|
||||
向页面注入一段自定义的CSS样式字符串。
|
||||
|
||||
参数:
|
||||
css: 包含CSS规则的字符串。
|
||||
|
||||
返回:
|
||||
Self: 当前构建器实例,以支持链式调用。
|
||||
"""
|
||||
self._component_css = css
|
||||
return self
|
||||
|
||||
def with_classes(self, *class_names: str) -> Self:
|
||||
"""
|
||||
为组件的根元素添加一个或多个CSS工具类。
|
||||
这些类来自主题预定义的工具集。
|
||||
|
||||
示例: .with_classes("p-4", "text-center", "font-bold")
|
||||
"""
|
||||
self._extra_classes.extend(class_names)
|
||||
return self
|
||||
|
||||
def build(self) -> T_DataModel:
|
||||
"""
|
||||
构建并返回配置好的数据模型。
|
||||
这是构建过程的最后一步,它会将所有配置应用到数据模型上。
|
||||
|
||||
返回:
|
||||
T_DataModel: 最终配置好的、可被渲染服务使用的数据模型实例。
|
||||
"""
|
||||
if self._style_name and hasattr(self._data, "style_name"):
|
||||
setattr(self._data, "style_name", self._style_name)
|
||||
|
||||
if self._inline_style and hasattr(self._data, "inline_style"):
|
||||
setattr(self._data, "inline_style", self._inline_style)
|
||||
if self._component_css and hasattr(self._data, "component_css"):
|
||||
setattr(self._data, "component_css", self._component_css)
|
||||
if self._variant and hasattr(self._data, "variant"):
|
||||
setattr(self._data, "variant", self._variant)
|
||||
|
||||
if self._extra_classes and hasattr(self._data, "extra_classes"):
|
||||
setattr(self._data, "extra_classes", self._extra_classes)
|
||||
|
||||
return self._data
|
||||
@@ -0,0 +1,177 @@
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
from typing_extensions import Self
|
||||
|
||||
from ..models.charts import (
|
||||
BaseChartData,
|
||||
EChartsAxis,
|
||||
EChartsData,
|
||||
EChartsGrid,
|
||||
EChartsSeries,
|
||||
EChartsTitle,
|
||||
EChartsTooltip,
|
||||
)
|
||||
from .base import BaseBuilder
|
||||
|
||||
T_ChartData = TypeVar("T_ChartData", bound=BaseChartData)
|
||||
|
||||
|
||||
class EChartsBuilder(BaseBuilder[EChartsData], Generic[T_ChartData]):
|
||||
"""
|
||||
一个统一的、泛型的 ECharts 图表构建器。
|
||||
提供了设置 ECharts `option` 的核心方法,以及一些常用图表的便利方法。
|
||||
"""
|
||||
|
||||
def __init__(self, template_name: str, title: str):
|
||||
model = EChartsData(
|
||||
template_path=template_name,
|
||||
title=EChartsTitle(text=title),
|
||||
grid=None,
|
||||
tooltip=None,
|
||||
xAxis=None,
|
||||
yAxis=None,
|
||||
legend=None,
|
||||
background_image=None,
|
||||
)
|
||||
super().__init__(model, template_name=template_name)
|
||||
|
||||
def set_title(
|
||||
self, text: str, left: Literal["left", "center", "right"] = "center"
|
||||
) -> Self:
|
||||
self._data.title_model = EChartsTitle(text=text, left=left)
|
||||
return self
|
||||
|
||||
def set_grid(
|
||||
self,
|
||||
left: str | None = None,
|
||||
right: str | None = None,
|
||||
top: str | None = None,
|
||||
bottom: str | None = None,
|
||||
containLabel: bool = True,
|
||||
) -> Self:
|
||||
self._data.grid_model = EChartsGrid(
|
||||
left=left, right=right, top=top, bottom=bottom, containLabel=containLabel
|
||||
)
|
||||
return self
|
||||
|
||||
def set_tooltip(self, trigger: Literal["item", "axis", "none"]) -> Self:
|
||||
self._data.tooltip_model = EChartsTooltip(trigger=trigger)
|
||||
return self
|
||||
|
||||
def set_x_axis(
|
||||
self,
|
||||
type: Literal["category", "value", "time", "log"],
|
||||
data: list[Any] | None = None,
|
||||
show: bool = True,
|
||||
) -> Self:
|
||||
self._data.x_axis_model = EChartsAxis(type=type, data=data, show=show)
|
||||
return self
|
||||
|
||||
def set_y_axis(
|
||||
self,
|
||||
type: Literal["category", "value", "time", "log"],
|
||||
data: list[Any] | None = None,
|
||||
show: bool = True,
|
||||
) -> Self:
|
||||
self._data.y_axis_model = EChartsAxis(type=type, data=data, show=show)
|
||||
return self
|
||||
|
||||
def add_series(
|
||||
self, type: str, data: list[Any], name: str | None = None, **kwargs: Any
|
||||
) -> Self:
|
||||
series = EChartsSeries(type=type, data=data, name=name, **kwargs)
|
||||
self._data.series_models.append(series)
|
||||
return self
|
||||
|
||||
def set_legend(
|
||||
self,
|
||||
data: list[str],
|
||||
orient: Literal["horizontal", "vertical"] = "horizontal",
|
||||
left: str = "auto",
|
||||
) -> Self:
|
||||
self._data.legend_model = {"data": data, "orient": orient, "left": left}
|
||||
return self
|
||||
|
||||
def set_option(self, key: str, value: Any) -> Self:
|
||||
"""
|
||||
[高级] 设置 ECharts `option` 中的一个原始键值对。
|
||||
这会覆盖由其他流畅API方法设置的同名配置。
|
||||
"""
|
||||
self._data.raw_options[key] = value
|
||||
return self
|
||||
|
||||
def set_background_image(self, image_name: str) -> Self:
|
||||
"""【兼容】为横向柱状图设置背景图片。"""
|
||||
self._data.background_image = image_name
|
||||
return self
|
||||
|
||||
|
||||
def bar_chart(
|
||||
title: str,
|
||||
items: list[tuple[str, int | float]],
|
||||
direction: Literal["horizontal", "vertical"] = "horizontal",
|
||||
) -> EChartsBuilder:
|
||||
"""便捷工厂函数:创建一个柱状图构建器。"""
|
||||
builder = EChartsBuilder("components/charts/bar_chart", title)
|
||||
categories = [item[0] for item in items]
|
||||
values = [item[1] for item in items]
|
||||
|
||||
if direction == "horizontal":
|
||||
builder.set_x_axis(type="value")
|
||||
builder.set_y_axis(type="category", data=categories)
|
||||
builder.add_series(
|
||||
type="bar",
|
||||
data=values,
|
||||
)
|
||||
else:
|
||||
builder.set_x_axis(type="category", data=categories)
|
||||
builder.set_y_axis(type="value")
|
||||
builder.add_series(type="bar", data=values)
|
||||
|
||||
return builder
|
||||
|
||||
|
||||
def pie_chart(title: str, items: list[tuple[str, int | float]]) -> EChartsBuilder:
|
||||
"""便捷工厂函数:创建一个饼图构建器。"""
|
||||
builder = EChartsBuilder("components/charts/pie_chart", title)
|
||||
data = [{"name": name, "value": value} for name, value in items]
|
||||
legend_data = [item[0] for item in items]
|
||||
|
||||
builder.set_legend(data=legend_data)
|
||||
builder.add_series(
|
||||
name=title,
|
||||
type="pie",
|
||||
data=data,
|
||||
)
|
||||
return builder
|
||||
|
||||
|
||||
def line_chart(
|
||||
title: str, categories: list[str], series: list[dict[str, Any]]
|
||||
) -> EChartsBuilder:
|
||||
"""便捷工厂函数:创建一个折线图构建器。"""
|
||||
builder = EChartsBuilder("components/charts/line_chart", title)
|
||||
|
||||
builder.set_x_axis(type="category", data=categories)
|
||||
builder.set_y_axis(type="value")
|
||||
for s in series:
|
||||
builder.add_series(
|
||||
type="line",
|
||||
name=s.get("name", ""),
|
||||
data=s.get("data", []),
|
||||
smooth=s.get("smooth", False),
|
||||
)
|
||||
return builder
|
||||
|
||||
|
||||
def radar_chart(
|
||||
title: str, indicators: list[tuple[str, int | float]], series: list[dict[str, Any]]
|
||||
) -> EChartsBuilder:
|
||||
"""便捷工厂函数:创建一个雷达图构建器。"""
|
||||
builder = EChartsBuilder("components/charts/radar_chart", title)
|
||||
legend_data = [s.get("name", "") for s in series]
|
||||
radar_indicators = [{"name": name, "max": max_val} for name, max_val in indicators]
|
||||
|
||||
builder.set_legend(data=legend_data)
|
||||
builder.set_option("radar", {"indicator": radar_indicators})
|
||||
builder.add_series(type="radar", data=series)
|
||||
return builder
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
小组件构建器模块
|
||||
包含各种UI小组件的构建器
|
||||
"""
|
||||
|
||||
from .alert import AlertBuilder
|
||||
from .avatar import AvatarBuilder, AvatarGroupBuilder
|
||||
from .badge import BadgeBuilder
|
||||
from .divider import DividerBuilder
|
||||
from .kpi_card import KpiCardBuilder
|
||||
from .progress_bar import ProgressBarBuilder
|
||||
from .timeline import TimelineBuilder
|
||||
from .user_info_block import UserInfoBlockBuilder
|
||||
|
||||
__all__ = [
|
||||
"AlertBuilder",
|
||||
"AvatarBuilder",
|
||||
"AvatarGroupBuilder",
|
||||
"BadgeBuilder",
|
||||
"DividerBuilder",
|
||||
"KpiCardBuilder",
|
||||
"ProgressBarBuilder",
|
||||
"TimelineBuilder",
|
||||
"UserInfoBlockBuilder",
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from typing import Literal
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.components.alert import Alert
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class AlertBuilder(BaseBuilder[Alert]):
|
||||
"""链式构建提示/标注框组件的辅助类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
type: Literal["info", "success", "warning", "error"] = "info",
|
||||
):
|
||||
data_model = Alert(title=title, content=content, type=type)
|
||||
super().__init__(data_model, template_name="components/widgets/alert")
|
||||
|
||||
def hide_icon(self) -> Self:
|
||||
"""隐藏提示框的默认图标"""
|
||||
self._data.show_icon = False
|
||||
return self
|
||||
@@ -0,0 +1,38 @@
|
||||
from typing import Literal
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.components.avatar import Avatar, AvatarGroup
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class AvatarBuilder(BaseBuilder[Avatar]):
|
||||
"""链式构建单个头像的辅助类"""
|
||||
|
||||
def __init__(self, src: str):
|
||||
data_model = Avatar(src=src, shape="circle", size=50)
|
||||
super().__init__(data_model, template_name="components/widgets/avatar")
|
||||
|
||||
def set_shape(self, shape: Literal["circle", "square"]) -> Self:
|
||||
self._data.shape = shape
|
||||
return self
|
||||
|
||||
def set_size(self, size: int) -> Self:
|
||||
self._data.size = size
|
||||
return self
|
||||
|
||||
|
||||
class AvatarGroupBuilder(BaseBuilder[AvatarGroup]):
|
||||
"""链式构建头像组的辅助类"""
|
||||
|
||||
def __init__(self):
|
||||
data_model = AvatarGroup(avatars=[], spacing=-15, max_count=None)
|
||||
super().__init__(data_model, template_name="components/widgets/avatar_group")
|
||||
|
||||
def add_avatar(self, avatar: Avatar | AvatarBuilder | str) -> Self:
|
||||
if isinstance(avatar, str):
|
||||
self._data.avatars.append(Avatar(src=avatar, shape="circle", size=50))
|
||||
elif isinstance(avatar, AvatarBuilder):
|
||||
self._data.avatars.append(avatar.build())
|
||||
else:
|
||||
self._data.avatars.append(avatar)
|
||||
return self
|
||||
@@ -0,0 +1,25 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...models.components.badge import Badge
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class BadgeBuilder(BaseBuilder[Badge]):
|
||||
"""链式构建徽章组件的辅助类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
color_scheme: Literal[
|
||||
"primary", "success", "warning", "error", "info"
|
||||
] = "info",
|
||||
):
|
||||
data_model = Badge(text=text, color_scheme=color_scheme)
|
||||
super().__init__(data_model, template_name="components/widgets/badge")
|
||||
|
||||
def set_color_scheme(
|
||||
self, color_scheme: Literal["primary", "success", "warning", "error", "info"]
|
||||
) -> "BadgeBuilder":
|
||||
"""设置徽章的颜色方案。"""
|
||||
self._data.color_scheme = color_scheme
|
||||
return self
|
||||
@@ -0,0 +1,20 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...models.components.divider import Divider
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class DividerBuilder(BaseBuilder[Divider]):
|
||||
"""链式构建分割线组件的辅助类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
margin: str = "2em 0",
|
||||
color: str = "#f7889c",
|
||||
style: Literal["solid", "dashed", "dotted"] = "solid",
|
||||
thickness: str = "1px",
|
||||
):
|
||||
data_model = Divider(
|
||||
margin=margin, color=color, style=style, thickness=thickness
|
||||
)
|
||||
super().__init__(data_model, template_name="components/widgets/divider")
|
||||
@@ -0,0 +1,31 @@
|
||||
from typing import Any, Literal
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.components.kpi_card import KpiCard
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class KpiCardBuilder(BaseBuilder[KpiCard]):
|
||||
"""链式构建统计卡片(KPI Card)的辅助类"""
|
||||
|
||||
def __init__(self, label: str, value: Any):
|
||||
data_model = KpiCard(label=label, value=value)
|
||||
super().__init__(data_model, template_name="components/widgets/kpi_card")
|
||||
|
||||
def with_unit(self, unit: str) -> Self:
|
||||
"""设置数值的单位"""
|
||||
self._data.unit = unit
|
||||
return self
|
||||
|
||||
def with_change(
|
||||
self, change: str, type: Literal["positive", "negative", "neutral"] = "neutral"
|
||||
) -> Self:
|
||||
"""设置与上一周期的变化率"""
|
||||
self._data.change = change
|
||||
self._data.change_type = type
|
||||
return self
|
||||
|
||||
def with_icon(self, svg_path: str) -> Self:
|
||||
"""设置卡片图标 (提供SVG path data)"""
|
||||
self._data.icon_svg = svg_path
|
||||
return self
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import Literal
|
||||
|
||||
from ...models.components.progress_bar import ProgressBar
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class ProgressBarBuilder(BaseBuilder[ProgressBar]):
|
||||
"""链式构建进度条组件的辅助类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
progress: float,
|
||||
label: str | None = None,
|
||||
color_scheme: Literal[
|
||||
"primary", "success", "warning", "error", "info"
|
||||
] = "primary",
|
||||
animated: bool = False,
|
||||
):
|
||||
data_model = ProgressBar(
|
||||
progress=progress,
|
||||
label=label,
|
||||
color_scheme=color_scheme,
|
||||
animated=animated,
|
||||
)
|
||||
super().__init__(data_model, template_name="components/widgets/progress_bar")
|
||||
|
||||
def set_label(self, label: str) -> "ProgressBarBuilder":
|
||||
"""设置进度条上显示的文本。"""
|
||||
self._data.label = label
|
||||
return self
|
||||
|
||||
def set_color_scheme(
|
||||
self, color_scheme: Literal["primary", "success", "warning", "error", "info"]
|
||||
) -> "ProgressBarBuilder":
|
||||
"""设置进度条的颜色方案。"""
|
||||
self._data.color_scheme = color_scheme
|
||||
return self
|
||||
|
||||
def set_animated(self, animated: bool = True) -> "ProgressBarBuilder":
|
||||
"""设置进度条是否显示动画效果。"""
|
||||
self._data.animated = animated
|
||||
return self
|
||||
@@ -0,0 +1,28 @@
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.components.timeline import Timeline, TimelineItem
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class TimelineBuilder(BaseBuilder[Timeline]):
|
||||
"""链式构建时间轴组件的辅助类"""
|
||||
|
||||
def __init__(self):
|
||||
data_model = Timeline(items=[])
|
||||
super().__init__(data_model, template_name="components/widgets/timeline")
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
timestamp: str,
|
||||
title: str,
|
||||
content: str,
|
||||
*,
|
||||
icon: str | None = None,
|
||||
color: str | None = None,
|
||||
) -> Self:
|
||||
"""向时间轴中添加一个事件点"""
|
||||
item = TimelineItem(
|
||||
timestamp=timestamp, title=title, content=content, icon=icon, color=color
|
||||
)
|
||||
self._data.items.append(item)
|
||||
return self
|
||||
@@ -0,0 +1,33 @@
|
||||
from ...models.components.user_info_block import UserInfoBlock
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class UserInfoBlockBuilder(BaseBuilder[UserInfoBlock]):
|
||||
"""链式构建用户信息块的辅助类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
avatar_url: str,
|
||||
subtitle: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
):
|
||||
data_model = UserInfoBlock(
|
||||
name=name, avatar_url=avatar_url, subtitle=subtitle, tags=tags or []
|
||||
)
|
||||
super().__init__(data_model, template_name="components/widgets/user_info_block")
|
||||
|
||||
def set_subtitle(self, subtitle: str) -> "UserInfoBlockBuilder":
|
||||
"""设置副标题。"""
|
||||
self._data.subtitle = subtitle
|
||||
return self
|
||||
|
||||
def add_tag(self, tag: str) -> "UserInfoBlockBuilder":
|
||||
"""添加一个标签。"""
|
||||
self._data.tags.append(tag)
|
||||
return self
|
||||
|
||||
def add_tags(self, tags: list[str]) -> "UserInfoBlockBuilder":
|
||||
"""批量添加标签。"""
|
||||
self._data.tags.extend(tags)
|
||||
return self
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
核心构建器模块
|
||||
包含基础的UI构建器类
|
||||
"""
|
||||
|
||||
from .card import CardBuilder
|
||||
from .details import DetailsBuilder
|
||||
from .layout import LayoutBuilder
|
||||
from .list import ListBuilder
|
||||
from .markdown import MarkdownBuilder
|
||||
from .notebook import NotebookBuilder
|
||||
from .table import TableBuilder
|
||||
from .text import TextBuilder
|
||||
|
||||
__all__ = [
|
||||
"CardBuilder",
|
||||
"DetailsBuilder",
|
||||
"LayoutBuilder",
|
||||
"ListBuilder",
|
||||
"MarkdownBuilder",
|
||||
"NotebookBuilder",
|
||||
"TableBuilder",
|
||||
"TextBuilder",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.core.base import RenderableComponent
|
||||
from ...models.core.card import CardData
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class CardBuilder(BaseBuilder[CardData]):
|
||||
"""链式构建通用卡片容器的辅助类"""
|
||||
|
||||
def __init__(self, content: "RenderableComponent | BaseBuilder"):
|
||||
content_model = content.build() if isinstance(content, BaseBuilder) else content
|
||||
data_model = CardData(content=content_model)
|
||||
super().__init__(data_model, template_name="components/core/card")
|
||||
|
||||
def set_header(self, header: "RenderableComponent | BaseBuilder") -> Self:
|
||||
"""设置卡片的头部组件"""
|
||||
header_model = header.build() if isinstance(header, BaseBuilder) else header
|
||||
self._data.header = header_model
|
||||
return self
|
||||
|
||||
def set_footer(self, footer: "RenderableComponent | BaseBuilder") -> Self:
|
||||
"""设置卡片的尾部组件"""
|
||||
footer_model = footer.build() if isinstance(footer, BaseBuilder) else footer
|
||||
self._data.footer = footer_model
|
||||
return self
|
||||
@@ -0,0 +1,19 @@
|
||||
from typing import Any
|
||||
from typing_extensions import Self
|
||||
|
||||
from ...models.core.details import DetailsData, DetailsItem
|
||||
from ..base import BaseBuilder
|
||||
|
||||
|
||||
class DetailsBuilder(BaseBuilder[DetailsData]):
|
||||
"""链式构建描述列表(键值对)的辅助类"""
|
||||
|
||||
def __init__(self, title: str | None = None):
|
||||
data_model = DetailsData(title=title, items=[])
|
||||
super().__init__(data_model, template_name="components/core/details")
|
||||
|
||||
def add_item(self, label: str, value: Any) -> Self:
|
||||
"""向列表中添加一个键值对项目"""
|
||||
value_str = str(value)
|
||||
self._data.items.append(DetailsItem(label=label, value=value_str))
|
||||
return self
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user