diff --git a/poetry.lock b/poetry.lock
index e00d88b1..53a0678d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -591,6 +591,75 @@ type = "legacy"
url = "https://mirrors.aliyun.com/pypi/simple"
reference = "ali"
+[[package]]
+name = "cffi"
+version = "1.16.0"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"},
+ {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"},
+ {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"},
+ {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"},
+ {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"},
+ {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"},
+ {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"},
+ {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"},
+ {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"},
+ {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"},
+ {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"},
+ {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"},
+ {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"},
+ {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"},
+ {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"},
+ {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"},
+ {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"},
+ {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"},
+ {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"},
+ {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"},
+ {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"},
+ {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"},
+ {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"},
+ {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"},
+ {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"},
+ {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"},
+ {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"},
+]
+
+[package.dependencies]
+pycparser = "*"
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
[[package]]
name = "chardet"
version = "5.2.0"
@@ -792,6 +861,60 @@ type = "legacy"
url = "https://mirrors.aliyun.com/pypi/simple"
reference = "ali"
+[[package]]
+name = "cryptography"
+version = "43.0.0"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"},
+ {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"},
+ {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"},
+ {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"},
+ {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"},
+ {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"},
+ {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"},
+ {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"},
+ {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"},
+ {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"},
+ {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"},
+ {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"},
+ {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"},
+ {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"},
+ {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"},
+ {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"},
+ {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"},
+ {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"},
+ {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"},
+ {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"},
+ {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"},
+ {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"},
+ {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"},
+ {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"},
+ {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"},
+ {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"},
+ {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"},
+]
+
+[package.dependencies]
+cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
+
+[package.extras]
+docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
+docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"]
+nox = ["nox"]
+pep8test = ["check-sdist", "click", "mypy", "ruff"]
+sdist = ["build"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
+test-randomorder = ["pytest-randomly"]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
[[package]]
name = "dateparser"
version = "1.2.0"
@@ -835,6 +958,29 @@ type = "legacy"
url = "https://mirrors.aliyun.com/pypi/simple"
reference = "ali"
+[[package]]
+name = "ecdsa"
+version = "0.19.0"
+description = "ECDSA cryptographic signature library (pure python)"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+ {file = "ecdsa-0.19.0-py2.py3-none-any.whl", hash = "sha256:2cea9b88407fdac7bbeca0833b189e4c9c53f2ef1e1eaa29f6224dbc809b707a"},
+ {file = "ecdsa-0.19.0.tar.gz", hash = "sha256:60eaad1199659900dd0af521ed462b793bbdf867432b3948e87416ae4caf6bf8"},
+]
+
+[package.dependencies]
+six = ">=1.9.0"
+
+[package.extras]
+gmpy = ["gmpy"]
+gmpy2 = ["gmpy2"]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
[[package]]
name = "emoji"
version = "2.10.1"
@@ -2513,6 +2659,22 @@ type = "legacy"
url = "https://mirrors.aliyun.com/pypi/simple"
reference = "ali"
+[[package]]
+name = "pycparser"
+version = "2.22"
+description = "C parser in Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
+ {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
+]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
[[package]]
name = "pydantic"
version = "1.10.14"
@@ -2737,6 +2899,33 @@ type = "legacy"
url = "https://mirrors.aliyun.com/pypi/simple"
reference = "ali"
+[[package]]
+name = "python-jose"
+version = "3.3.0"
+description = "JOSE implementation in Python"
+optional = false
+python-versions = "*"
+files = [
+ {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"},
+ {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"},
+]
+
+[package.dependencies]
+cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryptography\""}
+ecdsa = "!=0.15"
+pyasn1 = "*"
+rsa = "*"
+
+[package.extras]
+cryptography = ["cryptography (>=3.4.0)"]
+pycrypto = ["pyasn1", "pycrypto (>=2.6.0,<2.7.0)"]
+pycryptodome = ["pyasn1", "pycryptodome (>=3.3.1,<4.0.0)"]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
[[package]]
name = "python-markdown-math"
version = "0.8"
@@ -2756,6 +2945,25 @@ type = "legacy"
url = "https://mirrors.aliyun.com/pypi/simple"
reference = "ali"
+[[package]]
+name = "python-multipart"
+version = "0.0.9"
+description = "A streaming multipart parser for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"},
+ {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"},
+]
+
+[package.extras]
+dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
[[package]]
name = "python-slugify"
version = "8.0.2"
@@ -4137,4 +4345,4 @@ reference = "ali"
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
-content-hash = "3a23c97b954472fca2124960e82ba695c52f3448bec4458df94b3d884d23c1e4"
+content-hash = "1069f396df7f09336b9ea7737997061e4dfea458a561995a2afee74fd9cf36ad"
diff --git a/pyproject.toml b/pyproject.toml
index cbe43480..8faaa329 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,6 +45,8 @@ aiohttp = "^3.9.5"
dateparser = "^1.2.0"
nonebot-plugin-alconna = "^0.50.2"
bilireq = "0.2.3post0"
+python-jose = {extras = ["cryptography"], version = "^3.3.0"}
+python-multipart = "^0.0.9"
[tool.poetry.dev-dependencies]
diff --git a/zhenxun/configs/utils/__init__.py b/zhenxun/configs/utils/__init__.py
index 4a192b3d..d9f4367e 100644
--- a/zhenxun/configs/utils/__init__.py
+++ b/zhenxun/configs/utils/__init__.py
@@ -168,6 +168,8 @@ class PluginExtraData(BaseModel):
"""超级用户帮助"""
aliases: Set[str] = set()
"""额外名称"""
+ sql_list: list[str] | None = None
+ """常用sql"""
class NoSuchConfig(Exception):
diff --git a/zhenxun/models/fg_request.py b/zhenxun/models/fg_request.py
index ad740eff..43cbfdbc 100644
--- a/zhenxun/models/fg_request.py
+++ b/zhenxun/models/fg_request.py
@@ -9,7 +9,9 @@ from zhenxun.utils.exception import NotFoundError
class FgRequest(Model):
id = fields.IntField(pk=True, generated=True, auto_increment=True)
"""自增id"""
- request_type = fields.CharEnumField(RequestType, default=None, description="请求类型")
+ request_type = fields.CharEnumField(
+ RequestType, default=None, description="请求类型"
+ )
"""请求类型"""
platform = fields.CharField(255, description="平台")
"""平台"""
@@ -25,7 +27,9 @@ class FgRequest(Model):
"""对象名称"""
comment = fields.CharField(max_length=255, null=True, description="验证信息")
"""验证信息"""
- handle_type = fields.CharEnumField(RequestHandleType, null=True, description="处理类型")
+ handle_type = fields.CharEnumField(
+ RequestHandleType, null=True, description="处理类型"
+ )
"""处理类型"""
class Meta:
@@ -33,53 +37,61 @@ class FgRequest(Model):
table_description = "好友群组请求"
@classmethod
- async def approve(cls, bot: Bot, id: int, request_type: RequestType):
+ async def approve(cls, bot: Bot, id: int):
"""同意请求
参数:
bot: Bot
id: 请求id
- request_type: 请求类型
异常:
NotFoundError: 未发现请求
"""
- await cls._handle_request(bot, id, request_type, RequestHandleType.APPROVE)
+ await cls._handle_request(bot, id, RequestHandleType.APPROVE)
@classmethod
- async def refused(cls, bot: Bot, id: int, request_type: RequestType):
+ async def refused(cls, bot: Bot, id: int):
"""拒绝请求
参数:
bot: Bot
id: 请求id
- request_type: 请求类型
异常:
NotFoundError: 未发现请求
"""
- await cls._handle_request(bot, id, request_type, RequestHandleType.REFUSED)
+ await cls._handle_request(bot, id, RequestHandleType.REFUSED)
@classmethod
- async def ignore(cls, bot: Bot, id: int, request_type: RequestType):
+ async def ignore(cls, id: int):
+ """忽略请求
+
+ 参数:
+ id: 请求id
+
+ 异常:
+ NotFoundError: 未发现请求
+ """
+ await cls._handle_request(None, id, RequestHandleType.IGNORE)
+
+ @classmethod
+ async def expire(cls, id: int):
"""忽略请求
参数:
bot: Bot
id: 请求id
- request_type: 请求类型
异常:
NotFoundError: 未发现请求
"""
- await cls._handle_request(bot, id, request_type, RequestHandleType.IGNORE)
+ await cls._handle_request(None, id, RequestHandleType.EXPIRE)
@classmethod
async def _handle_request(
cls,
- bot: Bot,
+ bot: Bot | None,
id: int,
- request_type: RequestType,
handle_type: RequestHandleType,
):
"""处理请求
@@ -87,19 +99,21 @@ class FgRequest(Model):
参数:
bot: Bot
id: 请求id
- request_type: 请求类型
handle_type: 处理类型
异常:
NotFoundError: 未发现请求
"""
- req = await cls.get_or_none(id=id, request_type=request_type)
+ req = await cls.get_or_none(id=id)
if not req:
raise NotFoundError
req.handle_type = RequestHandleType
await req.save(update_fields=["handle_type"])
- if handle_type != RequestHandleType.IGNORE:
- if request_type == RequestType.FRIEND:
+ if bot and handle_type not in [
+ RequestHandleType.IGNORE,
+ RequestHandleType.EXPIRE,
+ ]:
+ if req.request_type == RequestType.FRIEND:
await bot.set_friend_add_request(
flag=req.flag, approve=handle_type == RequestHandleType.APPROVE
)
diff --git a/zhenxun/plugins/web_ui/__init__.py b/zhenxun/plugins/web_ui/__init__.py
new file mode 100644
index 00000000..37684372
--- /dev/null
+++ b/zhenxun/plugins/web_ui/__init__.py
@@ -0,0 +1,76 @@
+import asyncio
+
+import nonebot
+from fastapi import APIRouter, FastAPI
+from nonebot.adapters.onebot.v11 import Bot, MessageEvent
+from nonebot.log import default_filter, default_format
+from nonebot.matcher import Matcher
+from nonebot.message import run_preprocessor
+from nonebot.typing import T_State
+
+from zhenxun.configs.config import Config as gConfig
+from zhenxun.services.log import logger, logger_
+
+from .api.logs import router as ws_log_routes
+from .api.logs.log_manager import LOG_STORAGE
+from .api.tabs.database import router as database_router
+from .api.tabs.main import router as main_router
+from .api.tabs.main import ws_router as status_routes
+from .api.tabs.manage import router as manage_router
+from .api.tabs.manage import ws_router as chat_routes
+from .api.tabs.plugin_manage import router as plugin_router
+from .api.tabs.system import router as system_router
+from .auth import router as auth_router
+
+driver = nonebot.get_driver()
+
+gConfig.add_plugin_config("web-ui", "username", "admin", help="前端管理用户名")
+
+gConfig.add_plugin_config("web-ui", "password", None, help="前端管理密码")
+
+gConfig.set_name("web-ui", "web-ui")
+
+
+BaseApiRouter = APIRouter(prefix="/zhenxun/api")
+
+
+BaseApiRouter.include_router(auth_router)
+BaseApiRouter.include_router(main_router)
+BaseApiRouter.include_router(manage_router)
+BaseApiRouter.include_router(database_router)
+BaseApiRouter.include_router(plugin_router)
+BaseApiRouter.include_router(system_router)
+
+
+WsApiRouter = APIRouter(prefix="/zhenxun/socket")
+
+WsApiRouter.include_router(ws_log_routes)
+WsApiRouter.include_router(status_routes)
+WsApiRouter.include_router(chat_routes)
+
+
+@driver.on_startup
+def _():
+ try:
+
+ async def log_sink(message: str):
+ loop = None
+ if not loop:
+ try:
+ loop = asyncio.get_running_loop()
+ except Exception as e:
+ logger.warning("Web Ui log_sink", e=e)
+ if not loop:
+ loop = asyncio.new_event_loop()
+ loop.create_task(LOG_STORAGE.add(message.rstrip("\n")))
+
+ logger_.add(
+ log_sink, colorize=True, filter=default_filter, format=default_format
+ )
+
+ app: FastAPI = nonebot.get_app()
+ app.include_router(BaseApiRouter)
+ app.include_router(WsApiRouter)
+ logger.info("API启动成功", "Web UI")
+ except Exception as e:
+ logger.error("API启动失败", "Web UI", e=e)
diff --git a/zhenxun/plugins/web_ui/api/__init__.py b/zhenxun/plugins/web_ui/api/__init__.py
new file mode 100644
index 00000000..32d31b27
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/__init__.py
@@ -0,0 +1 @@
+from .tabs import *
diff --git a/zhenxun/plugins/web_ui/api/logs/__init__.py b/zhenxun/plugins/web_ui/api/logs/__init__.py
new file mode 100644
index 00000000..d6684888
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/logs/__init__.py
@@ -0,0 +1 @@
+from .logs import *
diff --git a/zhenxun/plugins/web_ui/api/logs/log_manager.py b/zhenxun/plugins/web_ui/api/logs/log_manager.py
new file mode 100644
index 00000000..71992c91
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/logs/log_manager.py
@@ -0,0 +1,35 @@
+import asyncio
+from typing import Awaitable, Callable, Generic, TypeVar
+
+PATTERN = r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))"
+
+_T = TypeVar("_T")
+LogListener = Callable[[_T], Awaitable[None]]
+
+
+class LogStorage(Generic[_T]):
+ """
+ 日志存储
+ """
+
+ def __init__(self, rotation: float = 5 * 60):
+ self.count, self.rotation = 0, rotation
+ self.logs: dict[int, str] = {}
+ self.listeners: set[LogListener[str]] = set()
+
+ async def add(self, log: str):
+ seq = self.count = self.count + 1
+ self.logs[seq] = log
+ asyncio.get_running_loop().call_later(self.rotation, self.remove, seq)
+ await asyncio.gather(
+ *map(lambda listener: listener(log), self.listeners),
+ return_exceptions=True,
+ )
+ return seq
+
+ def remove(self, seq: int):
+ del self.logs[seq]
+ return
+
+
+LOG_STORAGE: LogStorage[str] = LogStorage[str]()
diff --git a/zhenxun/plugins/web_ui/api/logs/logs.py b/zhenxun/plugins/web_ui/api/logs/logs.py
new file mode 100644
index 00000000..01c78096
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/logs/logs.py
@@ -0,0 +1,40 @@
+from fastapi import APIRouter, WebSocket
+from loguru import logger
+from nonebot.utils import escape_tag
+from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState
+
+from .log_manager import LOG_STORAGE
+
+router = APIRouter()
+
+
+@router.get("/logs", response_model=list[str])
+async def system_logs_history(reverse: bool = False):
+ """历史日志
+
+ 参数:
+ reverse: 反转顺序.
+ """
+ return LOG_STORAGE.list(reverse=reverse) # type: ignore
+
+
+@router.websocket("/logs")
+async def system_logs_realtime(websocket: WebSocket):
+ await websocket.accept()
+
+ async def log_listener(log: str):
+ await websocket.send_text(log)
+
+ LOG_STORAGE.listeners.add(log_listener)
+ try:
+ while websocket.client_state == WebSocketState.CONNECTED:
+ recv = await websocket.receive()
+ logger.trace(
+ f"{system_logs_realtime.__name__!r} received "
+ f"{escape_tag(repr(recv))}"
+ )
+ except WebSocketDisconnect:
+ pass
+ finally:
+ LOG_STORAGE.listeners.remove(log_listener)
+ return
diff --git a/zhenxun/plugins/web_ui/api/tabs/__init__.py b/zhenxun/plugins/web_ui/api/tabs/__init__.py
new file mode 100644
index 00000000..99ed6ea1
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/__init__.py
@@ -0,0 +1,5 @@
+from .database import *
+from .main import *
+from .manage import *
+from .plugin_manage import *
+from .system import *
diff --git a/zhenxun/plugins/web_ui/api/tabs/database/__init__.py b/zhenxun/plugins/web_ui/api/tabs/database/__init__.py
new file mode 100644
index 00000000..2fd77085
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/database/__init__.py
@@ -0,0 +1,121 @@
+import nonebot
+from fastapi import APIRouter, Request
+from nonebot.drivers import Driver
+from tortoise import Tortoise
+from tortoise.exceptions import OperationalError
+
+from zhenxun.models.plugin_info import PluginInfo
+from zhenxun.services.db_context import TestSQL
+
+from ....base_model import BaseResultModel, QueryModel, Result
+from ....utils import authentication
+from .models.model import SqlModel, SqlText
+from .models.sql_log import SqlLog
+
+router = APIRouter(prefix="/database")
+
+
+driver: Driver = nonebot.get_driver()
+
+
+SQL_DICT = {}
+
+
+SELECT_TABLE_SQL = """
+select a.tablename as name,d.description as desc from pg_tables a
+ left join pg_class c on relname=tablename
+ left join pg_description d on oid=objoid and objsubid=0 where a.schemaname = 'public'
+"""
+
+SELECT_TABLE_COLUMN_SQL = """
+SELECT column_name, data_type, character_maximum_length as max_length, is_nullable
+FROM information_schema.columns
+WHERE table_name = '{}';
+"""
+
+
+@driver.on_startup
+async def _():
+ for plugin in nonebot.get_loaded_plugins():
+ module = plugin.name
+ sql_list = []
+ if plugin.metadata and plugin.metadata.extra:
+ sql_list = plugin.metadata.extra.get("sql_list")
+ if module in SQL_DICT:
+ raise ValueError(f"{module} 常用SQL module 重复")
+ if sql_list:
+ SqlModel(
+ name="",
+ module=module,
+ sql_list=sql_list,
+ )
+ SQL_DICT[module] = SqlModel
+ if SQL_DICT:
+ result = await PluginInfo.filter(module__in=SQL_DICT.keys()).values_list(
+ "module", "name"
+ )
+ module2name = {r[0]: r[1] for r in result}
+ for s in SQL_DICT:
+ module = SQL_DICT[s].module
+ if module in module2name:
+ SQL_DICT[s].name = module2name[module]
+ else:
+ SQL_DICT[s].name = module
+
+
+@router.get(
+ "/get_table_list", dependencies=[authentication()], description="获取数据库表"
+)
+async def _() -> Result:
+ db = Tortoise.get_connection("default")
+ query = await db.execute_query_dict(SELECT_TABLE_SQL)
+ return Result.ok(query)
+
+
+@router.get(
+ "/get_table_column", dependencies=[authentication()], description="获取表字段"
+)
+async def _(table_name: str) -> Result:
+ db = Tortoise.get_connection("default")
+ print(SELECT_TABLE_COLUMN_SQL.format(table_name))
+ query = await db.execute_query_dict(SELECT_TABLE_COLUMN_SQL.format(table_name))
+ return Result.ok(query)
+
+
+@router.post("/exec_sql", dependencies=[authentication()], description="执行sql")
+async def _(sql: SqlText, request: Request) -> Result:
+ ip = request.client.host if request.client else "unknown"
+ try:
+ if sql.sql.lower().startswith("select"):
+ db = Tortoise.get_connection("default")
+ res = await db.execute_query_dict(sql.sql)
+ await SqlLog.add(ip or "0.0.0.0", sql.sql, "")
+ return Result.ok(res, "执行成功啦!")
+ else:
+ result = await TestSQL.raw(sql.sql)
+ await SqlLog.add(ip or "0.0.0.0", sql.sql, str(result))
+ return Result.ok(info="执行成功啦!")
+ except OperationalError as e:
+ await SqlLog.add(ip or "0.0.0.0", sql.sql, str(e), False)
+ return Result.warning_(f"sql执行错误: {e}")
+
+
+@router.post("/get_sql_log", dependencies=[authentication()], description="sql日志列表")
+async def _(query: QueryModel) -> Result:
+ total = await SqlLog.all().count()
+ if total % query.size:
+ total += 1
+ data = (
+ await SqlLog.all()
+ .order_by("-id")
+ .offset((query.index - 1) * query.size)
+ .limit(query.size)
+ )
+ return Result.ok(BaseResultModel(total=total, data=data))
+
+
+@router.get("/get_common_sql", dependencies=[authentication()], description="常用sql")
+async def _(plugin_name: str | None = None) -> Result:
+ if plugin_name:
+ return Result.ok(SQL_DICT.get(plugin_name))
+ return Result.ok(str(SQL_DICT))
diff --git a/zhenxun/plugins/web_ui/api/tabs/database/models/model.py b/zhenxun/plugins/web_ui/api/tabs/database/models/model.py
new file mode 100644
index 00000000..e18e4cfb
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/database/models/model.py
@@ -0,0 +1,24 @@
+from pydantic import BaseModel
+
+from zhenxun.utils.plugin_models.base import CommonSql
+
+
+class SqlText(BaseModel):
+ """
+ sql语句
+ """
+
+ sql: str
+
+
+class SqlModel(BaseModel):
+ """
+ 常用sql
+ """
+
+ name: str
+ """插件中文名称"""
+ module: str
+ """插件名称"""
+ sql_list: list[CommonSql]
+ """插件列表"""
diff --git a/zhenxun/plugins/web_ui/api/tabs/database/models/sql_log.py b/zhenxun/plugins/web_ui/api/tabs/database/models/sql_log.py
new file mode 100644
index 00000000..691f1b5a
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/database/models/sql_log.py
@@ -0,0 +1,37 @@
+from tortoise import fields
+
+from zhenxun.services.db_context import Model
+
+
+class SqlLog(Model):
+
+ id = fields.IntField(pk=True, generated=True, auto_increment=True)
+ """自增id"""
+ ip = fields.CharField(255)
+ """ip"""
+ sql = fields.CharField(255)
+ """sql"""
+ result = fields.CharField(255, null=True)
+ """结果"""
+ is_suc = fields.BooleanField(default=True)
+ """是否成功"""
+ create_time = fields.DatetimeField(auto_now_add=True)
+ """创建时间"""
+
+ class Meta:
+ table = "sql_log"
+ table_description = "sql执行日志"
+
+ @classmethod
+ async def add(
+ cls, ip: str, sql: str, result: str | None = None, is_suc: bool = True
+ ):
+ """获取用户在群内的等级
+
+ 参数:
+ ip: ip
+ sql: sql
+ result: 返回结果
+ is_suc: 是否成功
+ """
+ await cls.create(ip=ip, sql=sql, result=result, is_suc=is_suc)
diff --git a/zhenxun/plugins/web_ui/api/tabs/main/__init__.py b/zhenxun/plugins/web_ui/api/tabs/main/__init__.py
new file mode 100644
index 00000000..ed8bb576
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/main/__init__.py
@@ -0,0 +1,290 @@
+import asyncio
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import nonebot
+from fastapi import APIRouter, WebSocket
+from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState
+from tortoise.functions import Count
+from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK
+
+from zhenxun.models.chat_history import ChatHistory
+from zhenxun.models.group_info import GroupInfo
+from zhenxun.models.plugin_info import PluginInfo
+from zhenxun.models.statistics import Statistics
+from zhenxun.services.log import logger
+from zhenxun.utils.platform import PlatformUtils
+
+from ....base_model import Result
+from ....config import AVA_URL, GROUP_AVA_URL, QueryDateType
+from ....utils import authentication, get_system_status
+from .data_source import bot_live
+from .model import ActiveGroup, BaseInfo, ChatHistoryCount, HotPlugin
+
+run_time = time.time()
+
+ws_router = APIRouter()
+router = APIRouter(prefix="/main")
+
+
+@router.get("/get_base_info", dependencies=[authentication()], description="基础信息")
+async def _(bot_id: str | None = None) -> Result:
+ """获取Bot基础信息
+
+ 参数:
+ bot_id (Optional[str], optional): bot_id. Defaults to None.
+
+ 返回:
+ Result: 获取指定bot信息与bot列表
+ """
+ bot_list: list[BaseInfo] = []
+ if bots := nonebot.get_bots():
+ select_bot: BaseInfo
+ for key, bot in bots.items():
+ login_info = await bot.get_login_info()
+ bot_list.append(
+ BaseInfo(
+ bot=bot, # type: ignore
+ self_id=bot.self_id,
+ nickname=login_info["nickname"],
+ ava_url=AVA_URL.format(bot.self_id),
+ )
+ )
+ # 获取指定qq号的bot信息,若无指定 则获取第一个
+ if _bl := [b for b in bot_list if b.self_id == bot_id]:
+ select_bot = _bl[0]
+ else:
+ select_bot = bot_list[0]
+ select_bot.is_select = True
+ select_bot.config = select_bot.bot.config
+ now = datetime.now()
+ # 今日累计接收消息
+ select_bot.received_messages = await ChatHistory.filter(
+ bot_id=select_bot.self_id,
+ create_time__gte=now - timedelta(hours=now.hour),
+ ).count()
+ # 群聊数量
+ select_bot.group_count = len(await select_bot.bot.get_group_list())
+ # 好友数量
+ select_bot.friend_count = len(await select_bot.bot.get_friend_list())
+ for bot in bot_list:
+ bot.bot = None # type: ignore
+ # 插件加载数量
+ select_bot.plugin_count = await PluginInfo.all().count()
+ fail_count = await PluginInfo.filter(load_status=False).count()
+ select_bot.fail_plugin_count = fail_count
+ select_bot.success_plugin_count = (
+ select_bot.plugin_count - select_bot.fail_plugin_count
+ )
+ # 连接时间
+ select_bot.connect_time = bot_live.get(select_bot.self_id) or 0
+ if select_bot.connect_time:
+ connect_date = datetime.fromtimestamp(select_bot.connect_time)
+ connect_date_str = connect_date.strftime("%Y-%m-%d %H:%M:%S")
+ select_bot.connect_date = datetime.strptime(
+ connect_date_str, "%Y-%m-%d %H:%M:%S"
+ )
+ version_file = Path() / "__version__"
+ if version_file.exists():
+ if text := version_file.open().read():
+ if ver := text.replace("__version__: ", "").strip():
+ select_bot.version = ver
+ day_call = await Statistics.filter(
+ create_time__gte=now - timedelta(hours=now.hour)
+ ).count()
+ select_bot.day_call = day_call
+ return Result.ok(bot_list, "拿到信息啦!")
+ return Result.warning_("无Bot连接...")
+
+
+@router.get(
+ "/get_all_ch_count", dependencies=[authentication()], description="获取接收消息数量"
+)
+async def _(bot_id: str) -> Result:
+ now = datetime.now()
+ all_count = await ChatHistory.filter(bot_id=bot_id).count()
+ day_count = await ChatHistory.filter(
+ bot_id=bot_id, create_time__gte=now - timedelta(hours=now.hour)
+ ).count()
+ week_count = await ChatHistory.filter(
+ bot_id=bot_id, create_time__gte=now - timedelta(days=7)
+ ).count()
+ month_count = await ChatHistory.filter(
+ bot_id=bot_id, create_time__gte=now - timedelta(days=30)
+ ).count()
+ year_count = await ChatHistory.filter(
+ bot_id=bot_id, create_time__gte=now - timedelta(days=365)
+ ).count()
+ return Result.ok(
+ ChatHistoryCount(
+ num=all_count,
+ day=day_count,
+ week=week_count,
+ month=month_count,
+ year=year_count,
+ )
+ )
+
+
+@router.get(
+ "/get_ch_count", dependencies=[authentication()], description="获取接收消息数量"
+)
+async def _(bot_id: str, query_type: QueryDateType | None = None) -> Result:
+ if bots := nonebot.get_bots():
+ if not query_type:
+ return Result.ok(await ChatHistory.filter(bot_id=bot_id).count())
+ now = datetime.now()
+ if query_type == QueryDateType.DAY:
+ return Result.ok(
+ await ChatHistory.filter(
+ bot_id=bot_id, create_time__gte=now - timedelta(hours=now.hour)
+ ).count()
+ )
+ if query_type == QueryDateType.WEEK:
+ return Result.ok(
+ await ChatHistory.filter(
+ bot_id=bot_id, create_time__gte=now - timedelta(days=7)
+ ).count()
+ )
+ if query_type == QueryDateType.MONTH:
+ return Result.ok(
+ await ChatHistory.filter(
+ bot_id=bot_id, create_time__gte=now - timedelta(days=30)
+ ).count()
+ )
+ if query_type == QueryDateType.YEAR:
+ return Result.ok(
+ await ChatHistory.filter(
+ bot_id=bot_id, create_time__gte=now - timedelta(days=365)
+ ).count()
+ )
+ return Result.warning_("无Bot连接...")
+
+
+@router.get(
+ "get_fg_count", dependencies=[authentication()], description="好友/群组数量"
+)
+async def _(bot_id: str) -> Result:
+ if bots := nonebot.get_bots():
+ if bot_id not in bots:
+ return Result.warning_("指定Bot未连接...")
+ bot = bots[bot_id]
+ platform = PlatformUtils.get_platform(bot)
+ if platform == "qq":
+ data = {
+ "friend_count": len(await bot.get_friend_list()),
+ "group_count": len(await bot.get_group_list()),
+ }
+ return Result.ok(data)
+ return Result.warning_("暂不支持该平台...")
+ return Result.warning_("无Bot连接...")
+
+
+@router.get(
+ "/get_run_time", dependencies=[authentication()], description="获取nb运行时间"
+)
+async def _() -> Result:
+ return Result.ok(int(time.time() - run_time))
+
+
+@router.get(
+ "/get_active_group", dependencies=[authentication()], description="获取活跃群聊"
+)
+async def _(date_type: QueryDateType | None = None) -> Result:
+ query = ChatHistory
+ now = datetime.now()
+ if date_type == QueryDateType.DAY:
+ query = ChatHistory.filter(create_time__gte=now - timedelta(hours=now.hour))
+ if date_type == QueryDateType.WEEK:
+ query = ChatHistory.filter(create_time__gte=now - timedelta(days=7))
+ if date_type == QueryDateType.MONTH:
+ query = ChatHistory.filter(create_time__gte=now - timedelta(days=30))
+ if date_type == QueryDateType.YEAR:
+ query = ChatHistory.filter(create_time__gte=now - timedelta(days=365))
+ data_list = (
+ await query.annotate(count=Count("id"))
+ .filter(group_id__not_isnull=True)
+ .group_by("group_id")
+ .order_by("-count")
+ .limit(5)
+ .values_list("group_id", "count")
+ )
+ active_group_list = []
+ id2name = {}
+ if data_list:
+ if info_list := await GroupInfo.filter(
+ group_id__in=[x[0] for x in data_list]
+ ).all():
+ for group_info in info_list:
+ id2name[group_info.group_id] = group_info.group_name
+ for data in data_list:
+ active_group_list.append(
+ ActiveGroup(
+ group_id=data[0],
+ name=id2name.get(data[0]) or data[0],
+ chat_num=data[1],
+ ava_img=GROUP_AVA_URL.format(data[0], data[0]),
+ )
+ )
+ active_group_list = sorted(
+ active_group_list, key=lambda x: x.chat_num, reverse=True
+ )
+ if len(active_group_list) > 5:
+ active_group_list = active_group_list[:5]
+ return Result.ok(active_group_list)
+
+
+@router.get(
+ "/get_hot_plugin", dependencies=[authentication()], description="获取热门插件"
+)
+async def _(date_type: QueryDateType | None = None) -> Result:
+ query = Statistics
+ now = datetime.now()
+ if date_type == QueryDateType.DAY:
+ query = Statistics.filter(create_time__gte=now - timedelta(hours=now.hour))
+ if date_type == QueryDateType.WEEK:
+ query = Statistics.filter(create_time__gte=now - timedelta(days=7))
+ if date_type == QueryDateType.MONTH:
+ query = Statistics.filter(create_time__gte=now - timedelta(days=30))
+ if date_type == QueryDateType.YEAR:
+ query = Statistics.filter(create_time__gte=now - timedelta(days=365))
+ data_list = (
+ await query.annotate(count=Count("id"))
+ .group_by("plugin_name")
+ .order_by("-count")
+ .limit(5)
+ .values_list("plugin_name", "count")
+ )
+ hot_plugin_list = []
+ module_list = [x[0] for x in data_list]
+ plugins = await PluginInfo.filter(module__in=module_list).all()
+ module2name = {p.module: p.name for p in plugins}
+ for data in data_list:
+ module = data[0]
+ name = module2name.get(module) or module
+ hot_plugin_list.append(
+ HotPlugin(
+ module=data[0],
+ name=name,
+ count=data[1],
+ )
+ )
+ hot_plugin_list = sorted(hot_plugin_list, key=lambda x: x.count, reverse=True)
+ if len(hot_plugin_list) > 5:
+ hot_plugin_list = hot_plugin_list[:5]
+ return Result.ok(hot_plugin_list)
+
+
+@ws_router.websocket("/system_status")
+async def system_logs_realtime(websocket: WebSocket, sleep: int = 5):
+ await websocket.accept()
+ logger.debug("ws system_status is connect")
+ try:
+ while websocket.client_state == WebSocketState.CONNECTED:
+ system_status = await get_system_status()
+ await websocket.send_text(system_status.json())
+ await asyncio.sleep(sleep)
+ except (WebSocketDisconnect, ConnectionClosedError, ConnectionClosedOK):
+ pass
+ return
diff --git a/zhenxun/plugins/web_ui/api/tabs/main/data_source.py b/zhenxun/plugins/web_ui/api/tabs/main/data_source.py
new file mode 100644
index 00000000..ca445016
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/main/data_source.py
@@ -0,0 +1,35 @@
+import time
+
+import nonebot
+from nonebot.adapters.onebot.v11 import Bot
+from nonebot.drivers import Driver
+
+driver: Driver = nonebot.get_driver()
+
+
+class BotLive:
+ def __init__(self):
+ self._data = {}
+
+ def add(self, bot_id: str):
+ self._data[bot_id] = time.time()
+
+ def get(self, bot_id: str) -> int | None:
+ return self._data.get(bot_id)
+
+ def remove(self, bot_id: str):
+ if bot_id in self._data:
+ del self._data[bot_id]
+
+
+bot_live = BotLive()
+
+
+@driver.on_bot_connect
+async def _(bot: Bot):
+ bot_live.add(bot.self_id)
+
+
+@driver.on_bot_disconnect
+async def _(bot: Bot):
+ bot_live.remove(bot.self_id)
diff --git a/zhenxun/plugins/web_ui/api/tabs/main/model.py b/zhenxun/plugins/web_ui/api/tabs/main/model.py
new file mode 100644
index 00000000..c9d76706
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/main/model.py
@@ -0,0 +1,105 @@
+from datetime import datetime
+
+from nonebot.adapters import Bot
+from nonebot.config import Config
+from pydantic import BaseModel
+
+
+class SystemStatus(BaseModel):
+ """
+ 系统状态
+ """
+
+ cpu: float
+ memory: float
+ disk: float
+
+
+class BaseInfo(BaseModel):
+ """
+ 基础信息
+ """
+
+ bot: Bot
+ """Bot"""
+ self_id: str
+ """SELF ID"""
+ nickname: str
+ """昵称"""
+ ava_url: str
+ """头像url"""
+ friend_count: int = 0
+ """好友数量"""
+ group_count: int = 0
+ """群聊数量"""
+ received_messages: int = 0
+ """今日 累计接收消息"""
+ connect_time: int = 0
+ """连接时间"""
+ connect_date: datetime | None = None
+ """连接日期"""
+
+ plugin_count: int = 0
+ """加载插件数量"""
+ success_plugin_count: int = 0
+ """加载成功插件数量"""
+ fail_plugin_count: int = 0
+ """加载失败插件数量"""
+
+ is_select: bool = False
+ """当前选择"""
+
+ config: Config | None = None
+ """nb配置"""
+ day_call: int = 0
+ """今日调用插件次数"""
+ version: str = "unknown"
+ """真寻版本"""
+
+ class Config:
+ arbitrary_types_allowed = True
+
+
+class ChatHistoryCount(BaseModel):
+ """
+ 聊天记录数量
+ """
+
+ num: int
+ """总数"""
+ day: int
+ """一天内"""
+ week: int
+ """一周内"""
+ month: int
+ """一月内"""
+ year: int
+ """一年内"""
+
+
+class ActiveGroup(BaseModel):
+ """
+ 活跃群聊数据
+ """
+
+ group_id: str
+ """群组id"""
+ name: str
+ """群组名称"""
+ chat_num: int
+ """发言数量"""
+ ava_img: str
+ """群组头像"""
+
+
+class HotPlugin(BaseModel):
+ """
+ 热门插件
+ """
+
+ module: str
+ """模块名"""
+ name: str
+ """插件名称"""
+ count: int
+ """调用次数"""
diff --git a/zhenxun/plugins/web_ui/api/tabs/manage/__init__.py b/zhenxun/plugins/web_ui/api/tabs/manage/__init__.py
new file mode 100644
index 00000000..82a34d56
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/manage/__init__.py
@@ -0,0 +1,529 @@
+import re
+from typing import Literal
+
+import nonebot
+from fastapi import APIRouter
+from nonebot.adapters.onebot.v11 import ActionFailed
+from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState
+from tortoise.functions import Count
+
+from zhenxun.configs.config import NICKNAME
+from zhenxun.models.ban_console import BanConsole
+from zhenxun.models.chat_history import ChatHistory
+from zhenxun.models.fg_request import FgRequest
+from zhenxun.models.friend_user import FriendUser
+from zhenxun.models.group_console import GroupConsole
+from zhenxun.models.group_member_info import GroupInfoUser
+from zhenxun.models.plugin_info import PluginInfo
+from zhenxun.models.statistics import Statistics
+from zhenxun.models.task_info import TaskInfo
+from zhenxun.services.log import logger
+from zhenxun.utils.enum import RequestHandleType, RequestType
+from zhenxun.utils.exception import NotFoundError
+from zhenxun.utils.platform import PlatformUtils
+
+from ....base_model import Result
+from ....config import AVA_URL, GROUP_AVA_URL
+from ....utils import authentication
+from ...logs.log_manager import LOG_STORAGE
+from .model import (
+ DeleteFriend,
+ Friend,
+ FriendRequestResult,
+ GroupDetail,
+ GroupRequestResult,
+ GroupResult,
+ HandleRequest,
+ LeaveGroup,
+ Message,
+ MessageItem,
+ Plugin,
+ ReqResult,
+ SendMessage,
+ Task,
+ UpdateGroup,
+ UserDetail,
+)
+
+ws_router = APIRouter()
+router = APIRouter(prefix="/manage")
+
+SUB_PATTERN = r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))"
+
+GROUP_PATTERN = r'.*?Message (-?\d*) from (\d*)@\[群:(\d*)] "(.*)"'
+
+PRIVATE_PATTERN = r'.*?Message (-?\d*) from (\d*) "(.*)"'
+
+AT_PATTERN = r"\[CQ:at,qq=(.*)\]"
+
+IMAGE_PATTERN = r"\[CQ:image,.*,url=(.*);.*?\]"
+
+
+@router.get(
+ "/get_group_list", dependencies=[authentication()], description="获取群组列表"
+)
+async def _(bot_id: str) -> Result:
+ """
+ 获取群信息
+ """
+ if bots := nonebot.get_bots():
+ if bot_id not in bots:
+ return Result.warning_("指定Bot未连接...")
+ group_list_result = []
+ try:
+ group_info = {}
+ group_list = await bots[bot_id].get_group_list()
+ for g in group_list:
+ gid = g["group_id"]
+ g["ava_url"] = GROUP_AVA_URL.format(gid, gid)
+ group_list_result.append(GroupResult(**g))
+ except Exception as e:
+ logger.error("调用API错误", "/get_group_list", e=e)
+ return Result.fail(f"{type(e)}: {e}")
+ return Result.ok(group_list_result, "拿到了新鲜出炉的数据!")
+ return Result.warning_("无Bot连接...")
+
+
+@router.post(
+ "/update_group", dependencies=[authentication()], description="修改群组信息"
+)
+async def _(group: UpdateGroup) -> Result:
+ try:
+ group_id = group.group_id
+ if db_group := await GroupConsole.get_or_none(group_id=group_id):
+ db_group.level = group.level
+ db_group.status = group.status
+ if group.close_plugins:
+ db_group.block_plugin = ",".join(group.close_plugins) + ","
+ # TODO: 关闭task
+ await db_group.save(update_fields=["level", "status", "block_plugin"])
+ except Exception as e:
+ logger.error("调用API错误", "/get_group", e=e)
+ return Result.fail(f"{type(e)}: {e}")
+ return Result.ok(info="已完成记录!")
+
+
+@router.get(
+ "/get_friend_list", dependencies=[authentication()], description="获取好友列表"
+)
+async def _(bot_id: str) -> Result:
+ """
+ 获取群信息
+ """
+ if bots := nonebot.get_bots():
+ if bot_id not in bots:
+ return Result.warning_("指定Bot未连接...")
+ try:
+ platform = PlatformUtils.get_platform(bots[bot_id])
+ if platform != "qq":
+ return Result.warning_("该平台暂不支持该功能...")
+ friend_list = await bots[bot_id].get_friend_list()
+ for f in friend_list:
+ f["ava_url"] = AVA_URL.format(f["user_id"])
+ return Result.ok(
+ [Friend(**f) for f in friend_list if str(f["user_id"]) != bot_id],
+ "拿到了新鲜出炉的数据!",
+ )
+ except Exception as e:
+ logger.error("调用API错误", "/get_group_list", e=e)
+ return Result.fail(f"{type(e)}: {e}")
+ return Result.warning_("无Bot连接...")
+
+
+@router.get(
+ "/get_request_count", dependencies=[authentication()], description="获取请求数量"
+)
+async def _() -> Result:
+ f_count = await FgRequest.filter(request_type=RequestType.FRIEND).count()
+ g_count = await FgRequest.filter(request_type=RequestType.GROUP).count()
+ data = {
+ "friend_count": f_count,
+ "group_count": g_count,
+ }
+ return Result.ok(data, f"{NICKNAME}带来了最新的数据!")
+
+
+@router.get(
+ "/get_request_list", dependencies=[authentication()], description="获取请求列表"
+)
+async def _() -> Result:
+ try:
+ req_result = ReqResult()
+ data_list = await FgRequest.filter(handle_type__not_isnull=True).all()
+ for req in data_list:
+ if req.request_type == RequestType.FRIEND:
+ req_result.friend.append(
+ FriendRequestResult(
+ oid=req.id,
+ bot_id=req.bot_id,
+ id=req.user_id,
+ flag=req.flag,
+ nickname=req.nickname,
+ comment=req.comment,
+ ava_url=AVA_URL.format(req.user_id),
+ type=str(req.request_type).lower(),
+ )
+ )
+ else:
+ req_result.group.append(
+ GroupRequestResult(
+ oid=req.id,
+ bot_id=req.bot_id,
+ id=req.user_id,
+ flag=req.flag,
+ nickname=req.nickname,
+ comment=req.comment,
+ ava_url=GROUP_AVA_URL.format(req.group_id, req.group_id),
+ type=str(req.request_type).lower(),
+ invite_group=req.group_id,
+ group_name=None,
+ )
+ )
+ req_result.friend.reverse()
+ req_result.group.reverse()
+ except Exception as e:
+ logger.error("调用API错误", "/get_request", e=e)
+ return Result.fail(f"{type(e)}: {e}")
+ return Result.ok(req_result, f"{NICKNAME}带来了最新的数据!")
+
+
+@router.delete(
+ "/clear_request", dependencies=[authentication()], description="清空请求列表"
+)
+async def _(request_type: Literal["private", "group"]) -> Result:
+ await FgRequest.filter(handle_type__not_isnull=True).update(
+ handle_type=RequestHandleType.IGNORE
+ )
+ return Result.ok(info="成功清除了数据!")
+
+
+@router.post("/refuse_request", dependencies=[authentication()], description="拒绝请求")
+async def _(parma: HandleRequest) -> Result:
+ try:
+ if bots := nonebot.get_bots():
+ bot_id = parma.bot_id
+ if bot_id not in nonebot.get_bots():
+ return Result.warning_("指定Bot未连接...")
+ try:
+ await FgRequest.refused(bots[bot_id], parma.id)
+ except ActionFailed as e:
+ await FgRequest.expire(parma.id)
+ return Result.warning_("请求失败,可能该请求已失效或请求数据错误...")
+ except NotFoundError:
+ return Result.warning_("未找到此Id请求...")
+ return Result.ok(info="成功处理了请求!")
+ return Result.warning_("无Bot连接...")
+ except Exception as e:
+ logger.error("调用API错误", "/refuse_request", e=e)
+ return Result.fail(f"{type(e)}: {e}")
+
+
+@router.post("/delete_request", dependencies=[authentication()], description="忽略请求")
+async def _(parma: HandleRequest) -> Result:
+ await FgRequest.expire(parma.id)
+ return Result.ok(info="成功处理了请求!")
+
+
+@router.post(
+ "/approve_request", dependencies=[authentication()], description="同意请求"
+)
+async def _(parma: HandleRequest) -> Result:
+ try:
+ if bots := nonebot.get_bots():
+ bot_id = parma.bot_id
+ if bot_id not in nonebot.get_bots():
+ return Result.warning_("指定Bot未连接...")
+ if parma.request_type == "group":
+ if req := await FgRequest.get_or_none(id=parma.id):
+ if group := await GroupConsole.get_or_none(group_id=req.group_id):
+ await group.update_or_create(group_flag=1)
+ else:
+ group_info = await bots[bot_id].get_group_info(
+ group_id=req.group_id
+ )
+ await GroupConsole.update_or_create(
+ group_id=str(group_info["group_id"]),
+ defaults={
+ "group_name": group_info["group_name"],
+ "max_member_count": group_info["max_member_count"],
+ "member_count": group_info["member_count"],
+ "group_flag": 1,
+ },
+ )
+ else:
+ return Result.warning_("未找到此Id请求...")
+ try:
+ await FgRequest.approve(bots[bot_id], parma.id)
+ return Result.ok(info="成功处理了请求!")
+ except ActionFailed as e:
+ await FgRequest.expire(parma.id)
+ return Result.warning_("请求失败,可能该请求已失效或请求数据错误...")
+ return Result.warning_("无Bot连接...")
+ except Exception as e:
+ logger.error("调用API错误", "/approve_request", e=e)
+ return Result.fail(f"{type(e)}: {e}")
+
+
+@router.post("/leave_group", dependencies=[authentication()], description="退群")
+async def _(param: LeaveGroup) -> Result:
+ try:
+ if bots := nonebot.get_bots():
+ bot_id = param.bot_id
+ platform = PlatformUtils.get_platform(bots[bot_id])
+ if platform != "qq":
+ return Result.warning_("该平台不支持退群操作...")
+ group_list = await bots[bot_id].get_group_list()
+ if param.group_id not in [str(g["group_id"]) for g in group_list]:
+ return Result.warning_("Bot未在该群聊中...")
+ await bots[bot_id].set_group_leave(group_id=param.group_id)
+ return Result.ok(info="成功处理了请求!")
+ return Result.warning_("无Bot连接...")
+ except Exception as e:
+ logger.error("调用API错误", "/leave_group", e=e)
+ return Result.fail(f"{type(e)}: {e}")
+
+
+@router.post("/delete_friend", dependencies=[authentication()], description="删除好友")
+async def _(param: DeleteFriend) -> Result:
+ try:
+ if bots := nonebot.get_bots():
+ bot_id = param.bot_id
+ platform = PlatformUtils.get_platform(bots[bot_id])
+ if platform != "qq":
+ return Result.warning_("该平台不支持删除好友操作...")
+ friend_list = await bots[bot_id].get_friend_list()
+ if param.user_id not in [str(g["user_id"]) for g in friend_list]:
+ return Result.warning_("Bot未有其好友...")
+ await bots[bot_id].delete_friend(user_id=param.user_id)
+ return Result.ok(info="成功处理了请求!")
+ return Result.warning_("Bot未连接...")
+ except Exception as e:
+ logger.error("调用API错误", "/delete_friend", e=e)
+ return Result.fail(f"{type(e)}: {e}")
+
+
+@router.get(
+ "/get_friend_detail", dependencies=[authentication()], description="获取好友详情"
+)
+async def _(bot_id: str, user_id: str) -> Result:
+ if bots := nonebot.get_bots():
+ if bot_id in bots:
+ if fd := [
+ x
+ for x in await bots[bot_id].get_friend_list()
+ if str(x["user_id"]) == user_id
+ ]:
+ like_plugin_list = (
+ await Statistics.filter(user_id=user_id)
+ .annotate(count=Count("id"))
+ .group_by("plugin_name")
+ .order_by("-count")
+ .limit(5)
+ .values_list("plugin_name", "count")
+ )
+ like_plugin = {}
+ module_list = [x[0] for x in like_plugin_list]
+ plugins = await PluginInfo.filter(module__in=module_list).all()
+ module2name = {p.module: p.name for p in plugins}
+ for data in like_plugin_list:
+ name = module2name.get(data[0]) or data[0]
+ like_plugin[name] = data[1]
+ user = fd[0]
+ user_detail = UserDetail(
+ user_id=user_id,
+ ava_url=AVA_URL.format(user_id),
+ nickname=user["nickname"],
+ remark=user["remark"],
+ is_ban=await BanConsole.is_ban(user_id),
+ chat_count=await ChatHistory.filter(user_id=user_id).count(),
+ call_count=await Statistics.filter(user_id=user_id).count(),
+ like_plugin=like_plugin,
+ )
+ return Result.ok(user_detail)
+ else:
+ return Result.warning_("未添加指定好友...")
+ return Result.warning_("无Bot连接...")
+
+
+@router.get(
+ "/get_group_detail", dependencies=[authentication()], description="获取群组详情"
+)
+async def _(bot_id: str, group_id: str) -> Result:
+ if bots := nonebot.get_bots():
+ if bot_id in bots:
+ group = await GroupConsole.get_or_none(group_id=group_id)
+ if not group:
+ return Result.warning_("指定群组未被收录...")
+ like_plugin_list = (
+ await Statistics.filter(group_id=group_id)
+ .annotate(count=Count("id"))
+ .group_by("plugin_name")
+ .order_by("-count")
+ .limit(5)
+ .values_list("plugin_name", "count")
+ )
+ like_plugin = {}
+ plugins = await PluginInfo.all()
+ module2name = {p.module: p.name for p in plugins}
+ for data in like_plugin_list:
+ name = module2name.get(data[0]) or data[0]
+ like_plugin[name] = data[1]
+ close_plugins = []
+ if group.block_plugin:
+ for module in group.block_plugin.split(","):
+ module_ = module.replace(":super", "")
+ is_super_block = module.endswith(":super")
+ plugin = Plugin(
+ module=module_,
+ plugin_name=module,
+ is_super_block=is_super_block,
+ )
+ plugin.plugin_name = module2name.get(module) or module
+ close_plugins.append(plugin)
+ all_task = await TaskInfo.annotate().values_list("module", "name")
+ task_module2name = {x[0]: x[1] for x in all_task}
+ task_list = []
+ if group.block_task:
+ split_task = group.block_task.split(",")
+ for task in all_task:
+ task_list.append(
+ Task(
+ name=task[0],
+ zh_name=task_module2name.get(task[0]) or task[0],
+ status=task[0] not in split_task,
+ )
+ )
+ group_detail = GroupDetail(
+ group_id=group_id,
+ ava_url=GROUP_AVA_URL.format(group_id, group_id),
+ name=group.group_name,
+ member_count=group.member_count,
+ max_member_count=group.max_member_count,
+ chat_count=await ChatHistory.filter(group_id=group_id).count(),
+ call_count=await Statistics.filter(group_id=group_id).count(),
+ like_plugin=like_plugin,
+ level=group.level,
+ status=group.status,
+ close_plugins=close_plugins,
+ task=task_list,
+ )
+ return Result.ok(group_detail)
+ else:
+ return Result.warning_("未添加指定群组...")
+ return Result.warning_("无Bot连接...")
+
+
+@router.post(
+ "/send_message", dependencies=[authentication()], description="获取群组详情"
+)
+async def _(param: SendMessage) -> Result:
+ if bots := nonebot.get_bots():
+ if param.bot_id in bots:
+ platform = PlatformUtils.get_platform(bots[param.bot_id])
+ if platform != "qq":
+ return Result.warning_("暂不支持该平台...")
+ try:
+ if param.user_id:
+ await bots[param.bot_id].send_private_msg(
+ user_id=str(param.user_id), message=param.message
+ )
+ else:
+ await bots[param.bot_id].send_group_msg(
+ group_id=str(param.group_id), message=param.message
+ )
+ except Exception as e:
+ return Result.fail(str(e))
+ return Result.ok("发送成功!")
+ return Result.warning_("指定Bot未连接...")
+ return Result.warning_("无Bot连接...")
+
+
+MSG_LIST = []
+
+ID2NAME = {}
+
+
+async def message_handle(
+ sub_log: str, type: Literal["private", "group"]
+) -> Message | None:
+ global MSG_LIST, ID2NAME
+ pattern = PRIVATE_PATTERN if type == "private" else GROUP_PATTERN
+ msg_id = None
+ uid = None
+ gid = None
+ msg = None
+ img_list = re.findall(IMAGE_PATTERN, sub_log)
+ if r := re.search(pattern, sub_log):
+ if type == "private":
+ msg_id = r.group(1)
+ uid = r.group(2)
+ msg = r.group(3)
+ if uid not in ID2NAME:
+ if user := await FriendUser.get_or_none(user_id=uid):
+ ID2NAME[uid] = user.user_name or user.nickname
+ else:
+ msg_id = r.group(1)
+ uid = r.group(2)
+ gid = r.group(3)
+ msg = r.group(4)
+ if gid not in ID2NAME:
+ if user := await GroupInfoUser.get_or_none(user_id=uid, group_id=gid):
+ ID2NAME[uid] = user.user_name or user.nickname
+ if at_list := re.findall(AT_PATTERN, msg):
+ user_list = await GroupInfoUser.filter(
+ user_id__in=at_list, group_id=gid
+ ).all()
+ id2name = {u.user_id: (u.user_name or u.nickname) for u in user_list}
+ for qq in at_list:
+ msg = re.sub(rf"\[CQ:at,qq={qq}\]", f"@{id2name[qq] or ''}", msg)
+ if msg_id in MSG_LIST:
+ return
+ MSG_LIST.append(msg_id)
+ messages = []
+ if msg and uid:
+ rep = re.split(r"\[CQ:image.*\]", msg)
+ if img_list:
+ for i in range(len(rep)):
+ messages.append(MessageItem(type="text", msg=rep[i]))
+ if i < len(img_list):
+ messages.append(MessageItem(type="img", msg=img_list[i]))
+ else:
+ messages = [MessageItem(type="text", msg=x) for x in rep]
+ return Message(
+ object_id=uid if type == "private" else gid, # type: ignore
+ user_id=uid,
+ group_id=gid,
+ message=messages,
+ name=ID2NAME.get(uid) or "",
+ ava_url=AVA_URL.format(uid),
+ )
+ return None
+
+
+@ws_router.websocket("/chat")
+async def _(websocket: WebSocket):
+ await websocket.accept()
+
+ async def log_listener(log: str):
+ global MSG_LIST, ID2NAME
+ sub_log = re.sub(SUB_PATTERN, "", log)
+ img_list = re.findall(IMAGE_PATTERN, sub_log)
+ if "message.private.friend" in log:
+ if message := await message_handle(sub_log, "private"):
+ await websocket.send_json(message.dict())
+ else:
+ if r := re.search(GROUP_PATTERN, sub_log):
+ if message := await message_handle(sub_log, "group"):
+ await websocket.send_json(message.dict())
+ if len(MSG_LIST) > 30:
+ MSG_LIST = MSG_LIST[-1:]
+
+ LOG_STORAGE.listeners.add(log_listener)
+ try:
+ while websocket.client_state == WebSocketState.CONNECTED:
+ recv = await websocket.receive()
+ except WebSocketDisconnect:
+ pass
+ finally:
+ LOG_STORAGE.listeners.remove(log_listener)
+ return
diff --git a/zhenxun/plugins/web_ui/api/tabs/manage/model.py b/zhenxun/plugins/web_ui/api/tabs/manage/model.py
new file mode 100644
index 00000000..a1e16bf4
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/manage/model.py
@@ -0,0 +1,265 @@
+from typing import Literal
+
+from pydantic import BaseModel
+
+
+class Group(BaseModel):
+ """
+ 群组信息
+ """
+
+ group_id: str
+ """群组id"""
+ group_name: str
+ """群组名称"""
+ member_count: int
+ """成员人数"""
+ max_member_count: int
+ """群组最大人数"""
+
+
+class Task(BaseModel):
+ """
+ 被动技能
+ """
+
+ name: str
+ """被动名称"""
+ zh_name: str
+ """被动中文名称"""
+ status: bool
+ """状态"""
+
+
+class Plugin(BaseModel):
+ """
+ 插件
+ """
+
+ module: str
+ """模块名"""
+ plugin_name: str
+ """中文名"""
+ is_super_block: bool
+ """是否超级用户禁用"""
+
+
+class GroupResult(BaseModel):
+ """
+ 群组返回数据
+ """
+
+ group_id: str
+ """群组id"""
+ group_name: str
+ """群组名称"""
+ ava_url: str
+ """群组头像"""
+
+
+class Friend(BaseModel):
+ """
+ 好友数据
+ """
+
+ user_id: str
+ """用户id"""
+ nickname: str = ""
+ """昵称"""
+ remark: str = ""
+ """备注"""
+ ava_url: str = ""
+ """头像url"""
+
+
+class UpdateGroup(BaseModel):
+ """
+ 更新群组信息
+ """
+
+ group_id: str
+ """群号"""
+ status: bool
+ """状态"""
+ level: int
+ """群权限"""
+ task: list[str]
+ """被动状态"""
+ close_plugins: list[str]
+ """关闭插件"""
+
+
+class FriendRequestResult(BaseModel):
+ """
+ 好友/群组请求管理
+ """
+
+ bot_id: str
+ """bot_id"""
+ oid: int
+ """排序"""
+ id: str
+ """id"""
+ flag: str
+ """flag"""
+ nickname: str | None
+ """昵称"""
+ comment: str | None
+ """备注信息"""
+ ava_url: str
+ """头像"""
+ type: str
+ """类型 private group"""
+
+
+class GroupRequestResult(FriendRequestResult):
+ """
+ 群聊邀请请求
+ """
+
+ invite_group: str
+ """邀请群聊"""
+ group_name: str | None
+ """群聊名称"""
+
+
+class HandleRequest(BaseModel):
+ """
+ 操作请求接收数据
+ """
+
+ bot_id: str | None = None
+ """bot_id"""
+ id: int
+ """数据id"""
+ request_type: Literal["private", "group"]
+ """类型"""
+
+
+class LeaveGroup(BaseModel):
+ """
+ 退出群聊
+ """
+
+ bot_id: str
+ """bot_id"""
+ group_id: str
+ """群聊id"""
+
+
+class DeleteFriend(BaseModel):
+ """
+ 删除好友
+ """
+
+ bot_id: str
+ """bot_id"""
+ user_id: str
+ """用户id"""
+
+
+class ReqResult(BaseModel):
+ """
+ 好友/群组请求列表
+ """
+
+ friend: list[FriendRequestResult] = []
+ """好友请求列表"""
+ group: list[GroupRequestResult] = []
+ """群组请求列表"""
+
+
+class UserDetail(BaseModel):
+ """
+ 用户详情
+ """
+
+ user_id: str
+ """用户id"""
+ ava_url: str
+ """头像url"""
+ nickname: str
+ """昵称"""
+ remark: str
+ """备注"""
+ is_ban: bool
+ """是否被ban"""
+ chat_count: int
+ """发言次数"""
+ call_count: int
+ """功能调用次数"""
+ like_plugin: dict[str, int]
+ """最喜爱的功能"""
+
+
+class GroupDetail(BaseModel):
+ """
+ 用户详情
+ """
+
+ group_id: str
+ """群组id"""
+ ava_url: str
+ """头像url"""
+ name: str
+ """名称"""
+ member_count: int
+ """成员数"""
+ max_member_count: int
+ """最大成员数"""
+ chat_count: int
+ """发言次数"""
+ call_count: int
+ """功能调用次数"""
+ like_plugin: dict[str, int]
+ """最喜爱的功能"""
+ level: int
+ """群权限"""
+ status: bool
+ """状态(睡眠)"""
+ close_plugins: list[Plugin]
+ """关闭的插件"""
+ task: list[Task]
+ """被动列表"""
+
+
+class MessageItem(BaseModel):
+
+ type: str
+ """消息类型"""
+ msg: str
+ """内容"""
+
+
+class Message(BaseModel):
+ """
+ 消息
+ """
+
+ object_id: str
+ """主体id user_id 或 group_id"""
+ user_id: str
+ """用户id"""
+ group_id: str | None = None
+ """群组id"""
+ message: list[MessageItem]
+ """消息"""
+ name: str
+ """用户名称"""
+ ava_url: str
+ """用户头像"""
+
+
+class SendMessage(BaseModel):
+ """
+ 发送消息
+ """
+
+ bot_id: str
+ """bot id"""
+ user_id: str | None = None
+ """用户id"""
+ group_id: str | None = None
+ """群组id"""
+ message: str
+ """消息"""
diff --git a/zhenxun/plugins/web_ui/api/tabs/plugin_manage/__init__.py b/zhenxun/plugins/web_ui/api/tabs/plugin_manage/__init__.py
new file mode 100644
index 00000000..fce8f8e0
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/plugin_manage/__init__.py
@@ -0,0 +1,187 @@
+import re
+
+import cattrs
+from fastapi import APIRouter, Query
+
+from zhenxun.configs.config import Config
+from zhenxun.models.plugin_info import PluginInfo as DbPluginInfo
+from zhenxun.services.log import logger
+from zhenxun.utils.enum import BlockType, PluginType
+
+from ....base_model import Result
+from ....utils import authentication
+from .model import (
+ PluginConfig,
+ PluginCount,
+ PluginDetail,
+ PluginInfo,
+ PluginSwitch,
+ UpdatePlugin,
+)
+
+router = APIRouter(prefix="/plugin")
+
+
+@router.get(
+ "/get_plugin_list", dependencies=[authentication()], deprecated="获取插件列表" # type: ignore
+)
+async def _(
+ plugin_type: list[PluginType] = Query(None), menu_type: str | None = None
+) -> Result:
+ try:
+ plugin_list: list[PluginInfo] = []
+ query = DbPluginInfo
+ if plugin_type:
+ query = query.filter(plugin_type__in=plugin_type)
+ if menu_type:
+ query = query.filter(menu_type=menu_type)
+ plugins = await query.all()
+ for plugin in plugins:
+ plugin_info = PluginInfo(
+ module=plugin.module,
+ plugin_name=plugin.name,
+ default_status=plugin.default_status,
+ limit_superuser=plugin.limit_superuser,
+ cost_gold=plugin.cost_gold,
+ menu_type=plugin.menu_type,
+ version=plugin.version or 0,
+ level=plugin.level,
+ status=plugin.status,
+ author=plugin.author,
+ )
+ plugin_list.append(plugin_info)
+ except Exception as e:
+ logger.error("调用API错误", "/get_plugins", e=e)
+ return Result.fail(f"{type(e)}: {e}")
+ return Result.ok(plugin_list, "拿到了新鲜出炉的数据!")
+
+
+@router.get(
+ "/get_plugin_count", dependencies=[authentication()], deprecated="获取插件数量" # type: ignore
+)
+async def _() -> Result:
+ plugin_count = PluginCount()
+ plugin_count.normal = await DbPluginInfo.filter(
+ plugin_type=PluginType.NORMAL
+ ).count()
+ plugin_count.admin = await DbPluginInfo.filter(
+ plugin_type__in=[PluginType.ADMIN, PluginType.SUPER_AND_ADMIN]
+ ).count()
+ plugin_count.superuser = await DbPluginInfo.filter(
+ plugin_type=[PluginType.SUPERUSER, PluginType.SUPER_AND_ADMIN]
+ ).count()
+ plugin_count.other = await DbPluginInfo.filter(
+ plugin_type=PluginType.HIDDEN
+ ).count()
+ return Result.ok(plugin_count)
+
+
+@router.post(
+ "/update_plugin", dependencies=[authentication()], description="更新插件参数"
+)
+async def _(plugin: UpdatePlugin) -> Result:
+ try:
+ db_plugin = await DbPluginInfo.get_or_none(module=plugin.module)
+ if not db_plugin:
+ return Result.fail("插件不存在...")
+ db_plugin.default_status = plugin.default_status
+ db_plugin.limit_superuser = plugin.limit_superuser
+ db_plugin.cost_gold = plugin.cost_gold
+ db_plugin.level = plugin.level
+ db_plugin.menu_type = plugin.menu_type
+ db_plugin.block_type = plugin.block_type
+ if plugin.block_type == BlockType.ALL:
+ db_plugin.status = False
+ else:
+ db_plugin.status = True
+ await db_plugin.save()
+ # 配置项
+ if plugin.configs and (configs := Config.get(plugin.module)):
+ for key in plugin.configs:
+ if c := configs.configs.get(key):
+ value = plugin.configs[key]
+ if c.type and value is not None:
+ value = cattrs.structure(value, c.type)
+ Config.set_config(plugin.module, key, value)
+ except Exception as e:
+ logger.error("调用API错误", "/update_plugins", e=e)
+ return Result.fail(f"{type(e)}: {e}")
+ return Result.ok(info="已经帮你写好啦!")
+
+
+@router.post("/change_switch", dependencies=[authentication()], description="开关插件")
+async def _(param: PluginSwitch) -> Result:
+ db_plugin = await DbPluginInfo.get_or_none(module=param.module)
+ if not db_plugin:
+ return Result.fail("插件不存在...")
+ if not param.status:
+ db_plugin.block_type = BlockType.ALL
+ db_plugin.status = False
+ else:
+ db_plugin.block_type = None
+ db_plugin.status = True
+ await db_plugin.save()
+ return Result.ok(info="成功改变了开关状态!")
+
+
+@router.get(
+ "/get_plugin_menu_type", dependencies=[authentication()], description="获取插件类型"
+)
+async def _() -> Result:
+ menu_type_list = []
+ result = await DbPluginInfo.annotate().values_list("menu_type", flat=True)
+ for r in result:
+ if r not in menu_type_list:
+ menu_type_list.append(r)
+ return Result.ok(menu_type_list)
+
+
+@router.get("/get_plugin", dependencies=[authentication()], description="获取插件详情")
+async def _(module: str) -> Result:
+ db_plugin = await DbPluginInfo.get_or_none(module=module)
+ if not db_plugin:
+ return Result.fail("插件不存在...")
+ config_list = []
+ if config := Config.get(module):
+ for cfg in config.configs:
+ type_str = ""
+ type_inner = None
+ x = str(config.configs[cfg].type)
+ r = re.search(r"", str(config.configs[cfg].type))
+ if r:
+ type_str = r.group(1)
+ else:
+ r = re.search(r"typing\.(.*)\[(.*)\]", str(config.configs[cfg].type))
+ if r:
+ type_str = r.group(1)
+ if type_str:
+ type_str = type_str.lower()
+ type_inner = r.group(2)
+ if type_inner:
+ type_inner = [x.strip() for x in type_inner.split(",")]
+ config_list.append(
+ PluginConfig(
+ module=module,
+ key=cfg,
+ value=config.configs[cfg].value,
+ help=config.configs[cfg].help,
+ default_value=config.configs[cfg].default_value,
+ type=type_str,
+ type_inner=type_inner, # type: ignore
+ )
+ )
+ plugin_info = PluginDetail(
+ module=module,
+ plugin_name=db_plugin.name,
+ default_status=db_plugin.default_status,
+ limit_superuser=db_plugin.limit_superuser,
+ cost_gold=db_plugin.cost_gold,
+ menu_type=db_plugin.menu_type,
+ version=db_plugin.version or "0",
+ level=db_plugin.level,
+ status=db_plugin.status,
+ author=db_plugin.author,
+ config_list=config_list,
+ block_type=db_plugin.block_type,
+ )
+ return Result.ok(plugin_info)
diff --git a/zhenxun/plugins/web_ui/api/tabs/plugin_manage/model.py b/zhenxun/plugins/web_ui/api/tabs/plugin_manage/model.py
new file mode 100644
index 00000000..e2952038
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/plugin_manage/model.py
@@ -0,0 +1,125 @@
+from typing import Any
+
+from pydantic import BaseModel
+
+from zhenxun.utils.enum import BlockType
+
+
+class PluginSwitch(BaseModel):
+ """
+ 插件开关
+ """
+
+ module: str
+ """模块"""
+ status: bool
+ """开关状态"""
+
+
+class UpdateConfig(BaseModel):
+ """
+ 配置项修改参数
+ """
+
+ module: str
+ """模块"""
+ key: str
+ """配置项key"""
+ value: Any
+ """配置项值"""
+
+
+class UpdatePlugin(BaseModel):
+ """
+ 插件修改参数
+ """
+
+ module: str
+ """模块"""
+ default_status: bool
+ """默认开关"""
+ limit_superuser: bool
+ """限制超级用户"""
+ cost_gold: int
+ """金币花费"""
+ menu_type: str
+ """插件菜单类型"""
+ level: int
+ """插件所需群权限"""
+ block_type: BlockType | None = None
+ """禁用类型"""
+ configs: dict[str, Any] | None = None
+ """配置项"""
+
+
+class PluginInfo(BaseModel):
+ """
+ 基本插件信息
+ """
+
+ module: str
+ """插件名称"""
+ plugin_name: str
+ """插件中文名称"""
+ default_status: bool
+ """默认开关"""
+ limit_superuser: bool
+ """限制超级用户"""
+ cost_gold: int
+ """花费金币"""
+ menu_type: str
+ """插件菜单类型"""
+ version: str
+ """插件版本"""
+ level: int
+ """群权限"""
+ status: bool
+ """当前状态"""
+ author: str | None = None
+ """作者"""
+ block_type: BlockType | None = None
+ """禁用类型"""
+
+
+class PluginConfig(BaseModel):
+ """
+ 插件配置项
+ """
+
+ module: str
+ """模块"""
+ key: str
+ """键"""
+ value: Any
+ """值"""
+ help: str | None = None
+ """帮助"""
+ default_value: Any
+ """默认值"""
+ type: Any = None
+ """值类型"""
+ type_inner: list[str] | None = None
+ """List Tuple等内部类型检验"""
+
+
+class PluginCount(BaseModel):
+ """
+ 插件数量
+ """
+
+ normal: int = 0
+ """普通插件"""
+ admin: int = 0
+ """管理员插件"""
+ superuser: int = 0
+ """超级用户插件"""
+ other: int = 0
+ """其他插件"""
+
+
+class PluginDetail(PluginInfo):
+ """
+ 插件详情
+ """
+
+ config_list: list[PluginConfig]
diff --git a/zhenxun/plugins/web_ui/api/tabs/system/__init__.py b/zhenxun/plugins/web_ui/api/tabs/system/__init__.py
new file mode 100644
index 00000000..61430144
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/system/__init__.py
@@ -0,0 +1,121 @@
+import os
+import shutil
+from pathlib import Path
+from typing import List, Optional
+
+from fastapi import APIRouter
+
+from ....base_model import Result
+from ....utils import authentication, get_system_disk
+from .model import AddFile, DeleteFile, DirFile, RenameFile, SaveFile
+
+router = APIRouter(prefix="/system")
+
+
+
+@router.get("/get_dir_list", dependencies=[authentication()], description="获取文件列表")
+async def _(path: Optional[str] = None) -> Result:
+ base_path = Path(path) if path else Path()
+ data_list = []
+ for file in os.listdir(base_path):
+ data_list.append(DirFile(is_file=not (base_path / file).is_dir(), name=file, parent=path))
+ return Result.ok(data_list)
+
+
+@router.get("/get_resources_size", dependencies=[authentication()], description="获取文件列表")
+async def _(full_path: Optional[str] = None) -> Result:
+ return Result.ok(await get_system_disk(full_path))
+
+
+@router.post("/delete_file", dependencies=[authentication()], description="删除文件")
+async def _(param: DeleteFile) -> Result:
+ path = Path(param.full_path)
+ if not path or not path.exists():
+ return Result.warning_("文件不存在...")
+ try:
+ path.unlink()
+ return Result.ok('删除成功!')
+ except Exception as e:
+ return Result.warning_('删除失败: ' + str(e))
+
+@router.post("/delete_folder", dependencies=[authentication()], description="删除文件夹")
+async def _(param: DeleteFile) -> Result:
+ path = Path(param.full_path)
+ if not path or not path.exists() or path.is_file():
+ return Result.warning_("文件夹不存在...")
+ try:
+ shutil.rmtree(path.absolute())
+ return Result.ok('删除成功!')
+ except Exception as e:
+ return Result.warning_('删除失败: ' + str(e))
+
+
+@router.post("/rename_file", dependencies=[authentication()], description="重命名文件")
+async def _(param: RenameFile) -> Result:
+ path = (Path(param.parent) / param.old_name) if param.parent else Path(param.old_name)
+ if not path or not path.exists():
+ return Result.warning_("文件不存在...")
+ try:
+ path.rename(path.parent / param.name)
+ return Result.ok('重命名成功!')
+ except Exception as e:
+ return Result.warning_('重命名失败: ' + str(e))
+
+
+@router.post("/rename_folder", dependencies=[authentication()], description="重命名文件夹")
+async def _(param: RenameFile) -> Result:
+ path = (Path(param.parent) / param.old_name) if param.parent else Path(param.old_name)
+ if not path or not path.exists() or path.is_file():
+ return Result.warning_("文件夹不存在...")
+ try:
+ new_path = path.parent / param.name
+ shutil.move(path.absolute(), new_path.absolute())
+ return Result.ok('重命名成功!')
+ except Exception as e:
+ return Result.warning_('重命名失败: ' + str(e))
+
+
+@router.post("/add_file", dependencies=[authentication()], description="新建文件")
+async def _(param: AddFile) -> Result:
+ path = (Path(param.parent) / param.name) if param.parent else Path(param.name)
+ if path.exists():
+ return Result.warning_("文件已存在...")
+ try:
+ path.open('w')
+ return Result.ok('新建文件成功!')
+ except Exception as e:
+ return Result.warning_('新建文件失败: ' + str(e))
+
+
+@router.post("/add_folder", dependencies=[authentication()], description="新建文件夹")
+async def _(param: AddFile) -> Result:
+ path = (Path(param.parent) / param.name) if param.parent else Path(param.name)
+ if path.exists():
+ return Result.warning_("文件夹已存在...")
+ try:
+ path.mkdir()
+ return Result.ok('新建文件夹成功!')
+ except Exception as e:
+ return Result.warning_('新建文件夹失败: ' + str(e))
+
+
+@router.get("/read_file", dependencies=[authentication()], description="读取文件")
+async def _(full_path: str) -> Result:
+ path = Path(full_path)
+ if not path.exists():
+ return Result.warning_("文件不存在...")
+ try:
+ text = path.read_text(encoding='utf-8')
+ return Result.ok(text)
+ except Exception as e:
+ return Result.warning_('新建文件夹失败: ' + str(e))
+
+@router.post("/save_file", dependencies=[authentication()], description="读取文件")
+async def _(param: SaveFile) -> Result:
+ path = Path(param.full_path)
+ try:
+ with path.open('w') as f:
+ f.write(param.content)
+ return Result.ok("更新成功!")
+ except Exception as e:
+ return Result.warning_('新建文件夹失败: ' + str(e))
\ No newline at end of file
diff --git a/zhenxun/plugins/web_ui/api/tabs/system/model.py b/zhenxun/plugins/web_ui/api/tabs/system/model.py
new file mode 100644
index 00000000..b3b5a45f
--- /dev/null
+++ b/zhenxun/plugins/web_ui/api/tabs/system/model.py
@@ -0,0 +1,64 @@
+
+
+
+from datetime import datetime
+from typing import Literal, Optional
+
+from pydantic import BaseModel
+
+
+class DirFile(BaseModel):
+
+ """
+ 文件或文件夹
+ """
+
+ is_file: bool
+ """是否为文件"""
+ name: str
+ """文件夹或文件名称"""
+ parent: Optional[str] = None
+ """父级"""
+
+class DeleteFile(BaseModel):
+
+ """
+ 删除文件
+ """
+
+ full_path: str
+ """文件全路径"""
+
+class RenameFile(BaseModel):
+
+ """
+ 删除文件
+ """
+ parent: Optional[str]
+ """父路径"""
+ old_name: str
+ """旧名称"""
+ name: str
+ """新名称"""
+
+
+class AddFile(BaseModel):
+
+ """
+ 新建文件
+ """
+ parent: Optional[str]
+ """父路径"""
+ name: str
+ """新名称"""
+
+
+class SaveFile(BaseModel):
+
+ """
+ 保存文件
+ """
+ full_path: str
+ """全路径"""
+ content: str
+ """内容"""
diff --git a/zhenxun/plugins/web_ui/auth/__init__.py b/zhenxun/plugins/web_ui/auth/__init__.py
new file mode 100644
index 00000000..6551d1ad
--- /dev/null
+++ b/zhenxun/plugins/web_ui/auth/__init__.py
@@ -0,0 +1,47 @@
+import json
+from datetime import timedelta
+
+import nonebot
+from fastapi import APIRouter, Depends
+from fastapi.security import OAuth2PasswordRequestForm
+
+from zhenxun.configs.config import Config
+
+from ..base_model import Result
+from ..utils import (
+ ACCESS_TOKEN_EXPIRE_MINUTES,
+ create_token,
+ get_user,
+ token_data,
+ token_file,
+)
+
+app = nonebot.get_app()
+
+
+router = APIRouter()
+
+
+@router.post("/login")
+async def login_get_token(form_data: OAuth2PasswordRequestForm = Depends()):
+ username = Config.get_config("web-ui", "username")
+ password = Config.get_config("web-ui", "password")
+ if not username or not password:
+ return Result.fail("你滴配置文件里用户名密码配置项为空", 998)
+ if username != form_data.username or password != form_data.password:
+ return Result.fail("真笨, 账号密码都能记错!", 999)
+ user = get_user(form_data.username)
+ if not user:
+ return Result.fail("用户不存在...", 997)
+ access_token = create_token(
+ user=user,
+ expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+ )
+ token_data["token"].append(access_token)
+ if len(token_data["token"]) > 3:
+ token_data["token"] = token_data["token"][1:]
+ with open(token_file, "w", encoding="utf8") as f:
+ json.dump(token_data, f, ensure_ascii=False, indent=4)
+ return Result.ok(
+ {"access_token": access_token, "token_type": "bearer"}, "欢迎回家, 欧尼酱!"
+ )
diff --git a/zhenxun/plugins/web_ui/base_model.py b/zhenxun/plugins/web_ui/base_model.py
new file mode 100644
index 00000000..67bb280f
--- /dev/null
+++ b/zhenxun/plugins/web_ui/base_model.py
@@ -0,0 +1,108 @@
+from datetime import datetime
+from typing import Any, Generic, Optional, TypeVar
+
+from pydantic import BaseModel, validator
+from typing_extensions import Self
+
+T = TypeVar("T")
+
+
+class User(BaseModel):
+ username: str
+ password: str
+
+
+class Token(BaseModel):
+ access_token: str
+ token_type: str
+
+
+class Result(BaseModel):
+ """
+ 总体返回
+ """
+
+ suc: bool
+ """调用状态"""
+ code: int = 200
+ """code"""
+ info: str = "操作成功"
+ """info"""
+ warning: Optional[str] = None
+ """警告信息"""
+ data: Any = None
+ """返回数据"""
+
+ @classmethod
+ def warning_(cls, info: str, code: int = 200) -> Self:
+ return cls(suc=True, warning=info, code=code)
+
+ @classmethod
+ def fail(cls, info: str = "异常错误", code: int = 500) -> Self:
+ return cls(suc=False, info=info, code=code)
+
+ @classmethod
+ def ok(cls, data: Any = None, info: str = "操作成功", code: int = 200) -> Self:
+ return cls(suc=True, info=info, code=code, data=data)
+
+
+class QueryModel(BaseModel, Generic[T]):
+ """
+ 基本查询条件
+ """
+
+ index: int
+ """页数"""
+ size: int
+ """每页数量"""
+ data: T
+ """携带数据"""
+
+ @validator("index")
+ def index_validator(cls, index):
+ if index < 1:
+ raise ValueError("查询下标小于1...")
+ return index
+
+ @validator("size")
+ def size_validator(cls, size):
+ if size < 1:
+ raise ValueError("每页数量小于1...")
+ return size
+
+
+class BaseResultModel(BaseModel):
+ """
+ 基础返回
+ """
+
+ total: int
+ """总页数"""
+ data: Any
+ """数据"""
+
+
+class SystemStatus(BaseModel):
+ """
+ 系统状态
+ """
+
+ cpu: float
+ memory: float
+ disk: float
+ check_time: datetime
+
+
+class SystemFolderSize(BaseModel):
+ """
+ 资源文件占比
+ """
+
+ name: str
+ """名称"""
+ size: float
+ """大小"""
+ full_path: Optional[str]
+ """完整路径"""
+ is_dir: bool
+ """是否为文件夹"""
diff --git a/zhenxun/plugins/web_ui/config.py b/zhenxun/plugins/web_ui/config.py
new file mode 100644
index 00000000..0f16949a
--- /dev/null
+++ b/zhenxun/plugins/web_ui/config.py
@@ -0,0 +1,36 @@
+import nonebot
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel
+from strenum import StrEnum
+
+app = nonebot.get_app()
+
+origins = ["*"]
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+AVA_URL = "http://q1.qlogo.cn/g?b=qq&nk={}&s=160"
+
+GROUP_AVA_URL = "http://p.qlogo.cn/gh/{}/{}/640/"
+
+
+class QueryDateType(StrEnum):
+ """
+ 查询日期类型
+ """
+
+ DAY = "day"
+ """日"""
+ WEEK = "week"
+ """周"""
+ MONTH = "month"
+ """月"""
+ YEAR = "year"
+ """年"""
diff --git a/zhenxun/plugins/web_ui/utils.py b/zhenxun/plugins/web_ui/utils.py
new file mode 100644
index 00000000..f39f36ac
--- /dev/null
+++ b/zhenxun/plugins/web_ui/utils.py
@@ -0,0 +1,136 @@
+import os
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import psutil
+import ujson as json
+from fastapi import Depends, HTTPException
+from fastapi.security import OAuth2PasswordBearer
+from jose import JWTError, jwt
+from nonebot.utils import run_sync
+
+from zhenxun.configs.config import Config
+from zhenxun.configs.path_config import DATA_PATH
+
+from .base_model import SystemFolderSize, SystemStatus, User
+
+SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
+ALGORITHM = "HS256"
+ACCESS_TOKEN_EXPIRE_MINUTES = 30
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/login")
+
+token_file = DATA_PATH / "web_ui" / "token.json"
+token_file.parent.mkdir(parents=True, exist_ok=True)
+token_data = {"token": []}
+if token_file.exists():
+ try:
+ token_data = json.load(open(token_file, "r", encoding="utf8"))
+ except json.JSONDecodeError:
+ pass
+
+
+def get_user(uname: str) -> User | None:
+ """获取账号密码
+
+ 参数:
+ uname: uname
+
+ 返回:
+ Optional[User]: 用户信息
+ """
+ username = Config.get_config("web-ui", "username")
+ password = Config.get_config("web-ui", "password")
+ if username and password and uname == username:
+ return User(username=username, password=password)
+
+
+def create_token(user: User, expires_delta: timedelta | None = None):
+ """创建token
+
+ 参数:
+ user: 用户信息
+ expires_delta: 过期时间.
+ """
+ expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
+ return jwt.encode(
+ claims={"sub": user.username, "exp": expire},
+ key=SECRET_KEY,
+ algorithm=ALGORITHM,
+ )
+
+
+def authentication():
+ """权限验证
+
+ 异常:
+ JWTError: JWTError
+ HTTPException: HTTPException
+ """
+
+ # if token not in token_data["token"]:
+ def inner(token: str = Depends(oauth2_scheme)):
+ try:
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+ username, expire = payload.get("sub"), payload.get("exp")
+ user = get_user(username) # type: ignore
+ if user is None:
+ raise JWTError
+ except JWTError:
+ raise HTTPException(
+ status_code=400, detail="登录验证失败或已失效, 踢出房间!"
+ )
+
+ return Depends(inner)
+
+
+def _get_dir_size(dir_path: Path) -> float:
+ """获取文件夹大小
+
+ 参数:
+ dir_path: 文件夹路径
+ """
+ size = 0
+ for root, dirs, files in os.walk(dir_path):
+ size += sum([os.path.getsize(os.path.join(root, name)) for name in files])
+ return size
+
+
+@run_sync
+def get_system_status() -> SystemStatus:
+ """获取系统信息等"""
+ cpu = psutil.cpu_percent()
+ memory = psutil.virtual_memory().percent
+ disk = psutil.disk_usage("/").percent
+ return SystemStatus(
+ cpu=cpu,
+ memory=memory,
+ disk=disk,
+ check_time=datetime.now().replace(microsecond=0),
+ )
+
+
+@run_sync
+def get_system_disk(
+ full_path: str | None,
+) -> list[SystemFolderSize]:
+ """获取资源文件大小等"""
+ base_path = Path(full_path) if full_path else Path()
+ other_size = 0
+ data_list = []
+ for file in os.listdir(base_path):
+ f = base_path / file
+ if f.is_dir():
+ size = _get_dir_size(f) / 1024 / 1024
+ data_list.append(
+ SystemFolderSize(name=file, size=size, full_path=str(f), is_dir=True)
+ )
+ else:
+ other_size += f.stat().st_size / 1024 / 1024
+ if other_size:
+ data_list.append(
+ SystemFolderSize(
+ name="other_file", size=other_size, full_path=full_path, is_dir=False
+ )
+ )
+ return data_list
diff --git a/zhenxun/utils/enum.py b/zhenxun/utils/enum.py
index 681f9768..bf6c5daa 100644
--- a/zhenxun/utils/enum.py
+++ b/zhenxun/utils/enum.py
@@ -97,3 +97,5 @@ class RequestHandleType(StrEnum):
"""拒绝"""
IGNORE = "IGNORE"
"""忽略"""
+ EXPIRE = "EXPIRE"
+ """过期或失效"""
diff --git a/zhenxun/utils/plugin_models/base.py b/zhenxun/utils/plugin_models/base.py
new file mode 100644
index 00000000..a50f6b4a
--- /dev/null
+++ b/zhenxun/utils/plugin_models/base.py
@@ -0,0 +1,9 @@
+from pydantic import BaseModel
+
+
+class CommonSql(BaseModel):
+
+ sql: str
+ """sql语句"""
+ remark: str
+ """备注"""