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.
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
*.manifest
|
*.manifest
|
||||||
*.spec
|
*.spec
|
||||||
|
!resources.spec
|
||||||
|
|
||||||
# Installer logs
|
# Installer logs
|
||||||
pip-log.txt
|
pip-log.txt
|
||||||
|
|||||||
Generated
+1036
-941
File diff suppressed because it is too large
Load Diff
@@ -14,21 +14,21 @@ priority = "primary"
|
|||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.10"
|
python = "^3.10"
|
||||||
playwright = "^1.41.1"
|
playwright = "^1.41.1"
|
||||||
nonebot-adapter-onebot = "^2.3.1"
|
nonebot-adapter-onebot = ">=2.3.1"
|
||||||
nonebot-plugin-apscheduler = "^0.5"
|
nonebot-plugin-apscheduler = "^0.5"
|
||||||
tortoise-orm = { extras = ["asyncpg"], version = "^0.20.0" }
|
tortoise-orm = "^0.20.0"
|
||||||
cattrs = "^23.2.3"
|
cattrs = "^23.2.3"
|
||||||
ruamel-yaml = "^0.18.5"
|
ruamel-yaml = "^0.18.5"
|
||||||
strenum = "^0.4.15"
|
strenum = "^0.4.15"
|
||||||
nonebot-plugin-session = "^0.2.3"
|
nonebot-plugin-session = "^0.3.2"
|
||||||
ujson = "^5.9.0"
|
ujson = ">=5.9.0"
|
||||||
nb-cli = "^1.3.0"
|
nb-cli = ">=1.3.0"
|
||||||
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
|
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
|
||||||
pillow = "^10.0.0"
|
pillow = "^10.0.0"
|
||||||
retrying = "^1.3.4"
|
retrying = "^1.3.4"
|
||||||
aiofiles = "^23.2.1"
|
aiofiles = "^23.2.1"
|
||||||
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
|
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
|
||||||
pypinyin = "^0.51.0"
|
pypinyin = ">=0.51.0"
|
||||||
beautifulsoup4 = "^4.12.3"
|
beautifulsoup4 = "^4.12.3"
|
||||||
lxml = "^5.1.0"
|
lxml = "^5.1.0"
|
||||||
psutil = "^5.9.8"
|
psutil = "^5.9.8"
|
||||||
@@ -36,15 +36,20 @@ feedparser = "^6.0.11"
|
|||||||
imagehash = "^4.3.1"
|
imagehash = "^4.3.1"
|
||||||
cn2an = "^0.5.22"
|
cn2an = "^0.5.22"
|
||||||
dateparser = "^1.2.0"
|
dateparser = "^1.2.0"
|
||||||
bilireq = "0.2.3post0"
|
bilireq = ">=0.2.10"
|
||||||
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
|
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
|
||||||
python-multipart = "^0.0.9"
|
python-multipart = "^0.0.9"
|
||||||
aiocache = "^0.12.2"
|
aiocache = {extras = ["redis"], version = "^0.12.3"}
|
||||||
py-cpuinfo = "^9.0.0"
|
py-cpuinfo = "^9.0.0"
|
||||||
nonebot-plugin-alconna = "^0.54.0"
|
nonebot-plugin-alconna = ">=0.56.0"
|
||||||
tenacity = "^9.0.0"
|
tenacity = "^9.0.0"
|
||||||
nonebot-plugin-uninfo = ">0.4.1"
|
nonebot-plugin-uninfo = ">=0.7.3"
|
||||||
pydantic = "1.10.18"
|
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"
|
alibabacloud-devops20210625 = "^5.0.2"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
@@ -57,7 +62,6 @@ respx = "^0.21.1"
|
|||||||
ruff = "^0.8.0"
|
ruff = "^0.8.0"
|
||||||
pre-commit = "^4.0.0"
|
pre-commit = "^4.0.0"
|
||||||
|
|
||||||
|
|
||||||
[tool.nonebot]
|
[tool.nonebot]
|
||||||
plugins = [
|
plugins = [
|
||||||
"nonebot_plugin_apscheduler",
|
"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]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.10"
|
python = "^3.10"
|
||||||
playwright = "^1.41.1"
|
playwright = "^1.41.1"
|
||||||
nonebot-adapter-onebot = "^2.3.1"
|
nonebot-adapter-onebot = ">=2.3.1"
|
||||||
nonebot-plugin-apscheduler = "^0.5"
|
nonebot-plugin-apscheduler = "^0.5"
|
||||||
tortoise-orm = { extras = ["asyncpg"], version = "^0.20.0" }
|
tortoise-orm = "^0.20.0"
|
||||||
cattrs = "^23.2.3"
|
cattrs = "^23.2.3"
|
||||||
ruamel-yaml = "^0.18.5"
|
ruamel-yaml = "^0.18.5"
|
||||||
strenum = "^0.4.15"
|
strenum = "^0.4.15"
|
||||||
nonebot-plugin-session = "^0.2.3"
|
nonebot-plugin-session = "^0.3.2"
|
||||||
ujson = "^5.9.0"
|
ujson = ">=5.9.0"
|
||||||
nb-cli = "^1.3.0"
|
nb-cli = ">=1.3.0"
|
||||||
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
|
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
|
||||||
pillow = "^10.0.0"
|
pillow = "^10.0.0"
|
||||||
retrying = "^1.3.4"
|
retrying = "^1.3.4"
|
||||||
aiofiles = "^23.2.1"
|
aiofiles = "^23.2.1"
|
||||||
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
|
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
|
||||||
pypinyin = "^0.51.0"
|
pypinyin = ">=0.51.0"
|
||||||
beautifulsoup4 = "^4.12.3"
|
beautifulsoup4 = "^4.12.3"
|
||||||
lxml = "^5.1.0"
|
lxml = "^5.1.0"
|
||||||
psutil = "^5.9.8"
|
psutil = "^5.9.8"
|
||||||
@@ -36,15 +36,20 @@ feedparser = "^6.0.11"
|
|||||||
imagehash = "^4.3.1"
|
imagehash = "^4.3.1"
|
||||||
cn2an = "^0.5.22"
|
cn2an = "^0.5.22"
|
||||||
dateparser = "^1.2.0"
|
dateparser = "^1.2.0"
|
||||||
bilireq = "0.2.3post0"
|
bilireq = ">=0.2.10"
|
||||||
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
|
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
|
||||||
python-multipart = "^0.0.9"
|
python-multipart = "^0.0.9"
|
||||||
aiocache = "^0.12.2"
|
aiocache = {extras = ["redis"], version = "^0.12.3"}
|
||||||
py-cpuinfo = "^9.0.0"
|
py-cpuinfo = "^9.0.0"
|
||||||
nonebot-plugin-alconna = "^0.54.0"
|
nonebot-plugin-alconna = ">=0.56.0"
|
||||||
tenacity = "^9.0.0"
|
tenacity = "^9.0.0"
|
||||||
nonebot-plugin-uninfo = ">0.4.1"
|
nonebot-plugin-uninfo = ">=0.7.3"
|
||||||
pydantic = "2.10.6"
|
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"
|
alibabacloud-devops20210625 = "^5.0.2"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
|||||||
Generated
+542
-582
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -16,7 +16,7 @@ python = "^3.10"
|
|||||||
playwright = "^1.41.1"
|
playwright = "^1.41.1"
|
||||||
nonebot-adapter-onebot = ">=2.3.1"
|
nonebot-adapter-onebot = ">=2.3.1"
|
||||||
nonebot-plugin-apscheduler = "^0.5"
|
nonebot-plugin-apscheduler = "^0.5"
|
||||||
tortoise-orm = ">=0.20.0"
|
tortoise-orm = "^0.20.0"
|
||||||
cattrs = "^23.2.3"
|
cattrs = "^23.2.3"
|
||||||
ruamel-yaml = "^0.18.5"
|
ruamel-yaml = "^0.18.5"
|
||||||
strenum = "^0.4.15"
|
strenum = "^0.4.15"
|
||||||
@@ -24,7 +24,7 @@ nonebot-plugin-session = "^0.3.2"
|
|||||||
ujson = ">=5.9.0"
|
ujson = ">=5.9.0"
|
||||||
nb-cli = ">=1.3.0"
|
nb-cli = ">=1.3.0"
|
||||||
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
|
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
|
||||||
pillow = ">=10.0.0"
|
pillow = "^10.0.0"
|
||||||
retrying = "^1.3.4"
|
retrying = "^1.3.4"
|
||||||
aiofiles = "^23.2.1"
|
aiofiles = "^23.2.1"
|
||||||
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
|
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
|
||||||
@@ -145,4 +145,4 @@ asyncio_default_fixture_loop_scope = "session"
|
|||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
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"
|
playwright>=1.41.1,<2.0.0
|
||||||
aiofiles==23.2.1 ; python_version >= "3.10" and python_version < "4.0"
|
nonebot-adapter-onebot>=2.3.1
|
||||||
aiosqlite==0.17.0 ; python_version >= "3.10" and python_version < "4.0"
|
nonebot-plugin-apscheduler>=0.5,<0.6
|
||||||
annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0"
|
tortoise-orm>=0.20.0,<0.21.0
|
||||||
alibabacloud-devops20210625==5.0.2 ; python_version >= "3.10" and python_version < "4.0"
|
cattrs>=23.2.3,<24.0.0
|
||||||
anyio==4.8.0 ; python_version >= "3.10" and python_version < "4.0"
|
ruamel.yaml>=0.18.5,<0.19.0
|
||||||
apscheduler==3.11.0 ; python_version >= "3.10" and python_version < "4.0"
|
strenum>=0.4.15,<0.5.0
|
||||||
arclet-alconna-tools==0.7.10 ; python_version >= "3.10" and python_version < "4.0"
|
nonebot-plugin-session>=0.3.2,<0.4.0
|
||||||
arclet-alconna==1.8.35 ; python_version >= "3.10" and python_version < "4.0"
|
ujson>=5.9.0
|
||||||
arrow==1.3.0 ; python_version >= "3.10" and python_version < "4.0"
|
nb-cli>=1.3.0
|
||||||
async-timeout==5.0.1 ; python_version == "3.10"
|
nonebot2[fastapi]>=2.3.3
|
||||||
asyncpg==0.30.0 ; python_version >= "3.10" and python_version < "4.0"
|
Pillow>=10.0.0,<11.0.0
|
||||||
attrs==25.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
retrying>=1.3.4,<2.0.0
|
||||||
beautifulsoup4==4.13.3 ; python_version >= "3.10" and python_version < "4.0"
|
aiofiles>=23.2.1,<24.0.0
|
||||||
bilireq==0.2.3.post0 ; python_version >= "3.10" and python_version < "4.0"
|
nonebot-plugin-htmlrender>=0.6.0,<1.0.0
|
||||||
binaryornot==0.4.4 ; python_version >= "3.10" and python_version < "4.0"
|
pypinyin>=0.51.0
|
||||||
cashews==7.4.0 ; python_version >= "3.10" and python_version < "4.0"
|
beautifulsoup4>=4.12.3,<5.0.0
|
||||||
cattrs==23.2.3 ; python_version >= "3.10" and python_version < "4.0"
|
lxml>=5.1.0,<6.0.0
|
||||||
certifi==2025.1.31 ; python_version >= "3.10" and python_version < "4.0"
|
psutil>=5.9.8,<6.0.0
|
||||||
cffi==1.17.1 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
|
feedparser>=6.0.11,<7.0.0
|
||||||
chardet==5.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
ImageHash>=4.3.1,<5.0.0
|
||||||
charset-normalizer==3.4.1 ; python_version >= "3.10" and python_version < "4.0"
|
cn2an>=0.5.22,<0.6.0
|
||||||
click==8.1.8 ; python_version >= "3.10" and python_version < "4.0"
|
dateparser>=1.2.0,<2.0.0
|
||||||
cn2an==0.5.23 ; python_version >= "3.10" and python_version < "4.0"
|
bilireq>=0.2.10
|
||||||
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and (platform_system == "Windows" or sys_platform == "win32")
|
python-jose[cryptography]>=3.3.0,<4.0.0
|
||||||
cookiecutter==2.6.0 ; python_version >= "3.10" and python_version < "4.0"
|
python-multipart>=0.0.9,<0.1.0
|
||||||
cryptography==44.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
aiocache[redis]>=0.12.3,<0.13.0
|
||||||
dateparser==1.2.1 ; python_version >= "3.10" and python_version < "4.0"
|
py-cpuinfo>=9.0.0,<10.0.0
|
||||||
distlib==0.3.9 ; python_version >= "3.10" and python_version < "4.0"
|
nonebot-plugin-alconna>=0.56.0
|
||||||
ecdsa==0.19.0 ; python_version >= "3.10" and python_version < "4.0"
|
tenacity>=9.0.0,<10.0.0
|
||||||
exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "4.0"
|
nonebot-plugin-uninfo>=0.7.3
|
||||||
fastapi==0.115.8 ; python_version >= "3.10" and python_version < "4.0"
|
nonebot-plugin-waiter>=0.8.1,<0.9.0
|
||||||
feedparser==6.0.11 ; python_version >= "3.10" and python_version < "4.0"
|
multidict>=6.0.0,<7.0.0,!=6.3.2
|
||||||
filelock==3.17.0 ; python_version >= "3.10" and python_version < "4.0"
|
alibabacloud-devops20210625>=5.0.2,<6.0.0
|
||||||
greenlet==3.1.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
grpcio==1.70.0 ; python_version >= "3.10" and python_version < "4.0"
|
redis>=5
|
||||||
h11==0.14.0 ; python_version >= "3.10" and python_version < "4.0"
|
asyncpg>=0.20.0
|
||||||
httpcore==0.16.3 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
httptools==0.6.4 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
httpx==0.23.3 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
idna==3.10 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
imagehash==4.3.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
importlib-metadata==8.6.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
iso8601==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
jinja2==3.1.5 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
loguru==0.7.3 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
lxml==5.3.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
markdown-it-py==3.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
markdown==3.7 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
markupsafe==3.0.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
mdurl==0.1.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
msgpack==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
multidict==6.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nb-cli==1.4.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nepattern==0.7.7 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nonebot-adapter-onebot==2.4.6 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nonebot-plugin-alconna==0.54.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nonebot-plugin-apscheduler==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nonebot-plugin-htmlrender==0.6.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nonebot-plugin-session==0.2.3 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nonebot-plugin-uninfo==0.6.8 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nonebot-plugin-waiter==0.8.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nonebot2==2.4.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
nonebot2[fastapi]==2.4.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
noneprompt==0.1.9 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
numpy==2.2.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pillow==10.4.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
platformdirs==4.3.6 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
playwright==1.50.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
proces==0.1.7 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
prompt-toolkit==3.0.50 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
propcache==0.2.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
protobuf==4.25.6 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
psutil==5.9.8 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
py-cpuinfo==9.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pyasn1==0.6.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pycparser==2.22 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
|
|
||||||
pydantic-core==2.27.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pydantic==2.10.6 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pyee==12.1.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pyfiglet==1.0.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pygments==2.19.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pygtrie==2.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pymdown-extensions==10.14.3 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pypika-tortoise==0.1.6 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pypinyin==0.51.0 ; python_version >= "3.10" and python_version < "4"
|
|
||||||
python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
python-dotenv==1.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
python-jose[cryptography]==3.3.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
python-markdown-math==0.8 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
python-multipart==0.0.9 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
python-slugify==8.0.4 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pytz==2025.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pywavelets==1.8.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
pyyaml==6.0.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
regex==2024.11.6 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
retrying==1.3.4 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
rfc3986[idna2008]==1.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
rich==13.9.4 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
rsa==4.9 ; python_version >= "3.10" and python_version < "4"
|
|
||||||
ruamel-yaml-clib==0.2.12 ; platform_python_implementation == "CPython" and python_version < "3.13" and python_version >= "3.10"
|
|
||||||
ruamel-yaml==0.18.10 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
scipy==1.15.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
sgmllib3k==1.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
six==1.17.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
sniffio==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
soupsieve==2.6 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
starlette==0.45.3 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
strenum==0.4.15 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
tarina==0.6.8 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
tenacity==9.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
text-unidecode==1.3 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
tomli==2.2.1 ; python_version == "3.10"
|
|
||||||
tomlkit==0.13.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
tortoise-orm[asyncpg]==0.20.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
types-python-dateutil==2.9.0.20241206 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
tzdata==2025.1 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
|
|
||||||
tzlocal==5.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
ujson==5.10.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
urllib3==2.3.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
uvicorn[standard]==0.34.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
uvloop==0.21.0 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.10" and python_version < "4.0"
|
|
||||||
virtualenv==20.29.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
watchfiles==0.24.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
wcwidth==0.2.13 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
websockets==14.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
win32-setctime==1.2.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
|
|
||||||
yarl==1.18.3 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
zipp==3.21.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
|
|||||||
@@ -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(
|
async def test_check_update_release(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -322,7 +322,7 @@ async def test_check_update_release(
|
|||||||
assert (mock_backup_path / folder).exists()
|
assert (mock_backup_path / folder).exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip("不会修")
|
@pytest.mark.xfail
|
||||||
async def test_check_update_main(
|
async def test_check_update_main(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import cast
|
|||||||
from nonebot.adapters.onebot.v11 import Bot
|
from nonebot.adapters.onebot.v11 import Bot
|
||||||
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
|
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
|
||||||
from nonebug import App
|
from nonebug import App
|
||||||
|
import pytest
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from tests.config import BotId, GroupId, MessageId, UserId
|
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 = mocker.patch("zhenxun.builtin_plugins.check.data_source.platform")
|
||||||
mock_platform.uname.return_value = platform_uname
|
mock_platform.uname.return_value = platform_uname
|
||||||
|
|
||||||
mock_template_to_pic = mocker.patch("zhenxun.builtin_plugins.check.template_to_pic")
|
mock_render_service = mocker.patch(
|
||||||
mock_template_to_pic_return = mocker.AsyncMock()
|
"zhenxun.builtin_plugins.check.renderer_service.render"
|
||||||
mock_template_to_pic.return_value = mock_template_to_pic_return
|
)
|
||||||
|
mock_render_service_return = mocker.AsyncMock()
|
||||||
|
mock_render_service.return_value = mock_render_service_return
|
||||||
|
|
||||||
mock_build_message = mocker.patch(
|
mock_build_message = mocker.patch(
|
||||||
"zhenxun.builtin_plugins.check.MessageUtils.build_message"
|
"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 = mocker.AsyncMock()
|
||||||
mock_build_message.return_value = mock_build_message_return
|
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 (
|
return (
|
||||||
mock_psutil,
|
mock_psutil,
|
||||||
mock_cpuinfo,
|
mock_cpuinfo,
|
||||||
mock_platform,
|
mock_platform,
|
||||||
mock_template_to_pic,
|
mock_render_service,
|
||||||
mock_template_to_pic_return,
|
mock_render_service_return,
|
||||||
mock_build_message,
|
mock_build_message,
|
||||||
mock_build_message_return,
|
mock_build_message_return,
|
||||||
mock_template_path_new,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_check(
|
async def test_check(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -105,11 +104,10 @@ async def test_check(
|
|||||||
mock_psutil,
|
mock_psutil,
|
||||||
mock_cpuinfo,
|
mock_cpuinfo,
|
||||||
mock_platform,
|
mock_platform,
|
||||||
mock_template_to_pic,
|
mock_render_service,
|
||||||
mock_template_to_pic_return,
|
mock_render_service_return,
|
||||||
mock_build_message,
|
mock_build_message,
|
||||||
mock_build_message_return,
|
mock_build_message_return,
|
||||||
mock_template_path_new,
|
|
||||||
) = init_mocker(mocker, tmp_path)
|
) = init_mocker(mocker, tmp_path)
|
||||||
async with app.test_matcher(_self_check_matcher) as ctx:
|
async with app.test_matcher(_self_check_matcher) as ctx:
|
||||||
bot = create_bot(ctx)
|
bot = create_bot(ctx)
|
||||||
@@ -126,11 +124,12 @@ async def test_check(
|
|||||||
ctx.receive_event(bot=bot, event=event)
|
ctx.receive_event(bot=bot, event=event)
|
||||||
ctx.should_ignore_rule(_self_check_matcher)
|
ctx.should_ignore_rule(_self_check_matcher)
|
||||||
|
|
||||||
mock_template_to_pic.assert_awaited_once()
|
mock_render_service.assert_awaited_once()
|
||||||
mock_build_message.assert_called_once_with(mock_template_to_pic_return)
|
mock_build_message.assert_called_once_with(mock_render_service_return)
|
||||||
mock_build_message_return.send.assert_awaited_once()
|
mock_build_message_return.send.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_check_arm(
|
async def test_check_arm(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -161,11 +160,10 @@ async def test_check_arm(
|
|||||||
mock_psutil,
|
mock_psutil,
|
||||||
mock_cpuinfo,
|
mock_cpuinfo,
|
||||||
mock_platform,
|
mock_platform,
|
||||||
mock_template_to_pic,
|
mock_render_service,
|
||||||
mock_template_to_pic_return,
|
mock_render_service_return,
|
||||||
mock_build_message,
|
mock_build_message,
|
||||||
mock_build_message_return,
|
mock_build_message_return,
|
||||||
mock_template_path_new,
|
|
||||||
) = init_mocker(mocker, tmp_path)
|
) = init_mocker(mocker, tmp_path)
|
||||||
|
|
||||||
mock_platform.uname.return_value = platform_uname_arm
|
mock_platform.uname.return_value = platform_uname_arm
|
||||||
@@ -199,6 +197,6 @@ async def test_check_arm(
|
|||||||
mocker.call().decode().split().__getitem__().__float__(),
|
mocker.call().decode().split().__getitem__().__float__(),
|
||||||
] # type: ignore
|
] # type: ignore
|
||||||
)
|
)
|
||||||
mock_template_to_pic.assert_awaited_once()
|
mock_render_service.assert_awaited_once()
|
||||||
mock_build_message.assert_called_once_with(mock_template_to_pic_return)
|
mock_build_message.assert_called_once_with(mock_render_service_return)
|
||||||
mock_build_message_return.send.assert_awaited_once()
|
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.event import GroupMessageEvent
|
||||||
from nonebot.adapters.onebot.v11.message import Message
|
from nonebot.adapters.onebot.v11.message import Message
|
||||||
from nonebug import App
|
from nonebug import App
|
||||||
|
import pytest
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from tests.config import BotId, GroupId, MessageId, UserId
|
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
|
test_path = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_add_plugin_basic(
|
async def test_add_plugin_basic(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -60,6 +62,7 @@ async def test_add_plugin_basic(
|
|||||||
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
|
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_add_plugin_basic_commit_version(
|
async def test_add_plugin_basic_commit_version(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
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()
|
assert (mock_base_path / "plugins" / "bilibili_sub" / "__init__.py").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_add_plugin_basic_is_not_dir(
|
async def test_add_plugin_basic_is_not_dir(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -152,6 +156,7 @@ async def test_add_plugin_basic_is_not_dir(
|
|||||||
assert (mock_base_path / "plugins" / "jitang.py").is_file()
|
assert (mock_base_path / "plugins" / "jitang.py").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_add_plugin_extra(
|
async def test_add_plugin_extra(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -198,6 +203,7 @@ async def test_add_plugin_extra(
|
|||||||
assert (mock_base_path / "plugins" / "github_sub" / "__init__.py").is_file()
|
assert (mock_base_path / "plugins" / "github_sub" / "__init__.py").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_plugin_not_exist_add(
|
async def test_plugin_not_exist_add(
|
||||||
app: App,
|
app: App,
|
||||||
create_bot: Callable,
|
create_bot: Callable,
|
||||||
@@ -236,6 +242,7 @@ async def test_plugin_not_exist_add(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_add_plugin_exist(
|
async def test_add_plugin_exist(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
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.event import GroupMessageEvent
|
||||||
from nonebot.adapters.onebot.v11.message import Message
|
from nonebot.adapters.onebot.v11.message import Message
|
||||||
from nonebug import App
|
from nonebug import App
|
||||||
|
import pytest
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from tests.config import BotId, GroupId, MessageId, UserId
|
from tests.config import BotId, GroupId, MessageId, UserId
|
||||||
from tests.utils import _v11_group_message_event
|
from tests.utils import _v11_group_message_event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_remove_plugin(
|
async def test_remove_plugin(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -60,6 +62,7 @@ async def test_remove_plugin(
|
|||||||
assert not (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
|
assert not (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_plugin_not_exist_remove(
|
async def test_plugin_not_exist_remove(
|
||||||
app: App,
|
app: App,
|
||||||
create_bot: Callable,
|
create_bot: Callable,
|
||||||
@@ -92,6 +95,7 @@ async def test_plugin_not_exist_remove(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_remove_plugin_not_install(
|
async def test_remove_plugin_not_install(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
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.event import GroupMessageEvent
|
||||||
from nonebot.adapters.onebot.v11.message import Message
|
from nonebot.adapters.onebot.v11.message import Message
|
||||||
from nonebug import App
|
from nonebug import App
|
||||||
|
import pytest
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from tests.config import BotId, GroupId, MessageId, UserId
|
from tests.config import BotId, GroupId, MessageId, UserId
|
||||||
from tests.utils import _v11_group_message_event
|
from tests.utils import _v11_group_message_event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_search_plugin_name(
|
async def test_search_plugin_name(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -52,6 +54,7 @@ async def test_search_plugin_name(
|
|||||||
mock_build_message_return.send.assert_awaited_once()
|
mock_build_message_return.send.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_search_plugin_author(
|
async def test_search_plugin_author(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -93,6 +96,7 @@ async def test_search_plugin_author(
|
|||||||
mock_build_message_return.send.assert_awaited_once()
|
mock_build_message_return.send.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_plugin_not_exist_search(
|
async def test_plugin_not_exist_search(
|
||||||
app: App,
|
app: App,
|
||||||
create_bot: Callable,
|
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.event import GroupMessageEvent
|
||||||
from nonebot.adapters.onebot.v11.message import Message
|
from nonebot.adapters.onebot.v11.message import Message
|
||||||
from nonebug import App
|
from nonebug import App
|
||||||
|
import pytest
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from tests.config import BotId, GroupId, MessageId, UserId
|
from tests.config import BotId, GroupId, MessageId, UserId
|
||||||
from tests.utils import _v11_group_message_event
|
from tests.utils import _v11_group_message_event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_update_all_plugin_basic_need_update(
|
async def test_update_all_plugin_basic_need_update(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
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()
|
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_update_all_plugin_basic_is_new(
|
async def test_update_all_plugin_basic_is_new(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
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.event import GroupMessageEvent
|
||||||
from nonebot.adapters.onebot.v11.message import Message
|
from nonebot.adapters.onebot.v11.message import Message
|
||||||
from nonebug import App
|
from nonebug import App
|
||||||
|
import pytest
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
from respx import MockRouter
|
|
||||||
|
|
||||||
from tests.config import BotId, GroupId, MessageId, UserId
|
from tests.config import BotId, GroupId, MessageId, UserId
|
||||||
from tests.utils import _v11_group_message_event
|
from tests.utils import _v11_group_message_event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_update_plugin_basic_need_update(
|
async def test_update_plugin_basic_need_update(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
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()
|
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_update_plugin_basic_is_new(
|
async def test_update_plugin_basic_is_new(
|
||||||
app: App,
|
app: App,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -112,6 +114,7 @@ async def test_update_plugin_basic_is_new(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_plugin_not_exist_update(
|
async def test_plugin_not_exist_update(
|
||||||
app: App,
|
app: App,
|
||||||
create_bot: Callable,
|
create_bot: Callable,
|
||||||
@@ -150,9 +153,9 @@ async def test_plugin_not_exist_update(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail
|
||||||
async def test_update_plugin_not_install(
|
async def test_update_plugin_not_install(
|
||||||
app: App,
|
app: App,
|
||||||
mocked_api: MockRouter,
|
|
||||||
create_bot: Callable,
|
create_bot: Callable,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+17
-25
@@ -2,18 +2,14 @@ from nonebot.plugin import PluginMetadata
|
|||||||
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
|
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
|
||||||
from nonebot_plugin_session import EventSession
|
from nonebot_plugin_session import EventSession
|
||||||
|
|
||||||
from zhenxun.configs.config import Config
|
from zhenxun.configs.utils import PluginExtraData
|
||||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
from zhenxun.services.help_service import create_plugin_help_image
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils.enum import PluginType
|
from zhenxun.utils.enum import PluginType
|
||||||
from zhenxun.utils.exception import EmptyError
|
from zhenxun.utils.exception import EmptyError
|
||||||
from zhenxun.utils.message import MessageUtils
|
from zhenxun.utils.message import MessageUtils
|
||||||
from zhenxun.utils.rules import admin_check, ensure_group
|
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(
|
__plugin_meta__ = PluginMetadata(
|
||||||
name="群组管理员帮助",
|
name="群组管理员帮助",
|
||||||
description="管理员帮助列表",
|
description="管理员帮助列表",
|
||||||
@@ -30,17 +26,19 @@ __plugin_meta__ = PluginMetadata(
|
|||||||
precautions=[
|
precautions=[
|
||||||
"只有群主/群管理 才能使用哦,群主拥有6级权限,管理员拥有5级权限!"
|
"只有群主/群管理 才能使用哦,群主拥有6级权限,管理员拥有5级权限!"
|
||||||
],
|
],
|
||||||
configs=[
|
configs=[],
|
||||||
RegisterConfig(
|
|
||||||
key="type",
|
|
||||||
value="zhenxun",
|
|
||||||
help="管理员帮助样式,normal, zhenxun",
|
|
||||||
default_value="zhenxun",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
).to_dict(),
|
).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(
|
_matcher = on_alconna(
|
||||||
Alconna("管理员帮助"),
|
Alconna("管理员帮助"),
|
||||||
rule=admin_check(1) & ensure_group,
|
rule=admin_check(1) & ensure_group,
|
||||||
@@ -54,15 +52,9 @@ async def _(
|
|||||||
session: EventSession,
|
session: EventSession,
|
||||||
arparma: Arparma,
|
arparma: Arparma,
|
||||||
):
|
):
|
||||||
if not ADMIN_HELP_IMAGE.exists():
|
try:
|
||||||
try:
|
image_bytes = await build_html_help()
|
||||||
if Config.get_config("admin_help", "type") == "zhenxun":
|
await MessageUtils.build_message(image_bytes).send()
|
||||||
await build_html_help()
|
except EmptyError:
|
||||||
else:
|
await MessageUtils.build_message("当前管理员帮助为空...").finish(reply_to=True)
|
||||||
await build_help()
|
|
||||||
except EmptyError:
|
|
||||||
await MessageUtils.build_message("当前管理员帮助为空...").finish(
|
|
||||||
reply_to=True
|
|
||||||
)
|
|
||||||
await MessageUtils.build_message(ADMIN_HELP_IMAGE).send()
|
|
||||||
logger.info("查看管理员帮助", arparma.header_result, session=session)
|
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.enum import BlockType, PluginType
|
||||||
from zhenxun.utils.message import MessageUtils
|
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
|
from .command import _group_status_matcher, _status_matcher
|
||||||
|
|
||||||
base_config = Config.get("plugin_switch")
|
base_config = Config.get("plugin_switch")
|
||||||
@@ -154,7 +154,6 @@ async def _(
|
|||||||
else:
|
else:
|
||||||
result = await PluginManager.unblock_group_plugin(name, group_id)
|
result = await PluginManager.unblock_group_plugin(name, group_id)
|
||||||
logger.info(f"开启功能 {name}", arparma.header_result, session=session)
|
logger.info(f"开启功能 {name}", arparma.header_result, session=session)
|
||||||
delete_help_image(group_id)
|
|
||||||
await MessageUtils.build_message(result).finish(reply_to=True)
|
await MessageUtils.build_message(result).finish(reply_to=True)
|
||||||
elif session.user.id in bot.config.superusers:
|
elif session.user.id in bot.config.superusers:
|
||||||
"""私聊"""
|
"""私聊"""
|
||||||
@@ -218,7 +217,6 @@ async def _(
|
|||||||
session=session,
|
session=session,
|
||||||
target=group_id,
|
target=group_id,
|
||||||
)
|
)
|
||||||
delete_help_image()
|
|
||||||
await MessageUtils.build_message(result).finish(reply_to=True)
|
await MessageUtils.build_message(result).finish(reply_to=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -266,7 +264,6 @@ async def _(
|
|||||||
else:
|
else:
|
||||||
result = await PluginManager.block_group_plugin(name, group_id)
|
result = await PluginManager.block_group_plugin(name, group_id)
|
||||||
logger.info(f"关闭功能 {name}", arparma.header_result, session=session)
|
logger.info(f"关闭功能 {name}", arparma.header_result, session=session)
|
||||||
delete_help_image(group_id)
|
|
||||||
await MessageUtils.build_message(result).finish(reply_to=True)
|
await MessageUtils.build_message(result).finish(reply_to=True)
|
||||||
elif session.user.id in bot.config.superusers:
|
elif session.user.id in bot.config.superusers:
|
||||||
group_id = group.result if group.available else None
|
group_id = group.result if group.available else None
|
||||||
@@ -338,7 +335,6 @@ async def _(
|
|||||||
session=session,
|
session=session,
|
||||||
target=group_id,
|
target=group_id,
|
||||||
)
|
)
|
||||||
delete_help_image()
|
|
||||||
await MessageUtils.build_message(result).finish(reply_to=True)
|
await MessageUtils.build_message(result).finish(reply_to=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import os
|
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH
|
|
||||||
from zhenxun.models.group_console import GroupConsole
|
from zhenxun.models.group_console import GroupConsole
|
||||||
from zhenxun.models.plugin_info import PluginInfo
|
from zhenxun.models.plugin_info import PluginInfo
|
||||||
from zhenxun.models.task_info import TaskInfo
|
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.exception import GroupInfoNotFound
|
||||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
|
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:
|
def plugin_row_style(column: str, text: str) -> RowStyle:
|
||||||
"""被动技能文本风格
|
"""被动技能文本风格
|
||||||
|
|||||||
@@ -84,13 +84,16 @@ async def _(
|
|||||||
):
|
):
|
||||||
result = ""
|
result = ""
|
||||||
await MessageUtils.build_message("正在进行检查更新...").send(reply_to=True)
|
await MessageUtils.build_message("正在进行检查更新...").send(reply_to=True)
|
||||||
|
|
||||||
|
if not ver_type.available:
|
||||||
|
result += await UpdateManager.check_version()
|
||||||
|
logger.info("查看当前版本...", "检查更新", session=session)
|
||||||
|
await MessageUtils.build_message(result).finish()
|
||||||
|
return
|
||||||
|
|
||||||
ver_type_str = ver_type.result
|
ver_type_str = ver_type.result
|
||||||
source_str = source.result
|
source_str = source.result
|
||||||
if ver_type_str in {"main", "release"}:
|
if ver_type_str in {"main", "release"}:
|
||||||
if not ver_type.available:
|
|
||||||
result += await UpdateManager.check_version()
|
|
||||||
logger.info("查看当前版本...", "检查更新", session=session)
|
|
||||||
await MessageUtils.build_message(result).finish()
|
|
||||||
try:
|
try:
|
||||||
result += await UpdateManager.update_zhenxun(
|
result += await UpdateManager.update_zhenxun(
|
||||||
bot,
|
bot,
|
||||||
|
|||||||
@@ -1,37 +1,135 @@
|
|||||||
|
import asyncio
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from nonebot.adapters import Bot
|
from nonebot.adapters import Bot
|
||||||
|
from packaging.specifiers import SpecifierSet
|
||||||
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
|
from zhenxun.utils.http_utils import AsyncHttpx
|
||||||
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
|
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
|
||||||
from zhenxun.utils.manager.zhenxun_repo_manager import (
|
from zhenxun.utils.manager.zhenxun_repo_manager import (
|
||||||
ZhenxunRepoConfig,
|
ZhenxunRepoConfig,
|
||||||
ZhenxunRepoManager,
|
ZhenxunRepoManager,
|
||||||
)
|
)
|
||||||
from zhenxun.utils.platform import PlatformUtils
|
from zhenxun.utils.platform import PlatformUtils
|
||||||
|
from zhenxun.utils.repo_utils import RepoFileManager
|
||||||
|
|
||||||
LOG_COMMAND = "AutoUpdate"
|
LOG_COMMAND = "AutoUpdate"
|
||||||
|
|
||||||
|
|
||||||
class UpdateManager:
|
class UpdateManager:
|
||||||
|
@staticmethod
|
||||||
|
async def _get_latest_commit_date(owner: str, repo: str, path: str) -> str:
|
||||||
|
"""获取文件最新 commit 日期"""
|
||||||
|
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits"
|
||||||
|
params = {"path": path, "page": 1, "per_page": 1}
|
||||||
|
try:
|
||||||
|
data = await AsyncHttpx.get_json(api_url, params=params)
|
||||||
|
if data and isinstance(data, list) and data[0]:
|
||||||
|
date_str = data[0]["commit"]["committer"]["date"]
|
||||||
|
return date_str.split("T")[0]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"获取 {owner}/{repo}/{path} 的 commit 日期失败", e=e)
|
||||||
|
return "获取失败"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def check_version(cls) -> str:
|
async def check_version(cls) -> str:
|
||||||
"""检查更新版本
|
"""检查真寻和资源的版本"""
|
||||||
|
bot_cur_version = cls.__get_version()
|
||||||
|
|
||||||
返回:
|
release_task = ZhenxunRepoManager.zhenxun_get_latest_releases_data()
|
||||||
str: 更新信息
|
dev_version_task = RepoFileManager.get_file_content(
|
||||||
"""
|
ZhenxunRepoConfig.ZHENXUN_BOT_GITHUB_URL, "__version__"
|
||||||
cur_version = cls.__get_version()
|
|
||||||
release_data = await ZhenxunRepoManager.zhenxun_get_latest_releases_data()
|
|
||||||
if not release_data:
|
|
||||||
return "检查更新获取版本失败..."
|
|
||||||
return (
|
|
||||||
"检测到当前版本更新\n"
|
|
||||||
f"当前版本:{cur_version}\n"
|
|
||||||
f"最新版本:{release_data.get('name')}\n"
|
|
||||||
f"创建日期:{release_data.get('created_at')}\n"
|
|
||||||
f"更新内容:\n{release_data.get('body')}"
|
|
||||||
)
|
)
|
||||||
|
bot_commit_date_task = cls._get_latest_commit_date(
|
||||||
|
"HibiKier", "zhenxun_bot", "__version__"
|
||||||
|
)
|
||||||
|
res_commit_date_task = cls._get_latest_commit_date(
|
||||||
|
"zhenxun-org", "zhenxun-bot-resources", "__version__"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
release_data,
|
||||||
|
dev_version_text,
|
||||||
|
bot_commit_date,
|
||||||
|
res_commit_date,
|
||||||
|
) = await asyncio.gather(
|
||||||
|
release_task,
|
||||||
|
dev_version_task,
|
||||||
|
bot_commit_date_task,
|
||||||
|
res_commit_date_task,
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(release_data, dict):
|
||||||
|
bot_release_version = release_data.get("name", "获取失败")
|
||||||
|
bot_release_date = release_data.get("created_at", "").split("T")[0]
|
||||||
|
else:
|
||||||
|
bot_release_version = "获取失败"
|
||||||
|
bot_release_date = "获取失败"
|
||||||
|
logger.warning(f"获取 Bot release 信息失败: {release_data}")
|
||||||
|
|
||||||
|
if isinstance(dev_version_text, str):
|
||||||
|
bot_dev_version = dev_version_text.split(":")[-1].strip()
|
||||||
|
else:
|
||||||
|
bot_dev_version = "获取失败"
|
||||||
|
bot_commit_date = "获取失败"
|
||||||
|
logger.warning(f"获取 Bot dev 版本信息失败: {dev_version_text}")
|
||||||
|
|
||||||
|
bot_update_hint = ""
|
||||||
|
try:
|
||||||
|
cur_base_v = bot_cur_version.split("-")[0].lstrip("v")
|
||||||
|
dev_base_v = bot_dev_version.split("-")[0].lstrip("v")
|
||||||
|
|
||||||
|
if Version(cur_base_v) < Version(dev_base_v):
|
||||||
|
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
|
||||||
|
elif (
|
||||||
|
Version(cur_base_v) == Version(dev_base_v)
|
||||||
|
and bot_cur_version != bot_dev_version
|
||||||
|
):
|
||||||
|
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
|
||||||
|
except (InvalidVersion, TypeError, IndexError):
|
||||||
|
if bot_cur_version != bot_dev_version and bot_dev_version != "获取失败":
|
||||||
|
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
|
||||||
|
|
||||||
|
bot_update_info = (
|
||||||
|
f"当前版本: {bot_cur_version}\n"
|
||||||
|
f"最新开发版: {bot_dev_version} (更新于: {bot_commit_date})\n"
|
||||||
|
f"最新正式版: {bot_release_version} (发布于: {bot_release_date})"
|
||||||
|
f"{bot_update_hint}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res_version_file = ZhenxunRepoConfig.RESOURCE_PATH / "__version__"
|
||||||
|
res_cur_version = "未找到"
|
||||||
|
if res_version_file.exists():
|
||||||
|
if text := res_version_file.open(encoding="utf8").readline():
|
||||||
|
res_cur_version = text.split(":")[-1].strip()
|
||||||
|
|
||||||
|
res_latest_version = "获取失败"
|
||||||
|
try:
|
||||||
|
res_latest_version_text = await RepoFileManager.get_file_content(
|
||||||
|
ZhenxunRepoConfig.RESOURCE_GITHUB_URL, "__version__"
|
||||||
|
)
|
||||||
|
res_latest_version = res_latest_version_text.split(":")[-1].strip()
|
||||||
|
except Exception as e:
|
||||||
|
res_commit_date = "获取失败"
|
||||||
|
logger.warning(f"获取资源版本信息失败: {e}")
|
||||||
|
|
||||||
|
res_update_hint = ""
|
||||||
|
try:
|
||||||
|
if Version(res_cur_version) < Version(res_latest_version):
|
||||||
|
res_update_hint = "\n-> 发现新资源版本, 可用 `检查更新 resource` 更新"
|
||||||
|
except (InvalidVersion, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
res_update_info = (
|
||||||
|
f"当前版本: {res_cur_version}\n"
|
||||||
|
f"最新版本: {res_latest_version} (更新于: {res_commit_date})"
|
||||||
|
f"{res_update_hint}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"『绪山真寻 Bot』\n{bot_update_info}\n\n『真寻资源』\n{res_update_info}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def update_webui(
|
async def update_webui(
|
||||||
@@ -125,6 +223,7 @@ class UpdateManager:
|
|||||||
f"检测真寻已更新,当前版本:{cur_version}\n开始更新...",
|
f"检测真寻已更新,当前版本:{cur_version}\n开始更新...",
|
||||||
user_id,
|
user_id,
|
||||||
)
|
)
|
||||||
|
result_message = ""
|
||||||
if zip:
|
if zip:
|
||||||
new_version = await ZhenxunRepoManager.zhenxun_zip_update(version_type)
|
new_version = await ZhenxunRepoManager.zhenxun_zip_update(version_type)
|
||||||
await PlatformUtils.send_superuser(
|
await PlatformUtils.send_superuser(
|
||||||
@@ -133,7 +232,7 @@ class UpdateManager:
|
|||||||
await VirtualEnvPackageManager.install_requirement(
|
await VirtualEnvPackageManager.install_requirement(
|
||||||
ZhenxunRepoConfig.REQUIREMENTS_FILE
|
ZhenxunRepoConfig.REQUIREMENTS_FILE
|
||||||
)
|
)
|
||||||
return (
|
result_message = (
|
||||||
f"版本更新完成!\n版本: {cur_version} -> {new_version}\n"
|
f"版本更新完成!\n版本: {cur_version} -> {new_version}\n"
|
||||||
"请重新启动真寻以完成更新!"
|
"请重新启动真寻以完成更新!"
|
||||||
)
|
)
|
||||||
@@ -155,13 +254,54 @@ class UpdateManager:
|
|||||||
await VirtualEnvPackageManager.install_requirement(
|
await VirtualEnvPackageManager.install_requirement(
|
||||||
ZhenxunRepoConfig.REQUIREMENTS_FILE
|
ZhenxunRepoConfig.REQUIREMENTS_FILE
|
||||||
)
|
)
|
||||||
return (
|
result_message = (
|
||||||
f"版本更新完成!\n"
|
f"版本更新完成!\n"
|
||||||
f"版本: {cur_version} -> {result.new_version}\n"
|
f"版本: {cur_version} -> {result.new_version}\n"
|
||||||
f"变更文件个数: {len(result.changed_files)}"
|
f"变更文件个数: {len(result.changed_files)}"
|
||||||
f"{'' if source == 'git' else '(阿里云更新不支持查看变更文件)'}\n"
|
f"{'' if source == 'git' else '(阿里云更新不支持查看变更文件)'}\n"
|
||||||
"请重新启动真寻以完成更新!"
|
"请重新启动真寻以完成更新!"
|
||||||
)
|
)
|
||||||
|
resource_warning = ""
|
||||||
|
if version_type == "main":
|
||||||
|
try:
|
||||||
|
spec_content = await RepoFileManager.get_file_content(
|
||||||
|
ZhenxunRepoConfig.ZHENXUN_BOT_GITHUB_URL, "resources.spec"
|
||||||
|
)
|
||||||
|
required_spec_str = None
|
||||||
|
for line in spec_content.splitlines():
|
||||||
|
if line.startswith("require_resources_version:"):
|
||||||
|
required_spec_str = line.split(":", 1)[1].strip().strip("\"'")
|
||||||
|
break
|
||||||
|
if required_spec_str:
|
||||||
|
res_version_file = ZhenxunRepoConfig.RESOURCE_PATH / "__version__"
|
||||||
|
local_res_version_str = "0.0.0"
|
||||||
|
if res_version_file.exists():
|
||||||
|
if text := res_version_file.open(encoding="utf8").readline():
|
||||||
|
local_res_version_str = text.split(":")[-1].strip()
|
||||||
|
|
||||||
|
spec = SpecifierSet(required_spec_str)
|
||||||
|
local_ver = Version(local_res_version_str)
|
||||||
|
if not spec.contains(local_ver):
|
||||||
|
warning_header = (
|
||||||
|
f"⚠️ **资源版本不兼容!**\n"
|
||||||
|
f"当前代码需要资源版本: `{required_spec_str}`\n"
|
||||||
|
f"您当前的资源版本是: `{local_res_version_str}`\n"
|
||||||
|
"**将自动为您更新资源文件...**"
|
||||||
|
)
|
||||||
|
await PlatformUtils.send_superuser(bot, warning_header, user_id)
|
||||||
|
resource_update_source = None if zip else source
|
||||||
|
resource_update_result = await cls.update_resources(
|
||||||
|
source=resource_update_source, force=force
|
||||||
|
)
|
||||||
|
resource_warning = (
|
||||||
|
f"\n\n{warning_header}\n{resource_update_result}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"检查资源版本兼容性时出错: {e}", LOG_COMMAND, e=e)
|
||||||
|
resource_warning = (
|
||||||
|
"\n\n⚠️ 检查资源版本兼容性时出错,建议手动运行 `检查更新 resource`"
|
||||||
|
)
|
||||||
|
return result_message + resource_warning
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __get_version(cls) -> str:
|
def __get_version(cls) -> str:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from nonebot.plugin import PluginMetadata
|
from nonebot.plugin import PluginMetadata
|
||||||
from nonebot_plugin_alconna import (
|
from nonebot_plugin_alconna import (
|
||||||
@@ -15,15 +14,17 @@ from nonebot_plugin_alconna import (
|
|||||||
from nonebot_plugin_session import EventSession
|
from nonebot_plugin_session import EventSession
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
from zhenxun import ui
|
||||||
from zhenxun.configs.config import Config
|
from zhenxun.configs.config import Config
|
||||||
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
|
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
|
||||||
from zhenxun.models.chat_history import ChatHistory
|
from zhenxun.models.chat_history import ChatHistory
|
||||||
from zhenxun.models.group_member_info import GroupInfoUser
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
|
from zhenxun.services import avatar_service
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
|
from zhenxun.ui.builders import TableBuilder
|
||||||
|
from zhenxun.ui.models import ImageCell, TextCell
|
||||||
from zhenxun.utils.enum import PluginType
|
from zhenxun.utils.enum import PluginType
|
||||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
|
|
||||||
from zhenxun.utils.message import MessageUtils
|
from zhenxun.utils.message import MessageUtils
|
||||||
from zhenxun.utils.platform import PlatformUtils
|
|
||||||
|
|
||||||
__plugin_meta__ = PluginMetadata(
|
__plugin_meta__ = PluginMetadata(
|
||||||
name="消息统计",
|
name="消息统计",
|
||||||
@@ -123,64 +124,63 @@ async def _(
|
|||||||
if rank_data := await ChatHistory.get_group_msg_rank(
|
if rank_data := await ChatHistory.get_group_msg_rank(
|
||||||
group_id, fetch_count, "DES" if arparma.find("des") else "DESC", date_scope
|
group_id, fetch_count, "DES" if arparma.find("des") else "DESC", date_scope
|
||||||
):
|
):
|
||||||
idx = 1
|
rows_data = []
|
||||||
data_list = []
|
platform = "qq"
|
||||||
|
|
||||||
for uid, num in rank_data:
|
user_ids_in_rank = [str(uid) for uid, _ in rank_data]
|
||||||
if len(data_list) >= count.result:
|
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
|
break
|
||||||
|
|
||||||
user_in_group = await GroupInfoUser.filter(
|
uid_str = str(uid)
|
||||||
user_id=uid, group_id=group_id
|
user_in_group = users_in_group.get(uid_str)
|
||||||
).first()
|
|
||||||
|
|
||||||
if not user_in_group and not show_quit_member:
|
if not user_in_group and not show_quit_member:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if user_in_group:
|
user_name = (
|
||||||
user_name = user_in_group.user_name
|
user_in_group.user_name if user_in_group else f"{uid_str}(已退群)"
|
||||||
else:
|
)
|
||||||
user_name = f"{uid}(已退群)"
|
|
||||||
|
|
||||||
avatar_size = 40
|
avatar_path = await avatar_service.get_avatar_path(platform, uid_str)
|
||||||
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)
|
|
||||||
|
|
||||||
data_list.append([idx, avatar_tuple, user_name, num])
|
rows_data.append(
|
||||||
idx += 1
|
[
|
||||||
|
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 not date_scope:
|
||||||
if date_scope := await ChatHistory.get_group_first_msg_datetime(group_id):
|
first_msg_time = await ChatHistory.get_group_first_msg_datetime(group_id)
|
||||||
date_scope = date_scope.astimezone(
|
if first_msg_time:
|
||||||
|
date_scope_start = first_msg_time.astimezone(
|
||||||
pytz.timezone("Asia/Shanghai")
|
pytz.timezone("Asia/Shanghai")
|
||||||
).replace(microsecond=0)
|
).replace(microsecond=0)
|
||||||
|
date_str = f"{str(date_scope_start).split('+')[0]} - 至今"
|
||||||
else:
|
else:
|
||||||
date_scope = time_now.replace(microsecond=0)
|
date_str = f"{time_now.replace(microsecond=0)} - 至今"
|
||||||
date_str = f"{str(date_scope).split('+')[0]} - 至今"
|
|
||||||
else:
|
else:
|
||||||
date_str = (
|
date_str = (
|
||||||
f"{date_scope[0].replace(microsecond=0)} - "
|
f"{date_scope[0].replace(microsecond=0)} - "
|
||||||
f"{date_scope[1].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(
|
logger.info(
|
||||||
f"查看消息排行 数量={count.result}", arparma.header_result, session=session
|
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()
|
await MessageUtils.build_message("群组消息记录为空...").finish()
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ from nonebot.permission import SUPERUSER
|
|||||||
from nonebot.plugin import PluginMetadata
|
from nonebot.plugin import PluginMetadata
|
||||||
from nonebot.rule import Rule, to_me
|
from nonebot.rule import Rule, to_me
|
||||||
from nonebot_plugin_alconna import Alconna, on_alconna
|
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.config import Config
|
||||||
from zhenxun.configs.path_config import TEMPLATE_PATH
|
|
||||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils.enum import PluginType
|
from zhenxun.utils.enum import PluginType
|
||||||
@@ -67,18 +66,14 @@ _self_check_poke_matcher = on_notice(
|
|||||||
|
|
||||||
async def handle_self_check():
|
async def handle_self_check():
|
||||||
try:
|
try:
|
||||||
data = await get_status_info()
|
data_dict = await get_status_info()
|
||||||
image = await template_to_pic(
|
|
||||||
template_path=str((TEMPLATE_PATH / "check").absolute()),
|
image_bytes = await ui.render_template(
|
||||||
template_name="main.html",
|
"pages/builtin/check",
|
||||||
templates={"data": data},
|
data=data_dict,
|
||||||
pages={
|
|
||||||
"viewport": {"width": 195, "height": 750},
|
|
||||||
"base_url": f"file://{TEMPLATE_PATH}",
|
|
||||||
},
|
|
||||||
wait=2,
|
|
||||||
)
|
)
|
||||||
await MessageUtils.build_message(image).send()
|
|
||||||
|
await MessageUtils.build_message(image_bytes).send()
|
||||||
logger.info("自检成功", "自检")
|
logger.info("自检成功", "自检")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await MessageUtils.build_message(f"自检失败: {e}").send()
|
await MessageUtils.build_message(f"自检失败: {e}").send()
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ from nonebot_plugin_alconna import (
|
|||||||
)
|
)
|
||||||
from nonebot_plugin_uninfo import Uninfo
|
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.config import Config
|
||||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
@@ -36,18 +31,6 @@ __plugin_meta__ = PluginMetadata(
|
|||||||
plugin_type=PluginType.DEPENDANT,
|
plugin_type=PluginType.DEPENDANT,
|
||||||
is_show=False,
|
is_show=False,
|
||||||
configs=[
|
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(
|
RegisterConfig(
|
||||||
key="ENABLE_LLM_HELPER",
|
key="ENABLE_LLM_HELPER",
|
||||||
value=False,
|
value=False,
|
||||||
@@ -76,6 +59,13 @@ __plugin_meta__ = PluginMetadata(
|
|||||||
default_value=100,
|
default_value=100,
|
||||||
type=int,
|
type=int,
|
||||||
),
|
),
|
||||||
|
RegisterConfig(
|
||||||
|
key="HELP_STYLE",
|
||||||
|
value="default",
|
||||||
|
help="帮助页面的显示样式 (可选值: 'default', 'simple')",
|
||||||
|
default_value="default",
|
||||||
|
type=str,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).to_dict(),
|
).to_dict(),
|
||||||
)
|
)
|
||||||
@@ -88,12 +78,18 @@ _matcher = on_alconna(
|
|||||||
Option("-s|--superuser", action=store_true, help_text="超级用户帮助"),
|
Option("-s|--superuser", action=store_true, help_text="超级用户帮助"),
|
||||||
Option("-d|--detail", action=store_true, help_text="详细帮助"),
|
Option("-d|--detail", action=store_true, help_text="详细帮助"),
|
||||||
),
|
),
|
||||||
aliases={"help", "帮助", "菜单"},
|
aliases={"help", "菜单"},
|
||||||
rule=to_me(),
|
rule=to_me(),
|
||||||
priority=1,
|
priority=1,
|
||||||
block=True,
|
block=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_matcher.shortcut(
|
||||||
|
r"帮助(?P<name>.*?)",
|
||||||
|
command="功能",
|
||||||
|
arguments=["{name}"],
|
||||||
|
prefix=True,
|
||||||
|
)
|
||||||
|
|
||||||
_matcher.shortcut(
|
_matcher.shortcut(
|
||||||
r"详细帮助",
|
r"详细帮助",
|
||||||
@@ -119,8 +115,11 @@ async def _(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if name.available:
|
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(
|
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 (
|
is_plugin_found = not (
|
||||||
@@ -144,15 +143,8 @@ async def _(
|
|||||||
f"查看帮助详情失败,未找到: {name.result}", "帮助", session=session
|
f"查看帮助详情失败,未找到: {name.result}", "帮助", session=session
|
||||||
)
|
)
|
||||||
elif session.group and (gid := session.group.id):
|
elif session.group and (gid := session.group.id):
|
||||||
_image_path = GROUP_HELP_PATH / f"{gid}_{is_detail.result}.png"
|
image_bytes = await create_help_img(session, gid, is_detail.result)
|
||||||
if not _image_path.exists():
|
await MessageUtils.build_message(image_bytes).finish()
|
||||||
await create_help_img(session, gid, is_detail.result)
|
|
||||||
await MessageUtils.build_message(_image_path).finish()
|
|
||||||
else:
|
else:
|
||||||
if is_detail.result:
|
image_bytes = await create_help_img(session, None, is_detail.result)
|
||||||
_image_path = SIMPLE_DETAIL_HELP_IMAGE
|
await MessageUtils.build_message(image_bytes).finish()
|
||||||
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()
|
|
||||||
|
|||||||
@@ -1,74 +1,126 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import nonebot
|
import nonebot
|
||||||
from nonebot.plugin import PluginMetadata
|
|
||||||
from nonebot_plugin_htmlrender import template_to_pic
|
|
||||||
from nonebot_plugin_uninfo import Uninfo
|
from nonebot_plugin_uninfo import Uninfo
|
||||||
|
|
||||||
from zhenxun.configs.config import Config
|
from zhenxun import ui
|
||||||
from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH
|
from zhenxun.configs.config import BotConfig, Config
|
||||||
|
from zhenxun.configs.path_config import IMAGE_PATH
|
||||||
from zhenxun.configs.utils import PluginExtraData
|
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.level_user import LevelUser
|
||||||
from zhenxun.models.plugin_info import PluginInfo
|
from zhenxun.models.plugin_info import PluginInfo
|
||||||
from zhenxun.models.statistics import Statistics
|
from zhenxun.models.statistics import Statistics
|
||||||
from zhenxun.services import (
|
from zhenxun.services import (
|
||||||
LLMException,
|
LLMException,
|
||||||
LLMMessage,
|
LLMMessage,
|
||||||
|
avatar_service,
|
||||||
generate,
|
generate,
|
||||||
)
|
)
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils._image_template import Markdown
|
from zhenxun.ui.builders import (
|
||||||
from zhenxun.utils.enum import PluginType
|
NotebookBuilder,
|
||||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
|
PluginMenuBuilder,
|
||||||
|
|
||||||
from ._config import (
|
|
||||||
GROUP_HELP_PATH,
|
|
||||||
SIMPLE_DETAIL_HELP_IMAGE,
|
|
||||||
SIMPLE_HELP_IMAGE,
|
|
||||||
base_config,
|
|
||||||
)
|
)
|
||||||
from .html_help import build_html_image
|
from zhenxun.ui.models import PluginMenuCategory
|
||||||
from .normal_help import build_normal_image
|
from zhenxun.utils.common_utils import format_usage_for_markdown
|
||||||
from .zhenxun_help import build_zhenxun_image
|
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"
|
random_bk_path = IMAGE_PATH / "background" / "help" / "simple_help"
|
||||||
|
|
||||||
background = IMAGE_PATH / "background" / "0.png"
|
background = IMAGE_PATH / "background" / "0.png"
|
||||||
|
|
||||||
|
|
||||||
driver = nonebot.get_driver()
|
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(
|
async def create_help_img(
|
||||||
session: Uninfo, group_id: str | None, is_detail: bool
|
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
|
||||||
|
)
|
||||||
|
|
||||||
参数:
|
sorted_categories = dict(
|
||||||
session: Uninfo
|
sorted(classified_data.items(), key=lambda x: len(x[1]), reverse=True)
|
||||||
group_id: 群号
|
)
|
||||||
"""
|
categories_for_model = []
|
||||||
help_type = base_config.get("type", "").strip().lower()
|
plugin_count = 0
|
||||||
|
active_count = 0
|
||||||
|
|
||||||
match help_type:
|
if sorted_categories:
|
||||||
case "html":
|
menu_key = next(iter(sorted_categories.keys()))
|
||||||
result = BuildImage.open(
|
max_data = sorted_categories.pop(menu_key)
|
||||||
await build_html_image(session, group_id, is_detail)
|
main_category_name = "主要功能" if menu_key in ["normal", "功能"] else menu_key
|
||||||
)
|
categories_for_model.append({"name": main_category_name, "items": max_data})
|
||||||
case "zhenxun":
|
plugin_count += len(max_data)
|
||||||
result = BuildImage.open(
|
active_count += sum(1 for item in max_data if item["status"])
|
||||||
await build_zhenxun_image(session, group_id, is_detail)
|
|
||||||
)
|
for menu, value in sorted_categories.items():
|
||||||
case _:
|
category_name = "主要功能" if menu in ["normal", "功能"] else menu
|
||||||
result = await build_normal_image(group_id, is_detail)
|
categories_for_model.append({"name": category_name, "items": value})
|
||||||
if group_id:
|
plugin_count += len(value)
|
||||||
save_path = GROUP_HELP_PATH / f"{group_id}_{is_detail}.png"
|
active_count += sum(1 for item in value if item["status"])
|
||||||
elif is_detail:
|
|
||||||
save_path = SIMPLE_DETAIL_HELP_IMAGE
|
platform = PlatformUtils.get_platform(session)
|
||||||
else:
|
bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id
|
||||||
save_path = SIMPLE_HELP_IMAGE
|
bot_avatar_path = await avatar_service.get_avatar_path(platform, bot_id)
|
||||||
await result.save(save_path)
|
bot_avatar_url = bot_avatar_path.as_uri() if bot_avatar_path else ""
|
||||||
return save_path
|
|
||||||
|
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]:
|
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
|
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:
|
def min_leading_spaces(str_list: list[str]) -> int:
|
||||||
min_spaces = 9999
|
min_spaces = 9999
|
||||||
|
|
||||||
@@ -142,52 +164,16 @@ def split_text(text: str):
|
|||||||
return [s.replace(" ", " ") for s in split_text]
|
return [s.replace(" ", " ") for s in split_text]
|
||||||
|
|
||||||
|
|
||||||
async def get_zhenxun_help(
|
async def get_plugin_help(
|
||||||
module: str, metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool
|
user_id: str, name: str, is_superuser: bool, variant: str | None = None
|
||||||
) -> str | bytes:
|
) -> 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
|
user_id: 用户id
|
||||||
name: 插件名称或id
|
name: 插件名称或id
|
||||||
is_superuser: 是否为超级用户
|
is_superuser: 是否为超级用户
|
||||||
|
variant: 使用的皮肤/变体名称
|
||||||
"""
|
"""
|
||||||
type_list = await get_user_allow_help(user_id)
|
type_list = await get_user_allow_help(user_id)
|
||||||
if name.isdigit():
|
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(
|
plugin = await PluginInfo.get_or_none(
|
||||||
name__iexact=name, load_status=True, plugin_type__in=type_list
|
name__iexact=name, load_status=True, plugin_type__in=type_list
|
||||||
)
|
)
|
||||||
|
|
||||||
if plugin:
|
if plugin:
|
||||||
_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
|
_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
|
||||||
if _plugin and _plugin.metadata:
|
if _plugin and _plugin.metadata:
|
||||||
extra_data = PluginExtraData(**_plugin.metadata.extra)
|
extra_data = PluginExtraData(**_plugin.metadata.extra)
|
||||||
if Config.get_config("help", "detail_type") == "zhenxun":
|
|
||||||
return await get_zhenxun_help(
|
call_count = await Statistics.filter(plugin_name=plugin.module).count()
|
||||||
plugin.module, _plugin.metadata, extra_data, is_superuser
|
usage = _plugin.metadata.usage
|
||||||
)
|
if is_superuser:
|
||||||
else:
|
if not extra_data.superuser_help:
|
||||||
return await get_normal_help(_plugin.metadata, extra_data, is_superuser)
|
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 "糟糕! 该功能没有帮助喔..."
|
||||||
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 "抱歉,我暂时无法回答这个问题。"
|
reply_text = response.text if response else "抱歉,我暂时无法回答这个问题。"
|
||||||
threshold = Config.get_config("help", "LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD", 50)
|
threshold = Config.get_config("help", "LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD", 50)
|
||||||
|
|
||||||
if len(reply_text) > threshold:
|
if len(reply_text) > threshold:
|
||||||
markdown = Markdown()
|
builder = NotebookBuilder()
|
||||||
markdown.text(reply_text)
|
builder.text(reply_text)
|
||||||
return await markdown.build()
|
return await ui.render(builder.build())
|
||||||
|
|
||||||
return reply_text
|
return reply_text
|
||||||
|
|
||||||
except LLMException as e:
|
except LLMException as e:
|
||||||
|
|||||||
@@ -53,5 +53,5 @@ async def classify_plugin(
|
|||||||
classify[menu] = []
|
classify[menu] = []
|
||||||
classify[menu].append(handle(bot, plugin, group, is_detail))
|
classify[menu].append(handle(bot, plugin, group, is_detail))
|
||||||
for value in classify.values():
|
for value in classify.values():
|
||||||
value.sort(key=lambda x: x.id)
|
value.sort(key=lambda x: int(x["id"]))
|
||||||
return classify
|
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 typing import Any
|
||||||
|
|
||||||
from nonebot.adapters import Bot, Message
|
from nonebot.adapters import Bot, Message
|
||||||
|
from nonebot.adapters.onebot.v11 import MessageSegment
|
||||||
|
|
||||||
from zhenxun.configs.config import Config
|
from zhenxun.configs.config import Config
|
||||||
from zhenxun.models.bot_message_store import BotMessageStore
|
from zhenxun.models.bot_message_store import BotMessageStore
|
||||||
@@ -40,6 +41,35 @@ def replace_message(message: Message) -> str:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def format_message_for_log(message: Message) -> str:
|
||||||
|
"""
|
||||||
|
将消息对象转换为适合日志记录的字符串,对base64等长内容进行摘要处理。
|
||||||
|
"""
|
||||||
|
if not isinstance(message, Message):
|
||||||
|
return str(message)
|
||||||
|
|
||||||
|
log_parts = []
|
||||||
|
for seg in message:
|
||||||
|
seg: MessageSegment
|
||||||
|
if seg.type == "text":
|
||||||
|
log_parts.append(seg.data.get("text", ""))
|
||||||
|
elif seg.type in ("image", "record", "video"):
|
||||||
|
file_info = seg.data.get("file", "")
|
||||||
|
if isinstance(file_info, str) and file_info.startswith("base64://"):
|
||||||
|
b64_data = file_info[9:]
|
||||||
|
data_size_bytes = (len(b64_data) * 3) / 4 - b64_data.count("=", -2)
|
||||||
|
log_parts.append(
|
||||||
|
f"[{seg.type}: base64, size={data_size_bytes / 1024:.2f}KB]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log_parts.append(f"[{seg.type}]")
|
||||||
|
elif seg.type == "at":
|
||||||
|
log_parts.append(f"[@{seg.data.get('qq', 'unknown')}]")
|
||||||
|
else:
|
||||||
|
log_parts.append(f"[{seg.type}]")
|
||||||
|
return "".join(log_parts)
|
||||||
|
|
||||||
|
|
||||||
@Bot.on_called_api
|
@Bot.on_called_api
|
||||||
async def handle_api_result(
|
async def handle_api_result(
|
||||||
bot: Bot, exception: Exception | None, api: str, data: dict[str, Any], result: Any
|
bot: Bot, exception: Exception | None, api: str, data: dict[str, Any], result: Any
|
||||||
@@ -78,7 +108,7 @@ async def handle_api_result(
|
|||||||
else replace_message(message),
|
else replace_message(message),
|
||||||
platform=PlatformUtils.get_platform(bot),
|
platform=PlatformUtils.get_platform(bot),
|
||||||
)
|
)
|
||||||
logger.debug(f"消息发送记录,message: {message}")
|
logger.debug(f"消息发送记录,message: {format_message_for_log(message)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"消息发送记录发生错误...data: {data}, result: {result}",
|
f"消息发送记录发生错误...data: {data}, result: {result}",
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from nonebot_plugin_htmlrender import template_to_pic
|
|
||||||
from nonebot_plugin_uninfo import Uninfo
|
from nonebot_plugin_uninfo import Uninfo
|
||||||
from tortoise.expressions import RawSQL
|
from tortoise.expressions import RawSQL
|
||||||
from tortoise.functions import Count
|
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.chat_history import ChatHistory
|
||||||
from zhenxun.models.level_user import LevelUser
|
from zhenxun.models.level_user import LevelUser
|
||||||
from zhenxun.models.sign_user import SignUser
|
from zhenxun.models.sign_user import SignUser
|
||||||
from zhenxun.models.statistics import Statistics
|
from zhenxun.models.statistics import Statistics
|
||||||
from zhenxun.models.user_console import UserConsole
|
from zhenxun.models.user_console import UserConsole
|
||||||
|
from zhenxun.services import avatar_service
|
||||||
from zhenxun.utils.platform import PlatformUtils
|
from zhenxun.utils.platform import PlatformUtils
|
||||||
|
|
||||||
RACE = [
|
RACE = [
|
||||||
@@ -90,7 +90,7 @@ def get_level(impression: float) -> int:
|
|||||||
|
|
||||||
async def get_chat_history(
|
async def get_chat_history(
|
||||||
user_id: str, group_id: str | None
|
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
|
group_id: 群id
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
tuple[list[str], list[str]]: 日期列表, 次数列表
|
tuple[list[str], list[int]]: 日期列表, 次数列表
|
||||||
|
|
||||||
"""
|
"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
filter_date = now - timedelta(days=7, hours=now.hour, minutes=now.minute)
|
filter_date = now - timedelta(days=7)
|
||||||
date_list = (
|
date_list = (
|
||||||
await ChatHistory.filter(
|
await ChatHistory.filter(
|
||||||
user_id=user_id, group_id=group_id, create_time__gte=filter_date
|
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")
|
.group_by("date")
|
||||||
.values("date", "count")
|
.values("date", "count")
|
||||||
)
|
)
|
||||||
chart_date = []
|
chart_date: list[str] = []
|
||||||
count_list = []
|
count_list: list[int] = []
|
||||||
date2cnt = {str(date["date"]): date["count"] for date in date_list}
|
date2cnt = {str(item["date"]): item["count"] for item in date_list}
|
||||||
date = now.date()
|
current_date = now.date()
|
||||||
for _ in range(7):
|
for _ in range(7):
|
||||||
if str(date) in date2cnt:
|
date_str = str(current_date)
|
||||||
count_list.append(date2cnt[str(date)])
|
count_list.append(date2cnt.get(date_str, 0))
|
||||||
else:
|
chart_date.append(date_str[5:])
|
||||||
count_list.append(0)
|
current_date -= timedelta(days=1)
|
||||||
chart_date.append(str(date))
|
|
||||||
date -= timedelta(days=1)
|
|
||||||
for c in chart_date:
|
|
||||||
chart_date[chart_date.index(c)] = c[5:]
|
|
||||||
chart_date.reverse()
|
chart_date.reverse()
|
||||||
count_list.reverse()
|
count_list.reverse()
|
||||||
return chart_date, count_list
|
return chart_date, count_list
|
||||||
@@ -136,7 +132,6 @@ async def get_user_info(
|
|||||||
|
|
||||||
参数:
|
参数:
|
||||||
session: Uninfo
|
session: Uninfo
|
||||||
bot: Bot
|
|
||||||
user_id: 用户id
|
user_id: 用户id
|
||||||
group_id: 群id
|
group_id: 群id
|
||||||
nickname: 用户昵称
|
nickname: 用户昵称
|
||||||
@@ -145,50 +140,62 @@ async def get_user_info(
|
|||||||
bytes: 图片数据
|
bytes: 图片数据
|
||||||
"""
|
"""
|
||||||
platform = PlatformUtils.get_platform(session) or "qq"
|
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)
|
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
|
sign_level = 0
|
||||||
if sign_user := await SignUser.get_or_none(user_id=user_id):
|
if sign_user := await SignUser.get_or_none(user_id=user_id):
|
||||||
sign_level = get_level(float(sign_user.impression))
|
sign_level = get_level(float(sign_user.impression))
|
||||||
|
|
||||||
chat_count = await ChatHistory.filter(user_id=user_id, group_id=group_id).count()
|
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()
|
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"{user.uid}".rjust(8, "0")
|
||||||
uid = f"{uid[:4]} {uid[4:]}"
|
uid_formatted = f"{uid[:4]} {uid[4:]}"
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
weather = "moon" if now.hour < 6 or now.hour > 19 else "sun"
|
weather_icon_name = "moon" if now.hour < 6 or now.hour > 19 else "sun"
|
||||||
chart_date, count_list = await get_chat_history(user_id, group_id)
|
|
||||||
data = {
|
chart_labels, chart_data = await get_chat_history(user_id, group_id)
|
||||||
"date": now.date(),
|
|
||||||
"weather": weather,
|
profile_data = {
|
||||||
"ava_url": ava_url,
|
"page": {
|
||||||
"nickname": nickname,
|
"date": str(now.date()),
|
||||||
"title": "勇 者",
|
"weather_icon_name": weather_icon_name,
|
||||||
"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}",
|
|
||||||
},
|
},
|
||||||
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 typing import Any
|
||||||
|
|
||||||
|
from zhenxun.services import renderer_service
|
||||||
from zhenxun.services.llm.core import KeyStatus
|
from zhenxun.services.llm.core import KeyStatus
|
||||||
from zhenxun.services.llm.types import ModelModality
|
from zhenxun.services.llm.types import ModelModality
|
||||||
from zhenxun.utils._build_image import BuildImage
|
from zhenxun.ui.builders import MarkdownBuilder, TableBuilder
|
||||||
from zhenxun.utils._image_template import ImageTemplate, Markdown, RowStyle
|
from zhenxun.ui.models import StatusBadgeCell, TextCell
|
||||||
|
|
||||||
|
|
||||||
def _format_seconds(seconds: int) -> str:
|
def _format_seconds(seconds: int) -> str:
|
||||||
@@ -27,35 +28,40 @@ class Presenters:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def format_model_list_as_image(
|
async def format_model_list_as_image(
|
||||||
models: list[dict[str, Any]], show_all: bool
|
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:
|
if not models:
|
||||||
return await BuildImage.build_text_image(
|
builder = TableBuilder(
|
||||||
f"{title}\n\n当前没有配置任何LLM模型。"
|
title=title, tip="当前没有配置任何LLM模型。"
|
||||||
)
|
).set_headers(["提供商", "模型名称", "API类型", "状态"])
|
||||||
|
return await renderer_service.render(builder.build())
|
||||||
|
|
||||||
column_name = ["提供商", "模型名称", "API类型", "状态"]
|
column_name = ["提供商", "模型名称", "API类型", "状态"]
|
||||||
data_list = []
|
rows_data = []
|
||||||
for model in models:
|
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 ""
|
embed_tag = " (Embed)" if model.get("is_embedding_model", False) else ""
|
||||||
data_list.append(
|
rows_data.append(
|
||||||
[
|
[
|
||||||
model.get("provider_name", "N/A"),
|
TextCell(content=model.get("provider_name", "N/A")),
|
||||||
f"{model.get('model_name', 'N/A')}{embed_tag}",
|
TextCell(content=f"{model.get('model_name', 'N/A')}{embed_tag}"),
|
||||||
model.get("api_type", "N/A"),
|
TextCell(content=model.get("api_type", "N/A")),
|
||||||
status_text,
|
StatusBadgeCell(
|
||||||
|
text="可用" if is_available else "不可用",
|
||||||
|
status_type="ok" if is_available else "error",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return await ImageTemplate.table_page(
|
builder = TableBuilder(
|
||||||
head_text=title,
|
title=title, tip="使用 `llm info <Provider/ModelName>` 查看详情"
|
||||||
tip_text="使用 `llm info <Provider/ModelName>` 查看详情",
|
|
||||||
column_name=column_name,
|
|
||||||
data_list=data_list,
|
|
||||||
)
|
)
|
||||||
|
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
|
@staticmethod
|
||||||
async def format_model_details_as_markdown_image(details: dict[str, Any]) -> bytes:
|
async def format_model_details_as_markdown_image(details: dict[str, Any]) -> bytes:
|
||||||
@@ -76,77 +82,33 @@ class Presenters:
|
|||||||
if caps.is_embedding_model:
|
if caps.is_embedding_model:
|
||||||
cap_list.append("文本嵌入")
|
cap_list.append("文本嵌入")
|
||||||
|
|
||||||
md = Markdown()
|
builder = MarkdownBuilder()
|
||||||
md.head(f"🔎 模型详情: {provider.name}/{model.model_name}", level=1)
|
builder.head(f"🔎 模型详情: {provider.name}/{model.model_name}", 1)
|
||||||
md.text("---")
|
builder.text("---")
|
||||||
md.head("提供商信息", level=2)
|
builder.head("提供商信息", 2)
|
||||||
md.list(
|
builder.text(f"- **名称**: {provider.name}")
|
||||||
[
|
builder.text(f"- **API 类型**: {provider.api_type}")
|
||||||
f"**名称**: {provider.name}",
|
builder.text(f"- **API Base**: {provider.api_base or '默认'}")
|
||||||
f"**API 类型**: {provider.api_type}",
|
|
||||||
f"**API Base**: {provider.api_base or '默认'}",
|
builder.head("模型详情", 2)
|
||||||
]
|
|
||||||
)
|
|
||||||
md.head("模型详情", level=2)
|
|
||||||
|
|
||||||
temp_value = model.temperature or provider.temperature or "未设置"
|
temp_value = model.temperature or provider.temperature or "未设置"
|
||||||
token_value = model.max_tokens or provider.max_tokens or "未设置"
|
token_value = model.max_tokens or provider.max_tokens or "未设置"
|
||||||
|
|
||||||
md.list(
|
builder.text(f"- **名称**: {model.model_name}")
|
||||||
[
|
builder.text(f"- **默认温度**: {temp_value}")
|
||||||
f"**名称**: {model.model_name}",
|
builder.text(f"- **最大Token**: {token_value}")
|
||||||
f"**默认温度**: {temp_value}",
|
builder.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}")
|
||||||
f"**最大Token**: {token_value}",
|
|
||||||
f"**核心能力**: {', '.join(cap_list) or '纯文本'}",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return await md.build()
|
return await renderer_service.render(builder.with_style("light").build())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def format_key_status_as_image(
|
async def format_key_status_as_image(
|
||||||
provider_name: str, sorted_stats: list[dict[str, Any]]
|
provider_name: str, sorted_stats: list[dict[str, Any]]
|
||||||
) -> BuildImage:
|
) -> bytes:
|
||||||
"""将已排序的、详细的API Key状态格式化为表格图片"""
|
"""将已排序的、详细的API Key状态格式化为表格图片"""
|
||||||
title = f"🔑 '{provider_name}' 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 = []
|
data_list = []
|
||||||
|
|
||||||
for key_info in sorted_stats:
|
for key_info in sorted_stats:
|
||||||
@@ -155,15 +117,19 @@ class Presenters:
|
|||||||
if status_enum == KeyStatus.COOLDOWN:
|
if status_enum == KeyStatus.COOLDOWN:
|
||||||
cooldown_seconds = int(key_info["cooldown_seconds_left"])
|
cooldown_seconds = int(key_info["cooldown_seconds_left"])
|
||||||
formatted_time = _format_seconds(cooldown_seconds)
|
formatted_time = _format_seconds(cooldown_seconds)
|
||||||
status_text = f"❄️ 冷却中({formatted_time})"
|
status_cell = StatusBadgeCell(
|
||||||
|
text=f"冷却中({formatted_time})", status_type="info"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
status_text = {
|
status_map = {
|
||||||
KeyStatus.DISABLED: "🚫 永久禁用",
|
KeyStatus.DISABLED: ("永久禁用", "error"),
|
||||||
KeyStatus.ERROR: "❌ 错误",
|
KeyStatus.ERROR: ("错误", "error"),
|
||||||
KeyStatus.WARNING: "⚠️ 告警",
|
KeyStatus.WARNING: ("告警", "warning"),
|
||||||
KeyStatus.HEALTHY: "✅ 健康",
|
KeyStatus.HEALTHY: ("健康", "ok"),
|
||||||
KeyStatus.UNUSED: "⚪️ 未使用",
|
KeyStatus.UNUSED: ("未使用", "info"),
|
||||||
}.get(status_enum, "❔ 未知")
|
}
|
||||||
|
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 = key_info["total_calls"]
|
||||||
total_calls_text = (
|
total_calls_text = (
|
||||||
@@ -174,6 +140,13 @@ class Presenters:
|
|||||||
|
|
||||||
success_rate = key_info["success_rate"]
|
success_rate = key_info["success_rate"]
|
||||||
success_rate_text = f"{success_rate:.1f}%" if total_calls > 0 else "N/A"
|
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 = key_info["avg_latency"]
|
||||||
avg_latency_text = f"{avg_latency / 1000:.2f}" if avg_latency > 0 else "N/A"
|
avg_latency_text = f"{avg_latency / 1000:.2f}" if avg_latency > 0 else "N/A"
|
||||||
@@ -184,21 +157,29 @@ class Presenters:
|
|||||||
|
|
||||||
data_list.append(
|
data_list.append(
|
||||||
[
|
[
|
||||||
key_info["key_id"],
|
TextCell(content=key_info["key_id"]),
|
||||||
status_text,
|
status_cell,
|
||||||
total_calls_text,
|
TextCell(content=total_calls_text),
|
||||||
success_rate_text,
|
success_rate_cell,
|
||||||
avg_latency_text,
|
TextCell(content=avg_latency_text),
|
||||||
last_error,
|
TextCell(content=last_error),
|
||||||
key_info["suggested_action"],
|
TextCell(content=key_info["suggested_action"]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return await ImageTemplate.table_page(
|
builder = TableBuilder(
|
||||||
head_text=title,
|
title=title, tip="使用 `llm reset-key <Provider>` 重置Key状态"
|
||||||
tip_text="使用 `llm reset-key <Provider>` 重置Key状态",
|
|
||||||
column_name=column_name,
|
|
||||||
data_list=data_list,
|
|
||||||
text_style=_status_row_style,
|
|
||||||
column_space=15,
|
|
||||||
)
|
)
|
||||||
|
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_uninfo import Uninfo
|
||||||
from nonebot_plugin_waiter import prompt_until
|
from nonebot_plugin_waiter import prompt_until
|
||||||
|
|
||||||
|
from zhenxun import ui
|
||||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils.depends import UserName
|
from zhenxun.utils.depends import UserName
|
||||||
@@ -188,15 +189,33 @@ async def _(session: Uninfo, arparma: Arparma, amount: Match[int]):
|
|||||||
|
|
||||||
@_matcher.assign("user-info")
|
@_matcher.assign("user-info")
|
||||||
async def _(session: Uninfo, arparma: Arparma, uname: str = UserName()):
|
async def _(session: Uninfo, arparma: Arparma, uname: str = UserName()):
|
||||||
result = await BankManager.get_user_info(session, uname)
|
user_payload = await BankManager.get_user_info_data(session, uname)
|
||||||
await MessageUtils.build_message(result).send()
|
|
||||||
|
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)
|
logger.info("查看银行个人信息", arparma.header_result, session=session)
|
||||||
|
|
||||||
|
|
||||||
@_matcher.assign("bank-info")
|
@_matcher.assign("bank-info")
|
||||||
async def _(session: Uninfo, arparma: Arparma):
|
async def _(session: Uninfo, arparma: Arparma):
|
||||||
result = await BankManager.get_bank_info()
|
overview_payload = await BankManager.get_bank_info_data()
|
||||||
await MessageUtils.build_message(result).send()
|
|
||||||
|
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)
|
logger.info("查看银行信息", arparma.header_result, session=session)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,16 @@ import asyncio
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from nonebot_plugin_htmlrender import template_to_pic
|
|
||||||
from nonebot_plugin_uninfo import Uninfo
|
from nonebot_plugin_uninfo import Uninfo
|
||||||
from tortoise.expressions import RawSQL
|
from tortoise.expressions import RawSQL
|
||||||
from tortoise.functions import Count, Sum
|
from tortoise.functions import Count, Sum
|
||||||
|
|
||||||
from zhenxun.configs.config import Config
|
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 import MahiroBank
|
||||||
from zhenxun.models.mahiro_bank_log import MahiroBankLog
|
from zhenxun.models.mahiro_bank_log import MahiroBankLog
|
||||||
from zhenxun.models.sign_user import SignUser
|
from zhenxun.models.sign_user import SignUser
|
||||||
from zhenxun.models.user_console import UserConsole
|
from zhenxun.models.user_console import UserConsole
|
||||||
|
from zhenxun.services import avatar_service
|
||||||
from zhenxun.utils.enum import BankHandleType, GoldHandle
|
from zhenxun.utils.enum import BankHandleType, GoldHandle
|
||||||
from zhenxun.utils.platform import PlatformUtils
|
from zhenxun.utils.platform import PlatformUtils
|
||||||
|
|
||||||
@@ -158,15 +157,15 @@ class BankManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@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
|
session: Uninfo
|
||||||
uname: 用户id
|
uname: 用户id
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
bytes: 图片数据
|
dict: 用户银行数据字典
|
||||||
"""
|
"""
|
||||||
user_id = session.user.id
|
user_id = session.user.id
|
||||||
user = await cls.get_user(user_id=user_id)
|
user = await cls.get_user(user_id=user_id)
|
||||||
@@ -199,9 +198,9 @@ class BankManager:
|
|||||||
deposit_list = [
|
deposit_list = [
|
||||||
{
|
{
|
||||||
"id": deposit.id,
|
"id": deposit.id,
|
||||||
"date": now.date(),
|
"date": str(now.date()),
|
||||||
"start_time": str(deposit.create_time).split(".")[0],
|
"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,
|
"amount": deposit.amount,
|
||||||
"rate": f"{deposit.rate * 100:.2f}",
|
"rate": f"{deposit.rate * 100:.2f}",
|
||||||
"projected_revenue": int(
|
"projected_revenue": int(
|
||||||
@@ -212,12 +211,12 @@ class BankManager:
|
|||||||
for deposit in user_today_deposit
|
for deposit in user_today_deposit
|
||||||
]
|
]
|
||||||
platform = PlatformUtils.get_platform(session)
|
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,
|
"name": uname,
|
||||||
"rank": rank + 1,
|
"rank": rank + 1,
|
||||||
"avatar_url": PlatformUtils.get_user_avatar_url(
|
"avatar_url": avatar_url or "",
|
||||||
user_id, platform, session.self_id
|
|
||||||
),
|
|
||||||
"amount": user.amount,
|
"amount": user.amount,
|
||||||
"deposit_count": deposit_count,
|
"deposit_count": deposit_count,
|
||||||
"today_deposit_count": len(user_today_deposit),
|
"today_deposit_count": len(user_today_deposit),
|
||||||
@@ -225,21 +224,16 @@ class BankManager:
|
|||||||
"projected_revenue": projected_revenue,
|
"projected_revenue": projected_revenue,
|
||||||
"today_deposit_amount": today_deposit_amount,
|
"today_deposit_amount": today_deposit_amount,
|
||||||
"deposit_list": deposit_list,
|
"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
|
@classmethod
|
||||||
async def get_bank_info(cls) -> bytes:
|
async def get_bank_info_data(cls) -> dict:
|
||||||
|
"""获取银行总览数据(返回字典)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
dict: 银行总览数据字典
|
||||||
|
"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
now_start = now - timedelta(
|
now_start = now - timedelta(
|
||||||
hours=now.hour, minutes=now.minute, seconds=now.second
|
hours=now.hour, minutes=now.minute, seconds=now.second
|
||||||
@@ -293,27 +287,17 @@ class BankManager:
|
|||||||
if lasted_log:
|
if lasted_log:
|
||||||
date = now.date() - lasted_log.create_time.date()
|
date = now.date() - lasted_log.create_time.date()
|
||||||
date = (date.days or 1) + 1
|
date = (date.days or 1) + 1
|
||||||
data = {
|
return {
|
||||||
"amount_sum": bank_data[0]["amount_sum"],
|
"amount_sum": bank_data[0]["amount_sum"] or 0,
|
||||||
"user_count": bank_data[0]["user_count"],
|
"user_count": bank_data[0]["user_count"] or 0,
|
||||||
"today_count": today_count,
|
"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,
|
"interest_amount": interest_amount[0]["amount_sum"] or 0,
|
||||||
"active_user_count": active_user_count[0]["count"] or 0,
|
"active_user_count": active_user_count[0]["count"] or 0,
|
||||||
"e_data": e_date,
|
"e_data": e_date,
|
||||||
"e_amount": e_amount,
|
"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
|
@classmethod
|
||||||
async def deposit(
|
async def deposit(
|
||||||
@@ -406,7 +390,6 @@ class BankManager:
|
|||||||
bank_data[log.user_id].append(log)
|
bank_data[log.user_id].append(log)
|
||||||
log_create_list = []
|
log_create_list = []
|
||||||
log_update_list = []
|
log_update_list = []
|
||||||
# 计算每日默认金币
|
|
||||||
for bank_user in bank_user_list:
|
for bank_user in bank_user_list:
|
||||||
if user := user_data.get(bank_user.user_id):
|
if user := user_data.get(bank_user.user_id):
|
||||||
amount = bank_user.amount
|
amount = bank_user.amount
|
||||||
@@ -414,7 +397,6 @@ class BankManager:
|
|||||||
amount -= sum(log.amount for log in logs)
|
amount -= sum(log.amount for log in logs)
|
||||||
if not amount:
|
if not amount:
|
||||||
continue
|
continue
|
||||||
# 计算每日默认金币
|
|
||||||
gold = int(amount * bank_user.rate)
|
gold = int(amount * bank_user.rate)
|
||||||
user.gold += gold
|
user.gold += gold
|
||||||
log_create_list.append(
|
log_create_list.append(
|
||||||
@@ -426,7 +408,6 @@ class BankManager:
|
|||||||
is_completed=True,
|
is_completed=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# 计算每日存款金币
|
|
||||||
for user_id, logs in bank_data.items():
|
for user_id, logs in bank_data.items():
|
||||||
if user := user_data.get(user_id):
|
if user := user_data.get(user_id):
|
||||||
for log in logs:
|
for log in logs:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from nonebot.permission import SUPERUSER
|
from nonebot.permission import SUPERUSER
|
||||||
from nonebot.plugin import PluginMetadata
|
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 nonebot_plugin_session import EventSession
|
||||||
|
|
||||||
from zhenxun.configs.utils import PluginExtraData
|
from zhenxun.configs.utils import PluginExtraData
|
||||||
@@ -16,11 +16,16 @@ __plugin_meta__ = PluginMetadata(
|
|||||||
description="插件商店",
|
description="插件商店",
|
||||||
usage="""
|
usage="""
|
||||||
插件商店 : 查看当前的插件商店
|
插件商店 : 查看当前的插件商店
|
||||||
添加插件 id or module : 添加插件
|
添加插件 id或module或插件名称 ?[-s [git, ali]]: 添加插件
|
||||||
移除插件 id or module : 移除插件
|
使用-s时指定源,git为github,ali为阿里云
|
||||||
搜索插件 name or author : 搜索插件
|
移除插件 id或module: 移除插件
|
||||||
更新插件 id or module : 更新插件
|
搜索插件 name或author: 搜索插件
|
||||||
|
更新插件 id或module: 更新插件
|
||||||
更新全部插件 : 更新全部插件
|
更新全部插件 : 更新全部插件
|
||||||
|
|
||||||
|
示例:
|
||||||
|
添加插件 pix
|
||||||
|
添加插件 真寻日报 -s git
|
||||||
""".strip(),
|
""".strip(),
|
||||||
extra=PluginExtraData(
|
extra=PluginExtraData(
|
||||||
author="HibiKier",
|
author="HibiKier",
|
||||||
@@ -32,7 +37,11 @@ __plugin_meta__ = PluginMetadata(
|
|||||||
_matcher = on_alconna(
|
_matcher = on_alconna(
|
||||||
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("remove", Args["plugin_id", str]),
|
||||||
Subcommand("search", Args["plugin_name_or_author", str]),
|
Subcommand("search", Args["plugin_name_or_author", str]),
|
||||||
Subcommand("update", Args["plugin_id", str]),
|
Subcommand("update", Args["plugin_id", str]),
|
||||||
@@ -91,13 +100,20 @@ async def _(session: EventSession):
|
|||||||
|
|
||||||
|
|
||||||
@_matcher.assign("add")
|
@_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:
|
try:
|
||||||
if is_number(plugin_id):
|
result = await StoreManager.add_plugin(plugin_id, source_str)
|
||||||
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)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"添加插件 Id: {plugin_id}失败", "插件商店", session=session, e=e)
|
logger.error(f"添加插件 Id: {plugin_id}失败", "插件商店", session=session, e=e)
|
||||||
await MessageUtils.build_message(
|
await MessageUtils.build_message(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import random
|
import random
|
||||||
import shutil
|
import shutil
|
||||||
@@ -10,11 +11,12 @@ from zhenxun.configs.path_config import TEMP_PATH
|
|||||||
from zhenxun.models.plugin_info import PluginInfo
|
from zhenxun.models.plugin_info import PluginInfo
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.services.plugin_init import PluginInitManager
|
from zhenxun.services.plugin_init import PluginInitManager
|
||||||
|
from zhenxun.utils.enum import PluginType
|
||||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
|
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
|
||||||
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
|
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
|
||||||
from zhenxun.utils.repo_utils import RepoFileManager
|
from zhenxun.utils.repo_utils import RepoFileManager
|
||||||
from zhenxun.utils.repo_utils.models import RepoFileInfo, RepoType
|
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 (
|
from .config import (
|
||||||
BASE_PATH,
|
BASE_PATH,
|
||||||
@@ -163,13 +165,17 @@ class StoreManager:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_plugin_by_value(
|
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]:
|
) -> tuple[StorePluginInfo, bool]:
|
||||||
"""获取插件信息
|
"""获取插件信息
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
index_or_module: 插件索引或模块名
|
index_or_module: 插件索引或模块名
|
||||||
is_update: 是否是更新插件
|
is_update: 是否是更新插件
|
||||||
|
is_remove: 是否是移除插件
|
||||||
|
|
||||||
异常:
|
异常:
|
||||||
PluginStoreException: 插件不存在
|
PluginStoreException: 插件不存在
|
||||||
@@ -179,6 +185,8 @@ class StoreManager:
|
|||||||
StorePluginInfo: 插件信息
|
StorePluginInfo: 插件信息
|
||||||
bool: 是否是外部插件
|
bool: 是否是外部插件
|
||||||
"""
|
"""
|
||||||
|
plugin_list: list[StorePluginInfo]
|
||||||
|
extra_plugin_list: list[StorePluginInfo]
|
||||||
plugin_list, extra_plugin_list = await cls.get_data()
|
plugin_list, extra_plugin_list = await cls.get_data()
|
||||||
plugin_info = None
|
plugin_info = None
|
||||||
is_external = False
|
is_external = False
|
||||||
@@ -196,14 +204,32 @@ class StoreManager:
|
|||||||
break
|
break
|
||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
raise PluginStoreException(f"插件不存在: {plugin_key}")
|
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} 已安装,无需重复安装")
|
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
|
return plugin_info, is_external
|
||||||
|
|
||||||
@classmethod
|
@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]}"
|
plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}"
|
||||||
logger.info(f"正在安装插件 {plugin_info.name}...", LOG_COMMAND)
|
logger.info(f"正在安装插件 {plugin_info.name}...", LOG_COMMAND)
|
||||||
await cls.install_plugin_with_repo(
|
await cls.install_plugin_with_repo(
|
||||||
plugin_info.github_url,
|
plugin_info,
|
||||||
plugin_info.module_path,
|
|
||||||
plugin_info.is_dir,
|
|
||||||
is_external,
|
is_external,
|
||||||
|
source,
|
||||||
)
|
)
|
||||||
return f"插件 {plugin_info.name} 安装成功! 重启后生效"
|
return f"插件 {plugin_info.name} 安装成功! 重启后生效"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def install_plugin_with_repo(
|
async def install_plugin_with_repo(
|
||||||
cls,
|
cls,
|
||||||
github_url: str,
|
plugin_info: StorePluginInfo,
|
||||||
module_path: str,
|
|
||||||
is_dir: bool,
|
|
||||||
is_external: bool = False,
|
is_external: bool = False,
|
||||||
|
source: str | None = None,
|
||||||
):
|
):
|
||||||
"""安装插件
|
"""安装插件
|
||||||
|
|
||||||
@@ -245,19 +269,39 @@ class StoreManager:
|
|||||||
is_external: 是否是外部仓库
|
is_external: 是否是外部仓库
|
||||||
"""
|
"""
|
||||||
repo_type = RepoType.GITHUB if is_external else None
|
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:
|
if is_dir:
|
||||||
files = await RepoFileManager.list_directory_files(
|
files = await RepoFileManager.list_directory_files(
|
||||||
github_url, replace_module_path, repo_type=repo_type
|
github_url, replace_module_path, repo_type=repo_type
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
files = [RepoFileInfo(path=f"{replace_module_path}.py", is_dir=False)]
|
files = [RepoFileInfo(path=f"{replace_module_path}.py", is_dir=False)]
|
||||||
local_path = BASE_PATH / "plugins" if is_external else BASE_PATH
|
if not is_external:
|
||||||
|
target_dir = BASE_PATH
|
||||||
|
elif is_dir and module_path == ".":
|
||||||
|
target_dir = BASE_PATH / "plugins" / plugin_name
|
||||||
|
else:
|
||||||
|
target_dir = BASE_PATH / "plugins"
|
||||||
files = [file for file in files if not file.is_dir]
|
files = [file for file in files if not file.is_dir]
|
||||||
download_files = [(file.path, local_path / file.path) for file in files]
|
download_files = [(file.path, target_dir / file.path) for file in files]
|
||||||
await RepoFileManager.download_files(
|
result = await RepoFileManager.download_files(
|
||||||
github_url, download_files, repo_type=repo_type
|
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 = [
|
requirement_paths = [
|
||||||
file
|
file
|
||||||
@@ -268,7 +312,7 @@ class StoreManager:
|
|||||||
|
|
||||||
is_install_req = False
|
is_install_req = False
|
||||||
for requirement_path in requirement_paths:
|
for requirement_path in requirement_paths:
|
||||||
requirement_file = local_path / requirement_path.path
|
requirement_file = target_dir / requirement_path.path
|
||||||
if requirement_file.exists():
|
if requirement_file.exists():
|
||||||
is_install_req = True
|
is_install_req = True
|
||||||
await VirtualEnvPackageManager.install_requirement(requirement_file)
|
await VirtualEnvPackageManager.install_requirement(requirement_file)
|
||||||
@@ -310,22 +354,21 @@ class StoreManager:
|
|||||||
返回:
|
返回:
|
||||||
str: 返回消息
|
str: 返回消息
|
||||||
"""
|
"""
|
||||||
plugin_info, _ = await cls.get_plugin_by_value(index_or_module)
|
plugin_info, _ = await cls.get_plugin_by_value(index_or_module, is_remove=True)
|
||||||
path = BASE_PATH
|
module_path = plugin_info.module_path
|
||||||
if plugin_info.github_url:
|
module = module_path.split(".")[-1]
|
||||||
path = BASE_PATH / "plugins"
|
path = BASE_PATH.parent / Path(module_path.replace(".", os.sep))
|
||||||
for p in plugin_info.module_path.split("."):
|
|
||||||
path = path / p
|
|
||||||
if not plugin_info.is_dir:
|
if not plugin_info.is_dir:
|
||||||
path = Path(f"{path}.py")
|
path = path.parent / f"{module}.py"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return f"插件 {plugin_info.name} 不存在..."
|
return f"插件 {plugin_info.name} 不存在..."
|
||||||
logger.debug(f"尝试移除插件 {plugin_info.name} 文件: {path}", LOG_COMMAND)
|
logger.debug(f"尝试移除插件 {plugin_info.name} 文件: {path}", LOG_COMMAND)
|
||||||
if plugin_info.is_dir:
|
if plugin_info.is_dir:
|
||||||
shutil.rmtree(path)
|
# 处理 Windows 下 .git 等目录内只读文件导致的 WinError 5
|
||||||
|
shutil.rmtree(path, onerror=win_on_rm_error)
|
||||||
else:
|
else:
|
||||||
path.unlink()
|
path.unlink()
|
||||||
await PluginInitManager.remove(f"zhenxun.{plugin_info.module_path}")
|
await PluginInitManager.remove(module_path)
|
||||||
return f"插件 {plugin_info.name} 移除成功! 重启后生效"
|
return f"插件 {plugin_info.name} 移除成功! 重启后生效"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -392,9 +435,7 @@ class StoreManager:
|
|||||||
if plugin_info.github_url is None:
|
if plugin_info.github_url is None:
|
||||||
plugin_info.github_url = DEFAULT_GITHUB_URL
|
plugin_info.github_url = DEFAULT_GITHUB_URL
|
||||||
await cls.install_plugin_with_repo(
|
await cls.install_plugin_with_repo(
|
||||||
plugin_info.github_url,
|
plugin_info,
|
||||||
plugin_info.module_path,
|
|
||||||
plugin_info.is_dir,
|
|
||||||
is_external,
|
is_external,
|
||||||
)
|
)
|
||||||
return f"插件 {plugin_info.name} 更新成功! 重启后生效"
|
return f"插件 {plugin_info.name} 更新成功! 重启后生效"
|
||||||
@@ -416,7 +457,7 @@ class StoreManager:
|
|||||||
update_success_list = []
|
update_success_list = []
|
||||||
result = "--已更新{}个插件 {}个失败 {}个成功--"
|
result = "--已更新{}个插件 {}个失败 {}个成功--"
|
||||||
logger.info(f"尝试更新全部插件 {plugin_name_list}", LOG_COMMAND)
|
logger.info(f"尝试更新全部插件 {plugin_name_list}", LOG_COMMAND)
|
||||||
for plugin_info in plugin_list:
|
for plugin_info in all_plugin_list:
|
||||||
try:
|
try:
|
||||||
db_plugin_list = await cls.get_loaded_plugins("module", "version")
|
db_plugin_list = await cls.get_loaded_plugins("module", "version")
|
||||||
suc_plugin = {p[0]: (p[1] or "Unknown") for p in db_plugin_list}
|
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
|
plugin_info.github_url = DEFAULT_GITHUB_URL
|
||||||
is_external = False
|
is_external = False
|
||||||
await cls.install_plugin_with_repo(
|
await cls.install_plugin_with_repo(
|
||||||
plugin_info.github_url,
|
plugin_info,
|
||||||
plugin_info.module_path,
|
|
||||||
plugin_info.is_dir,
|
|
||||||
is_external,
|
is_external,
|
||||||
)
|
)
|
||||||
update_success_list.append(plugin_info.name)
|
update_success_list.append(plugin_info.name)
|
||||||
@@ -496,11 +535,11 @@ class StoreManager:
|
|||||||
raise PluginStoreException("插件ID不存在...")
|
raise PluginStoreException("插件ID不存在...")
|
||||||
return all_plugin_list[idx].module
|
return all_plugin_list[idx].module
|
||||||
elif isinstance(plugin_id, str):
|
elif isinstance(plugin_id, str):
|
||||||
result = (
|
if plugin_id in [v.module for v in all_plugin_list]:
|
||||||
None
|
return plugin_id
|
||||||
if plugin_id not in [v.module for v in all_plugin_list]
|
|
||||||
else plugin_id
|
for plugin_info in all_plugin_list:
|
||||||
) or next(v for v in all_plugin_list if v.name == plugin_id).module
|
if plugin_info.name.lower() == plugin_id.lower():
|
||||||
if not result:
|
return plugin_info.module
|
||||||
raise PluginStoreException("插件 Module / 名称 不存在...")
|
|
||||||
return result
|
raise PluginStoreException("插件 Module / 名称 不存在...")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import random
|
import random
|
||||||
import time
|
|
||||||
|
|
||||||
from nonebot import on_message, on_request
|
from nonebot import on_message, on_request
|
||||||
from nonebot.adapters.onebot.v11 import (
|
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.v11 import Bot as v11Bot
|
||||||
from nonebot.adapters.onebot.v12 import Bot as v12Bot
|
from nonebot.adapters.onebot.v12 import Bot as v12Bot
|
||||||
from nonebot.plugin import PluginMetadata
|
from nonebot.plugin import PluginMetadata
|
||||||
from nonebot_plugin_apscheduler import scheduler
|
|
||||||
from nonebot_plugin_session import EventSession
|
from nonebot_plugin_session import EventSession
|
||||||
|
|
||||||
from zhenxun.configs.config import BotConfig, Config
|
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: 其他平台请求
|
# TODO: 其他平台请求
|
||||||
|
|
||||||
friend_req = on_request(priority=5, block=True)
|
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)
|
_t = on_message(priority=999, block=False, rule=lambda: False)
|
||||||
|
|
||||||
|
|
||||||
cache = CacheRoot.cache_dict(
|
cache = CacheRoot.cache_dict("REQUEST_CACHE", 60, str)
|
||||||
"REQUEST_CACHE", (base_config.get("TIP_MESSAGE_LIMIT") or 360) * 60, str
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@friend_req.handle()
|
@friend_req.handle()
|
||||||
async def _(bot: v12Bot | v11Bot, event: FriendRequestEvent, session: EventSession):
|
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)
|
||||||
logger.debug("收录好友请求...", "好友请求", target=event.user_id)
|
user = await bot.get_stranger_info(user_id=event.user_id)
|
||||||
user = await bot.get_stranger_info(user_id=event.user_id)
|
nickname = user["nickname"]
|
||||||
nickname = user["nickname"]
|
# sex = user["sex"]
|
||||||
# sex = user["sex"]
|
# age = str(user["age"])
|
||||||
# age = str(user["age"])
|
comment = event.comment
|
||||||
comment = event.comment
|
if base_config.get("AUTO_ADD_FRIEND"):
|
||||||
if base_config.get("AUTO_ADD_FRIEND"):
|
logger.debug(
|
||||||
logger.debug(
|
"已开启好友请求自动同意,成功通过该请求",
|
||||||
"已开启好友请求自动同意,成功通过该请求",
|
"好友请求",
|
||||||
"好友请求",
|
target=event.user_id,
|
||||||
target=event.user_id,
|
)
|
||||||
)
|
await asyncio.sleep(random.randint(1, 10))
|
||||||
await asyncio.sleep(random.randint(1, 10))
|
await bot.set_friend_add_request(flag=event.flag, approve=True)
|
||||||
await bot.set_friend_add_request(flag=event.flag, approve=True)
|
await FriendUser.create(
|
||||||
await FriendUser.create(
|
user_id=str(user["user_id"]), user_name=user["nickname"]
|
||||||
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"])
|
|
||||||
else:
|
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()
|
@group_req.handle()
|
||||||
@@ -227,7 +214,7 @@ async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSessio
|
|||||||
"\n在群组中 群组管理员与群主 允许使用管理员帮助"
|
"\n在群组中 群组管理员与群主 允许使用管理员帮助"
|
||||||
"(包括ban与功能开关等)\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")
|
cache.set(f"{event.group_id}", "1")
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"收录 用户[{event.user_id}] 群聊[{event.group_id}] 群聊请求",
|
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}",
|
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("没有找到任何相关的定时任务。")
|
await schedule_cmd.finish("没有找到任何相关的定时任务。")
|
||||||
|
|
||||||
img = await presenters.format_schedule_list_as_image(
|
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)
|
await MessageUtils.build_message(img).send(reply_to=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from zhenxun import ui
|
||||||
from zhenxun.models.scheduled_job import ScheduledJob
|
from zhenxun.models.scheduled_job import ScheduledJob
|
||||||
from zhenxun.services.scheduler import scheduler_manager
|
from zhenxun.services.scheduler import scheduler_manager
|
||||||
from zhenxun.utils._image_template import ImageTemplate, RowStyle
|
from zhenxun.ui.builders import TableBuilder
|
||||||
|
from zhenxun.ui.models import StatusBadgeCell, TextCell
|
||||||
from zhenxun.utils.pydantic_compat import model_json_schema
|
from zhenxun.utils.pydantic_compat import model_json_schema
|
||||||
|
|
||||||
|
|
||||||
@@ -118,19 +120,6 @@ def format_update_success(schedule_info: ScheduledJob) -> str:
|
|||||||
return _format_operation_result_card("🔄️ 成功更新定时任务配置!", schedule_info)
|
return _format_operation_result_card("🔄️ 成功更新定时任务配置!", schedule_info)
|
||||||
|
|
||||||
|
|
||||||
def _status_row_style(column: str, text: str) -> RowStyle:
|
|
||||||
"""为状态列设置颜色"""
|
|
||||||
style = RowStyle()
|
|
||||||
if column == "状态":
|
|
||||||
if text == "启用":
|
|
||||||
style.font_color = "#67C23A"
|
|
||||||
elif text == "暂停":
|
|
||||||
style.font_color = "#F56C6C"
|
|
||||||
elif text == "运行中":
|
|
||||||
style.font_color = "#409EFF"
|
|
||||||
return style
|
|
||||||
|
|
||||||
|
|
||||||
def _format_params(schedule_status: dict) -> str:
|
def _format_params(schedule_status: dict) -> str:
|
||||||
"""将任务参数格式化为人类可读的字符串"""
|
"""将任务参数格式化为人类可读的字符串"""
|
||||||
if kwargs := schedule_status.get("job_kwargs"):
|
if kwargs := schedule_status.get("job_kwargs"):
|
||||||
@@ -157,36 +146,47 @@ async def format_schedule_list_as_image(
|
|||||||
]
|
]
|
||||||
all_statuses = await asyncio.gather(*status_tasks)
|
all_statuses = await asyncio.gather(*status_tasks)
|
||||||
|
|
||||||
def get_status_text(status_value):
|
data_list = []
|
||||||
if isinstance(status_value, bool):
|
for s in all_statuses:
|
||||||
return "启用" if status_value else "暂停"
|
if not s:
|
||||||
return str(status_value)
|
continue
|
||||||
|
|
||||||
data_list = [
|
status_value = s["is_enabled"]
|
||||||
[
|
if status_value == "运行中":
|
||||||
s["id"],
|
status_cell = StatusBadgeCell(text="运行中", status_type="info")
|
||||||
s["plugin_name"],
|
else:
|
||||||
s.get("bot_id") or "N/A",
|
is_enabled = status_value == "启用"
|
||||||
s["group_id"] or "全局",
|
status_cell = StatusBadgeCell(
|
||||||
s["next_run_time"],
|
text="启用" if is_enabled else "暂停",
|
||||||
_format_trigger_info(s),
|
status_type="ok" if is_enabled else "error",
|
||||||
_format_params(s),
|
)
|
||||||
get_status_text(s["is_enabled"]),
|
|
||||||
]
|
data_list.append(
|
||||||
for s in all_statuses
|
[
|
||||||
if s
|
TextCell(content=str(s["id"])),
|
||||||
]
|
TextCell(content=s["plugin_name"]),
|
||||||
|
TextCell(content=s.get("bot_id") or "N/A"),
|
||||||
|
TextCell(content=s["group_id"] or "全局"),
|
||||||
|
TextCell(content=s["next_run_time"]),
|
||||||
|
TextCell(content=_format_trigger_info(s)),
|
||||||
|
TextCell(content=_format_params(s)),
|
||||||
|
status_cell,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
if not data_list:
|
if not data_list:
|
||||||
return "没有找到任何相关的定时任务。"
|
return "没有找到任何相关的定时任务。"
|
||||||
|
|
||||||
return await ImageTemplate.table_page(
|
builder = TableBuilder(
|
||||||
head_text=title,
|
title, f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务"
|
||||||
tip_text=f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务",
|
)
|
||||||
column_name=["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"],
|
builder.set_headers(
|
||||||
data_list=data_list,
|
["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"]
|
||||||
column_space=20,
|
).add_rows(data_list)
|
||||||
text_style=_status_row_style,
|
return await ui.render(
|
||||||
|
builder.build(),
|
||||||
|
viewport={"width": 1400, "height": 10},
|
||||||
|
device_scale_factor=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async def _(session: Uninfo, arparma: Arparma, nickname: str = UserName()):
|
|||||||
nickname,
|
nickname,
|
||||||
PlatformUtils.get_platform(session),
|
PlatformUtils.get_platform(session),
|
||||||
):
|
):
|
||||||
await MessageUtils.build_message(image.pic2bytes()).finish(reply_to=True)
|
await MessageUtils.build_message(image).finish(reply_to=True) # type: ignore
|
||||||
return await MessageUtils.build_message("你的道具为空捏...").send(reply_to=True)
|
return await MessageUtils.build_message("你的道具为空捏...").send(reply_to=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import inspect
|
import inspect
|
||||||
@@ -7,26 +8,27 @@ from types import MappingProxyType
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from nonebot.adapters import Bot, Event
|
from nonebot.adapters import Bot, Event
|
||||||
from nonebot.compat import model_dump
|
|
||||||
from nonebot_plugin_alconna import At, UniMessage, UniMsg
|
from nonebot_plugin_alconna import At, UniMessage, UniMsg
|
||||||
from nonebot_plugin_uninfo import Uninfo
|
from nonebot_plugin_uninfo import Uninfo
|
||||||
from pydantic import BaseModel, Field, create_model
|
from pydantic import BaseModel, Field, create_model
|
||||||
from tortoise.expressions import Q
|
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.friend_user import FriendUser
|
||||||
from zhenxun.models.goods_info import GoodsInfo
|
from zhenxun.models.goods_info import GoodsInfo
|
||||||
from zhenxun.models.group_member_info import GroupInfoUser
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
from zhenxun.models.user_console import UserConsole
|
from zhenxun.models.user_console import UserConsole
|
||||||
from zhenxun.models.user_gold_log import UserGoldLog
|
from zhenxun.models.user_gold_log import UserGoldLog
|
||||||
from zhenxun.models.user_props_log import UserPropsLog
|
from zhenxun.models.user_props_log import UserPropsLog
|
||||||
|
from zhenxun.services import avatar_service
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
|
from zhenxun.ui.models import ImageCell, TextCell
|
||||||
from zhenxun.utils.enum import GoldHandle, PropHandle
|
from zhenxun.utils.enum import GoldHandle, PropHandle
|
||||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
|
|
||||||
from zhenxun.utils.platform import PlatformUtils
|
from zhenxun.utils.platform import PlatformUtils
|
||||||
|
from zhenxun.utils.pydantic_compat import model_dump
|
||||||
|
|
||||||
from .config import ICON_PATH, PLATFORM_PATH, base_config
|
from .config import ICON_PATH, PLATFORM_PATH
|
||||||
from .html_image import html_image
|
|
||||||
from .normal_image import normal_image
|
|
||||||
|
|
||||||
|
|
||||||
class Goods(BaseModel):
|
class Goods(BaseModel):
|
||||||
@@ -91,9 +93,7 @@ class ShopParam(BaseModel):
|
|||||||
return model_dump(self, **kwargs)
|
return model_dump(self, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def gold_rank(
|
async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes | str:
|
||||||
session: Uninfo, group_id: str | None, num: int
|
|
||||||
) -> BuildImage | str:
|
|
||||||
query = UserConsole
|
query = UserConsole
|
||||||
if group_id:
|
if group_id:
|
||||||
uid_list = await GroupInfoUser.filter(group_id=group_id).values_list(
|
uid_list = await GroupInfoUser.filter(group_id=group_id).values_list(
|
||||||
@@ -124,16 +124,20 @@ async def gold_rank(
|
|||||||
data_list = []
|
data_list = []
|
||||||
platform = PlatformUtils.get_platform(session)
|
platform = PlatformUtils.get_platform(session)
|
||||||
for i, user in enumerate(user_list):
|
for i, user in enumerate(user_list):
|
||||||
ava_bytes = await PlatformUtils.get_user_avatar(
|
avatar_path = await avatar_service.get_avatar_path(platform, user[0])
|
||||||
user[0], platform, session.self_id
|
|
||||||
)
|
|
||||||
data_list.append(
|
data_list.append(
|
||||||
[
|
[
|
||||||
f"{i + 1}",
|
TextCell(content=f"{i + 1}"),
|
||||||
(ava_bytes, 30, 30) if platform == "qq" else "",
|
ImageCell(
|
||||||
uid2name.get(user[0]),
|
src=avatar_path.as_uri() if avatar_path else "", shape="circle"
|
||||||
user[1],
|
)
|
||||||
(PLATFORM_PATH.get(platform), 30, 30),
|
if avatar_path
|
||||||
|
else TextCell(content=""),
|
||||||
|
TextCell(content=uid2name.get(user[0]) or user[0]),
|
||||||
|
TextCell(content=str(user[1]), bold=True),
|
||||||
|
ImageCell(src=platform_path.resolve().as_uri())
|
||||||
|
if (platform_path := PLATFORM_PATH.get(platform))
|
||||||
|
else TextCell(content=""),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if group_id:
|
if group_id:
|
||||||
@@ -142,7 +146,11 @@ async def gold_rank(
|
|||||||
else:
|
else:
|
||||||
title = "金币全局排行"
|
title = "金币全局排行"
|
||||||
tip = f"你的排名在全局第 {index} 位哦!"
|
tip = f"你的排名在全局第 {index} 位哦!"
|
||||||
return await ImageTemplate.table_page(title, tip, column_name, data_list)
|
from zhenxun.ui.builders import TableBuilder
|
||||||
|
|
||||||
|
builder = TableBuilder(title, tip)
|
||||||
|
builder.set_headers(column_name).add_rows(data_list)
|
||||||
|
return await ui.render(builder.build())
|
||||||
|
|
||||||
|
|
||||||
class ShopManage:
|
class ShopManage:
|
||||||
@@ -150,9 +158,7 @@ class ShopManage:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_shop_image(cls) -> bytes:
|
async def get_shop_image(cls) -> bytes:
|
||||||
if base_config.get("style") == "zhenxun":
|
return await prepare_shop_data()
|
||||||
return await html_image()
|
|
||||||
return await normal_image()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __build_params(
|
def __build_params(
|
||||||
@@ -494,7 +500,7 @@ class ShopManage:
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def my_props(
|
async def my_props(
|
||||||
cls, user_id: str, name: str, platform: str | None = None
|
cls, user_id: str, name: str, platform: str | None = None
|
||||||
) -> BuildImage | None:
|
) -> bytes | None:
|
||||||
"""获取道具背包
|
"""获取道具背包
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
@@ -526,10 +532,10 @@ class ShopManage:
|
|||||||
if not prop:
|
if not prop:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
icon = ""
|
icon = None
|
||||||
if prop.icon:
|
if prop.icon:
|
||||||
icon_path = ICON_PATH / prop.icon
|
icon_path = ICON_PATH / prop.icon
|
||||||
icon = (icon_path, 33, 33) if icon_path.exists() else ""
|
icon = icon_path if icon_path.exists() else None
|
||||||
|
|
||||||
table_rows.append(
|
table_rows.append(
|
||||||
[
|
[
|
||||||
@@ -545,12 +551,11 @@ class ShopManage:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
column_name = ["-", "使用ID", "名称", "数量", "简介"]
|
column_name = ["-", "使用ID", "名称", "数量", "简介"]
|
||||||
return await ImageTemplate.table_page(
|
from zhenxun.ui.builders import TableBuilder
|
||||||
f"{name}的道具仓库",
|
|
||||||
"通过 使用道具[ID/名称] 令道具生效",
|
builder = TableBuilder(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
|
||||||
column_name,
|
builder.set_headers(column_name).add_rows(table_rows)
|
||||||
table_rows,
|
return await ui.render(builder.build())
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def my_cost(cls, user_id: str, platform: str | None = None) -> int:
|
async def my_cost(cls, user_id: str, platform: str | None = None) -> int:
|
||||||
@@ -565,3 +570,62 @@ class ShopManage:
|
|||||||
"""
|
"""
|
||||||
user = await UserConsole.get_user(user_id, platform)
|
user = await UserConsole.get_user(user_id, platform)
|
||||||
return user.gold
|
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.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")
|
base_config = Config.get("shop")
|
||||||
|
|
||||||
@@ -17,4 +17,4 @@ PLATFORM_PATH = {
|
|||||||
|
|
||||||
LEFT_RIGHT_IMAGE = ["1.png", "2.png", "qq.png"]
|
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,
|
default_value=0.05,
|
||||||
type=float,
|
type=float,
|
||||||
),
|
),
|
||||||
RegisterConfig(
|
|
||||||
key="IMAGE_STYLE",
|
|
||||||
value="zhenxun",
|
|
||||||
help="签到图片样式, [normal, zhenxun]",
|
|
||||||
default_value="zhenxun",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
limits=[PluginCdBlock()],
|
limits=[PluginCdBlock()],
|
||||||
).to_dict(),
|
).to_dict(),
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import secrets
|
|||||||
from nonebot_plugin_uninfo import Uninfo
|
from nonebot_plugin_uninfo import Uninfo
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
from zhenxun import ui
|
||||||
from zhenxun.configs.path_config import IMAGE_PATH
|
from zhenxun.configs.path_config import IMAGE_PATH
|
||||||
from zhenxun.models.friend_user import FriendUser
|
from zhenxun.models.friend_user import FriendUser
|
||||||
from zhenxun.models.group_member_info import GroupInfoUser
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
from zhenxun.models.sign_log import SignLog
|
from zhenxun.models.sign_log import SignLog
|
||||||
from zhenxun.models.sign_user import SignUser
|
from zhenxun.models.sign_user import SignUser
|
||||||
from zhenxun.models.user_console import UserConsole
|
from zhenxun.models.user_console import UserConsole
|
||||||
|
from zhenxun.services.avatar_service import avatar_service
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
|
from zhenxun.ui.models import ImageCell, TextCell
|
||||||
from zhenxun.utils.platform import PlatformUtils
|
from zhenxun.utils.platform import PlatformUtils
|
||||||
|
|
||||||
from ._random_event import random_event
|
from ._random_event import random_event
|
||||||
@@ -33,7 +35,7 @@ class SignManage:
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def rank(
|
async def rank(
|
||||||
cls, session: Uninfo, num: int, group_id: str | None = None
|
cls, session: Uninfo, num: int, group_id: str | None = None
|
||||||
) -> BuildImage | str: # sourcery skip: avoid-builtin-shadow
|
) -> bytes | str:
|
||||||
"""好感度排行
|
"""好感度排行
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
@@ -42,7 +44,7 @@ class SignManage:
|
|||||||
group_id: 群组id
|
group_id: 群组id
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
BuildImage: 构造图片
|
bytes: 构造图片
|
||||||
"""
|
"""
|
||||||
query = SignUser
|
query = SignUser
|
||||||
if group_id:
|
if group_id:
|
||||||
@@ -78,17 +80,23 @@ class SignManage:
|
|||||||
data_list = []
|
data_list = []
|
||||||
platform = PlatformUtils.get_platform(session)
|
platform = PlatformUtils.get_platform(session)
|
||||||
for i, user in enumerate(user_list):
|
for i, user in enumerate(user_list):
|
||||||
bytes = await PlatformUtils.get_user_avatar(
|
avatar_path = await avatar_service.get_avatar_path(
|
||||||
user[0], platform, session.self_id
|
platform=user[3] or "qq", identifier=user[0]
|
||||||
)
|
)
|
||||||
data_list.append(
|
data_list.append(
|
||||||
[
|
[
|
||||||
f"{i + 1}",
|
TextCell(content=f"{i + 1}"),
|
||||||
(bytes, 30, 30) if user[3] == "qq" else "",
|
ImageCell(
|
||||||
uid2name.get(user[0]),
|
src=avatar_path.as_uri() if avatar_path else "", shape="circle"
|
||||||
user[1],
|
)
|
||||||
user[2],
|
if avatar_path
|
||||||
(PLATFORM_PATH.get(user[3]), 30, 30),
|
else TextCell(content=""),
|
||||||
|
TextCell(content=uid2name.get(user[0]) or user[0]),
|
||||||
|
TextCell(content=str(user[1]), bold=True),
|
||||||
|
TextCell(content=str(user[2])),
|
||||||
|
ImageCell(src=platform_path.resolve().as_uri())
|
||||||
|
if (platform_path := PLATFORM_PATH.get(platform))
|
||||||
|
else TextCell(content=""),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if group_id:
|
if group_id:
|
||||||
@@ -97,7 +105,11 @@ class SignManage:
|
|||||||
else:
|
else:
|
||||||
title = "好感度全局排行"
|
title = "好感度全局排行"
|
||||||
tip = f"你的排名在全局第 {index} 位哦!"
|
tip = f"你的排名在全局第 {index} 位哦!"
|
||||||
return await ImageTemplate.table_page(title, tip, column_name, data_list)
|
from zhenxun.ui.builders import TableBuilder
|
||||||
|
|
||||||
|
builder = TableBuilder(title, tip)
|
||||||
|
builder.set_headers(column_name).add_rows(data_list)
|
||||||
|
return await ui.render(builder.build())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def sign(
|
async def sign(
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
from zhenxun.configs.path_config import IMAGE_PATH
|
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_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 = {
|
lik2relation = {
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from io import BytesIO
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import random
|
import random
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
import nonebot
|
import nonebot
|
||||||
from nonebot.drivers import Driver
|
from nonebot.drivers import Driver
|
||||||
from nonebot_plugin_htmlrender import template_to_pic
|
|
||||||
from nonebot_plugin_uninfo import Uninfo
|
from nonebot_plugin_uninfo import Uninfo
|
||||||
import pytz
|
|
||||||
|
|
||||||
|
from zhenxun import ui
|
||||||
from zhenxun.configs.config import BotConfig, Config
|
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.models.sign_user import SignUser
|
||||||
from zhenxun.utils.http_utils import AsyncHttpx
|
from zhenxun.services import avatar_service
|
||||||
from zhenxun.utils.image_utils import BuildImage
|
|
||||||
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
|
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
|
||||||
from zhenxun.utils.platform import PlatformUtils
|
from zhenxun.utils.platform import PlatformUtils
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
SIGN_BACKGROUND_PATH,
|
|
||||||
SIGN_BORDER_PATH,
|
|
||||||
SIGN_RESOURCE_PATH,
|
|
||||||
SIGN_TODAY_CARD_PATH,
|
SIGN_TODAY_CARD_PATH,
|
||||||
level2attitude,
|
level2attitude,
|
||||||
lik2level,
|
lik2level,
|
||||||
@@ -57,9 +50,7 @@ LG_MESSAGE = [
|
|||||||
|
|
||||||
@PriorityLifecycle.on_startup(priority=5)
|
@PriorityLifecycle.on_startup(priority=5)
|
||||||
async def init_image():
|
async def init_image():
|
||||||
SIGN_RESOURCE_PATH.mkdir(parents=True, exist_ok=True)
|
|
||||||
SIGN_TODAY_CARD_PATH.mkdir(exist_ok=True, parents=True)
|
SIGN_TODAY_CARD_PATH.mkdir(exist_ok=True, parents=True)
|
||||||
# await generate_progress_bar_pic()
|
|
||||||
clear_sign_data_pic()
|
clear_sign_data_pic()
|
||||||
|
|
||||||
|
|
||||||
@@ -88,290 +79,54 @@ async def get_card(
|
|||||||
返回:
|
返回:
|
||||||
Path: 卡片路径
|
Path: 卡片路径
|
||||||
"""
|
"""
|
||||||
await generate_progress_bar_pic()
|
|
||||||
user_id = user.user_id
|
user_id = user.user_id
|
||||||
date = datetime.now().date()
|
date = datetime.now().date()
|
||||||
_type = "view" if is_card_view else "sign"
|
_type = "view" if is_card_view else "sign"
|
||||||
file_name = f"{user_id}_{_type}_{date}.png"
|
file_name = f"{user_id}_{_type}_{date}.png"
|
||||||
view_name = f"{user_id}_view_{date}.png"
|
card_file = SIGN_TODAY_CARD_PATH / file_name
|
||||||
card_file = Path(SIGN_TODAY_CARD_PATH) / file_name
|
|
||||||
if card_file.exists():
|
if card_file.exists():
|
||||||
return IMAGE_PATH / "sign" / "today_card" / file_name
|
return card_file
|
||||||
|
|
||||||
if add_impression == -1:
|
if add_impression == -1:
|
||||||
card_file = Path(SIGN_TODAY_CARD_PATH) / view_name
|
view_name = f"{user_id}_view_{date}.png"
|
||||||
if card_file.exists():
|
view_card_file = SIGN_TODAY_CARD_PATH / view_name
|
||||||
return card_file
|
if view_card_file.exists():
|
||||||
|
return view_card_file
|
||||||
is_card_view = True
|
is_card_view = True
|
||||||
return (
|
|
||||||
await _generate_html_card(
|
return await _generate_html_card(
|
||||||
user, session, nickname, add_impression, gold, gift, is_double, is_card_view
|
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
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _generate_card(
|
def get_level_and_next_impression(impression: float) -> tuple[int, int | float, int]:
|
||||||
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]:
|
|
||||||
"""获取当前好感等级与下一等级的差距
|
"""获取当前好感等级与下一等级的差距
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
impression: 好感度
|
impression: 好感度
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
tuple[str, int, int]: 好感度等级,下一等级好感度要求,已达到的好感度要求
|
tuple[int, int, int]: 好感度等级,下一等级好感度要求,已达到的好感度要求
|
||||||
"""
|
"""
|
||||||
|
|
||||||
keys = list(lik2level.keys())
|
keys = list(lik2level.keys())
|
||||||
level, next_impression, previous_impression = (
|
level_int, next_impression, previous_impression = (
|
||||||
lik2level[keys[-1]],
|
int(lik2level[keys[-1]]),
|
||||||
keys[-2],
|
keys[-2],
|
||||||
keys[-1],
|
keys[-1],
|
||||||
)
|
)
|
||||||
for i in range(len(keys)):
|
for i in range(len(keys)):
|
||||||
if impression >= keys[i]:
|
if impression >= keys[i]:
|
||||||
level, next_impression, previous_impression = (
|
level_int, next_impression, previous_impression = (
|
||||||
lik2level[keys[i]],
|
int(lik2level[keys[i]]),
|
||||||
keys[i - 1],
|
keys[i - 1],
|
||||||
keys[i],
|
keys[i],
|
||||||
)
|
)
|
||||||
if i == 0:
|
if i == 0:
|
||||||
next_impression = impression
|
next_impression = impression
|
||||||
break
|
break
|
||||||
return level, next_impression, previous_impression
|
return level_int, next_impression, previous_impression
|
||||||
|
|
||||||
|
|
||||||
def clear_sign_data_pic():
|
def clear_sign_data_pic():
|
||||||
@@ -394,7 +149,7 @@ async def _generate_html_card(
|
|||||||
is_double: bool = False,
|
is_double: bool = False,
|
||||||
is_card_view: bool = False,
|
is_card_view: bool = False,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""生成签到卡片
|
"""使用渲染服务生成签到卡片
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
user: SignUser
|
user: SignUser
|
||||||
@@ -404,79 +159,133 @@ async def _generate_html_card(
|
|||||||
gold: 金币
|
gold: 金币
|
||||||
gift: 礼物
|
gift: 礼物
|
||||||
is_double: 是否触发双倍.
|
is_double: 是否触发双倍.
|
||||||
is_card_view: 是否展示好感度卡片.
|
is_card_view: 是否为卡片视图.
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
Path: 卡片路径
|
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)
|
impression = float(user.impression)
|
||||||
user_console = await user.user_console
|
user_console = await user.user_console
|
||||||
if user_console and user_console.uid is not None:
|
if user_console and user_console.uid is not None:
|
||||||
uid = f"{user_console.uid}".rjust(12, "0")
|
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:
|
else:
|
||||||
uid = "XXXX XXXX XXXX"
|
uid_formatted = "XXXX XXXX XXXX"
|
||||||
|
|
||||||
level, next_impression, previous_impression = get_level_and_next_impression(
|
level, next_impression, previous_impression = get_level_and_next_impression(
|
||||||
impression
|
impression
|
||||||
)
|
)
|
||||||
interpolation = next_impression - impression
|
|
||||||
message = f"{BotConfig.self_nickname}希望你开心!"
|
attitude = f"对你的态度: {level2attitude.get(str(level), '未知')}"
|
||||||
hour = datetime.now().hour
|
interpolation_val = max(0, next_impression - impression)
|
||||||
if hour > 6 and hour < 10:
|
interpolation = f"{interpolation_val:.2f}"
|
||||||
message = random.choice(MORNING_MESSAGE)
|
|
||||||
elif hour >= 0 and hour < 6:
|
denominator = next_impression - previous_impression
|
||||||
message = random.choice(LG_MESSAGE)
|
progress = (
|
||||||
_impression = f"{add_impression}(×2)" if is_double else add_impression
|
100.0
|
||||||
process = 1 - (next_impression - impression) / (
|
if denominator == 0
|
||||||
next_impression - previous_impression
|
else min(100.0, ((impression - previous_impression) / denominator) * 100)
|
||||||
)
|
)
|
||||||
now = datetime.now()
|
|
||||||
data = {
|
hour = now.hour
|
||||||
"ava_url": PlatformUtils.get_user_avatar_url(
|
if 6 < hour < 10:
|
||||||
user.user_id, PlatformUtils.get_platform(session), session.self_id
|
message = random.choice(MORNING_MESSAGE)
|
||||||
),
|
elif 0 <= hour < 6:
|
||||||
"name": nickname,
|
message = random.choice(LG_MESSAGE)
|
||||||
"uid": uid,
|
else:
|
||||||
"sign_count": f"{user.sign_count}",
|
message = f"{BotConfig.self_nickname}希望你开心!"
|
||||||
"message": f"{BotConfig.self_nickname}说: {message}",
|
bot_message = f"{BotConfig.self_nickname}说: {message}"
|
||||||
"cur_impression": f"{impression:.2f}",
|
|
||||||
"impression": f"好感度+{_impression}",
|
temperature = random.randint(1, 40)
|
||||||
"gold": f"金币+{gold}",
|
weather_icon_name = f"{random.randint(0, 11)}.png"
|
||||||
"gift": gift,
|
tag_icon_name = f"{random.randint(0, 5)}.png"
|
||||||
"level": f"{level} [{lik2relation[level]}]",
|
|
||||||
"attitude": f"对你的态度: {level2attitude[level]}",
|
font_size = 45
|
||||||
"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,
|
|
||||||
}
|
|
||||||
if len(nickname) > 6:
|
if len(nickname) > 6:
|
||||||
data["font_size"] = 27
|
font_size = 27
|
||||||
_type = "sign"
|
|
||||||
|
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:
|
if is_card_view:
|
||||||
_type = "view"
|
|
||||||
value_list = (
|
value_list = (
|
||||||
await SignUser.annotate()
|
await SignUser.annotate()
|
||||||
.order_by("-impression")
|
.order_by("-impression")
|
||||||
.values_list("user_id", flat=True)
|
.values_list("user_id", flat=True)
|
||||||
)
|
)
|
||||||
index = value_list.index(user.user_id) + 1 # type: ignore
|
rank = value_list.index(user.user_id) + 1 if user.user_id in value_list else 0
|
||||||
data["impression"] = f"好感度排名第 {index} 位"
|
total_gold = user_console.gold if user_console else 0
|
||||||
data["gold"] = f"总金币:{gold}"
|
|
||||||
data["gift"] = ""
|
reward_info = {
|
||||||
pic = await template_to_pic(
|
"impression_added": 0,
|
||||||
template_path=str((TEMPLATE_PATH / "sign").absolute()),
|
"gold_added": 0,
|
||||||
template_name="main.html",
|
"gift_received": "",
|
||||||
templates={"data": data},
|
"is_double": False,
|
||||||
pages={
|
}
|
||||||
"viewport": {"width": 465, "height": 926},
|
|
||||||
"base_url": f"file://{TEMPLATE_PATH}",
|
else:
|
||||||
},
|
reward_info = {
|
||||||
wait=2,
|
"impression_added": add_impression,
|
||||||
)
|
"gold_added": gold or 0,
|
||||||
image = BuildImage.open(pic)
|
"gift_received": gift,
|
||||||
date = now.date()
|
"is_double": is_double,
|
||||||
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"
|
|
||||||
|
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 import ChartUtils
|
||||||
from zhenxun.utils.echart_utils.models import Barh
|
from zhenxun.utils.echart_utils.models import Barh
|
||||||
from zhenxun.utils.enum import PluginType
|
from zhenxun.utils.enum import PluginType
|
||||||
from zhenxun.utils.image_utils import BuildImage
|
|
||||||
from zhenxun.utils.time_utils import TimeUtils
|
from zhenxun.utils.time_utils import TimeUtils
|
||||||
|
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ class StatisticsManage:
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def get_global_statistics(
|
async def get_global_statistics(
|
||||||
cls, plugin_name: str | None, day: int | None, title: str
|
cls, plugin_name: str | None, day: int | None, title: str
|
||||||
) -> BuildImage | str:
|
) -> bytes | str:
|
||||||
query = Statistics
|
query = Statistics
|
||||||
if plugin_name:
|
if plugin_name:
|
||||||
query = query.filter(plugin_name=plugin_name)
|
query = query.filter(plugin_name=plugin_name)
|
||||||
@@ -114,7 +113,7 @@ class StatisticsManage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@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}
|
module2count = {x[0]: x[1] for x in data_list}
|
||||||
plugin_info = await PluginInfo.filter(
|
plugin_info = await PluginInfo.filter(
|
||||||
module__in=module2count.keys(),
|
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_alconna import Alconna, Arparma, on_alconna
|
||||||
from nonebot_plugin_session import EventSession
|
from nonebot_plugin_session import EventSession
|
||||||
|
|
||||||
from zhenxun.configs.config import Config
|
from zhenxun.configs.utils import PluginExtraData
|
||||||
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
|
from zhenxun.services.help_service import create_plugin_help_image
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils.enum import PluginType
|
from zhenxun.utils.enum import PluginType
|
||||||
from zhenxun.utils.exception import EmptyError
|
from zhenxun.utils.exception import EmptyError
|
||||||
from zhenxun.utils.message import MessageUtils
|
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(
|
__plugin_meta__ = PluginMetadata(
|
||||||
name="超级用户帮助",
|
name="超级用户帮助",
|
||||||
description="超级用户帮助",
|
description="超级用户帮助",
|
||||||
@@ -24,17 +20,18 @@ __plugin_meta__ = PluginMetadata(
|
|||||||
author="HibiKier",
|
author="HibiKier",
|
||||||
version="0.1",
|
version="0.1",
|
||||||
plugin_type=PluginType.SUPERUSER,
|
plugin_type=PluginType.SUPERUSER,
|
||||||
configs=[
|
|
||||||
RegisterConfig(
|
|
||||||
key="type",
|
|
||||||
value="zhenxun",
|
|
||||||
help="超级用户帮助样式,normal, zhenxun",
|
|
||||||
default_value="zhenxun",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
).to_dict(),
|
).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(
|
_matcher = on_alconna(
|
||||||
Alconna("超级用户帮助"),
|
Alconna("超级用户帮助"),
|
||||||
permission=SUPERUSER,
|
permission=SUPERUSER,
|
||||||
@@ -45,15 +42,11 @@ _matcher = on_alconna(
|
|||||||
|
|
||||||
@_matcher.handle()
|
@_matcher.handle()
|
||||||
async def _(session: EventSession, arparma: Arparma):
|
async def _(session: EventSession, arparma: Arparma):
|
||||||
if not SUPERUSER_HELP_IMAGE.exists():
|
try:
|
||||||
try:
|
image_bytes = await build_html_help()
|
||||||
if Config.get_config("admin_help", "type") == "zhenxun":
|
await MessageUtils.build_message(image_bytes).send()
|
||||||
await build_html_help()
|
except EmptyError:
|
||||||
else:
|
await MessageUtils.build_message("当前超级用户帮助为空...").finish(
|
||||||
await build_help()
|
reply_to=True
|
||||||
except EmptyError:
|
)
|
||||||
await MessageUtils.build_message("当前超级用户帮助为空...").finish(
|
|
||||||
reply_to=True
|
|
||||||
)
|
|
||||||
await MessageUtils.build_message(SUPERUSER_HELP_IMAGE).send()
|
|
||||||
logger.info("查看超级用户帮助", arparma.header_result, session=session)
|
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 ....base_model import Result
|
||||||
from ....config import QueryDateType
|
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 .data_source import ApiDataSource
|
||||||
from .model import (
|
from .model import (
|
||||||
ActiveGroup,
|
ActiveGroup,
|
||||||
@@ -234,7 +234,6 @@ async def _(param: BotManageUpdateParam):
|
|||||||
bot_data.block_plugins = CommonUtils.convert_module_format(param.block_plugins)
|
bot_data.block_plugins = CommonUtils.convert_module_format(param.block_plugins)
|
||||||
bot_data.block_tasks = CommonUtils.convert_module_format(param.block_tasks)
|
bot_data.block_tasks = CommonUtils.convert_module_format(param.block_tasks)
|
||||||
await bot_data.save(update_fields=["block_plugins", "block_tasks"])
|
await bot_data.save(update_fields=["block_plugins", "block_tasks"])
|
||||||
clear_help_image()
|
|
||||||
return Result.ok()
|
return Result.ok()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{router.prefix}/update_bot_manage 调用错误", "WebUi", e=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 zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
|
||||||
|
|
||||||
from ....base_model import Result
|
from ....base_model import Result
|
||||||
from ....utils import authentication, clear_help_image
|
from ....utils import authentication
|
||||||
from .data_source import ApiDataSource
|
from .data_source import ApiDataSource
|
||||||
from .model import (
|
from .model import (
|
||||||
BatchUpdatePlugins,
|
BatchUpdatePlugins,
|
||||||
@@ -82,7 +82,6 @@ async def _() -> Result[PluginCount]:
|
|||||||
async def _(param: UpdatePlugin) -> Result:
|
async def _(param: UpdatePlugin) -> Result:
|
||||||
try:
|
try:
|
||||||
await ApiDataSource.update_plugin(param)
|
await ApiDataSource.update_plugin(param)
|
||||||
clear_help_image()
|
|
||||||
return Result.ok(info="已经帮你写好啦!")
|
return Result.ok(info="已经帮你写好啦!")
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
return Result.fail("插件数据不存在...")
|
return Result.fail("插件数据不存在...")
|
||||||
@@ -110,7 +109,6 @@ async def _(param: PluginSwitch) -> Result:
|
|||||||
db_plugin.block_type = None
|
db_plugin.block_type = None
|
||||||
db_plugin.status = True
|
db_plugin.status = True
|
||||||
await db_plugin.save()
|
await db_plugin.save()
|
||||||
clear_help_image()
|
|
||||||
return Result.ok(info="成功改变了开关状态!")
|
return Result.ok(info="成功改变了开关状态!")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{router.prefix}/change_switch 调用错误", "WebUi", e=e)
|
logger.error(f"{router.prefix}/change_switch 调用错误", "WebUi", e=e)
|
||||||
@@ -177,7 +175,6 @@ async def _(
|
|||||||
updated_count=result_dict["updated_count"],
|
updated_count=result_dict["updated_count"],
|
||||||
errors=result_dict["errors"],
|
errors=result_dict["errors"],
|
||||||
)
|
)
|
||||||
clear_help_image()
|
|
||||||
return Result.ok(result_model, "插件配置更新完成")
|
return Result.ok(result_model, "插件配置更新完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{router.prefix}/plugins/batch_update 调用错误", "WebUi", e=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
|
old_name=payload.old_name, new_name=payload.new_name
|
||||||
)
|
)
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
clear_help_image()
|
|
||||||
return Result.ok(
|
return Result.ok(
|
||||||
info=result.get(
|
info=result.get(
|
||||||
"info",
|
"info",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import psutil
|
|||||||
import ujson as json
|
import ujson as json
|
||||||
|
|
||||||
from zhenxun.configs.config import Config
|
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
|
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}"
|
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:
|
def get_user(uname: str) -> User | None:
|
||||||
"""获取账号密码
|
"""获取账号密码
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ DATA_PATH = Path() / "data"
|
|||||||
# 临时数据路径
|
# 临时数据路径
|
||||||
TEMP_PATH = Path() / "resources" / "temp"
|
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)
|
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)
|
FONT_PATH.mkdir(parents=True, exist_ok=True)
|
||||||
DATA_PATH.mkdir(parents=True, exist_ok=True)
|
DATA_PATH.mkdir(parents=True, exist_ok=True)
|
||||||
TEMP_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 tortoise import fields
|
||||||
|
|
||||||
|
from zhenxun.services.data_access import DataAccess
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils.enum import CacheType, DbLockType
|
from zhenxun.utils.enum import CacheType, DbLockType
|
||||||
@@ -57,14 +58,15 @@ class BanConsole(Model):
|
|||||||
"""
|
"""
|
||||||
if not user_id and not group_id:
|
if not user_id and not group_id:
|
||||||
raise UserAndGroupIsNone()
|
raise UserAndGroupIsNone()
|
||||||
|
dao = DataAccess(cls)
|
||||||
if user_id:
|
if user_id:
|
||||||
return (
|
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
|
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:
|
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
|
@classmethod
|
||||||
async def check_ban_level(
|
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.plugin_info import PluginInfo
|
||||||
from zhenxun.models.task_info import TaskInfo
|
from zhenxun.models.task_info import TaskInfo
|
||||||
from zhenxun.services.cache import CacheRoot
|
from zhenxun.services.cache import CacheRoot
|
||||||
|
from zhenxun.services.data_access import DataAccess
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
from zhenxun.utils.enum import CacheType, DbLockType, PluginType
|
from zhenxun.utils.enum import CacheType, DbLockType, PluginType
|
||||||
|
|
||||||
@@ -254,13 +255,14 @@ class GroupConsole(Model):
|
|||||||
返回:
|
返回:
|
||||||
Self: GroupConsole
|
Self: GroupConsole
|
||||||
"""
|
"""
|
||||||
|
dao = DataAccess(cls)
|
||||||
if channel_id:
|
if channel_id:
|
||||||
return await cls.safe_get_or_none(
|
return await dao.safe_get_or_none(
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
channel_id=channel_id,
|
channel_id=channel_id,
|
||||||
clean_duplicates=clean_duplicates,
|
clean_duplicates=clean_duplicates,
|
||||||
)
|
)
|
||||||
return await cls.safe_get_or_none(
|
return await dao.safe_get_or_none(
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
channel_id__isnull=True,
|
channel_id__isnull=True,
|
||||||
clean_duplicates=clean_duplicates,
|
clean_duplicates=clean_duplicates,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class PluginInfo(Model):
|
|||||||
返回:
|
返回:
|
||||||
Self | None: 插件
|
Self | None: 插件
|
||||||
"""
|
"""
|
||||||
if filter_parent:
|
if not kwargs.get("plugin_type") and filter_parent:
|
||||||
return await cls.get_or_none(
|
return await cls.get_or_none(
|
||||||
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
|
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
|
||||||
)
|
)
|
||||||
@@ -96,7 +96,7 @@ class PluginInfo(Model):
|
|||||||
返回:
|
返回:
|
||||||
list[Self]: 插件列表
|
list[Self]: 插件列表
|
||||||
"""
|
"""
|
||||||
if filter_parent:
|
if not kwargs.get("plugin_type") and filter_parent:
|
||||||
return await cls.filter(
|
return await cls.filter(
|
||||||
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
|
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ require("nonebot_plugin_htmlrender")
|
|||||||
require("nonebot_plugin_uninfo")
|
require("nonebot_plugin_uninfo")
|
||||||
require("nonebot_plugin_waiter")
|
require("nonebot_plugin_waiter")
|
||||||
|
|
||||||
|
from .avatar_service import avatar_service
|
||||||
from .db_context import Model, disconnect, with_db_timeout
|
from .db_context import Model, disconnect, with_db_timeout
|
||||||
from .llm import (
|
from .llm import (
|
||||||
AI,
|
AI,
|
||||||
@@ -43,6 +44,7 @@ from .llm import (
|
|||||||
)
|
)
|
||||||
from .log import logger
|
from .log import logger
|
||||||
from .plugin_init import PluginInit, PluginInitManager
|
from .plugin_init import PluginInit, PluginInitManager
|
||||||
|
from .renderer import renderer_service
|
||||||
from .scheduler import scheduler_manager
|
from .scheduler import scheduler_manager
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -56,6 +58,7 @@ __all__ = [
|
|||||||
"Model",
|
"Model",
|
||||||
"PluginInit",
|
"PluginInit",
|
||||||
"PluginInitManager",
|
"PluginInitManager",
|
||||||
|
"avatar_service",
|
||||||
"chat",
|
"chat",
|
||||||
"clear_model_cache",
|
"clear_model_cache",
|
||||||
"code",
|
"code",
|
||||||
@@ -69,6 +72,7 @@ __all__ = [
|
|||||||
"list_available_models",
|
"list_available_models",
|
||||||
"list_embedding_models",
|
"list_embedding_models",
|
||||||
"logger",
|
"logger",
|
||||||
|
"renderer_service",
|
||||||
"scheduler_manager",
|
"scheduler_manager",
|
||||||
"search",
|
"search",
|
||||||
"set_global_default_model_name",
|
"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 0
|
||||||
return data.expire_time
|
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: 字典值
|
T: 字典值
|
||||||
"""
|
"""
|
||||||
if value := self._data.get(key):
|
if value := self._data.get(key):
|
||||||
return value.value if self.expire_time(key) else None
|
if self.expire_time(key):
|
||||||
return None
|
raise KeyError(f"键 {key} 已过期")
|
||||||
|
return value.value
|
||||||
|
raise KeyError(f"键 {key} 不存在")
|
||||||
|
|
||||||
def __setitem__(self, key: str, value: T) -> None:
|
def __setitem__(self, key: str, value: T) -> None:
|
||||||
"""设置字典项
|
"""设置字典项
|
||||||
@@ -78,16 +80,7 @@ class CacheDict(Generic[T]):
|
|||||||
返回:
|
返回:
|
||||||
bool: 是否存在
|
bool: 是否存在
|
||||||
"""
|
"""
|
||||||
if key not in self._data:
|
return False if key not in self._data else bool(self.expire_time(key))
|
||||||
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
|
|
||||||
|
|
||||||
def get(self, key: str, default: Any = None) -> T | None:
|
def get(self, key: str, default: Any = None) -> T | None:
|
||||||
"""获取字典项,如果不存在返回默认值
|
"""获取字典项,如果不存在返回默认值
|
||||||
@@ -99,8 +92,12 @@ class CacheDict(Generic[T]):
|
|||||||
返回:
|
返回:
|
||||||
Any: 字典值或默认值
|
Any: 字典值或默认值
|
||||||
"""
|
"""
|
||||||
value = self[key]
|
if value := self._data.get(key):
|
||||||
return default if value is None else value
|
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):
|
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)
|
T = TypeVar("T", bound=Model)
|
||||||
|
|
||||||
|
cache = CacheRoot.cache_dict("DB_TEST_BAN", 10, int)
|
||||||
|
|
||||||
|
|
||||||
class DataAccess(Generic[T]):
|
class DataAccess(Generic[T]):
|
||||||
"""数据访问层,根据配置决定是否使用缓存
|
"""数据访问层,根据配置决定是否使用缓存
|
||||||
@@ -167,6 +169,7 @@ class DataAccess(Generic[T]):
|
|||||||
return await with_db_timeout(
|
return await with_db_timeout(
|
||||||
db_query_func(*args, **kwargs),
|
db_query_func(*args, **kwargs),
|
||||||
operation=f"{self.model_cls.__name__}.{db_query_func.__name__}",
|
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:
|
if cache_key is not None:
|
||||||
data = await self.cache.get(cache_key)
|
data = await self.cache.get(cache_key)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{self.model_cls.__name__} self.cache.get(cache_key)"
|
f"{self.model_cls.__name__} key: {cache_key}"
|
||||||
f" 从缓存获取到的数据 {type(data)}: {data}"
|
f" 从缓存获取到的数据 {type(data)}: {data}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if data == self._NULL_RESULT:
|
if data == self._NULL_RESULT:
|
||||||
# 空结果缓存命中
|
# 空结果缓存命中
|
||||||
self._cache_stats[self.cache_type]["null_hits"] += 1
|
self._cache_stats[self.cache_type]["null_hits"] += 1
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ class Model(TortoiseModel):
|
|||||||
return await with_db_timeout(
|
return await with_db_timeout(
|
||||||
cls.get_or_none(*args, using_db=using_db, **kwargs),
|
cls.get_or_none(*args, using_db=using_db, **kwargs),
|
||||||
operation=f"{cls.__name__}.get_or_none",
|
operation=f"{cls.__name__}.get_or_none",
|
||||||
|
source="DataBaseModel",
|
||||||
)
|
)
|
||||||
except MultipleObjectsReturned:
|
except MultipleObjectsReturned:
|
||||||
# 如果出现多个记录的情况,进行特殊处理
|
# 如果出现多个记录的情况,进行特殊处理
|
||||||
@@ -239,6 +240,7 @@ class Model(TortoiseModel):
|
|||||||
records = await with_db_timeout(
|
records = await with_db_timeout(
|
||||||
cls.filter(*args, **kwargs).all(),
|
cls.filter(*args, **kwargs).all(),
|
||||||
operation=f"{cls.__name__}.filter.all",
|
operation=f"{cls.__name__}.filter.all",
|
||||||
|
source="DataBaseModel",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not records:
|
if not records:
|
||||||
@@ -255,6 +257,7 @@ class Model(TortoiseModel):
|
|||||||
await with_db_timeout(
|
await with_db_timeout(
|
||||||
record.delete(),
|
record.delete(),
|
||||||
operation=f"{cls.__name__}.delete_duplicate",
|
operation=f"{cls.__name__}.delete_duplicate",
|
||||||
|
source="DataBaseModel",
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{cls.__name__} 删除重复记录:"
|
f"{cls.__name__} 删除重复记录:"
|
||||||
@@ -269,11 +272,13 @@ class Model(TortoiseModel):
|
|||||||
return await with_db_timeout(
|
return await with_db_timeout(
|
||||||
cls.filter(*args, **kwargs).order_by("-id").first(),
|
cls.filter(*args, **kwargs).order_by("-id").first(),
|
||||||
operation=f"{cls.__name__}.filter.order_by.first",
|
operation=f"{cls.__name__}.filter.order_by.first",
|
||||||
|
source="DataBaseModel",
|
||||||
)
|
)
|
||||||
# 如果没有 id 字段,则返回第一个记录
|
# 如果没有 id 字段,则返回第一个记录
|
||||||
return await with_db_timeout(
|
return await with_db_timeout(
|
||||||
cls.filter(*args, **kwargs).first(),
|
cls.filter(*args, **kwargs).first(),
|
||||||
operation=f"{cls.__name__}.filter.first",
|
operation=f"{cls.__name__}.filter.first",
|
||||||
|
source="DataBaseModel",
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ from .config import (
|
|||||||
|
|
||||||
|
|
||||||
async def with_db_timeout(
|
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()
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
|
logger.debug(f"开始执行数据库操作: {operation} 来源: {source}")
|
||||||
result = await asyncio.wait_for(coro, timeout=timeout)
|
result = await asyncio.wait_for(coro, timeout=timeout)
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
if elapsed > SLOW_QUERY_THRESHOLD and operation:
|
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
|
@classmethod
|
||||||
async def remove(cls, module_path: str):
|
async def remove(cls, module_path: str):
|
||||||
"""运行指定插件安装方法"""
|
"""运行指定插件移除方法"""
|
||||||
if model := cls.plugins.get(module_path):
|
if model := cls.plugins.get(module_path):
|
||||||
if model.remove:
|
if model.remove:
|
||||||
class_ = model.class_()
|
class_ = model.class_()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from typing import Any
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from ...models.core.base import RenderableComponent
|
||||||
|
from ...models.core.layout import LayoutData, LayoutItem
|
||||||
|
from ..base import BaseBuilder
|
||||||
|
|
||||||
|
__all__ = ["LayoutBuilder"]
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutBuilder(BaseBuilder[LayoutData]):
|
||||||
|
"""
|
||||||
|
一个用于将多个UI组件组合成单张图片的链式构建器。
|
||||||
|
它通过在单个渲染流程中动态包含子模板来实现高质量的输出。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(LayoutData(), template_name="")
|
||||||
|
self._options: dict[str, Any] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def column(
|
||||||
|
cls, *, gap: str = "20px", align_items: str = "stretch", **options: Any
|
||||||
|
) -> Self:
|
||||||
|
builder = cls()
|
||||||
|
builder._template_name = "components/core/layouts/column"
|
||||||
|
builder._options["gap"] = gap
|
||||||
|
builder._options["align_items"] = align_items
|
||||||
|
builder._options.update(options)
|
||||||
|
return builder
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def row(
|
||||||
|
cls, *, gap: str = "10px", align_items: str = "center", **options: Any
|
||||||
|
) -> Self:
|
||||||
|
builder = cls()
|
||||||
|
builder._template_name = "components/core/layouts/row"
|
||||||
|
builder._options["gap"] = gap
|
||||||
|
builder._options["align_items"] = align_items
|
||||||
|
builder._options.update(options)
|
||||||
|
return builder
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def grid(cls, columns: int = 2, **options: Any) -> Self:
|
||||||
|
builder = cls()
|
||||||
|
builder._template_name = "components/core/layouts/grid"
|
||||||
|
builder._options["columns"] = columns
|
||||||
|
builder._options.update(options)
|
||||||
|
return builder
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def hstack(
|
||||||
|
cls, components: list["BaseBuilder | RenderableComponent"], **options: Any
|
||||||
|
) -> Self:
|
||||||
|
builder = cls.row(**options)
|
||||||
|
for component in components:
|
||||||
|
builder.add_item(component)
|
||||||
|
return builder
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def vstack(
|
||||||
|
cls, components: list["BaseBuilder | RenderableComponent"], **options: Any
|
||||||
|
) -> Self:
|
||||||
|
builder = cls.column(**options)
|
||||||
|
for component in components:
|
||||||
|
builder.add_item(component)
|
||||||
|
return builder
|
||||||
|
|
||||||
|
def add_item(
|
||||||
|
self,
|
||||||
|
component: "BaseBuilder | RenderableComponent",
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
向布局中添加一个组件项。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
component: 一个 `BaseBuilder` 实例 (如 `TableBuilder()`) 或一个已构建的
|
||||||
|
`RenderableComponent` 数据模型。
|
||||||
|
metadata: (可选) 与此项目关联的元数据,可在布局模板中访问。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
Self: 当前构建器实例,以支持链式调用。
|
||||||
|
"""
|
||||||
|
component_data = (
|
||||||
|
component.data if isinstance(component, BaseBuilder) else component
|
||||||
|
)
|
||||||
|
self._data.children.append(
|
||||||
|
LayoutItem(component=component_data, metadata=metadata)
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_option(self, key: str, value: Any) -> Self:
|
||||||
|
"""
|
||||||
|
为布局模板添加一个自定义选项。
|
||||||
|
|
||||||
|
例如,`add_option("padding", "30px")` 会在模板的 `data.options`
|
||||||
|
字典中添加 `{"padding": "30px"}`。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
key: 选项的键名。
|
||||||
|
value: 选项的值。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
Self: 当前构建器实例,以支持链式调用。
|
||||||
|
"""
|
||||||
|
self._options[key] = value
|
||||||
|
return self
|
||||||
|
|
||||||
|
def build(self) -> LayoutData:
|
||||||
|
"""
|
||||||
|
构建并返回 LayoutData 模型实例。
|
||||||
|
"""
|
||||||
|
if not self._template_name:
|
||||||
|
raise ValueError(
|
||||||
|
"必须通过工厂方法 (如 LayoutBuilder.column()) 初始化布局类型。"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._data.options = self._options
|
||||||
|
self._data.layout_type = self._template_name.split("/")[-1]
|
||||||
|
return super().build()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user