Compare commits
673 Commits
v4.3.7
...
v5.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33ee5c05a7 | ||
|
|
7952e88885 | ||
|
|
c1f7ea6922 | ||
|
|
196aa9873b | ||
|
|
334c17c055 | ||
|
|
0a20ee299d | ||
| 7d1ceda958 | |||
| 2f07c72025 | |||
|
|
5da96242aa | ||
|
|
5069a65559 | ||
| 21d1874602 | |||
|
|
1d91204842 | ||
|
|
3d204980a2 | ||
|
|
1eae8c80e5 | ||
|
|
a72ce489bd | ||
|
|
efa9f28aad | ||
|
|
4beed5048a | ||
|
|
e286fc8d55 | ||
|
|
68b1ed4238 | ||
|
|
e5b43a9c45 | ||
|
|
98a2b0f176 | ||
|
|
06770eb3cc | ||
|
|
dfc403733f | ||
|
|
f423f3b577 | ||
| fcd8f042b7 | |||
|
|
3140c29832 | ||
|
|
1c97228bc0 | ||
|
|
8ce747a839 | ||
|
|
124d3e34d6 | ||
| 837af62a60 | |||
| 14a8a308c5 | |||
| 28ce06c92d | |||
| 2d6dae3fc9 | |||
| d2066e9631 | |||
| 13caed7207 | |||
| 2c57426051 | |||
| bb602d92cf | |||
| 57a3505cab | |||
|
|
2ab402e8ff | ||
|
|
40d06cce54 | ||
|
|
7b0307070e | ||
|
|
09038aeb09 | ||
|
|
f869c19353 | ||
|
|
b6fd72f0d8 | ||
|
|
f9192ef9eb | ||
|
|
6f77c29e19 | ||
|
|
4e44669695 | ||
|
|
f04b7bf073 | ||
|
|
0ac0225d7d | ||
|
|
128cf3de4c | ||
|
|
e528936c8d | ||
|
|
eed0319098 | ||
| 74ea0af9bc | |||
| 69fac94058 | |||
| 29bd8a4aff | |||
|
|
54dc1d392d | ||
|
|
5c55db7067 | ||
|
|
bbe5286601 | ||
|
|
06b3147b53 | ||
|
|
dbd1731d7f | ||
|
|
320d7ab17d | ||
|
|
4ad346ed14 | ||
|
|
f9f8b56d18 | ||
| 2101ae9908 | |||
| dbc5784691 | |||
|
|
632ad33562 | ||
|
|
d3639d44e1 | ||
| af28869d2a | |||
|
|
34df37c040 | ||
| bd58a512c9 | |||
|
|
cf4c8cfe17 | ||
|
|
ff8f501b98 | ||
|
|
59de0ec510 | ||
|
|
cda50ea134 | ||
|
|
97ec518e43 | ||
| d338210426 | |||
| b3fe49b8d1 | |||
| 96c022c2ec | |||
|
|
0f1cbfc82d | ||
|
|
53d91fb3f8 | ||
|
|
7e74af93a0 | ||
| 2331d7e38b | |||
| 6fd46087bb | |||
|
|
76f330e4d3 | ||
|
|
1a541cbc63 | ||
|
|
133ab0e78c | ||
|
|
35aa3cc42d | ||
|
|
4114e4ffc0 | ||
|
|
22ebe7e51b | ||
| 0437778c14 | |||
| 70fc623f54 | |||
|
|
32df65fb65 | ||
|
|
4f1e49ce85 | ||
|
|
db0ac79e44 | ||
|
|
d5331b728d | ||
| 1c640c3df8 | |||
| de45255feb | |||
|
|
1f89639463 | ||
|
|
cb741c7b2e | ||
| 6897f35d1e | |||
| 7c34b3ca94 | |||
|
|
74a72961f7 | ||
|
|
981c3a8624 | ||
|
|
a16f9b2da4 | ||
|
|
1f85360c92 | ||
|
|
5f7cdd6a57 | ||
|
|
887f10ef3f | ||
|
|
14c61815c6 | ||
|
|
4abce146a9 | ||
|
|
0e8094e52f | ||
|
|
f3680a8274 | ||
|
|
cd706640d9 | ||
|
|
e0ba88d7b0 | ||
|
|
4cc1d1186e | ||
|
|
5eab1b0986 | ||
|
|
60e8ac0ce9 | ||
|
|
a5e09bc489 | ||
|
|
199907eb26 | ||
|
|
969e223fb4 | ||
|
|
e62b9b3943 | ||
|
|
a9769c6397 | ||
|
|
d21d2f9a1d | ||
|
|
bd6d7e8189 | ||
|
|
c0af9d7de0 | ||
| e569930287 | |||
| 5cd0ca29a4 | |||
| b3bb43dcdf | |||
| 01ee70f3fb | |||
|
|
ed6b52451b | ||
|
|
6150142d62 | ||
|
|
bf99e41cee | ||
|
|
4db49e0bb9 | ||
|
|
f559a71de2 | ||
|
|
b5b208c611 | ||
| 7da7a8b4f7 | |||
|
|
010d99ce78 | ||
|
|
9946a8376a | ||
|
|
b11550c500 | ||
|
|
1e8ada6c36 | ||
|
|
aacf32ac61 | ||
|
|
308bb83bec | ||
|
|
4dff160b82 | ||
|
|
cd1d114cac | ||
|
|
f36d647594 | ||
|
|
edadc6f7ce | ||
|
|
1585ab564d | ||
|
|
6c52d5a2f0 | ||
|
|
63fd3d590b | ||
|
|
139fd0bcc3 | ||
|
|
481a204a19 | ||
|
|
62877257e4 | ||
| 2a493bd62a | |||
| 249e8c1a08 | |||
| 2d44389587 | |||
| 84ec172871 | |||
| ac9418f787 | |||
|
|
e79830565e | ||
|
|
4298961311 | ||
|
|
cec8944192 | ||
|
|
6b78162784 | ||
|
|
7ca0dcc918 | ||
|
|
804c3ed62c | ||
|
|
aaee9aa1ba | ||
| 660b82da7a | |||
|
|
d0d5d76f10 | ||
|
|
4026b35636 | ||
| 2558cfca75 | |||
| 4b4aa8ba84 | |||
|
|
1ae2a9bb87 | ||
|
|
3b9d27383b | ||
|
|
17d7e3bad0 | ||
|
|
8ea6c9565e | ||
|
|
67b327e192 | ||
|
|
f98a749efa | ||
|
|
9d067f1c6a | ||
|
|
d1fdd5f672 | ||
|
|
76fcc13d4a | ||
|
|
76b32ad2c1 | ||
|
|
ad56904e56 | ||
| 5f12ddf332 | |||
| 4d8d71f294 | |||
| a15b2bd8ce | |||
| 820649225d | |||
|
|
d0651e2104 | ||
|
|
8910e09493 | ||
|
|
eafd6eb808 | ||
|
|
e07c1a844a | ||
|
|
6846415899 | ||
|
|
4e2419a5f1 | ||
|
|
1866495379 | ||
|
|
c4d1ed3184 | ||
|
|
aa6836d26d | ||
| e49d67f501 | |||
|
|
1ab865df7b | ||
|
|
b36fb89566 | ||
| 94dea445cf | |||
| c8380ddb90 | |||
|
|
6c7a0226fd | ||
|
|
79bd982383 | ||
|
|
e097e40826 | ||
|
|
642b34eca3 | ||
| fbd7fa6a00 | |||
|
|
19537698eb | ||
| 805783c85b | |||
| fd12a6359d | |||
| 04158a40bc | |||
|
|
a10c927253 | ||
|
|
b16bf0fe3f | ||
|
|
47e7291885 | ||
|
|
a034f10a19 | ||
|
|
80eb0bc49d | ||
|
|
90b8020c0a | ||
|
|
a33e2675dd | ||
|
|
ed81cc65ce | ||
|
|
fd8f602da2 | ||
| 4b4be6fa0f | |||
|
|
ab48b91f26 | ||
|
|
8ae2e82d18 | ||
|
|
d43d96e8eb | ||
|
|
d207c65df7 | ||
| 6138fc47b1 | |||
| d9aa6305da | |||
|
|
80aa2873f7 | ||
|
|
3b7b3b8f74 | ||
|
|
b4e4c97cbf | ||
|
|
e3c0add012 | ||
| 7237523237 | |||
| 81d9d3d66c | |||
| 86f8884e45 | |||
|
|
88bc883fe1 | ||
| 482077c862 | |||
|
|
5c4fd4024b | ||
| 75ff858f68 | |||
| 64fb077d65 | |||
|
|
d2e45038fd | ||
|
|
e3c1143c0c | ||
| fb4dc7fa83 | |||
|
|
3490eff76e | ||
|
|
54cdef691a | ||
|
|
4f8c12292b | ||
|
|
1fd357a49c | ||
|
|
e144d62cdb | ||
|
|
a4472b4f9c | ||
|
|
8de3405aa5 | ||
|
|
afd450eee6 | ||
|
|
6798d896bc | ||
|
|
b38c81b08c | ||
| 364e7a273f | |||
| a0c5334f57 | |||
|
|
41bb159542 | ||
|
|
706bb8584d | ||
|
|
a43b12bc17 | ||
|
|
cbba670eb2 | ||
|
|
ac182a7c77 | ||
|
|
9c4e8d256a | ||
|
|
4a8fa68632 | ||
|
|
6bf30b4dc6 | ||
| 2636858fc0 | |||
| 250cccc0b9 | |||
|
|
f15ed86852 | ||
|
|
33a2cee5ef | ||
|
|
bd94a26a15 | ||
|
|
02788d3bfc | ||
| fc8afe6624 | |||
|
|
93a61232cf | ||
|
|
c4cd53277f | ||
|
|
f3e5a03a0f | ||
|
|
bf62c25c93 | ||
| b9a7b6a889 | |||
|
|
1ffd8c1a5a | ||
|
|
58bd4a1765 | ||
|
|
278800c077 | ||
|
|
8142c0398f | ||
|
|
5e2224dfdb | ||
|
|
a206bb9e5c | ||
|
|
79feb73b27 | ||
|
|
97678368e1 | ||
|
|
678e09e536 | ||
|
|
377e5250f9 | ||
| 003f150a74 | |||
|
|
54e289ce56 | ||
| f26d336195 | |||
|
|
4fa7313b4f | ||
|
|
a0a2998fcb | ||
|
|
5007939d1c | ||
|
|
c10d39ba83 | ||
|
|
d0e0af9ed7 | ||
|
|
1a4a6fd5a3 | ||
|
|
f1c3818249 | ||
|
|
eabd8b21a1 | ||
|
|
09a294b38f | ||
| 949044fcdb | |||
| 272fd9b425 | |||
| 6787b015c5 | |||
|
|
8a21b3da9b | ||
| 1473fa64ed | |||
| 8eb27555f5 | |||
| 05c90689e5 | |||
| 07adbfd68c | |||
| 375cf3286a | |||
| 2fec029cb7 | |||
| 0f4ea4786f | |||
|
|
d74e42c281 | ||
|
|
b926880ae9 | ||
|
|
ffd13e8362 | ||
| baf8a642b8 | |||
| 6b00c8a6be | |||
| f4fe4ec019 | |||
| 17cc1fa8a3 | |||
| c91cd55586 | |||
| 24b9dc98d0 | |||
| 909af77649 | |||
| c8b1bf6d08 | |||
| 7f622de857 | |||
| b586e311be | |||
| 11e3c2281e | |||
| a40fa37bfd | |||
| 0162e1a9b7 | |||
| 68df1d5332 | |||
|
|
bd8a26a5ae | ||
|
|
4125c93267 | ||
| b4ebf2ccc1 | |||
| 6941850d90 | |||
| 329ebd8633 | |||
|
|
2ac631a18d | ||
|
|
0e47461c3c | ||
| 1d02894957 | |||
| 76aff5d627 | |||
|
|
42710ecf60 | ||
|
|
e98ebe3e84 | ||
|
|
716d864343 | ||
| 432ec7b9b1 | |||
| 7b74098a09 | |||
|
|
39c8db0846 | ||
|
|
919f273970 | ||
|
|
dd0fc19f5d | ||
| ed3edd5eff | |||
|
|
f45aff61fd | ||
| e4cbbbcdea | |||
|
|
88006ec5f4 | ||
| 49821abc12 | |||
|
|
ee8075e4b8 | ||
| f41f066abc | |||
| 7575909bad | |||
| 8d044e684a | |||
| 88374e5da4 | |||
| c057c2ae21 | |||
| c5fd0c1253 | |||
| fe35e37371 | |||
| fab4645132 | |||
| 5d87a86a33 | |||
| f281ceb5ef | |||
| 2a77e33206 | |||
|
|
fe3fe6398d | ||
| f6c1e591f0 | |||
| f28b4cbad0 | |||
|
|
f62740f20b | ||
| c15c74895b | |||
| 7db6fcbcc8 | |||
| 7fe2ae2acc | |||
| ab7afb3d3b | |||
|
|
389e06cf9e | ||
|
|
05abc0e678 | ||
|
|
1fa9cbeb7a | ||
|
|
928746fe88 | ||
| 96ef72d300 | |||
| 9f6c86dbbc | |||
| d70112216f | |||
|
|
e26ca8e81a | ||
|
|
b5df09cfd2 | ||
|
|
163cb78fc9 | ||
|
|
bce9777eae | ||
|
|
af402330b2 | ||
|
|
fbcc149849 | ||
|
|
4c2a6407a1 | ||
|
|
331e0b55ee | ||
|
|
da0b379b69 | ||
|
|
18a145b69b | ||
| 464758073b | |||
|
|
dea5f3db9a | ||
| fdb724ae8b | |||
| f7ac9daaac | |||
| 183e35ac97 | |||
| 2f87d79713 | |||
| 3e0a9f3a94 | |||
|
|
52fbd9225d | ||
| dc27127322 | |||
|
|
92a4ed9529 | ||
|
|
275e9e6bcc | ||
|
|
94ce37a948 | ||
|
|
998aaffd70 | ||
|
|
515367f61f | ||
|
|
8e2c6bb642 | ||
| 08daef4dcd | |||
|
|
1fdac22bea | ||
| 2f182519a5 | |||
| b30073cb80 | |||
| b3a15de00b | |||
|
|
22a8cdb8d8 | ||
|
|
9acec97257 | ||
| 32ddeef6f0 | |||
| a646578128 | |||
| bf9b911cb2 | |||
|
|
7ece8da1db | ||
| a040dfc4ef | |||
| 7cc754f8b9 | |||
| 71a5966700 | |||
| a738f102a6 | |||
| 9f849608db | |||
|
|
7a253effa6 | ||
|
|
3c6c776828 | ||
|
|
2326cfcaa3 | ||
|
|
1f5cf3acff | ||
|
|
829cac0f6a | ||
|
|
b18ad0fefa | ||
|
|
2391e5b806 | ||
| 68616fe75a | |||
|
|
512667b850 | ||
|
|
6765fbb36e | ||
|
|
9732a3e65f | ||
| 2237dba3c5 | |||
| 95126a85d8 | |||
|
|
13e9d248e8 | ||
| 9aeda23ade | |||
| 16ede80fd4 | |||
| 7d728cb3ae | |||
|
|
bb84099072 | ||
|
|
bec82534f1 | ||
|
|
9c73dccd1e | ||
| 9fb25a2d33 | |||
|
|
175efdefde | ||
|
|
ff78f8eb27 | ||
|
|
df7006dc1e | ||
| bc6ae5562e | |||
|
|
46ee99c5f2 | ||
| c4dde028b2 | |||
|
|
b543fedfaa | ||
|
|
09371f0a5c | ||
| b4665309b9 | |||
| 218784a8de | |||
| 16ab7fc176 | |||
| 85cd830046 | |||
| 76a6f8c33e | |||
| da9aa71c20 | |||
| bf12f1f29a | |||
|
|
91209ad9e2 | ||
|
|
6b729be34e | ||
|
|
409a7a2d03 | ||
| 679c695700 | |||
|
|
135555e3ea | ||
|
|
9a87a62353 | ||
| 1b94986e9a | |||
| c8d3425293 | |||
| 0a49d45160 | |||
| 0171c3ca4d | |||
| e7f898f357 | |||
|
|
ba1fcd1f26 | ||
|
|
7941f5cafd | ||
| 6aca142696 | |||
| f85a3024ef | |||
| 161dc478ae | |||
| ae151a9311 | |||
|
|
8cbd542a75 | ||
| 18202045bf | |||
| 89bf0cbad7 | |||
| 4ff041854c | |||
|
|
b5c118eee5 | ||
| be2c446906 | |||
| 5c57841cd9 | |||
| 40ca642c07 | |||
|
|
6898e548a5 | ||
| 8c88e4e6a2 | |||
| 5ca4c5cc81 | |||
| eb0f2d521e | |||
| d68e423768 | |||
|
|
4ca7f9053f | ||
|
|
d61b90baa4 | ||
|
|
4a9c9ab1f3 | ||
|
|
6fb4fa7683 | ||
|
|
911eb60ae9 | ||
| c116efd6f4 | |||
|
|
8e3c62a518 | ||
|
|
16bf2c9494 | ||
| 9a9a4dad01 | |||
|
|
07b5f5c00b | ||
|
|
b60225cb74 | ||
|
|
250b2e9509 | ||
| f68c1c95eb | |||
|
|
e71a518b49 | ||
| 5531f6e87a | |||
|
|
a8ec29d2ed | ||
| 19aab99398 | |||
| 0b1ed48471 | |||
|
|
0b9c6320eb | ||
|
|
03b20787b3 | ||
|
|
9f8f411c33 | ||
|
|
76438459f7 | ||
| 92ea6026f8 | |||
|
|
9031ea6b3f | ||
|
|
b576440612 | ||
|
|
a4891131fc | ||
|
|
5f57ce54aa | ||
|
|
f1859a5877 | ||
| 94d038d563 | |||
| ffae1e583b | |||
| 8916cbd097 | |||
| 790a75ac87 | |||
| d4f8165b87 | |||
|
|
8948d0fb18 | ||
|
|
908da0bc47 | ||
|
|
aae17208b0 | ||
|
|
cbd8918c61 | ||
|
|
f2b4f9e8fc | ||
|
|
a2a11647bb | ||
|
|
1d9c275b61 | ||
|
|
52468928c6 | ||
|
|
3d9471779a | ||
|
|
155c4b00d5 | ||
|
|
f4b45e9eae | ||
|
|
d836b58b2d | ||
|
|
4fc747f1c6 | ||
|
|
b27f2c43ae | ||
|
|
c5f947e14a | ||
|
|
67c41ab3ee | ||
|
|
9f330e2245 | ||
|
|
d785262312 | ||
|
|
27faaabcf2 | ||
| 226d68cb1c | |||
|
|
e776cc2319 | ||
|
|
3cdb1e511d | ||
| a4f867665f | |||
|
|
6b0583b139 | ||
| 2aad0c65c2 | |||
| 6b646378b6 | |||
| 747ad6387b | |||
| 228e66315c | |||
|
|
97c283797a | ||
|
|
eb1fade6f5 | ||
|
|
8d6071f794 | ||
|
|
8a109e34f8 | ||
|
|
fd72d72692 | ||
|
|
63ffacff96 | ||
|
|
1b4bb6fccc | ||
|
|
9b492b5e0d | ||
|
|
8427bd9f6b | ||
|
|
2c915161d5 | ||
|
|
75b06ca770 | ||
|
|
c3468a3387 | ||
|
|
a2f4adb647 | ||
|
|
403f69df8b | ||
|
|
12cf10f97a | ||
|
|
6084befe2c | ||
|
|
1aa99ea613 | ||
|
|
d539c0f808 | ||
|
|
bc509806fb | ||
|
|
c52820550f | ||
|
|
98b30f90a1 | ||
|
|
4efbafc174 | ||
|
|
6d3fda50d3 | ||
|
|
70b936012f | ||
|
|
54917fbe6d | ||
|
|
abeb9f054d | ||
|
|
c6d6c5fb2a | ||
|
|
5b0d7f0012 | ||
|
|
d9043aab0a | ||
|
|
b9281b68ab | ||
|
|
5c6a20be4e | ||
|
|
1c0a65957d | ||
|
|
7c315624b1 | ||
|
|
0572caa528 | ||
|
|
4233040585 | ||
|
|
c27dc8e380 | ||
|
|
e746756e56 | ||
|
|
1829d1cd0b | ||
|
|
fb979e5639 | ||
|
|
e7d0a85ad5 | ||
|
|
a384711327 | ||
|
|
3fd4778a48 | ||
|
|
4841dc09b3 | ||
|
|
b3aa4fc776 | ||
|
|
a9b3b8b6f4 | ||
|
|
56ef196695 | ||
| 242238d341 | |||
| f66f6d38fe | |||
| d58077f58b | |||
| 4d4d6dbedf | |||
| f60b276916 | |||
| 87857fd499 | |||
| 3c371cd079 | |||
|
|
428b849bcc | ||
|
|
85f3b4f607 | ||
|
|
916396f855 | ||
|
|
211c8d2b04 | ||
|
|
92e274d3fd | ||
|
|
d511ea48d5 | ||
|
|
1aa4da1adf | ||
|
|
0e8b6b0b6b | ||
|
|
1a2c1b976f | ||
|
|
1cc242fa51 | ||
|
|
18dfdba15d | ||
|
|
b04ac4eec6 | ||
|
|
c009f0c891 | ||
|
|
d2dc0bd295 | ||
|
|
ddbb5b7f19 | ||
|
|
954c25090b | ||
|
|
0b6cc59de1 | ||
|
|
2271b5741d | ||
|
|
8a438b041f | ||
|
|
dd92fcc4d8 | ||
|
|
8f66ca0e16 | ||
|
|
895ba1d24a | ||
| e49b807bef | |||
|
|
73c15b5e93 | ||
|
|
e505ea8c51 | ||
|
|
21e7df7c3e | ||
|
|
2d72ca66a4 | ||
|
|
4725a30165 | ||
|
|
f3c977f1b3 | ||
|
|
9a0e7265c6 | ||
|
|
3f8e2fbe6b | ||
|
|
590b13e916 | ||
|
|
0f6aee56e5 | ||
|
|
daf18e7295 | ||
|
|
9bcc87f663 | ||
|
|
e7205ce0aa | ||
|
|
e3c4b2edc8 | ||
|
|
222a3b35a2 | ||
|
|
cd5dfd56b2 | ||
|
|
7d5c6b8222 | ||
|
|
4dbf4736e4 | ||
|
|
d50504181e | ||
|
|
c7e94dfcd1 | ||
|
|
a752b67ca1 | ||
|
|
078736337d | ||
|
|
de1058a28c | ||
|
|
740797a689 | ||
|
|
26328920a2 | ||
|
|
9c447bbdf9 | ||
|
|
fac85a889f | ||
|
|
f5d898c89e | ||
| 974a4b634a | |||
| 3127c83603 | |||
|
|
8d69e43f72 | ||
|
|
86df9e7a50 | ||
| 59ff9bf818 | |||
|
|
1641e32e3d | ||
|
|
a482087abd | ||
| bc5b15cec2 | |||
| 3787c25a77 | |||
|
|
0b06b499e4 | ||
|
|
04079dd57b | ||
|
|
34ac0c5ab3 | ||
| 0d904b229e | |||
| c0f887ff9d | |||
|
|
cf95075d01 | ||
|
|
d78a764d87 | ||
|
|
a368f4b722 | ||
|
|
803fe4568f | ||
|
|
1162d5dcc1 | ||
|
|
aa83058e39 | ||
|
|
061f205224 | ||
|
|
5d966f98df | ||
|
|
0037914db8 | ||
|
|
13d0115475 | ||
|
|
5bdb5ad2bf | ||
| a5d733c8df | |||
|
|
0b038e2997 | ||
|
|
5e46040db6 | ||
|
|
f2b04dd0f6 | ||
|
|
2177c1b40e | ||
|
|
d1f4cffe8f | ||
|
|
74ce441b90 | ||
|
|
5893aa2426 |
129
.github/workflows/build-app.yml
vendored
129
.github/workflows/build-app.yml
vendored
@@ -1,129 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
name: Build AUTO_MAA
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
pre_check:
|
||||
name: Pre Checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Repo Check
|
||||
id: repo_check
|
||||
run: |
|
||||
if [[ "$GITHUB_REPOSITORY" != "DLmaster361/AUTO_MAA" ]]; then
|
||||
echo "When forking this repository to make your own builds, you have to adjust this check."
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
build_AUTO_MAA:
|
||||
runs-on: windows-latest
|
||||
needs: pre_check
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 pytest
|
||||
pip install -r requirements.txt
|
||||
choco install innosetup
|
||||
echo "C:\Program Files (x86)\Inno Setup 6" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
- name: Package
|
||||
id: package
|
||||
run: |
|
||||
copy app\utils\package.py .\
|
||||
python package.py
|
||||
- name: Read version
|
||||
id: read_version
|
||||
run: |
|
||||
$MAIN_VERSION=(Get-Content -Path "version_info.txt" -TotalCount 1).Trim()
|
||||
"AUTO_MAA_version=$MAIN_VERSION" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
$UPDATER_VERSION=(Get-Content -Path "version_info.txt" -TotalCount 2 | Select-Object -Index 1).Trim()
|
||||
"updater_version=$UPDATER_VERSION" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: AUTO_MAA_${{ env.AUTO_MAA_version }}
|
||||
path: AUTO_MAA_${{ env.AUTO_MAA_version }}.zip
|
||||
- name: Upload Version_Info Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: version_info
|
||||
path: version_info.txt
|
||||
publish_release:
|
||||
name: Publish release
|
||||
needs: build_AUTO_MAA
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: AUTO_MAA_*
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
- name: Download Version_Info
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: version_info
|
||||
path: ./
|
||||
- name: Create release
|
||||
id: create_release
|
||||
run: |
|
||||
set -xe
|
||||
shopt -s nullglob
|
||||
NAME="$(sed 's/\r$//g' <(head -n 1 version_info.txt))"
|
||||
TAGNAME="$(sed 's/\r$//g' <(head -n 1 version_info.txt))"
|
||||
NOTES_MAIN="$(sed 's/\r$//g' <(tail -n +3 version_info.txt))"
|
||||
NOTES_TAIL="\`\`\`本release通过GitHub Actions自动构建\`\`\`"
|
||||
NOTES="$NOTES_MAIN<br><br>$NOTES_TAIL"
|
||||
if [ "${{ github.ref_name }}" == "main" ]; then
|
||||
PRERELEASE_FLAG=""
|
||||
else
|
||||
PRERELEASE_FLAG="--prerelease"
|
||||
fi
|
||||
gh release create "$TAGNAME" --target "main" --title "$NAME" --notes "$NOTES" $PRERELEASE_FLAG artifacts/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.WORKFLOW_TOKEN }}
|
||||
- name: Trigger MirrorChyanUploading
|
||||
run: |
|
||||
gh workflow run --repo $GITHUB_REPOSITORY mirrorchyan
|
||||
gh workflow run --repo $GITHUB_REPOSITORY mirrorchyan_release_note
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
21
.github/workflows/mirrorchyan.yml
vendored
21
.github/workflows/mirrorchyan.yml
vendored
@@ -1,21 +0,0 @@
|
||||
name: mirrorchyan
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mirrorchyan:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- id: uploading
|
||||
uses: MirrorChyan/uploading-action@v1
|
||||
with:
|
||||
filetype: latest-release
|
||||
filename: "AUTO_MAA*.zip"
|
||||
mirrorchyan_rid: AUTO_MAA
|
||||
|
||||
owner: DLmaster361
|
||||
repo: AUTO_MAA
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
upload_token: ${{ secrets.MirrorChyanUploadToken }}
|
||||
19
.github/workflows/mirrorchyan_release_note.yml
vendored
19
.github/workflows/mirrorchyan_release_note.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: mirrorchyan_release_note
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [edited]
|
||||
|
||||
jobs:
|
||||
mirrorchyan:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- id: uploading
|
||||
uses: MirrorChyan/release-note-action@v1
|
||||
with:
|
||||
mirrorchyan_rid: AUTO_MAA
|
||||
|
||||
upload_token: ${{ secrets.MirrorChyanUploadToken }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -1,8 +1,31 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
logs/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
.python-version
|
||||
list/
|
||||
uv.lock
|
||||
|
||||
# IDE and editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# User files
|
||||
config/
|
||||
data/
|
||||
debug/
|
||||
history/
|
||||
resources/notice.json
|
||||
resources/theme_image.json
|
||||
resources/images/Home/BannerTheme.jpg
|
||||
script/
|
||||
res/notice.json
|
||||
res/theme_image.json
|
||||
res/images/Home/BannerTheme.jpg
|
||||
104
README.md
104
README.md
@@ -1,102 +1,2 @@
|
||||
<h1 align="center">AUTO_MAA</h1>
|
||||
<p align="center">
|
||||
MAA多账号管理与自动化软件<br><br>
|
||||
<img alt="软件图标" src="https://github.com/DLmaster361/AUTO_MAA/blob/main/resources/images/AUTO_MAA.png">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/DLmaster361/AUTO_MAA/stargazers"><img alt="GitHub Stars" src="https://img.shields.io/github/stars/DLmaster361/AUTO_MAA?style=flat-square"></a>
|
||||
<a href="https://github.com/DLmaster361/AUTO_MAA/network"><img alt="GitHub Forks" src="https://img.shields.io/github/forks/DLmaster361/AUTO_MAA?style=flat-square"></a>
|
||||
<a href="https://github.com/DLmaster361/AUTO_MAA/releases/latest"><img alt="GitHub Downloads" src="https://img.shields.io/github/downloads/DLmaster361/AUTO_MAA/total?style=flat-square"></a>
|
||||
<a href="https://github.com/DLmaster361/AUTO_MAA/issues"><img alt="GitHub Issues" src="https://img.shields.io/github/issues/DLmaster361/AUTO_MAA?style=flat-square"></a>
|
||||
<a href="https://github.com/DLmaster361/AUTO_MAA/graphs/contributors"><img alt="GitHub Contributors" src="https://img.shields.io/github/contributors/DLmaster361/AUTO_MAA?style=flat-square"></a>
|
||||
<a href="https://github.com/DLmaster361/AUTO_MAA/blob/main/LICENSE"><img alt="GitHub License" src="https://img.shields.io/github/license/DLmaster361/AUTO_MAA?style=flat-square"></a>
|
||||
<a href="https://mirrorchyan.com/zh/projects?rid=AUTO_MAA"><img alt="mirrorc" src="https://img.shields.io/badge/Mirror%E9%85%B1-%239af3f6?logo=countingworkspro&logoColor=4f46e5"></a>
|
||||
</p>
|
||||
|
||||
## 软件介绍
|
||||
|
||||
### 性质
|
||||
|
||||
本软件是明日方舟第三方软件`MAA`的第三方工具,即第3<sup>3</sup>方软件。旨在优化MAA多账号功能体验,并通过一些方法解决MAA项目未能解决的部分问题,提高代理的稳定性。
|
||||
|
||||
### 原理
|
||||
|
||||
本软件可以存储多个明日方舟账号数据,并通过以下流程实现代理功能:
|
||||
|
||||
1. **配置:** 根据对应用户的配置信息,生成配置文件并将其导入MAA。
|
||||
2. **监测:** 在MAA开始代理后,持续读取MAA的日志以判断其运行状态。当软件认定MAA出现异常时,通过重启MAA使之仍能继续完成任务。
|
||||
3. **循环:** 重复上述步骤,使MAA依次完成各个用户的自动代理任务。
|
||||
|
||||
### 优势
|
||||
|
||||
- **节省运行开销:** 只需要一份MAA软件与一个模拟器,无需多开就能完成多账号代理,羸弱的电脑也能代理日常。
|
||||
- **自定义空间大:** 依靠高级用户配置模式,支持MAA几乎所有设置选项自定义,支持模拟器多开。
|
||||
- **调度方法自由:** 通过调度队列功能,自由实现MAA多开等多种调度方式。
|
||||
- **一键代理无忧:** 无须中途手动修改MAA配置,将繁琐交给AUTO_MAA,把游戏留给自己。
|
||||
- **代理结果复核:** 通过人工排查功能核实各用户代理情况,堵住自动代理的最后一丝风险。
|
||||
|
||||
## 重要声明
|
||||
|
||||
本开发团队承诺,不会修改明日方舟游戏本体与相关配置文件。本项目使用GPL开源,相关细则如下:
|
||||
|
||||
- **作者:** AUTO_MAA软件作者为DLmaster、DLmaster361或DLmaster_361,以上均指代同一人。
|
||||
- **使用:** AUTO_MAA使用者可以按自己的意愿自由使用本软件。依据GPL,对于由此可能产生的损失,AUTO_MAA项目组不负任何责任。
|
||||
- **分发:** AUTO_MAA允许任何人自由分发本软件,包括进行商业活动牟利。若为直接分发本软件,必须遵循GPL向接收者提供本软件项目地址、完整的软件源码与GPL协议原文(件);若为修改软件后进行分发,必须遵循GPL向接收者提供本软件项目地址、修改前的完整软件源码副本与GPL协议原文(件),违反者可能会被追究法律责任。
|
||||
- **传播:** AUTO_MAA原则上允许传播者自由传播本软件,但无论在何种传播过程中,不得删除项目作者与开发者所留版权声明,不得隐瞒项目作者与相关开发者的存在。由于软件性质,项目组不希望发现任何人在明日方舟官方媒体(包括官方媒体账号与森空岛社区等)或明日方舟游戏相关内容(包括同好群、线下活动与游戏内容讨论等)下提及AUTO_MAA或MAA,希望各位理解。
|
||||
- **衍生:** AUTO_MAA允许任何人对软件本体或软件部分代码进行二次开发或利用。但依据GPL,相关成果再次分发时也必须使用GPL或兼容的协议开源。
|
||||
- **贡献:** 不论是直接参与软件的维护编写,或是撰写文档、测试、反馈BUG、给出建议、参与讨论,都为AUTO_MAA项目的发展完善做出了不可忽视的贡献。项目组提倡各位贡献者遵照GitHub开源社区惯例,发布Issues参与项目。避免私信或私发邮件(安全性漏洞或敏感问题除外),以帮助更多用户。
|
||||
|
||||
以上细则是本项目对GPL的相关补充与强调。未提及的以GPL为准,发生冲突的以本细则为准。如有不清楚的部分,请发Issues询问。若发生纠纷,相关内容也没有在Issues上提及的,项目组拥有最终解释权。
|
||||
|
||||
**注意**
|
||||
|
||||
- 由于本软件有修改其它目录JSON文件等行为,使用前请将AUTO_MAA添加入Windows Defender信任区以及防病毒软件的信任区或开发者目录,避免被误杀。
|
||||
|
||||
---
|
||||
|
||||
# 使用方法
|
||||
|
||||
访问AUTO_MAA官方文档站以获取使用指南和项目相关信息
|
||||
|
||||
- [AUTO_MAA官方文档站](https://clozya.github.io/AUTOMAA_docs)
|
||||
|
||||
---
|
||||
|
||||
# 关于
|
||||
|
||||
## 项目开发情况
|
||||
|
||||
可在[《AUTO_MAA开发者协作文档》](https://docs.qq.com/aio/DQ3Z5eHNxdmxFQmZX)的`开发任务`页面中查看开发进度。
|
||||
|
||||
## 贡献者
|
||||
|
||||
感谢以下贡献者对本项目做出的贡献
|
||||
|
||||
<a href="https://github.com/DLmaster361/AUTO_MAA/graphs/contributors">
|
||||
|
||||
<img src="https://contrib.rocks/image?repo=DLmaster361/AUTO_MAA" />
|
||||
|
||||
</a>
|
||||
|
||||

|
||||
|
||||
感谢 [AoXuan (@ClozyA)](https://github.com/ClozyA) 为本项目提供的下载服务器
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#DLmaster361/AUTO_MAA&Date)
|
||||
|
||||
## 交流与赞助
|
||||
|
||||
欢迎加入AUTO_MAA项目组,欢迎反馈bug
|
||||
|
||||
- QQ交流群:[957750551](https://qm.qq.com/q/bd9fISNoME)
|
||||
|
||||
---
|
||||
|
||||
如果喜欢这个项目的话,给作者来杯咖啡吧!
|
||||
|
||||

|
||||
TEST
|
||||
TEST
|
||||
@@ -1,49 +1,34 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA主程序包
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__version__ = "5.0.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
from .core import QueueConfig, MaaConfig, MaaUserConfig, Task, TaskManager, MainTimer
|
||||
from .models import MaaManager
|
||||
from .services import Notify, Crypto, System
|
||||
from .ui import AUTO_MAA
|
||||
|
||||
__all__ = [
|
||||
"QueueConfig",
|
||||
"MaaConfig",
|
||||
"MaaUserConfig",
|
||||
"Task",
|
||||
"TaskManager",
|
||||
"MainTimer",
|
||||
"MaaManager",
|
||||
"Notify",
|
||||
"Crypto",
|
||||
"System",
|
||||
"AUTO_MAA",
|
||||
]
|
||||
from .api import *
|
||||
from .core import *
|
||||
from .models import *
|
||||
from .services import *
|
||||
from .utils import *
|
||||
|
||||
__all__ = ["api", "core", "models", "services", "utils"]
|
||||
|
||||
47
app/api/__init__.py
Normal file
47
app/api/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
__version__ = "5.0.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
from .core import router as core_router
|
||||
from .info import router as info_router
|
||||
from .scripts import router as scripts_router
|
||||
from .plan import router as plan_router
|
||||
from .queue import router as queue_router
|
||||
from .dispatch import router as dispatch_router
|
||||
from .history import router as history_router
|
||||
from .setting import router as setting_router
|
||||
from .update import router as update_router
|
||||
|
||||
__all__ = [
|
||||
"core_router",
|
||||
"info_router",
|
||||
"scripts_router",
|
||||
"plan_router",
|
||||
"queue_router",
|
||||
"dispatch_router",
|
||||
"history_router",
|
||||
"setting_router",
|
||||
"update_router",
|
||||
]
|
||||
95
app/api/core.py
Normal file
95
app/api/core.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import time
|
||||
import asyncio
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.core import Config, Broadcast, TaskManager
|
||||
from app.services import System
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/core", tags=["核心信息"])
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def connect_websocket(websocket: WebSocket):
|
||||
|
||||
if Config.websocket is not None:
|
||||
await websocket.close(code=1000, reason="已有连接")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
Config.websocket = websocket
|
||||
last_pong = time.monotonic()
|
||||
last_ping = time.monotonic()
|
||||
data = {}
|
||||
|
||||
await TaskManager.start_startup_queue()
|
||||
|
||||
while True:
|
||||
|
||||
try:
|
||||
|
||||
data = await asyncio.wait_for(websocket.receive_json(), timeout=1000005.0)
|
||||
if data.get("type") == "Signal" and "Pong" in data.get("data", {}):
|
||||
last_pong = time.monotonic()
|
||||
elif data.get("type") == "Signal" and "Ping" in data.get("data", {}):
|
||||
await websocket.send_json(
|
||||
WebSocketMessage(
|
||||
id="Main", type="Signal", data={"Pong": "无描述"}
|
||||
).model_dump()
|
||||
)
|
||||
else:
|
||||
await Broadcast.put(data)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
if last_pong < last_ping:
|
||||
await websocket.close(code=1000, reason="Ping超时")
|
||||
break
|
||||
await websocket.send_json(
|
||||
WebSocketMessage(
|
||||
id="Main", type="Signal", data={"Ping": "无描述"}
|
||||
).model_dump()
|
||||
)
|
||||
last_ping = time.monotonic()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
Config.websocket = None
|
||||
await System.set_power("KillSelf")
|
||||
|
||||
|
||||
@router.post("/close")
|
||||
async def close():
|
||||
"""关闭后端程序"""
|
||||
|
||||
try:
|
||||
await System.set_power("KillSelf")
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
87
app/api/dispatch.py
Normal file
87
app/api/dispatch.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config, TaskManager
|
||||
from app.services import System
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/dispatch", tags=["任务调度"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/start", summary="添加任务", response_model=TaskCreateOut, status_code=200
|
||||
)
|
||||
async def add_task(task: TaskCreateIn = Body(...)) -> TaskCreateOut:
|
||||
|
||||
try:
|
||||
task_id = await TaskManager.add_task(task.mode, task.taskId)
|
||||
except Exception as e:
|
||||
return TaskCreateOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
websocketId="",
|
||||
)
|
||||
return TaskCreateOut(websocketId=str(task_id))
|
||||
|
||||
|
||||
@router.post("/stop", summary="中止任务", response_model=OutBase, status_code=200)
|
||||
async def stop_task(task: DispatchIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await TaskManager.stop_task(task.taskId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/set/power", summary="设置电源标志", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def set_power(task: PowerIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
Config.power_sign = task.signal
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cancel/power", summary="取消电源任务", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def cancel_power_task() -> OutBase:
|
||||
|
||||
try:
|
||||
await System.cancel_power_task()
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
88
app/api/history.py
Normal file
88
app/api/history.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/history", tags=["历史记录"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/search",
|
||||
summary="搜索历史记录总览信息",
|
||||
response_model=HistorySearchOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def search_history(history: HistorySearchIn) -> HistorySearchOut:
|
||||
|
||||
try:
|
||||
data = await Config.search_history(
|
||||
history.mode,
|
||||
datetime.strptime(history.start_date, "%Y-%m-%d").date(),
|
||||
datetime.strptime(history.end_date, "%Y-%m-%d").date(),
|
||||
)
|
||||
for date, users in data.items():
|
||||
for user, records in users.items():
|
||||
record = await Config.merge_statistic_info(records)
|
||||
# 安全检查:确保 index 字段存在
|
||||
if "index" not in record:
|
||||
record["index"] = []
|
||||
record["index"] = [HistoryIndexItem(**_) for _ in record["index"]]
|
||||
record = HistoryData(**record)
|
||||
data[date][user] = record
|
||||
except Exception as e:
|
||||
return HistorySearchOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
data={},
|
||||
)
|
||||
return HistorySearchOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/data",
|
||||
summary="从指定文件内获取历史记录数据",
|
||||
response_model=HistoryDataGetOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_history_data(history: HistoryDataGetIn = Body(...)) -> HistoryDataGetOut:
|
||||
|
||||
try:
|
||||
path = Path(history.jsonPath)
|
||||
data = await Config.merge_statistic_info([path])
|
||||
data.pop("index", None)
|
||||
data["log_content"] = path.with_suffix(".log").read_text(encoding="utf-8")
|
||||
data = HistoryData(**data)
|
||||
except Exception as e:
|
||||
return HistoryDataGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
data=HistoryData(**{}),
|
||||
)
|
||||
return HistoryDataGetOut(data=data)
|
||||
215
app/api/info.py
Normal file
215
app/api/info.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/info", tags=["信息获取"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/version",
|
||||
summary="获取后端git版本信息",
|
||||
response_model=VersionOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_git_version() -> VersionOut:
|
||||
|
||||
try:
|
||||
is_latest, commit_hash, commit_time = await Config.get_git_version()
|
||||
except Exception as e:
|
||||
return VersionOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
if_need_update=False,
|
||||
current_hash="unknown",
|
||||
current_time="unknown",
|
||||
current_version=Config.version(),
|
||||
)
|
||||
return VersionOut(
|
||||
if_need_update=not is_latest,
|
||||
current_hash=commit_hash,
|
||||
current_time=commit_time,
|
||||
current_version=Config.version(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/combox/stage",
|
||||
summary="获取关卡号下拉框信息",
|
||||
response_model=ComboBoxOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_stage_combox(
|
||||
stage: GetStageIn = Body(..., description="关卡号类型")
|
||||
) -> ComboBoxOut:
|
||||
|
||||
try:
|
||||
raw_data = await Config.get_stage_info(stage.type)
|
||||
data = (
|
||||
[ComboBoxItem(**item) for item in raw_data if isinstance(item, dict)]
|
||||
if raw_data
|
||||
else []
|
||||
)
|
||||
except Exception as e:
|
||||
return ComboBoxOut(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
|
||||
)
|
||||
return ComboBoxOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/combox/script",
|
||||
summary="获取脚本下拉框信息",
|
||||
response_model=ComboBoxOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_script_combox() -> ComboBoxOut:
|
||||
|
||||
try:
|
||||
raw_data = await Config.get_script_combox()
|
||||
data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
|
||||
except Exception as e:
|
||||
return ComboBoxOut(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
|
||||
)
|
||||
return ComboBoxOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/combox/task",
|
||||
summary="获取可选任务下拉框信息",
|
||||
response_model=ComboBoxOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_task_combox() -> ComboBoxOut:
|
||||
|
||||
try:
|
||||
raw_data = await Config.get_task_combox()
|
||||
data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
|
||||
except Exception as e:
|
||||
return ComboBoxOut(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
|
||||
)
|
||||
return ComboBoxOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/combox/plan",
|
||||
summary="获取可选计划下拉框信息",
|
||||
response_model=ComboBoxOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_plan_combox() -> ComboBoxOut:
|
||||
|
||||
try:
|
||||
raw_data = await Config.get_plan_combox()
|
||||
data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
|
||||
except Exception as e:
|
||||
return ComboBoxOut(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
|
||||
)
|
||||
return ComboBoxOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notice/get", summary="获取通知信息", response_model=NoticeOut, status_code=200
|
||||
)
|
||||
async def get_notice_info() -> NoticeOut:
|
||||
|
||||
try:
|
||||
if_need_show, data = await Config.get_notice()
|
||||
except Exception as e:
|
||||
return NoticeOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
if_need_show=False,
|
||||
data={},
|
||||
)
|
||||
return NoticeOut(if_need_show=if_need_show, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notice/confirm", summary="确认通知", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def confirm_notice() -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.set("Data", "IfShowNotice", False)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
# @router.post(
|
||||
# "/apps_info", summary="获取可下载应用信息", response_model=InfoOut, status_code=200
|
||||
# )
|
||||
# async def get_apps_info() -> InfoOut:
|
||||
|
||||
# try:
|
||||
# data = await Config.get_server_info("apps_info")
|
||||
# except Exception as e:
|
||||
# return InfoOut(
|
||||
# code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data={}
|
||||
# )
|
||||
# return InfoOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webconfig",
|
||||
summary="获取配置分享中心的配置信息",
|
||||
response_model=InfoOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_web_config() -> InfoOut:
|
||||
|
||||
try:
|
||||
data = await Config.get_web_config()
|
||||
except Exception as e:
|
||||
return InfoOut(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data={}
|
||||
)
|
||||
return InfoOut(data={"WebConfig": data})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/get/overview", summary="信息总览", response_model=InfoOut, status_code=200
|
||||
)
|
||||
async def get_overview() -> InfoOut:
|
||||
try:
|
||||
stage = await Config.get_stage_info("Info")
|
||||
proxy = await Config.get_proxy_overview()
|
||||
except Exception as e:
|
||||
return InfoOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
data={"Stage": [], "Proxy": []},
|
||||
)
|
||||
return InfoOut(data={"Stage": stage, "Proxy": proxy})
|
||||
106
app/api/plan.py
Normal file
106
app/api/plan.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/plan", tags=["计划管理"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/add", summary="添加计划表", response_model=PlanCreateOut, status_code=200
|
||||
)
|
||||
async def add_plan(plan: PlanCreateIn = Body(...)) -> PlanCreateOut:
|
||||
|
||||
try:
|
||||
uid, config = await Config.add_plan(plan.type)
|
||||
data = MaaPlanConfig(**(await config.toDict()))
|
||||
except Exception as e:
|
||||
return PlanCreateOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
planId="",
|
||||
data=MaaPlanConfig(**{}),
|
||||
)
|
||||
return PlanCreateOut(planId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post("/get", summary="查询计划表", response_model=PlanGetOut, status_code=200)
|
||||
async def get_plan(plan: PlanGetIn = Body(...)) -> PlanGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_plan(plan.planId)
|
||||
index = [PlanIndexItem(**_) for _ in index]
|
||||
data = {uid: MaaPlanConfig(**cfg) for uid, cfg in data.items()}
|
||||
except Exception as e:
|
||||
return PlanGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return PlanGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update", summary="更新计划表配置信息", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_plan(plan: PlanUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_plan(plan.planId, plan.data.model_dump(exclude_unset=True))
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/delete", summary="删除计划表", response_model=OutBase, status_code=200)
|
||||
async def delete_plan(plan: PlanDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_plan(plan.planId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/order", summary="重新排序计划表", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def reorder_plan(plan: PlanReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_plan(plan.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
259
app/api/queue.py
Normal file
259
app/api/queue.py
Normal file
@@ -0,0 +1,259 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/queue", tags=["调度队列管理"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/add", summary="添加调度队列", response_model=QueueCreateOut, status_code=200
|
||||
)
|
||||
async def add_queue() -> QueueCreateOut:
|
||||
|
||||
try:
|
||||
uid, config = await Config.add_queue()
|
||||
data = QueueConfig(**(await config.toDict()))
|
||||
except Exception as e:
|
||||
return QueueCreateOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
queueId="",
|
||||
data=QueueConfig(**{}),
|
||||
)
|
||||
return QueueCreateOut(queueId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/get", summary="查询调度队列配置信息", response_model=QueueGetOut, status_code=200
|
||||
)
|
||||
async def get_queues(queue: QueueGetIn = Body(...)) -> QueueGetOut:
|
||||
|
||||
try:
|
||||
index, config = await Config.get_queue(queue.queueId)
|
||||
index = [QueueIndexItem(**_) for _ in index]
|
||||
data = {uid: QueueConfig(**cfg) for uid, cfg in config.items()}
|
||||
except Exception as e:
|
||||
return QueueGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return QueueGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update", summary="更新调度队列配置信息", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_queue(queue: QueueUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_queue(
|
||||
queue.queueId, queue.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/delete", summary="删除调度队列", response_model=OutBase, status_code=200)
|
||||
async def delete_queue(queue: QueueDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_queue(queue.queueId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/order", summary="重新排序", response_model=OutBase, status_code=200)
|
||||
async def reorder_queue(script: QueueReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_queue(script.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/time/get", summary="查询定时项", response_model=TimeSetGetOut, status_code=200
|
||||
)
|
||||
async def get_time_set(time: TimeSetGetIn = Body(...)) -> TimeSetGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_time_set(time.queueId, time.timeSetId)
|
||||
index = [TimeSetIndexItem(**_) for _ in index]
|
||||
data = {uid: TimeSet(**cfg) for uid, cfg in data.items()}
|
||||
except Exception as e:
|
||||
return TimeSetGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return TimeSetGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/time/add", summary="添加定时项", response_model=TimeSetCreateOut, status_code=200
|
||||
)
|
||||
async def add_time_set(time: QueueSetInBase = Body(...)) -> TimeSetCreateOut:
|
||||
|
||||
uid, config = await Config.add_time_set(time.queueId)
|
||||
data = TimeSet(**(await config.toDict()))
|
||||
return TimeSetCreateOut(timeSetId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/time/update", summary="更新定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_time_set(time: TimeSetUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_time_set(
|
||||
time.queueId, time.timeSetId, time.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/time/delete", summary="删除定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def delete_time_set(time: TimeSetDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_time_set(time.queueId, time.timeSetId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/time/order", summary="重新排序定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def reorder_time_set(time: TimeSetReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_time_set(time.queueId, time.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item/get", summary="查询队列项", response_model=QueueItemGetOut, status_code=200
|
||||
)
|
||||
async def get_item(item: QueueItemGetIn = Body(...)) -> QueueItemGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_queue_item(item.queueId, item.queueItemId)
|
||||
index = [QueueItemIndexItem(**_) for _ in index]
|
||||
data = {uid: QueueItem(**cfg) for uid, cfg in data.items()}
|
||||
except Exception as e:
|
||||
return QueueItemGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return QueueItemGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item/add",
|
||||
summary="添加队列项",
|
||||
response_model=QueueItemCreateOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def add_item(item: QueueSetInBase = Body(...)) -> QueueItemCreateOut:
|
||||
|
||||
uid, config = await Config.add_queue_item(item.queueId)
|
||||
data = QueueItem(**(await config.toDict()))
|
||||
return QueueItemCreateOut(queueItemId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item/update", summary="更新队列项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_item(item: QueueItemUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_queue_item(
|
||||
item.queueId, item.queueItemId, item.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item/delete", summary="删除队列项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def delete_item(item: QueueItemDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_queue_item(item.queueId, item.queueItemId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item/order", summary="重新排序队列项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def reorder_item(item: QueueItemReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_queue_item(item.queueId, item.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
283
app/api/scripts.py
Normal file
283
app/api/scripts.py
Normal file
@@ -0,0 +1,283 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import uuid
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/scripts", tags=["脚本管理"])
|
||||
|
||||
|
||||
SCRIPT_BOOK = {"MaaConfig": MaaConfig, "GeneralConfig": GeneralConfig}
|
||||
USER_BOOK = {"MaaConfig": MaaUserConfig, "GeneralConfig": GeneralUserConfig}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/add", summary="添加脚本", response_model=ScriptCreateOut, status_code=200
|
||||
)
|
||||
async def add_script(script: ScriptCreateIn = Body(...)) -> ScriptCreateOut:
|
||||
|
||||
try:
|
||||
uid, config = await Config.add_script(script.type)
|
||||
data = SCRIPT_BOOK[type(config).__name__](**(await config.toDict()))
|
||||
except Exception as e:
|
||||
return ScriptCreateOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
scriptId="",
|
||||
data=GeneralConfig(**{}),
|
||||
)
|
||||
return ScriptCreateOut(scriptId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/get", summary="查询脚本配置信息", response_model=ScriptGetOut, status_code=200
|
||||
)
|
||||
async def get_script(script: ScriptGetIn = Body(...)) -> ScriptGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_script(script.scriptId)
|
||||
index = [ScriptIndexItem(**_) for _ in index]
|
||||
data = {
|
||||
uid: SCRIPT_BOOK[next((_.type for _ in index if _.uid == uid), "General")](
|
||||
**cfg
|
||||
)
|
||||
for uid, cfg in data.items()
|
||||
}
|
||||
except Exception as e:
|
||||
return ScriptGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return ScriptGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update", summary="更新脚本配置信息", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_script(script: ScriptUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_script(
|
||||
script.scriptId, script.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/delete", summary="删除脚本", response_model=OutBase, status_code=200)
|
||||
async def delete_script(script: ScriptDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_script(script.scriptId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/order", summary="重新排序脚本", response_model=OutBase, status_code=200)
|
||||
async def reorder_script(script: ScriptReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_script(script.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import/file", summary="从文件加载脚本", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def import_script_from_file(script: ScriptFileIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.import_script_from_file(script.scriptId, script.jsonFile)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/export/file", summary="导出脚本到文件", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def export_script_to_file(script: ScriptFileIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.export_script_to_file(script.scriptId, script.jsonFile)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import/web", summary="从网络加载脚本", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def import_script_from_web(script: ScriptUrlIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.import_script_from_web(script.scriptId, script.url)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/Upload/web", summary="上传脚本配置到网络", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def upload_script_to_web(script: ScriptUploadIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.upload_script_to_web(
|
||||
script.scriptId, script.config_name, script.author, script.description
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/get", summary="查询用户", response_model=UserGetOut, status_code=200
|
||||
)
|
||||
async def get_user(user: UserGetIn = Body(...)) -> UserGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_user(user.scriptId, user.userId)
|
||||
index = [UserIndexItem(**_) for _ in index]
|
||||
data = {
|
||||
uid: USER_BOOK[
|
||||
type(Config.ScriptConfig[uuid.UUID(user.scriptId)]).__name__
|
||||
](**cfg)
|
||||
for uid, cfg in data.items()
|
||||
}
|
||||
except Exception as e:
|
||||
return UserGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return UserGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/add", summary="添加用户", response_model=UserCreateOut, status_code=200
|
||||
)
|
||||
async def add_user(user: UserInBase = Body(...)) -> UserCreateOut:
|
||||
|
||||
try:
|
||||
uid, config = await Config.add_user(user.scriptId)
|
||||
data = USER_BOOK[type(Config.ScriptConfig[uuid.UUID(user.scriptId)]).__name__](
|
||||
**(await config.toDict())
|
||||
)
|
||||
except Exception as e:
|
||||
return UserCreateOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
userId="",
|
||||
data=GeneralUserConfig(**{}),
|
||||
)
|
||||
return UserCreateOut(userId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/update", summary="更新用户配置信息", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_user(user: UserUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_user(
|
||||
user.scriptId, user.userId, user.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/delete", summary="删除用户", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def delete_user(user: UserDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_user(user.scriptId, user.userId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/order", summary="重新排序用户", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def reorder_user(user: UserReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_user(user.scriptId, user.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/infrastructure",
|
||||
summary="导入基建配置文件",
|
||||
response_model=OutBase,
|
||||
status_code=200,
|
||||
)
|
||||
async def import_infrastructure(user: UserSetIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.set_infrastructure(user.scriptId, user.userId, user.jsonFile)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
198
app/api/setting.py
Normal file
198
app/api/setting.py
Normal file
@@ -0,0 +1,198 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.services import System, Notify
|
||||
from app.models.schema import *
|
||||
from app.models.config import Webhook as WebhookConfig
|
||||
|
||||
router = APIRouter(prefix="/api/setting", tags=["全局设置"])
|
||||
|
||||
|
||||
@router.post("/get", summary="查询配置", response_model=SettingGetOut, status_code=200)
|
||||
async def get_scripts() -> SettingGetOut:
|
||||
"""查询配置"""
|
||||
|
||||
try:
|
||||
data = await Config.get_setting()
|
||||
except Exception as e:
|
||||
return SettingGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
data=GlobalConfig(**{}),
|
||||
)
|
||||
return SettingGetOut(data=GlobalConfig(**data))
|
||||
|
||||
|
||||
@router.post("/update", summary="更新配置", response_model=OutBase, status_code=200)
|
||||
async def update_script(script: SettingUpdateIn = Body(...)) -> OutBase:
|
||||
"""更新配置"""
|
||||
|
||||
try:
|
||||
data = script.data.model_dump(exclude_unset=True)
|
||||
await Config.update_setting(data)
|
||||
|
||||
if data.get("Start", {}).get("IfSelfStart", None) is not None:
|
||||
await System.set_SelfStart()
|
||||
if data.get("Function", None) is not None:
|
||||
function = data["Function"]
|
||||
if function.get("IfAllowSleep", None) is not None:
|
||||
await System.set_Sleep()
|
||||
if function.get("IfSkipMumuSplashAds", None) is not None:
|
||||
MuMu_splash_ads_path = (
|
||||
Path(os.getenv("APPDATA") or "")
|
||||
/ "Netease/MuMuPlayer-12.0/data/startupImage"
|
||||
)
|
||||
if Config.get("Function", "IfSkipMumuSplashAds"):
|
||||
if MuMu_splash_ads_path.exists() and MuMu_splash_ads_path.is_dir():
|
||||
shutil.rmtree(MuMu_splash_ads_path)
|
||||
MuMu_splash_ads_path.touch()
|
||||
else:
|
||||
if MuMu_splash_ads_path.exists() and MuMu_splash_ads_path.is_file():
|
||||
MuMu_splash_ads_path.unlink()
|
||||
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/test_notify", summary="测试通知", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def test_notify() -> OutBase:
|
||||
"""测试通知"""
|
||||
|
||||
try:
|
||||
await Notify.send_test_notification()
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/get",
|
||||
summary="查询 webhook 配置",
|
||||
response_model=WebhookGetOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_webhook(webhook: WebhookGetIn = Body(...)) -> WebhookGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_webhook(None, None, webhook.webhookId)
|
||||
index = [WebhookIndexItem(**_) for _ in index]
|
||||
data = {uid: Webhook(**cfg) for uid, cfg in data.items()}
|
||||
except Exception as e:
|
||||
return WebhookGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return WebhookGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/add",
|
||||
summary="添加定时项",
|
||||
response_model=WebhookCreateOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def add_webhook() -> WebhookCreateOut:
|
||||
|
||||
uid, config = await Config.add_webhook(None, None)
|
||||
data = Webhook(**(await config.toDict()))
|
||||
return WebhookCreateOut(webhookId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/update", summary="更新定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_webhook(webhook: WebhookUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_webhook(
|
||||
None, None, webhook.webhookId, webhook.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/delete", summary="删除定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def delete_webhook(webhook: WebhookDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_webhook(None, None, webhook.webhookId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/order", summary="重新排序定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def reorder_webhook(webhook: WebhookReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_webhook(None, None, webhook.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/test", summary="测试Webhook配置", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def test_webhook(webhook: WebhookTestIn = Body(...)) -> OutBase:
|
||||
"""测试自定义Webhook"""
|
||||
|
||||
try:
|
||||
webhook_config = WebhookConfig()
|
||||
await webhook_config.load(webhook.data.model_dump())
|
||||
await Notify.WebhookPush(
|
||||
"AUTO-MAS Webhook测试",
|
||||
"这是一条测试消息,如果您收到此消息,说明Webhook配置正确!",
|
||||
webhook_config,
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(code=500, status="error", message=f"Webhook测试失败: {str(e)}")
|
||||
return OutBase()
|
||||
82
app/api/update.py
Normal file
82
app/api/update.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.services import Updater
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/update", tags=["软件更新"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/check", summary="检查更新", response_model=UpdateCheckOut, status_code=200
|
||||
)
|
||||
async def check_update(version: UpdateCheckIn = Body(...)) -> UpdateCheckOut:
|
||||
|
||||
try:
|
||||
if_need, latest_version, update_info = await Updater.check_update(
|
||||
current_version=version.current_version, if_force=version.if_force
|
||||
)
|
||||
except Exception as e:
|
||||
return UpdateCheckOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
if_need_update=False,
|
||||
latest_version="",
|
||||
update_info={},
|
||||
)
|
||||
return UpdateCheckOut(
|
||||
if_need_update=if_need, latest_version=latest_version, update_info=update_info
|
||||
)
|
||||
|
||||
|
||||
@router.post("/download", summary="下载更新", response_model=OutBase, status_code=200)
|
||||
async def download_update() -> OutBase:
|
||||
|
||||
try:
|
||||
task = asyncio.create_task(Updater.download_update())
|
||||
Config.temp_task.append(task)
|
||||
task.add_done_callback(lambda t: Config.temp_task.remove(t))
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/install", summary="安装更新", response_model=OutBase, status_code=200)
|
||||
async def install_update() -> OutBase:
|
||||
|
||||
try:
|
||||
task = asyncio.create_task(Updater.install_update())
|
||||
Config.temp_task.append(task)
|
||||
task.add_done_callback(lambda t: Config.temp_task.remove(t))
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
@@ -1,48 +1,41 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA核心组件包
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__version__ = "5.0.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
from .config import QueueConfig, MaaConfig, MaaUserConfig, Config
|
||||
from .main_info_bar import MainInfoBar
|
||||
from .network import Network
|
||||
from .task_manager import Task, TaskManager
|
||||
from .broadcast import Broadcast
|
||||
from .config import Config, MaaConfig, GeneralConfig, MaaUserConfig, GeneralUserConfig
|
||||
from .timer import MainTimer
|
||||
from .task_manager import TaskManager
|
||||
|
||||
__all__ = [
|
||||
"Broadcast",
|
||||
"Config",
|
||||
"QueueConfig",
|
||||
"MaaConfig",
|
||||
"MaaUserConfig",
|
||||
"MainInfoBar",
|
||||
"Network",
|
||||
"Task",
|
||||
"TaskManager",
|
||||
"GeneralConfig",
|
||||
"MainTimer",
|
||||
"TaskManager",
|
||||
"MaaUserConfig",
|
||||
"GeneralUserConfig",
|
||||
]
|
||||
|
||||
52
app/core/broadcast.py
Normal file
52
app/core/broadcast.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from typing import Set
|
||||
|
||||
from app.utils import get_logger
|
||||
|
||||
|
||||
logger = get_logger("消息广播")
|
||||
|
||||
|
||||
class _Broadcast:
|
||||
|
||||
def __init__(self):
|
||||
self.__subscribers: Set[asyncio.Queue] = set()
|
||||
|
||||
async def subscribe(self, queue: asyncio.Queue):
|
||||
"""订阅者注册"""
|
||||
self.__subscribers.add(queue)
|
||||
|
||||
async def unsubscribe(self, queue: asyncio.Queue):
|
||||
"""取消订阅"""
|
||||
self.__subscribers.remove(queue)
|
||||
|
||||
async def put(self, item):
|
||||
"""向所有订阅者广播消息"""
|
||||
for subscriber in self.__subscribers:
|
||||
await subscriber.put(deepcopy(item))
|
||||
|
||||
|
||||
Broadcast = _Broadcast()
|
||||
3113
app/core/config.py
3113
app/core/config.py
File diff suppressed because it is too large
Load Diff
@@ -1,68 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA信息通知栏
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from PySide6.QtCore import Qt
|
||||
from qfluentwidgets import InfoBar, InfoBarPosition
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
class _MainInfoBar:
|
||||
"""信息通知栏"""
|
||||
|
||||
def push_info_bar(self, mode: str, title: str, content: str, time: int):
|
||||
"""推送到信息通知栏"""
|
||||
if Config.main_window is None:
|
||||
logger.error("信息通知栏未设置父窗口")
|
||||
return None
|
||||
|
||||
# 定义模式到 InfoBar 方法的映射
|
||||
mode_mapping = {
|
||||
"success": InfoBar.success,
|
||||
"warning": InfoBar.warning,
|
||||
"error": InfoBar.error,
|
||||
"info": InfoBar.info,
|
||||
}
|
||||
|
||||
# 根据 mode 获取对应的 InfoBar 方法
|
||||
info_bar_method = mode_mapping.get(mode)
|
||||
if info_bar_method:
|
||||
info_bar_method(
|
||||
title=title,
|
||||
content=content,
|
||||
orient=Qt.Horizontal,
|
||||
isClosable=True,
|
||||
position=InfoBarPosition.TOP_RIGHT,
|
||||
duration=time,
|
||||
parent=Config.main_window,
|
||||
)
|
||||
else:
|
||||
logger.error(f"未知的通知栏模式: {mode}")
|
||||
|
||||
|
||||
MainInfoBar = _MainInfoBar()
|
||||
@@ -1,120 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA网络请求线程
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from PySide6.QtCore import QThread, QEventLoop, QTimer
|
||||
import time
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _Network(QThread):
|
||||
|
||||
max_retries = 3
|
||||
timeout = 10
|
||||
backoff_factor = 0.1
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.if_running = False
|
||||
self.mode = None
|
||||
self.url = None
|
||||
self.loop = QEventLoop()
|
||||
self.wait_loop = QEventLoop()
|
||||
|
||||
@logger.catch
|
||||
def run(self) -> None:
|
||||
"""运行网络请求线程"""
|
||||
|
||||
self.if_running = True
|
||||
|
||||
if self.mode == "get":
|
||||
self.get_json(self.url)
|
||||
elif self.mode == "get_file":
|
||||
self.get_file(self.url, self.path)
|
||||
|
||||
self.if_running = False
|
||||
|
||||
def set_info(self, mode: str, url: str, path: Path = None) -> None:
|
||||
"""设置网络请求信息"""
|
||||
|
||||
while self.if_running:
|
||||
QTimer.singleShot(self.backoff_factor * 1000, self.wait_loop.quit)
|
||||
self.wait_loop.exec()
|
||||
|
||||
self.mode = mode
|
||||
self.url = url
|
||||
self.path = path
|
||||
|
||||
self.stutus_code = None
|
||||
self.response_json = None
|
||||
self.error_message = None
|
||||
|
||||
def get_json(self, url: str) -> None:
|
||||
"""通过get方法获取json数据"""
|
||||
|
||||
response = None
|
||||
|
||||
for _ in range(self.max_retries):
|
||||
try:
|
||||
response = requests.get(url, timeout=self.timeout)
|
||||
self.stutus_code = response.status_code
|
||||
self.response_json = response.json()
|
||||
self.error_message = None
|
||||
break
|
||||
except Exception as e:
|
||||
self.stutus_code = response.status_code if response else None
|
||||
self.response_json = None
|
||||
self.error_message = str(e)
|
||||
time.sleep(self.backoff_factor)
|
||||
|
||||
self.loop.quit()
|
||||
|
||||
def get_file(self, url: str, path: Path) -> None:
|
||||
"""通过get方法下载文件"""
|
||||
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
with open(path, "wb") as file:
|
||||
file.write(response.content)
|
||||
self.stutus_code = response.status_code
|
||||
else:
|
||||
self.stutus_code = response.status_code
|
||||
self.error_message = "下载失败"
|
||||
|
||||
except Exception as e:
|
||||
self.stutus_code = response.status_code if response else None
|
||||
self.error_message = str(e)
|
||||
|
||||
self.loop.quit()
|
||||
|
||||
|
||||
Network = _Network()
|
||||
@@ -1,334 +1,384 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA业务调度器
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from PySide6.QtCore import QThread, QObject, Signal
|
||||
from qfluentwidgets import MessageBox
|
||||
from datetime import datetime
|
||||
from packaging import version
|
||||
from typing import Dict, Union
|
||||
import uuid
|
||||
import asyncio
|
||||
from functools import partial
|
||||
from typing import Dict, Optional, Literal
|
||||
|
||||
from .config import Config
|
||||
from .main_info_bar import MainInfoBar
|
||||
from .network import Network
|
||||
from app.models import MaaManager
|
||||
from .config import Config, MaaConfig, GeneralConfig, QueueConfig
|
||||
from app.services import System
|
||||
from app.models.schema import WebSocketMessage
|
||||
from app.utils import get_logger
|
||||
from app.task import *
|
||||
from app.utils.constants import POWER_SIGN_MAP
|
||||
|
||||
|
||||
class Task(QThread):
|
||||
"""业务线程"""
|
||||
|
||||
check_maa_version = Signal(str)
|
||||
push_info_bar = Signal(str, str, str, int)
|
||||
question = Signal(str, str)
|
||||
question_response = Signal(bool)
|
||||
update_user_info = Signal(str, dict)
|
||||
create_task_list = Signal(list)
|
||||
create_user_list = Signal(list)
|
||||
update_task_list = Signal(list)
|
||||
update_user_list = Signal(list)
|
||||
update_log_text = Signal(str)
|
||||
accomplish = Signal(list)
|
||||
|
||||
def __init__(
|
||||
self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]]
|
||||
):
|
||||
super(Task, self).__init__()
|
||||
|
||||
self.mode = mode
|
||||
self.name = name
|
||||
self.info = info
|
||||
|
||||
self.logs = []
|
||||
|
||||
self.question_response.connect(lambda: print("response"))
|
||||
|
||||
@logger.catch
|
||||
def run(self):
|
||||
|
||||
if "设置MAA" in self.mode:
|
||||
|
||||
logger.info(f"任务开始:设置{self.name}")
|
||||
self.push_info_bar.emit("info", "设置MAA", self.name, 3000)
|
||||
|
||||
self.task = MaaManager(
|
||||
self.mode,
|
||||
Config.member_dict[self.name],
|
||||
(None if "全局" in self.mode else self.info["SetMaaInfo"]["Path"]),
|
||||
)
|
||||
self.task.check_maa_version.connect(self.check_maa_version.emit)
|
||||
self.task.push_info_bar.connect(self.push_info_bar.emit)
|
||||
self.task.accomplish.connect(lambda: self.accomplish.emit([]))
|
||||
|
||||
self.task.run()
|
||||
|
||||
else:
|
||||
|
||||
self.task_list = [
|
||||
[
|
||||
(
|
||||
value
|
||||
if Config.member_dict[value]["Config"].get(
|
||||
Config.member_dict[value]["Config"].MaaSet_Name
|
||||
)
|
||||
== ""
|
||||
else f"{value} - {Config.member_dict[value]["Config"].get(Config.member_dict[value]["Config"].MaaSet_Name)}"
|
||||
),
|
||||
"等待",
|
||||
value,
|
||||
]
|
||||
for _, value in sorted(
|
||||
self.info["Queue"].items(), key=lambda x: int(x[0][7:])
|
||||
)
|
||||
if value != "禁用"
|
||||
]
|
||||
|
||||
self.create_task_list.emit(self.task_list)
|
||||
|
||||
for task in self.task_list:
|
||||
|
||||
if self.isInterruptionRequested():
|
||||
break
|
||||
|
||||
task[1] = "运行"
|
||||
self.update_task_list.emit(self.task_list)
|
||||
|
||||
if task[2] in Config.running_list:
|
||||
|
||||
task[1] = "跳过"
|
||||
self.update_task_list.emit(self.task_list)
|
||||
logger.info(f"跳过任务:{task[0]}")
|
||||
self.push_info_bar.emit("info", "跳过任务", task[0], 3000)
|
||||
continue
|
||||
|
||||
Config.running_list.append(task[2])
|
||||
logger.info(f"任务开始:{task[0]}")
|
||||
self.push_info_bar.emit("info", "任务开始", task[0], 3000)
|
||||
|
||||
if Config.member_dict[task[2]]["Type"] == "Maa":
|
||||
|
||||
self.task = MaaManager(
|
||||
self.mode[0:4],
|
||||
Config.member_dict[task[2]],
|
||||
)
|
||||
|
||||
self.task.check_maa_version.connect(self.check_maa_version.emit)
|
||||
self.task.question.connect(self.question.emit)
|
||||
self.question_response.disconnect()
|
||||
self.question_response.connect(self.task.question_response.emit)
|
||||
self.task.push_info_bar.connect(self.push_info_bar.emit)
|
||||
self.task.create_user_list.connect(self.create_user_list.emit)
|
||||
self.task.update_user_list.connect(self.update_user_list.emit)
|
||||
self.task.update_log_text.connect(self.update_log_text.emit)
|
||||
self.task.update_user_info.connect(self.update_user_info.emit)
|
||||
self.task.accomplish.connect(
|
||||
lambda log: self.task_accomplish(task[2], log)
|
||||
)
|
||||
|
||||
self.task.run()
|
||||
|
||||
Config.running_list.remove(task[2])
|
||||
|
||||
task[1] = "完成"
|
||||
self.update_task_list.emit(self.task_list)
|
||||
logger.info(f"任务完成:{task[0]}")
|
||||
self.push_info_bar.emit("info", "任务完成", task[0], 3000)
|
||||
|
||||
self.accomplish.emit(self.logs)
|
||||
|
||||
def task_accomplish(self, name: str, log: dict):
|
||||
"""保存保存任务结果"""
|
||||
|
||||
self.logs.append([name, log])
|
||||
self.task.deleteLater()
|
||||
logger = get_logger("业务调度")
|
||||
|
||||
|
||||
class _TaskManager(QObject):
|
||||
class _TaskManager:
|
||||
"""业务调度器"""
|
||||
|
||||
create_gui = Signal(Task)
|
||||
connect_gui = Signal(Task)
|
||||
|
||||
def __init__(self):
|
||||
super(_TaskManager, self).__init__()
|
||||
super().__init__()
|
||||
|
||||
self.task_dict: Dict[str, Task] = {}
|
||||
self.task_dict: Dict[uuid.UUID, asyncio.Task] = {}
|
||||
|
||||
def add_task(
|
||||
self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]]
|
||||
):
|
||||
"""添加任务"""
|
||||
async def add_task(
|
||||
self, mode: Literal["自动代理", "人工排查", "设置脚本"], uid: str
|
||||
) -> uuid.UUID:
|
||||
"""
|
||||
添加任务
|
||||
|
||||
if name in Config.running_list or name in self.task_dict:
|
||||
:param mode: 任务模式
|
||||
:param uid: 任务UID
|
||||
"""
|
||||
|
||||
logger.warning(f"任务已存在:{name}")
|
||||
MainInfoBar.push_info_bar("warning", "任务已存在", name, 5000)
|
||||
return None
|
||||
actual_id = uuid.UUID(uid)
|
||||
|
||||
logger.info(f"任务开始:{name}")
|
||||
MainInfoBar.push_info_bar("info", "任务开始", name, 3000)
|
||||
|
||||
Config.running_list.append(name)
|
||||
self.task_dict[name] = Task(mode, name, info)
|
||||
self.task_dict[name].check_maa_version.connect(self.check_maa_version)
|
||||
self.task_dict[name].question.connect(
|
||||
lambda title, content: self.push_dialog(name, title, content)
|
||||
)
|
||||
self.task_dict[name].push_info_bar.connect(MainInfoBar.push_info_bar)
|
||||
self.task_dict[name].update_user_info.connect(Config.change_user_info)
|
||||
self.task_dict[name].accomplish.connect(
|
||||
lambda logs: self.remove_task(mode, name, logs)
|
||||
)
|
||||
|
||||
if "新调度台" in mode:
|
||||
self.create_gui.emit(self.task_dict[name])
|
||||
|
||||
elif "主调度台" in mode:
|
||||
self.connect_gui.emit(self.task_dict[name])
|
||||
|
||||
self.task_dict[name].start()
|
||||
|
||||
def stop_task(self, name: str):
|
||||
"""中止任务"""
|
||||
|
||||
logger.info(f"中止任务:{name}")
|
||||
MainInfoBar.push_info_bar("info", "中止任务", name, 3000)
|
||||
|
||||
if name == "ALL":
|
||||
|
||||
for name in self.task_dict:
|
||||
|
||||
self.task_dict[name].task.requestInterruption()
|
||||
self.task_dict[name].requestInterruption()
|
||||
self.task_dict[name].quit()
|
||||
self.task_dict[name].wait()
|
||||
|
||||
elif name in self.task_dict:
|
||||
|
||||
self.task_dict[name].task.requestInterruption()
|
||||
self.task_dict[name].requestInterruption()
|
||||
self.task_dict[name].quit()
|
||||
self.task_dict[name].wait()
|
||||
|
||||
def remove_task(self, mode: str, name: str, logs: list):
|
||||
"""任务结束后的处理"""
|
||||
|
||||
logger.info(f"任务结束:{name}")
|
||||
MainInfoBar.push_info_bar("info", "任务结束", name, 3000)
|
||||
|
||||
self.task_dict[name].deleteLater()
|
||||
self.task_dict.pop(name)
|
||||
Config.running_list.remove(name)
|
||||
|
||||
if "调度队列" in name and "人工排查" not in mode:
|
||||
|
||||
if len(logs) > 0:
|
||||
time = logs[0][1]["Time"]
|
||||
history = ""
|
||||
for log in logs:
|
||||
history += f"任务名称:{log[0]},{log[1]["History"].replace("\n","\n ")}\n"
|
||||
Config.save_history(name, {"Time": time, "History": history})
|
||||
if mode == "设置脚本":
|
||||
if actual_id in Config.ScriptConfig:
|
||||
task_id = actual_id
|
||||
actual_id = None
|
||||
else:
|
||||
Config.save_history(
|
||||
name,
|
||||
{
|
||||
"Time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"History": "没有任务被执行",
|
||||
},
|
||||
for script_id, script in Config.ScriptConfig.items():
|
||||
if actual_id in script.UserData:
|
||||
task_id = script_id
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"任务 {uid} 无法找到对应脚本配置")
|
||||
elif actual_id in Config.QueueConfig:
|
||||
task_id = actual_id
|
||||
actual_id = None
|
||||
elif actual_id in Config.ScriptConfig:
|
||||
task_id = uuid.uuid4()
|
||||
else:
|
||||
raise ValueError(f"任务 {uid} 无法找到对应脚本配置")
|
||||
|
||||
if task_id in self.task_dict or (
|
||||
actual_id is not None and actual_id in self.task_dict
|
||||
):
|
||||
raise RuntimeError(f"任务 {task_id} 已在运行")
|
||||
|
||||
logger.info(f"创建任务: {task_id}, 模式: {mode}")
|
||||
self.task_dict[task_id] = asyncio.create_task(
|
||||
self.run_task(mode, task_id, actual_id)
|
||||
)
|
||||
self.task_dict[task_id].add_done_callback(
|
||||
lambda t: asyncio.create_task(self.remove_task(t, mode, task_id))
|
||||
)
|
||||
|
||||
return task_id
|
||||
|
||||
@logger.catch
|
||||
async def run_task(
|
||||
self, mode: str, task_id: uuid.UUID, actual_id: Optional[uuid.UUID]
|
||||
):
|
||||
|
||||
logger.info(f"开始运行任务: {task_id}, 模式: {mode}")
|
||||
|
||||
if mode == "设置脚本":
|
||||
|
||||
if isinstance(Config.ScriptConfig[task_id], MaaConfig):
|
||||
task_item = MaaManager(mode, task_id, actual_id, str(task_id))
|
||||
elif isinstance(Config.ScriptConfig[task_id], GeneralConfig):
|
||||
task_item = GeneralManager(mode, task_id, actual_id, str(task_id))
|
||||
else:
|
||||
logger.error(
|
||||
f"不支持的脚本类型: {type(Config.ScriptConfig[task_id]).__name__}"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Info",
|
||||
data={"Error": "脚本类型不支持"},
|
||||
).model_dump()
|
||||
)
|
||||
return
|
||||
|
||||
uid = actual_id or uuid.uuid4()
|
||||
self.task_dict[uid] = asyncio.create_task(task_item.run())
|
||||
self.task_dict[uid].add_done_callback(
|
||||
lambda t: asyncio.create_task(task_item.final_task(t))
|
||||
)
|
||||
self.task_dict[uid].add_done_callback(partial(self.task_dict.pop, uid))
|
||||
try:
|
||||
await self.task_dict[uid]
|
||||
except Exception as e:
|
||||
logger.error(f"任务 {task_id} 运行出错: {type(e).__name__}: {str(e)}")
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Info",
|
||||
data={"Error": f"任务运行时出错 {type(e).__name__}: {str(e)}"},
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
if (
|
||||
Config.queue_dict[name]["Config"].get(
|
||||
Config.queue_dict[name]["Config"].queueSet_AfterAccomplish
|
||||
)
|
||||
!= "None"
|
||||
):
|
||||
else:
|
||||
|
||||
from app.ui import ProgressRingMessageBox
|
||||
# 初始化任务列表
|
||||
if task_id in Config.QueueConfig:
|
||||
|
||||
mode_book = {
|
||||
"Shutdown": "关机",
|
||||
"Hibernate": "休眠",
|
||||
"Sleep": "睡眠",
|
||||
"KillSelf": "关闭AUTO_MAA",
|
||||
}
|
||||
task_list = []
|
||||
for queue_item in Config.QueueConfig[task_id].QueueItem.values():
|
||||
if queue_item.get("Info", "ScriptId") == "-":
|
||||
continue
|
||||
script_uid = uuid.UUID(queue_item.get("Info", "ScriptId"))
|
||||
|
||||
choice = ProgressRingMessageBox(
|
||||
Config.main_window,
|
||||
f"{mode_book[Config.queue_dict[name]["Config"].get(Config.queue_dict[name]["Config"].queueSet_AfterAccomplish)]}倒计时",
|
||||
)
|
||||
if choice.exec():
|
||||
System.set_power(
|
||||
Config.queue_dict[name]["Config"].get(
|
||||
Config.queue_dict[name]["Config"].queueSet_AfterAccomplish
|
||||
)
|
||||
task_list.append(
|
||||
{
|
||||
"script_id": str(script_uid),
|
||||
"status": "等待",
|
||||
"name": Config.ScriptConfig[script_uid].get("Info", "Name"),
|
||||
"user_list": [
|
||||
{
|
||||
"user_id": str(user_id),
|
||||
"status": "等待",
|
||||
"name": config.get("Info", "Name"),
|
||||
}
|
||||
for user_id, config in Config.ScriptConfig[
|
||||
script_uid
|
||||
].UserData.items()
|
||||
if config.get("Info", "Status")
|
||||
and config.get("Info", "RemainedDay") != 0
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def check_maa_version(self, v: str):
|
||||
"""检查MAA版本"""
|
||||
elif actual_id is not None and actual_id in Config.ScriptConfig:
|
||||
|
||||
Network.set_info(
|
||||
mode="get",
|
||||
url="https://mirrorchyan.com/api/resources/MAA/latest?user_agent=AutoMaaGui&os=win&arch=x64&channel=stable",
|
||||
)
|
||||
Network.start()
|
||||
Network.loop.exec()
|
||||
if Network.stutus_code == 200:
|
||||
maa_info = Network.response_json
|
||||
task_list = [
|
||||
{
|
||||
"script_id": str(actual_id),
|
||||
"status": "等待",
|
||||
"name": Config.ScriptConfig[actual_id].get("Info", "Name"),
|
||||
"user_list": [
|
||||
{
|
||||
"user_id": str(user_id),
|
||||
"status": "等待",
|
||||
"name": config.get("Info", "Name"),
|
||||
}
|
||||
for user_id, config in Config.ScriptConfig[
|
||||
actual_id
|
||||
].UserData.items()
|
||||
if config.get("Info", "Status")
|
||||
and config.get("Info", "RemainedDay") != 0
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id), type="Update", data={"task_dict": task_list}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
# 清理用户列表初值
|
||||
for task in task_list:
|
||||
task.pop("user_list", None)
|
||||
|
||||
for task in task_list:
|
||||
|
||||
script_id = uuid.UUID(task["script_id"])
|
||||
|
||||
# 检查任务是否在运行列表中
|
||||
if script_id in self.task_dict:
|
||||
|
||||
task["status"] = "跳过"
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Update",
|
||||
data={"task_list": task_list},
|
||||
).model_dump()
|
||||
)
|
||||
logger.info(f"跳过任务: {script_id}, 该任务已在运行列表中")
|
||||
continue
|
||||
|
||||
# 检查任务对应脚本是否仍存在
|
||||
if script_id in self.task_dict:
|
||||
|
||||
task["status"] = "异常"
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Update",
|
||||
data={"task_list": task_list},
|
||||
).model_dump()
|
||||
)
|
||||
logger.info(f"跳过任务: {script_id}, 该任务对应脚本已被删除")
|
||||
continue
|
||||
|
||||
# 标记为运行中
|
||||
task["status"] = "运行"
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id), type="Update", data={"task_list": task_list}
|
||||
).model_dump()
|
||||
)
|
||||
logger.info(f"任务开始: {script_id}")
|
||||
|
||||
if isinstance(Config.ScriptConfig[script_id], MaaConfig):
|
||||
task_item = MaaManager(mode, script_id, None, str(task_id))
|
||||
elif isinstance(Config.ScriptConfig[script_id], GeneralConfig):
|
||||
task_item = GeneralManager(mode, script_id, actual_id, str(task_id))
|
||||
else:
|
||||
logger.error(
|
||||
f"不支持的脚本类型: {type(Config.ScriptConfig[script_id]).__name__}"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Info",
|
||||
data={"Error": "脚本类型不支持"},
|
||||
).model_dump()
|
||||
)
|
||||
continue
|
||||
|
||||
self.task_dict[script_id] = asyncio.create_task(task_item.run())
|
||||
self.task_dict[script_id].add_done_callback(
|
||||
lambda t: asyncio.create_task(task_item.final_task(t))
|
||||
)
|
||||
self.task_dict[script_id].add_done_callback(
|
||||
partial(self.task_dict.pop, script_id)
|
||||
)
|
||||
try:
|
||||
await self.task_dict[script_id]
|
||||
task["status"] = "完成"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"任务 {script_id} 运行出错: {type(e).__name__}: {str(e)}"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Info",
|
||||
data={
|
||||
"Error": f"任务运行时出错 {type(e).__name__}: {str(e)}"
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
task["status"] = "异常"
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Update",
|
||||
data={"task_list": task_list},
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
async def stop_task(self, task_id: str) -> None:
|
||||
"""
|
||||
中止任务
|
||||
|
||||
:param task_id: 任务ID
|
||||
"""
|
||||
|
||||
logger.info(f"中止任务: {task_id}")
|
||||
|
||||
if task_id == "ALL":
|
||||
for task in self.task_dict.values():
|
||||
task.cancel()
|
||||
else:
|
||||
logger.warning(f"获取MAA版本信息时出错:{Network.error_message}")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning",
|
||||
"获取MAA版本信息时出错",
|
||||
f"网络错误:{Network.stutus_code}",
|
||||
5000,
|
||||
)
|
||||
return None
|
||||
uid = uuid.UUID(task_id)
|
||||
if uid not in self.task_dict:
|
||||
raise ValueError("任务未在运行")
|
||||
self.task_dict[uid].cancel()
|
||||
|
||||
if version.parse(maa_info["data"]["version_name"]) > version.parse(v):
|
||||
async def remove_task(
|
||||
self, task: asyncio.Task, mode: str, task_id: uuid.UUID
|
||||
) -> None:
|
||||
"""
|
||||
处理任务结束后的收尾工作
|
||||
|
||||
logger.info(
|
||||
f"检测到MAA版本过低:{v},最新版本:{maa_info['data']['version_name']}"
|
||||
)
|
||||
MainInfoBar.push_info_bar(
|
||||
"info",
|
||||
"MAA版本过低",
|
||||
f"当前版本:{v},最新稳定版:{maa_info['data']['version_name']}",
|
||||
-1,
|
||||
)
|
||||
Parameters
|
||||
----------
|
||||
task : asyncio.Task
|
||||
任务对象
|
||||
mode : str
|
||||
任务模式
|
||||
task_id : uuid.UUID
|
||||
任务ID
|
||||
"""
|
||||
|
||||
def push_dialog(self, name: str, title: str, content: str):
|
||||
"""推送对话框"""
|
||||
logger.info(f"任务结束: {task_id}")
|
||||
|
||||
choice = MessageBox(title, content, Config.main_window)
|
||||
choice.yesButton.setText("是")
|
||||
choice.cancelButton.setText("否")
|
||||
# 从任务字典中移除任务
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"任务 {task_id} 已结束")
|
||||
self.task_dict.pop(task_id)
|
||||
|
||||
self.task_dict[name].question_response.emit(bool(choice.exec()))
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id), type="Signal", data={"Accomplish": "无描述"}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
if mode == "自动代理" and task_id in Config.QueueConfig:
|
||||
|
||||
if Config.power_sign == "NoAction":
|
||||
Config.power_sign = Config.QueueConfig[task_id].get(
|
||||
"Info", "AfterAccomplish"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Main", type="Update", data={"PowerSign": Config.power_sign}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
if len(self.task_dict) == 0 and Config.power_sign != "NoAction":
|
||||
logger.info(f"所有任务已结束,准备执行电源操作: {Config.power_sign}")
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Main",
|
||||
type="Message",
|
||||
data={
|
||||
"type": "Countdown",
|
||||
"title": f"{POWER_SIGN_MAP[Config.power_sign]}倒计时",
|
||||
"message": f"程序将在倒计时结束后执行 {POWER_SIGN_MAP[Config.power_sign]} 操作",
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
await System.start_power_task()
|
||||
|
||||
async def start_startup_queue(self):
|
||||
"""开始运行启动时运行的调度队列"""
|
||||
|
||||
logger.info("开始运行启动时任务")
|
||||
for uid, queue in Config.QueueConfig.items():
|
||||
|
||||
if queue.get("Info", "StartUpEnabled") and uid not in self.task_dict:
|
||||
logger.info(f"启动时需要运行的队列:{uid}")
|
||||
task_id = await TaskManager.add_task("自动代理", str(uid))
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="TaskManager", type="Signal", data={"newTask": str(task_id)}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
logger.success("启动时任务开始运行")
|
||||
|
||||
|
||||
TaskManager = _TaskManager()
|
||||
|
||||
@@ -1,117 +1,147 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA主业务定时器
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from PySide6.QtCore import QTimer
|
||||
import asyncio
|
||||
import keyboard
|
||||
from datetime import datetime
|
||||
import pyautogui
|
||||
|
||||
from .config import Config
|
||||
from app.services import Matomo, System
|
||||
from app.utils import get_logger
|
||||
from app.models.schema import WebSocketMessage
|
||||
from .config import Config, QueueConfig
|
||||
from .task_manager import TaskManager
|
||||
from app.services import System
|
||||
|
||||
|
||||
class _MainTimer(QWidget):
|
||||
logger = get_logger("主业务定时器")
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.if_FailSafeException = False
|
||||
class _MainTimer:
|
||||
|
||||
self.Timer = QTimer()
|
||||
self.Timer.timeout.connect(self.timed_start)
|
||||
self.Timer.timeout.connect(self.set_silence)
|
||||
self.Timer.start(1000)
|
||||
self.LongTimer = QTimer()
|
||||
self.LongTimer.timeout.connect(self.long_timed_task)
|
||||
self.LongTimer.start(3600000)
|
||||
async def second_task(self):
|
||||
"""每秒定期任务"""
|
||||
logger.info("每秒定期任务启动")
|
||||
|
||||
def long_timed_task(self):
|
||||
"""长时间定期检定任务"""
|
||||
while True:
|
||||
|
||||
Config.get_gameid()
|
||||
Config.main_window.setting.show_notice()
|
||||
if Config.get(Config.update_IfAutoUpdate):
|
||||
Config.main_window.setting.check_update()
|
||||
await self.set_silence()
|
||||
await self.timed_start()
|
||||
|
||||
def timed_start(self):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def hour_task(self):
|
||||
"""每小时定期任务"""
|
||||
|
||||
logger.info("每小时定期任务启动")
|
||||
|
||||
while True:
|
||||
|
||||
if (
|
||||
datetime.strptime(
|
||||
Config.get("Data", "LastStatisticsUpload"), "%Y-%m-%d %H:%M:%S"
|
||||
).date()
|
||||
!= datetime.now().date()
|
||||
):
|
||||
await Matomo.send_event(
|
||||
"App",
|
||||
"Version",
|
||||
Config.version(),
|
||||
1 if "beta" in Config.version() else 0,
|
||||
)
|
||||
await Config.set(
|
||||
"Data",
|
||||
"LastStatisticsUpload",
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
@logger.catch()
|
||||
async def timed_start(self):
|
||||
"""定时启动代理任务"""
|
||||
|
||||
for name, info in Config.queue_dict.items():
|
||||
curtime = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
if not info["Config"].get(info["Config"].queueSet_Enabled):
|
||||
for uid, queue in Config.QueueConfig.items():
|
||||
|
||||
if not queue.get("Info", "TimeEnabled"):
|
||||
continue
|
||||
|
||||
data = info["Config"].toDict()
|
||||
# 避免重复调起任务
|
||||
if curtime == queue.get("Data", "LastTimedStart"):
|
||||
continue
|
||||
|
||||
time_set = [
|
||||
data["Time"][f"TimeSet_{_}"]
|
||||
for _ in range(10)
|
||||
if data["Time"][f"TimeEnabled_{_}"]
|
||||
]
|
||||
# 按时间调起代理任务
|
||||
curtime = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
if (
|
||||
curtime[11:16] in time_set
|
||||
and curtime
|
||||
!= info["Config"].get(info["Config"].Data_LastProxyTime)[:16]
|
||||
and name not in Config.running_list
|
||||
):
|
||||
for time_set in queue.TimeSet.values():
|
||||
if (
|
||||
time_set.get("Info", "Enabled")
|
||||
and curtime[11:16] == time_set.get("Info", "Time")
|
||||
and uid not in Config.task_dict
|
||||
):
|
||||
logger.info(f"定时唤起任务:{uid}")
|
||||
task_id = await TaskManager.add_task("自动代理", str(uid))
|
||||
await queue.set("Data", "LastTimedStart", curtime)
|
||||
await Config.QueueConfig.save()
|
||||
|
||||
logger.info(f"定时任务:{name}")
|
||||
TaskManager.add_task("自动代理_新调度台", name, data)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="TaskManager",
|
||||
type="Signal",
|
||||
data={"newTask": str(task_id)},
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
def set_silence(self):
|
||||
"""设置静默模式"""
|
||||
@logger.catch()
|
||||
async def set_silence(self):
|
||||
"""静默模式通过模拟老板键来隐藏模拟器窗口"""
|
||||
|
||||
if (
|
||||
not Config.if_ignore_silence
|
||||
and Config.get(Config.function_IfSilence)
|
||||
and Config.get(Config.function_BossKey) != ""
|
||||
len(Config.if_ignore_silence) == 0
|
||||
and Config.get("Function", "IfSilence")
|
||||
and Config.get("Function", "BossKey") != ""
|
||||
):
|
||||
|
||||
windows = System.get_window_info()
|
||||
if any(
|
||||
str(emulator_path) in window
|
||||
for window in windows
|
||||
for emulator_path in Config.silence_list
|
||||
):
|
||||
windows = await System.get_window_info()
|
||||
|
||||
emulator_windows = []
|
||||
for window in windows:
|
||||
for emulator_path, endtime in Config.silence_dict.items():
|
||||
if (
|
||||
datetime.now() < endtime
|
||||
and str(emulator_path) in window
|
||||
and window[0] != "新通知" # 此处排除雷电名为新通知的窗口
|
||||
):
|
||||
emulator_windows.append(window)
|
||||
|
||||
if emulator_windows:
|
||||
|
||||
logger.info(f"检测到模拟器窗口: {emulator_windows}")
|
||||
try:
|
||||
pyautogui.hotkey(
|
||||
*[
|
||||
keyboard.press_and_release(
|
||||
"+".join(
|
||||
_.strip().lower()
|
||||
for _ in Config.get(Config.function_BossKey).split("+")
|
||||
]
|
||||
for _ in Config.get("Function", "BossKey").split("+")
|
||||
)
|
||||
)
|
||||
except pyautogui.FailSafeException as e:
|
||||
if not self.if_FailSafeException:
|
||||
logger.warning(f"FailSafeException: {e}")
|
||||
self.if_FailSafeException = True
|
||||
logger.info(f"模拟按键: {Config.get('Function', 'BossKey')}")
|
||||
except Exception as e:
|
||||
logger.exception(f"模拟按键时出错: {e}")
|
||||
|
||||
|
||||
MainTimer = _MainTimer()
|
||||
|
||||
934
app/models/ConfigBase.py
Normal file
934
app/models/ConfigBase.py
Normal file
@@ -0,0 +1,934 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import win32com.client
|
||||
from copy import deepcopy
|
||||
from urllib.parse import urlparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Any, Dict, Union, Optional, TypeVar, Generic, Type
|
||||
|
||||
|
||||
from app.utils import dpapi_encrypt, dpapi_decrypt
|
||||
from app.utils.constants import RESERVED_NAMES, ILLEGAL_CHARS, DEFAULT_DATETIME
|
||||
|
||||
|
||||
class ConfigValidator:
|
||||
"""基础配置验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
"""验证值是否合法"""
|
||||
return True
|
||||
|
||||
def correct(self, value: Any) -> Any:
|
||||
"""修正非法值"""
|
||||
return value
|
||||
|
||||
|
||||
class RangeValidator(ConfigValidator):
|
||||
"""范围验证器"""
|
||||
|
||||
def __init__(self, min: int | float, max: int | float):
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.range = (min, max)
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, (int | float)):
|
||||
return False
|
||||
return self.min <= value <= self.max
|
||||
|
||||
def correct(self, value: Any) -> int | float:
|
||||
if not isinstance(value, (int, float)):
|
||||
try:
|
||||
value = float(value)
|
||||
except TypeError:
|
||||
return self.min
|
||||
return min(max(self.min, value), self.max)
|
||||
|
||||
|
||||
class OptionsValidator(ConfigValidator):
|
||||
"""选项验证器"""
|
||||
|
||||
def __init__(self, options: list):
|
||||
if not options:
|
||||
raise ValueError("可选项不能为空")
|
||||
|
||||
self.options = options
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
return value in self.options
|
||||
|
||||
def correct(self, value: Any) -> Any:
|
||||
return value if self.validate(value) else self.options[0]
|
||||
|
||||
|
||||
class UUIDValidator(ConfigValidator):
|
||||
"""UUID验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
def correct(self, value: Any) -> Any:
|
||||
return value if self.validate(value) else str(uuid.uuid4())
|
||||
|
||||
|
||||
class DateTimeValidator(ConfigValidator):
|
||||
"""日期时间验证器"""
|
||||
|
||||
def __init__(self, date_format: str) -> None:
|
||||
if not date_format:
|
||||
raise ValueError("日期时间格式不能为空")
|
||||
self.date_format = date_format
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
datetime.strptime(value, self.date_format)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return DEFAULT_DATETIME.strftime(self.date_format)
|
||||
try:
|
||||
datetime.strptime(value, self.date_format)
|
||||
return value
|
||||
except ValueError:
|
||||
return DEFAULT_DATETIME.strftime(self.date_format)
|
||||
|
||||
|
||||
class JSONValidator(ConfigValidator):
|
||||
|
||||
def __init__(self, tpye: type[dict] | type[list] = dict) -> None:
|
||||
self.type = tpye
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
data = json.loads(value)
|
||||
if isinstance(data, self.type):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
return (
|
||||
value if self.validate(value) else ("{ }" if self.type == dict else "[ ]")
|
||||
)
|
||||
|
||||
|
||||
class EncryptValidator(ConfigValidator):
|
||||
"""加密数据验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
dpapi_decrypt(value)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def correct(self, value: Any) -> Any:
|
||||
return value if self.validate(value) else dpapi_encrypt("数据损坏, 请重新设置")
|
||||
|
||||
|
||||
class BoolValidator(OptionsValidator):
|
||||
"""布尔值验证器"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__([True, False])
|
||||
|
||||
|
||||
class FileValidator(ConfigValidator):
|
||||
"""文件路径验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
if not Path(value).is_absolute():
|
||||
return False
|
||||
if Path(value).suffix == ".lnk":
|
||||
return False
|
||||
return True
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
value = str(Path.cwd())
|
||||
if not Path(value).is_absolute():
|
||||
value = Path(value).resolve().as_posix()
|
||||
if Path(value).suffix == ".lnk":
|
||||
try:
|
||||
shell = win32com.client.Dispatch("WScript.Shell")
|
||||
shortcut = shell.CreateShortcut(value)
|
||||
value = shortcut.TargetPath
|
||||
except:
|
||||
pass
|
||||
return Path(value).resolve().as_posix()
|
||||
|
||||
|
||||
class FolderValidator(ConfigValidator):
|
||||
"""文件夹路径验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
if not Path(value).is_absolute():
|
||||
return False
|
||||
return True
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
value = str(Path.cwd())
|
||||
return Path(value).resolve().as_posix()
|
||||
|
||||
|
||||
class UserNameValidator(ConfigValidator):
|
||||
"""用户名验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
||||
if not value or not value.strip():
|
||||
return False
|
||||
|
||||
if value != value.strip() or value != value.strip("."):
|
||||
return False
|
||||
|
||||
if any(char in ILLEGAL_CHARS for char in value):
|
||||
return False
|
||||
|
||||
if value.upper() in RESERVED_NAMES:
|
||||
return False
|
||||
if len(value) > 255:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
value = "默认用户名"
|
||||
|
||||
value = value.strip().strip(".")
|
||||
|
||||
value = "".join(char for char in value if char not in ILLEGAL_CHARS)
|
||||
|
||||
if value.upper() in RESERVED_NAMES or not value:
|
||||
value = "默认用户名"
|
||||
|
||||
if len(value) > 255:
|
||||
value = value[:255]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class URLValidator(ConfigValidator):
|
||||
"""URL格式验证器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schemes: list[str] | None = None,
|
||||
require_netloc: bool = True,
|
||||
default: str = "",
|
||||
):
|
||||
"""
|
||||
:param schemes: 允许的协议列表, 若为 None 则允许任意协议
|
||||
:param require_netloc: 是否要求必须包含网络位置, 如域名或IP
|
||||
"""
|
||||
self.schemes = [s.lower() for s in schemes] if schemes else None
|
||||
self.require_netloc = require_netloc
|
||||
self.default = default
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
|
||||
if value == self.default:
|
||||
return True
|
||||
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# 检查协议
|
||||
if self.schemes is not None:
|
||||
if not parsed.scheme or parsed.scheme.lower() not in self.schemes:
|
||||
return False
|
||||
else:
|
||||
# 不限制协议仍要求有 scheme
|
||||
if not parsed.scheme:
|
||||
return False
|
||||
|
||||
# 检查是否包含网络位置
|
||||
if self.require_netloc and not parsed.netloc:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
|
||||
if self.validate(value):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
# 简单尝试:若看起来像域名,加上 https://
|
||||
stripped = value.strip()
|
||||
if stripped and not stripped.startswith(("http://", "https://")):
|
||||
candidate = f"https://{stripped}"
|
||||
if self.validate(candidate):
|
||||
return candidate
|
||||
|
||||
return self.default
|
||||
|
||||
|
||||
class ConfigItem:
|
||||
"""配置项"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
group: str,
|
||||
name: str,
|
||||
default: Any,
|
||||
validator: Optional[ConfigValidator] = None,
|
||||
):
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
group: str
|
||||
配置项分组名称
|
||||
|
||||
name: str
|
||||
配置项字段名称
|
||||
|
||||
default: Any
|
||||
配置项默认值
|
||||
|
||||
validator: ConfigValidator
|
||||
配置项验证器, 默认为 None, 表示不进行验证
|
||||
"""
|
||||
super().__init__()
|
||||
self.group = group
|
||||
self.name = name
|
||||
self.value: Any = default
|
||||
self.validator = validator or ConfigValidator()
|
||||
self.is_locked = False
|
||||
|
||||
if not self.validator.validate(self.value):
|
||||
raise ValueError(
|
||||
f"配置项 '{self.group}.{self.name}' 的默认值 '{self.value}' 不合法"
|
||||
)
|
||||
|
||||
def setValue(self, value: Any):
|
||||
"""
|
||||
设置配置项值, 将自动进行验证和修正
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value: Any
|
||||
要设置的值, 可以是任何合法类型
|
||||
"""
|
||||
|
||||
if (
|
||||
dpapi_decrypt(self.value)
|
||||
if isinstance(self.validator, EncryptValidator)
|
||||
else self.value
|
||||
) == value:
|
||||
return
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError(f"配置项 '{self.group}.{self.name}' 已锁定, 无法修改")
|
||||
|
||||
# deepcopy new value
|
||||
try:
|
||||
self.value = deepcopy(value)
|
||||
except:
|
||||
self.value = value
|
||||
|
||||
if isinstance(self.validator, EncryptValidator):
|
||||
if self.validator.validate(self.value):
|
||||
self.value = self.value
|
||||
else:
|
||||
self.value = dpapi_encrypt(self.value)
|
||||
|
||||
if not self.validator.validate(self.value):
|
||||
self.value = self.validator.correct(self.value)
|
||||
|
||||
def getValue(self, if_decrypt: bool = True) -> Any:
|
||||
"""
|
||||
获取配置项值
|
||||
"""
|
||||
|
||||
v = (
|
||||
self.value
|
||||
if self.validator.validate(self.value)
|
||||
else self.validator.correct(self.value)
|
||||
)
|
||||
|
||||
if isinstance(self.validator, EncryptValidator) and if_decrypt:
|
||||
return dpapi_decrypt(v)
|
||||
return v
|
||||
|
||||
def lock(self):
|
||||
"""
|
||||
锁定配置项, 锁定后无法修改配置项值
|
||||
"""
|
||||
self.is_locked = True
|
||||
|
||||
def unlock(self):
|
||||
"""
|
||||
解锁配置项, 解锁后可以修改配置项值
|
||||
"""
|
||||
self.is_locked = False
|
||||
|
||||
|
||||
class ConfigBase:
|
||||
"""
|
||||
配置基类
|
||||
|
||||
这个类提供了基本的配置项管理功能, 包括连接配置文件、加载配置数据、获取和设置配置项值等。
|
||||
|
||||
此类不支持直接实例化, 必须通过子类来实现具体的配置项, 请继承此类并在子类中定义具体的配置项。
|
||||
若将配置项设为类属性, 则所有实例都会共享同一份配置项数据。
|
||||
若将配置项设为实例属性, 则每个实例都会有独立的配置项数据。
|
||||
子配置项可以是 `MultipleConfig` 的实例。
|
||||
"""
|
||||
|
||||
def __init__(self, if_save_multi_config: bool = True):
|
||||
|
||||
self.file: Optional[Path] = None
|
||||
self.if_save_multi_config = if_save_multi_config
|
||||
self.is_locked = False
|
||||
|
||||
async def connect(self, path: Path):
|
||||
"""
|
||||
将配置文件连接到指定配置文件
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path: Path
|
||||
配置文件路径, 必须为 JSON 文件, 如果不存在则会创建
|
||||
"""
|
||||
|
||||
if path.suffix != ".json":
|
||||
raise ValueError("配置文件必须是扩展名为 '.json' 的 JSON 文件")
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
self.file = path
|
||||
|
||||
if not self.file.exists():
|
||||
self.file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.file.touch()
|
||||
|
||||
try:
|
||||
data = json.loads(self.file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
|
||||
await self.load(data)
|
||||
|
||||
async def load(self, data: dict):
|
||||
"""
|
||||
从字典加载配置数据
|
||||
|
||||
这个方法会遍历字典中的配置项, 并将其设置到对应的 ConfigItem 实例中。
|
||||
如果字典中包含 "SubConfigsInfo" 键, 则会加载子配置项, 这些子配置项应该是 MultipleConfig 的实例。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data: dict
|
||||
配置数据字典
|
||||
"""
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
# update the value of config item
|
||||
if data.get("SubConfigsInfo"):
|
||||
for k, v in data["SubConfigsInfo"].items():
|
||||
if hasattr(self, k):
|
||||
sub_config = getattr(self, k)
|
||||
if isinstance(sub_config, MultipleConfig):
|
||||
await sub_config.load(v)
|
||||
data.pop("SubConfigsInfo")
|
||||
|
||||
for group, info in data.items():
|
||||
for name, value in info.items():
|
||||
if hasattr(self, f"{group}_{name}"):
|
||||
configItem = getattr(self, f"{group}_{name}")
|
||||
if isinstance(configItem, ConfigItem):
|
||||
configItem.setValue(value)
|
||||
|
||||
if self.file:
|
||||
await self.save()
|
||||
|
||||
async def toDict(
|
||||
self, ignore_multi_config: bool = False, if_decrypt: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""将配置项转换为字典"""
|
||||
|
||||
data = {}
|
||||
for name in dir(self):
|
||||
item = getattr(self, name)
|
||||
|
||||
if isinstance(item, ConfigItem):
|
||||
|
||||
if not data.get(item.group):
|
||||
data[item.group] = {}
|
||||
if item.name:
|
||||
data[item.group][item.name] = item.getValue(if_decrypt)
|
||||
|
||||
elif not ignore_multi_config and isinstance(item, MultipleConfig):
|
||||
|
||||
if not data.get("SubConfigsInfo"):
|
||||
data["SubConfigsInfo"] = {}
|
||||
data["SubConfigsInfo"][name] = await item.toDict()
|
||||
|
||||
return data
|
||||
|
||||
def get(self, group: str, name: str) -> Any:
|
||||
"""获取配置项的值"""
|
||||
|
||||
if not hasattr(self, f"{group}_{name}"):
|
||||
raise AttributeError(f"配置项 '{group}.{name}' 不存在")
|
||||
|
||||
configItem = getattr(self, f"{group}_{name}")
|
||||
if isinstance(configItem, ConfigItem):
|
||||
return configItem.getValue()
|
||||
else:
|
||||
raise TypeError(f"配置项 '{group}.{name}' 不是 ConfigItem 实例")
|
||||
|
||||
async def set(self, group: str, name: str, value: Any):
|
||||
"""
|
||||
设置配置项的值
|
||||
|
||||
Parameters
|
||||
----------
|
||||
group: str
|
||||
配置项分组名称
|
||||
name: str
|
||||
配置项名称
|
||||
value: Any
|
||||
配置项新值
|
||||
"""
|
||||
|
||||
if not hasattr(self, f"{group}_{name}"):
|
||||
raise AttributeError(f"配置项 '{group}.{name}' 不存在")
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
configItem = getattr(self, f"{group}_{name}")
|
||||
if isinstance(configItem, ConfigItem):
|
||||
configItem.setValue(value)
|
||||
if self.file:
|
||||
await self.save()
|
||||
else:
|
||||
raise TypeError(f"配置项 '{group}.{name}' 不是 ConfigItem 实例")
|
||||
|
||||
async def save(self):
|
||||
"""保存配置"""
|
||||
|
||||
if not self.file:
|
||||
raise ValueError("文件路径未设置, 请先调用 `connect` 方法连接配置文件")
|
||||
|
||||
self.file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.file.write_text(
|
||||
json.dumps(
|
||||
await self.toDict(not self.if_save_multi_config, if_decrypt=False),
|
||||
ensure_ascii=False,
|
||||
indent=4,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
async def lock(self):
|
||||
"""
|
||||
锁定配置项, 锁定后无法修改配置项值
|
||||
"""
|
||||
|
||||
self.is_locked = True
|
||||
|
||||
for name in dir(self):
|
||||
item = getattr(self, name)
|
||||
if isinstance(item, ConfigItem):
|
||||
item.lock()
|
||||
elif isinstance(item, MultipleConfig):
|
||||
await item.lock()
|
||||
|
||||
async def unlock(self):
|
||||
"""
|
||||
解锁配置项, 解锁后可以修改配置项值
|
||||
"""
|
||||
|
||||
self.is_locked = False
|
||||
|
||||
for name in dir(self):
|
||||
item = getattr(self, name)
|
||||
if isinstance(item, ConfigItem):
|
||||
item.unlock()
|
||||
elif isinstance(item, MultipleConfig):
|
||||
await item.unlock()
|
||||
|
||||
|
||||
T = TypeVar("T", bound="ConfigBase")
|
||||
|
||||
|
||||
class MultipleConfig(Generic[T]):
|
||||
"""
|
||||
多配置项管理类
|
||||
|
||||
这个类允许管理多个配置项实例, 可以添加、删除、修改配置项, 并将其保存到 JSON 文件中。
|
||||
允许通过 `config[uuid]` 访问配置项, 使用 `uuid in config` 检查是否存在配置项, 使用 `len(config)` 获取配置项数量。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sub_config_type: List[type]
|
||||
子配置项的类型列表, 必须是 ConfigBase 的子类
|
||||
"""
|
||||
|
||||
def __init__(self, sub_config_type: List[Type[T]]):
|
||||
|
||||
if not sub_config_type:
|
||||
raise ValueError("子配置项类型列表不能为空")
|
||||
|
||||
for config_type in sub_config_type:
|
||||
if not issubclass(config_type, ConfigBase):
|
||||
raise TypeError(
|
||||
f"配置类型 {config_type.__name__} 必须是 ConfigBase 的子类"
|
||||
)
|
||||
|
||||
self.sub_config_type: List[Type[T]] = sub_config_type
|
||||
self.file: Path | None = None
|
||||
self.order: List[uuid.UUID] = []
|
||||
self.data: Dict[uuid.UUID, T] = {}
|
||||
self.is_locked = False
|
||||
|
||||
def __getitem__(self, key: uuid.UUID) -> T:
|
||||
"""允许通过 config[uuid] 访问配置项"""
|
||||
if key not in self.data:
|
||||
raise KeyError(f"配置项 '{key}' 不存在")
|
||||
return self.data[key]
|
||||
|
||||
def __contains__(self, key: uuid.UUID) -> bool:
|
||||
"""允许使用 uuid in config 检查是否存在"""
|
||||
return key in self.data
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""允许使用 len(config) 获取配置项数量"""
|
||||
return len(self.data)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""更好的字符串表示"""
|
||||
return f"MultipleConfig(items={len(self.data)}, types={[t.__name__ for t in self.sub_config_type]})"
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""用户友好的字符串表示"""
|
||||
return f"MultipleConfig with {len(self.data)} items"
|
||||
|
||||
async def connect(self, path: Path):
|
||||
"""
|
||||
将配置文件连接到指定配置文件
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path: Path
|
||||
配置文件路径, 必须为 JSON 文件, 如果不存在则会创建
|
||||
"""
|
||||
|
||||
if path.suffix != ".json":
|
||||
raise ValueError("配置文件必须是带有 '.json' 扩展名的 JSON 文件。")
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
self.file = path
|
||||
|
||||
if not self.file.exists():
|
||||
self.file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.file.touch()
|
||||
|
||||
try:
|
||||
data = json.loads(self.file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
|
||||
await self.load(data)
|
||||
|
||||
async def load(self, data: dict):
|
||||
"""
|
||||
从字典加载配置数据
|
||||
|
||||
这个方法会遍历字典中的配置项, 并将其设置到对应的 ConfigBase 实例中。
|
||||
如果字典中包含 "instances" 键, 则会加载子配置项, 这些子配置项应该是 ConfigBase 子类的实例。
|
||||
如果字典中没有 "instances" 键, 则清空当前配置项。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data: dict
|
||||
配置数据字典
|
||||
"""
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
if not data.get("instances"):
|
||||
self.order = []
|
||||
self.data = {}
|
||||
return
|
||||
|
||||
self.order = []
|
||||
self.data = {}
|
||||
|
||||
for instance in data["instances"]:
|
||||
|
||||
if not isinstance(instance, dict) or not data.get(instance.get("uid")):
|
||||
continue
|
||||
|
||||
type_name = instance.get("type", self.sub_config_type[0].__name__)
|
||||
|
||||
for class_type in self.sub_config_type:
|
||||
|
||||
if class_type.__name__ == type_name:
|
||||
self.order.append(uuid.UUID(instance["uid"]))
|
||||
self.data[self.order[-1]] = class_type()
|
||||
await self.data[self.order[-1]].load(data[instance["uid"]])
|
||||
break
|
||||
|
||||
else:
|
||||
|
||||
raise ValueError(f"未知的子配置类型: {type_name}")
|
||||
|
||||
if self.file:
|
||||
await self.save()
|
||||
|
||||
async def toDict(
|
||||
self, ignore_multi_config: bool = False, if_decrypt: bool = True
|
||||
) -> Dict[str, Union[list, dict]]:
|
||||
"""
|
||||
将配置项转换为字典
|
||||
|
||||
返回一个字典, 包含所有配置项的 UID 和类型, 以及每个配置项的具体数据。
|
||||
"""
|
||||
|
||||
data: Dict[str, Union[list, dict]] = {
|
||||
"instances": [
|
||||
{"uid": str(_), "type": type(self.data[_]).__name__} for _ in self.order
|
||||
]
|
||||
}
|
||||
for uid, config in self.items():
|
||||
data[str(uid)] = await config.toDict(ignore_multi_config, if_decrypt)
|
||||
return data
|
||||
|
||||
async def get(self, uid: uuid.UUID) -> Dict[str, Union[list, dict]]:
|
||||
"""
|
||||
获取指定 UID 的配置项
|
||||
|
||||
Parameters
|
||||
----------
|
||||
uid: uuid.UUID
|
||||
要获取的配置项的唯一标识符
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Union[list, dict]]
|
||||
对应的配置项数据字典
|
||||
"""
|
||||
|
||||
if uid not in self.data:
|
||||
raise ValueError(f"配置项 '{uid}' 不存在。")
|
||||
|
||||
data: Dict[str, Union[list, dict]] = {
|
||||
"instances": [
|
||||
{"uid": str(_), "type": type(self.data[_]).__name__}
|
||||
for _ in self.order
|
||||
if _ == uid
|
||||
]
|
||||
}
|
||||
data[str(uid)] = await self.data[uid].toDict()
|
||||
|
||||
return data
|
||||
|
||||
async def save(self):
|
||||
"""保存配置"""
|
||||
|
||||
if not self.file:
|
||||
raise ValueError("文件路径未设置, 请先调用 `connect` 方法连接配置文件")
|
||||
|
||||
self.file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.file.write_text(
|
||||
json.dumps(await self.toDict(), ensure_ascii=False, indent=4),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
async def add(self, config_type: Type[T]) -> tuple[uuid.UUID, T]:
|
||||
"""
|
||||
添加一个新的配置项
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config_type: type
|
||||
配置项的类型, 必须是初始化时已声明的 ConfigBase 子类
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[uuid.UUID, ConfigBase]
|
||||
新创建的配置项的唯一标识符和实例
|
||||
"""
|
||||
|
||||
if config_type not in self.sub_config_type:
|
||||
raise ValueError(f"配置类型 {config_type.__name__} 不被允许")
|
||||
|
||||
uid = uuid.uuid4()
|
||||
self.order.append(uid)
|
||||
self.data[uid] = config_type()
|
||||
|
||||
if self.file:
|
||||
await self.save()
|
||||
|
||||
return uid, self.data[uid]
|
||||
|
||||
async def remove(self, uid: uuid.UUID):
|
||||
"""
|
||||
移除配置项
|
||||
|
||||
Parameters
|
||||
----------
|
||||
uid: uuid.UUID
|
||||
要移除的配置项的唯一标识符
|
||||
"""
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
if uid not in self.data:
|
||||
raise ValueError(f"配置项 '{uid}' 不存在")
|
||||
|
||||
if self.data[uid].is_locked:
|
||||
raise ValueError(f"配置项 '{uid}' 已锁定, 无法移除")
|
||||
|
||||
self.data.pop(uid)
|
||||
self.order.remove(uid)
|
||||
|
||||
if self.file:
|
||||
await self.save()
|
||||
|
||||
async def setOrder(self, order: List[uuid.UUID]):
|
||||
"""
|
||||
设置配置项的顺序
|
||||
|
||||
Parameters
|
||||
----------
|
||||
order: List[uuid.UUID]
|
||||
新的配置项顺序
|
||||
"""
|
||||
|
||||
if set(order) != set(self.data.keys()):
|
||||
raise ValueError("顺序与当前配置项不匹配")
|
||||
|
||||
self.order = order
|
||||
|
||||
if self.file:
|
||||
await self.save()
|
||||
|
||||
async def lock(self):
|
||||
"""
|
||||
锁定配置项, 锁定后无法修改配置项值
|
||||
"""
|
||||
|
||||
self.is_locked = True
|
||||
|
||||
for item in self.values():
|
||||
await item.lock()
|
||||
|
||||
async def unlock(self):
|
||||
"""
|
||||
解锁配置项, 解锁后可以修改配置项值
|
||||
"""
|
||||
|
||||
self.is_locked = False
|
||||
|
||||
for item in self.values():
|
||||
await item.unlock()
|
||||
|
||||
def keys(self):
|
||||
"""返回配置项的所有唯一标识符"""
|
||||
|
||||
return iter(self.order)
|
||||
|
||||
def values(self):
|
||||
"""返回配置项的所有实例"""
|
||||
|
||||
if not self.data:
|
||||
return iter([])
|
||||
|
||||
return iter([self.data[_] for _ in self.order])
|
||||
|
||||
def items(self):
|
||||
"""返回配置项的所有唯一标识符和实例的元组"""
|
||||
|
||||
return zip(self.keys(), self.values())
|
||||
|
||||
|
||||
class MultipleUIDValidator(ConfigValidator):
|
||||
"""多配置管理类UID验证器"""
|
||||
|
||||
def __init__(
|
||||
self, default: Any, related_config: Dict[str, MultipleConfig], config_name: str
|
||||
):
|
||||
self.default = default
|
||||
self.related_config = related_config
|
||||
self.config_name = config_name
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if value == self.default:
|
||||
return True
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
uid = uuid.UUID(value)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if uid in self.related_config.get(self.config_name, {}):
|
||||
return True
|
||||
return False
|
||||
|
||||
def correct(self, value: Any) -> Any:
|
||||
if self.validate(value):
|
||||
return value
|
||||
return self.default
|
||||
1714
app/models/MAA.py
1714
app/models/MAA.py
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,31 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA模组包
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__version__ = "5.0.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
from .MAA import MaaManager
|
||||
from .ConfigBase import *
|
||||
from .config import *
|
||||
from .schema import *
|
||||
|
||||
__all__ = ["MaaManager"]
|
||||
__all__ = ["ConfigBase", "config", "schema"]
|
||||
|
||||
569
app/models/config.py
Normal file
569
app/models/config.py
Normal file
@@ -0,0 +1,569 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .ConfigBase import *
|
||||
|
||||
|
||||
class Webhook(ConfigBase):
|
||||
"""Webhook 配置"""
|
||||
|
||||
Info_Name = ConfigItem("Info", "Name", "")
|
||||
Info_Enabled = ConfigItem("Info", "Enabled", True, BoolValidator())
|
||||
|
||||
Data_Url = ConfigItem("Data", "Url", "", URLValidator())
|
||||
Data_Template = ConfigItem("Data", "Template", "")
|
||||
Data_Headers = ConfigItem("Data", "Headers", "{ }", JSONValidator())
|
||||
Data_Method = ConfigItem(
|
||||
"Data", "Method", "POST", OptionsValidator(["POST", "GET"])
|
||||
)
|
||||
|
||||
|
||||
class GlobalConfig(ConfigBase):
|
||||
"""全局配置"""
|
||||
|
||||
Function_HistoryRetentionTime = ConfigItem(
|
||||
"Function",
|
||||
"HistoryRetentionTime",
|
||||
0,
|
||||
OptionsValidator([7, 15, 30, 60, 90, 180, 365, 0]),
|
||||
)
|
||||
Function_IfAllowSleep = ConfigItem(
|
||||
"Function", "IfAllowSleep", False, BoolValidator()
|
||||
)
|
||||
Function_IfSilence = ConfigItem("Function", "IfSilence", False, BoolValidator())
|
||||
Function_BossKey = ConfigItem("Function", "BossKey", "")
|
||||
Function_IfAgreeBilibili = ConfigItem(
|
||||
"Function", "IfAgreeBilibili", False, BoolValidator()
|
||||
)
|
||||
Function_IfSkipMumuSplashAds = ConfigItem(
|
||||
"Function", "IfSkipMumuSplashAds", False, BoolValidator()
|
||||
)
|
||||
|
||||
Voice_Enabled = ConfigItem("Voice", "Enabled", False, BoolValidator())
|
||||
Voice_Type = ConfigItem(
|
||||
"Voice", "Type", "simple", OptionsValidator(["simple", "noisy"])
|
||||
)
|
||||
|
||||
Start_IfSelfStart = ConfigItem("Start", "IfSelfStart", False, BoolValidator())
|
||||
Start_IfMinimizeDirectly = ConfigItem(
|
||||
"Start", "IfMinimizeDirectly", False, BoolValidator()
|
||||
)
|
||||
|
||||
UI_IfShowTray = ConfigItem("UI", "IfShowTray", False, BoolValidator())
|
||||
UI_IfToTray = ConfigItem("UI", "IfToTray", False, BoolValidator())
|
||||
|
||||
Notify_SendTaskResultTime = ConfigItem(
|
||||
"Notify",
|
||||
"SendTaskResultTime",
|
||||
"不推送",
|
||||
OptionsValidator(["不推送", "任何时刻", "仅失败时"]),
|
||||
)
|
||||
Notify_IfSendStatistic = ConfigItem(
|
||||
"Notify", "IfSendStatistic", False, BoolValidator()
|
||||
)
|
||||
Notify_IfSendSixStar = ConfigItem("Notify", "IfSendSixStar", False, BoolValidator())
|
||||
Notify_IfPushPlyer = ConfigItem("Notify", "IfPushPlyer", False, BoolValidator())
|
||||
Notify_IfSendMail = ConfigItem("Notify", "IfSendMail", False, BoolValidator())
|
||||
Notify_SMTPServerAddress = ConfigItem("Notify", "SMTPServerAddress", "")
|
||||
Notify_AuthorizationCode = ConfigItem(
|
||||
"Notify", "AuthorizationCode", "", EncryptValidator()
|
||||
)
|
||||
Notify_FromAddress = ConfigItem("Notify", "FromAddress", "")
|
||||
Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
|
||||
Notify_IfServerChan = ConfigItem("Notify", "IfServerChan", False, BoolValidator())
|
||||
Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
|
||||
Notify_CustomWebhooks = MultipleConfig([Webhook])
|
||||
|
||||
Update_IfAutoUpdate = ConfigItem("Update", "IfAutoUpdate", False, BoolValidator())
|
||||
Update_Source = ConfigItem(
|
||||
"Update",
|
||||
"Source",
|
||||
"GitHub",
|
||||
OptionsValidator(["GitHub", "MirrorChyan", "AutoSite"]),
|
||||
)
|
||||
Update_ProxyAddress = ConfigItem("Update", "ProxyAddress", "")
|
||||
Update_MirrorChyanCDK = ConfigItem(
|
||||
"Update", "MirrorChyanCDK", "", EncryptValidator()
|
||||
)
|
||||
|
||||
Data_UID = ConfigItem("Data", "UID", str(uuid.uuid4()), UUIDValidator())
|
||||
Data_LastStatisticsUpload = ConfigItem(
|
||||
"Data",
|
||||
"LastStatisticsUpload",
|
||||
"2000-01-01 00:00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Data_LastStageUpdated = ConfigItem(
|
||||
"Data",
|
||||
"LastStageUpdated",
|
||||
"2000-01-01 00:00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Data_StageTimeStamp = ConfigItem(
|
||||
"Data",
|
||||
"StageTimeStamp",
|
||||
"2000-01-01 00:00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Data_Stage = ConfigItem("Data", "Stage", "{ }", JSONValidator())
|
||||
Data_LastNoticeUpdated = ConfigItem(
|
||||
"Data",
|
||||
"LastNoticeUpdated",
|
||||
"2000-01-01 00:00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Data_IfShowNotice = ConfigItem("Data", "IfShowNotice", True, BoolValidator())
|
||||
Data_Notice = ConfigItem("Data", "Notice", "{ }", JSONValidator())
|
||||
Data_LastWebConfigUpdated = ConfigItem(
|
||||
"Data",
|
||||
"LastWebConfigUpdated",
|
||||
"2000-01-01 00:00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Data_WebConfig = ConfigItem("Data", "WebConfig", "{ }", JSONValidator())
|
||||
|
||||
|
||||
class QueueItem(ConfigBase):
|
||||
"""队列项配置"""
|
||||
|
||||
related_config: dict[str, MultipleConfig] = {}
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_ScriptId = ConfigItem(
|
||||
"Info",
|
||||
"ScriptId",
|
||||
"-",
|
||||
MultipleUIDValidator("-", self.related_config, "ScriptConfig"),
|
||||
)
|
||||
|
||||
|
||||
class TimeSet(ConfigBase):
|
||||
"""时间设置配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Enabled = ConfigItem("Info", "Enabled", False, BoolValidator())
|
||||
self.Info_Time = ConfigItem("Info", "Time", "00:00", DateTimeValidator("%H:%M"))
|
||||
|
||||
|
||||
class QueueConfig(ConfigBase):
|
||||
"""队列配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新队列")
|
||||
self.Info_TimeEnabled = ConfigItem(
|
||||
"Info", "TimeEnabled", False, BoolValidator()
|
||||
)
|
||||
self.Info_StartUpEnabled = ConfigItem(
|
||||
"Info", "StartUpEnabled", False, BoolValidator()
|
||||
)
|
||||
self.Info_AfterAccomplish = ConfigItem(
|
||||
"Info",
|
||||
"AfterAccomplish",
|
||||
"NoAction",
|
||||
OptionsValidator(
|
||||
[
|
||||
"NoAction",
|
||||
"KillSelf",
|
||||
"Sleep",
|
||||
"Hibernate",
|
||||
"Shutdown",
|
||||
"ShutdownForce",
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
self.Data_LastTimedStart = ConfigItem(
|
||||
"Data",
|
||||
"LastTimedStart",
|
||||
"2000-01-01 00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M"),
|
||||
)
|
||||
|
||||
self.TimeSet = MultipleConfig([TimeSet])
|
||||
self.QueueItem = MultipleConfig([QueueItem])
|
||||
|
||||
|
||||
class MaaUserConfig(ConfigBase):
|
||||
"""MAA用户配置"""
|
||||
|
||||
related_config: dict[str, MultipleConfig] = {}
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
|
||||
self.Info_Id = ConfigItem("Info", "Id", "")
|
||||
self.Info_Mode = ConfigItem(
|
||||
"Info", "Mode", "简洁", OptionsValidator(["简洁", "详细"])
|
||||
)
|
||||
self.Info_StageMode = ConfigItem(
|
||||
"Info",
|
||||
"StageMode",
|
||||
"Fixed",
|
||||
MultipleUIDValidator("Fixed", self.related_config, "PlanConfig"),
|
||||
)
|
||||
self.Info_Server = ConfigItem(
|
||||
"Info",
|
||||
"Server",
|
||||
"Official",
|
||||
OptionsValidator(
|
||||
["Official", "Bilibili", "YoStarEN", "YoStarJP", "YoStarKR", "txwy"]
|
||||
),
|
||||
)
|
||||
self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator())
|
||||
self.Info_RemainedDay = ConfigItem(
|
||||
"Info", "RemainedDay", -1, RangeValidator(-1, 9999)
|
||||
)
|
||||
self.Info_Annihilation = ConfigItem(
|
||||
"Info",
|
||||
"Annihilation",
|
||||
"Annihilation",
|
||||
OptionsValidator(
|
||||
[
|
||||
"Close",
|
||||
"Annihilation",
|
||||
"Chernobog@Annihilation",
|
||||
"LungmenOutskirts@Annihilation",
|
||||
"LungmenDowntown@Annihilation",
|
||||
]
|
||||
),
|
||||
)
|
||||
self.Info_Routine = ConfigItem("Info", "Routine", True, BoolValidator())
|
||||
self.Info_InfrastMode = ConfigItem(
|
||||
"Info",
|
||||
"InfrastMode",
|
||||
"Normal",
|
||||
OptionsValidator(["Normal", "Rotation", "Custom"]),
|
||||
)
|
||||
self.Info_InfrastPath = ConfigItem(
|
||||
"Info", "InfrastPath", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Info_Password = ConfigItem("Info", "Password", "", EncryptValidator())
|
||||
self.Info_Notes = ConfigItem("Info", "Notes", "无")
|
||||
self.Info_MedicineNumb = ConfigItem(
|
||||
"Info", "MedicineNumb", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
self.Info_SeriesNumb = ConfigItem(
|
||||
"Info",
|
||||
"SeriesNumb",
|
||||
"0",
|
||||
OptionsValidator(["0", "6", "5", "4", "3", "2", "1", "-1"]),
|
||||
)
|
||||
self.Info_Stage = ConfigItem("Info", "Stage", "-")
|
||||
self.Info_Stage_1 = ConfigItem("Info", "Stage_1", "-")
|
||||
self.Info_Stage_2 = ConfigItem("Info", "Stage_2", "-")
|
||||
self.Info_Stage_3 = ConfigItem("Info", "Stage_3", "-")
|
||||
self.Info_Stage_Remain = ConfigItem("Info", "Stage_Remain", "-")
|
||||
self.Info_IfSkland = ConfigItem("Info", "IfSkland", False, BoolValidator())
|
||||
self.Info_SklandToken = ConfigItem(
|
||||
"Info", "SklandToken", "", EncryptValidator()
|
||||
)
|
||||
|
||||
self.Data_LastProxyDate = ConfigItem(
|
||||
"Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
|
||||
)
|
||||
self.Data_LastAnnihilationDate = ConfigItem(
|
||||
"Data", "LastAnnihilationDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
|
||||
)
|
||||
self.Data_LastSklandDate = ConfigItem(
|
||||
"Data", "LastSklandDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
|
||||
)
|
||||
self.Data_ProxyTimes = ConfigItem(
|
||||
"Data", "ProxyTimes", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
self.Data_IfPassCheck = ConfigItem("Data", "IfPassCheck", True, BoolValidator())
|
||||
self.Data_CustomInfrastPlanIndex = ConfigItem(
|
||||
"Data", "CustomInfrastPlanIndex", "0"
|
||||
)
|
||||
|
||||
self.Task_IfWakeUp = ConfigItem("Task", "IfWakeUp", True, BoolValidator())
|
||||
self.Task_IfRecruiting = ConfigItem(
|
||||
"Task", "IfRecruiting", True, BoolValidator()
|
||||
)
|
||||
self.Task_IfBase = ConfigItem("Task", "IfBase", True, BoolValidator())
|
||||
self.Task_IfCombat = ConfigItem("Task", "IfCombat", True, BoolValidator())
|
||||
self.Task_IfMall = ConfigItem("Task", "IfMall", True, BoolValidator())
|
||||
self.Task_IfMission = ConfigItem("Task", "IfMission", True, BoolValidator())
|
||||
self.Task_IfAutoRoguelike = ConfigItem(
|
||||
"Task", "IfAutoRoguelike", False, BoolValidator()
|
||||
)
|
||||
self.Task_IfReclamation = ConfigItem(
|
||||
"Task", "IfReclamation", False, BoolValidator()
|
||||
)
|
||||
|
||||
self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator())
|
||||
self.Notify_IfSendStatistic = ConfigItem(
|
||||
"Notify", "IfSendStatistic", False, BoolValidator()
|
||||
)
|
||||
self.Notify_IfSendSixStar = ConfigItem(
|
||||
"Notify", "IfSendSixStar", False, BoolValidator()
|
||||
)
|
||||
self.Notify_IfSendMail = ConfigItem(
|
||||
"Notify", "IfSendMail", False, BoolValidator()
|
||||
)
|
||||
self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
|
||||
self.Notify_IfServerChan = ConfigItem(
|
||||
"Notify", "IfServerChan", False, BoolValidator()
|
||||
)
|
||||
self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
|
||||
self.Notify_CustomWebhooks = MultipleConfig([Webhook])
|
||||
|
||||
|
||||
class MaaConfig(ConfigBase):
|
||||
"""MAA配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新 MAA 脚本")
|
||||
self.Info_Path = ConfigItem("Info", "Path", str(Path.cwd()), FolderValidator())
|
||||
|
||||
self.Run_TaskTransitionMethod = ConfigItem(
|
||||
"Run",
|
||||
"TaskTransitionMethod",
|
||||
"ExitEmulator",
|
||||
OptionsValidator(["NoAction", "ExitGame", "ExitEmulator"]),
|
||||
)
|
||||
self.Run_ProxyTimesLimit = ConfigItem(
|
||||
"Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
self.Run_ADBSearchRange = ConfigItem(
|
||||
"Run", "ADBSearchRange", 0, RangeValidator(0, 3)
|
||||
)
|
||||
self.Run_RunTimesLimit = ConfigItem(
|
||||
"Run", "RunTimesLimit", 3, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Run_AnnihilationTimeLimit = ConfigItem(
|
||||
"Run", "AnnihilationTimeLimit", 40, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Run_RoutineTimeLimit = ConfigItem(
|
||||
"Run", "RoutineTimeLimit", 10, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Run_AnnihilationWeeklyLimit = ConfigItem(
|
||||
"Run", "AnnihilationWeeklyLimit", True, BoolValidator()
|
||||
)
|
||||
|
||||
self.UserData = MultipleConfig([MaaUserConfig])
|
||||
|
||||
|
||||
class MaaPlanConfig(ConfigBase):
|
||||
"""MAA计划表配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新 MAA 计划表")
|
||||
self.Info_Mode = ConfigItem(
|
||||
"Info", "Mode", "ALL", OptionsValidator(["ALL", "Weekly"])
|
||||
)
|
||||
|
||||
self.config_item_dict: dict[str, Dict[str, ConfigItem]] = {}
|
||||
|
||||
for group in [
|
||||
"ALL",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
]:
|
||||
self.config_item_dict[group] = {}
|
||||
|
||||
self.config_item_dict[group]["MedicineNumb"] = ConfigItem(
|
||||
group, "MedicineNumb", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
self.config_item_dict[group]["SeriesNumb"] = ConfigItem(
|
||||
group,
|
||||
"SeriesNumb",
|
||||
"0",
|
||||
OptionsValidator(["0", "6", "5", "4", "3", "2", "1", "-1"]),
|
||||
)
|
||||
self.config_item_dict[group]["Stage"] = ConfigItem(group, "Stage", "-")
|
||||
self.config_item_dict[group]["Stage_1"] = ConfigItem(group, "Stage_1", "-")
|
||||
self.config_item_dict[group]["Stage_2"] = ConfigItem(group, "Stage_2", "-")
|
||||
self.config_item_dict[group]["Stage_3"] = ConfigItem(group, "Stage_3", "-")
|
||||
self.config_item_dict[group]["Stage_Remain"] = ConfigItem(
|
||||
group, "Stage_Remain", "-"
|
||||
)
|
||||
|
||||
for name in [
|
||||
"MedicineNumb",
|
||||
"SeriesNumb",
|
||||
"Stage",
|
||||
"Stage_1",
|
||||
"Stage_2",
|
||||
"Stage_3",
|
||||
"Stage_Remain",
|
||||
]:
|
||||
setattr(self, f"{group}_{name}", self.config_item_dict[group][name])
|
||||
|
||||
def get_current_info(self, name: str) -> ConfigItem:
|
||||
"""获取当前的计划表配置项"""
|
||||
|
||||
if self.get("Info", "Mode") == "ALL":
|
||||
|
||||
return self.config_item_dict["ALL"][name]
|
||||
|
||||
elif self.get("Info", "Mode") == "Weekly":
|
||||
|
||||
dt = datetime.now()
|
||||
if dt.time() < datetime.min.time().replace(hour=4):
|
||||
dt = dt - timedelta(days=1)
|
||||
today = dt.strftime("%A")
|
||||
|
||||
if today in self.config_item_dict:
|
||||
return self.config_item_dict[today][name]
|
||||
else:
|
||||
return self.config_item_dict["ALL"][name]
|
||||
|
||||
else:
|
||||
raise ValueError("非法的计划表模式")
|
||||
|
||||
|
||||
class GeneralUserConfig(ConfigBase):
|
||||
"""通用脚本用户配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
|
||||
self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator())
|
||||
self.Info_RemainedDay = ConfigItem(
|
||||
"Info", "RemainedDay", -1, RangeValidator(-1, 9999)
|
||||
)
|
||||
self.Info_IfScriptBeforeTask = ConfigItem(
|
||||
"Info", "IfScriptBeforeTask", False, BoolValidator()
|
||||
)
|
||||
self.Info_ScriptBeforeTask = ConfigItem(
|
||||
"Info", "ScriptBeforeTask", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Info_IfScriptAfterTask = ConfigItem(
|
||||
"Info", "IfScriptAfterTask", False, BoolValidator()
|
||||
)
|
||||
self.Info_ScriptAfterTask = ConfigItem(
|
||||
"Info", "ScriptAfterTask", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Info_Notes = ConfigItem("Info", "Notes", "无")
|
||||
|
||||
self.Data_LastProxyDate = ConfigItem(
|
||||
"Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
|
||||
)
|
||||
self.Data_ProxyTimes = ConfigItem(
|
||||
"Data", "ProxyTimes", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
|
||||
self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator())
|
||||
self.Notify_IfSendStatistic = ConfigItem(
|
||||
"Notify", "IfSendStatistic", False, BoolValidator()
|
||||
)
|
||||
self.Notify_IfSendMail = ConfigItem(
|
||||
"Notify", "IfSendMail", False, BoolValidator()
|
||||
)
|
||||
self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
|
||||
self.Notify_IfServerChan = ConfigItem(
|
||||
"Notify", "IfServerChan", False, BoolValidator()
|
||||
)
|
||||
self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
|
||||
self.Notify_CustomWebhooks = MultipleConfig([Webhook])
|
||||
|
||||
|
||||
class GeneralConfig(ConfigBase):
|
||||
"""通用配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新通用脚本")
|
||||
self.Info_RootPath = ConfigItem(
|
||||
"Info", "RootPath", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
|
||||
self.Script_ScriptPath = ConfigItem(
|
||||
"Script", "ScriptPath", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Script_Arguments = ConfigItem("Script", "Arguments", "")
|
||||
self.Script_IfTrackProcess = ConfigItem(
|
||||
"Script", "IfTrackProcess", False, BoolValidator()
|
||||
)
|
||||
self.Script_ConfigPath = ConfigItem(
|
||||
"Script", "ConfigPath", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Script_ConfigPathMode = ConfigItem(
|
||||
"Script", "ConfigPathMode", "File", OptionsValidator(["File", "Folder"])
|
||||
)
|
||||
self.Script_UpdateConfigMode = ConfigItem(
|
||||
"Script",
|
||||
"UpdateConfigMode",
|
||||
"Never",
|
||||
OptionsValidator(["Never", "Success", "Failure", "Always"]),
|
||||
)
|
||||
self.Script_LogPath = ConfigItem(
|
||||
"Script", "LogPath", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Script_LogPathFormat = ConfigItem("Script", "LogPathFormat", "%Y-%m-%d")
|
||||
self.Script_LogTimeStart = ConfigItem(
|
||||
"Script", "LogTimeStart", 1, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Script_LogTimeEnd = ConfigItem(
|
||||
"Script", "LogTimeEnd", 1, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Script_LogTimeFormat = ConfigItem(
|
||||
"Script", "LogTimeFormat", "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
self.Script_SuccessLog = ConfigItem("Script", "SuccessLog", "")
|
||||
self.Script_ErrorLog = ConfigItem("Script", "ErrorLog", "")
|
||||
|
||||
self.Game_Enabled = ConfigItem("Game", "Enabled", False, BoolValidator())
|
||||
self.Game_Type = ConfigItem(
|
||||
"Game", "Type", "Emulator", OptionsValidator(["Emulator", "Client"])
|
||||
)
|
||||
self.Game_Path = ConfigItem("Game", "Path", str(Path.cwd()), FileValidator())
|
||||
self.Game_Arguments = ConfigItem("Game", "Arguments", "")
|
||||
self.Game_WaitTime = ConfigItem("Game", "WaitTime", 0, RangeValidator(0, 9999))
|
||||
self.Game_IfForceClose = ConfigItem(
|
||||
"Game", "IfForceClose", False, BoolValidator()
|
||||
)
|
||||
|
||||
self.Run_ProxyTimesLimit = ConfigItem(
|
||||
"Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
self.Run_RunTimesLimit = ConfigItem(
|
||||
"Run", "RunTimesLimit", 3, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Run_RunTimeLimit = ConfigItem(
|
||||
"Run", "RunTimeLimit", 10, RangeValidator(1, 9999)
|
||||
)
|
||||
|
||||
self.UserData = MultipleConfig([GeneralUserConfig])
|
||||
|
||||
|
||||
CLASS_BOOK = {"MAA": MaaConfig, "MaaPlan": MaaPlanConfig, "General": GeneralConfig}
|
||||
"""配置类映射表"""
|
||||
872
app/models/schema.py
Normal file
872
app/models/schema.py
Normal file
@@ -0,0 +1,872 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any, Dict, List, Union, Optional, Literal
|
||||
|
||||
|
||||
class OutBase(BaseModel):
|
||||
code: int = Field(default=200, description="状态码")
|
||||
status: str = Field(default="success", description="操作状态")
|
||||
message: str = Field(default="操作成功", description="操作消息")
|
||||
|
||||
|
||||
class InfoOut(OutBase):
|
||||
data: Dict[str, Any] = Field(..., description="收到的服务器数据")
|
||||
|
||||
|
||||
class VersionOut(OutBase):
|
||||
if_need_update: bool = Field(..., description="后端代码是否需要更新")
|
||||
current_hash: str = Field(..., description="后端代码当前哈希值")
|
||||
current_time: str = Field(..., description="后端代码当前时间戳")
|
||||
current_version: str = Field(..., description="后端当前版本号")
|
||||
|
||||
|
||||
class NoticeOut(OutBase):
|
||||
if_need_show: bool = Field(..., description="是否需要显示公告")
|
||||
data: Dict[str, str] = Field(
|
||||
..., description="公告信息, key为公告标题, value为公告内容"
|
||||
)
|
||||
|
||||
|
||||
class ComboBoxItem(BaseModel):
|
||||
label: str = Field(..., description="展示值")
|
||||
value: Optional[str] = Field(..., description="实际值")
|
||||
|
||||
|
||||
class ComboBoxOut(OutBase):
|
||||
data: List[ComboBoxItem] = Field(..., description="下拉框选项")
|
||||
|
||||
|
||||
class GetStageIn(BaseModel):
|
||||
type: Literal[
|
||||
"Today",
|
||||
"ALL",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
] = Field(
|
||||
...,
|
||||
description="选择的日期类型, Today为当天, ALL为包含当天未开放关卡在内的所有项",
|
||||
)
|
||||
|
||||
|
||||
class WebhookIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["Webhook"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class Webhook_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="Webhook名称")
|
||||
Enabled: Optional[bool] = Field(default=None, description="是否启用")
|
||||
|
||||
|
||||
class Webhook_Data(BaseModel):
|
||||
Url: Optional[str] = Field(default=None, description="Webhook URL")
|
||||
Template: Optional[str] = Field(default=None, description="消息模板")
|
||||
Headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头")
|
||||
Method: Optional[Literal["POST", "GET"]] = Field(
|
||||
default=None, description="请求方法"
|
||||
)
|
||||
|
||||
|
||||
class Webhook(BaseModel):
|
||||
Info: Optional[Webhook_Info] = Field(default=None, description="Webhook基础信息")
|
||||
Data: Optional[Webhook_Data] = Field(default=None, description="Webhook配置数据")
|
||||
|
||||
|
||||
class GlobalConfig_Function(BaseModel):
|
||||
HistoryRetentionTime: Optional[Literal[7, 15, 30, 60, 90, 180, 365, 0]] = Field(
|
||||
None, description="历史记录保留时间, 0表示永久保存"
|
||||
)
|
||||
IfAllowSleep: Optional[bool] = Field(default=None, description="允许休眠")
|
||||
IfSilence: Optional[bool] = Field(default=None, description="静默模式")
|
||||
BossKey: Optional[str] = Field(default=None, description="模拟器老板键")
|
||||
IfAgreeBilibili: Optional[bool] = Field(
|
||||
default=None, description="同意哔哩哔哩用户协议"
|
||||
)
|
||||
IfSkipMumuSplashAds: Optional[bool] = Field(
|
||||
default=None, description="跳过Mumu模拟器启动广告"
|
||||
)
|
||||
|
||||
|
||||
class GlobalConfig_Voice(BaseModel):
|
||||
Enabled: Optional[bool] = Field(default=None, description="语音功能是否启用")
|
||||
Type: Optional[Literal["simple", "noisy"]] = Field(
|
||||
default=None, description="语音类型, simple为简洁, noisy为聒噪"
|
||||
)
|
||||
|
||||
|
||||
class GlobalConfig_Start(BaseModel):
|
||||
IfSelfStart: Optional[bool] = Field(
|
||||
default=None, description="是否在系统启动时自动运行"
|
||||
)
|
||||
IfMinimizeDirectly: Optional[bool] = Field(
|
||||
default=None, description="启动时是否直接最小化到托盘而不显示主窗口"
|
||||
)
|
||||
|
||||
|
||||
class GlobalConfig_UI(BaseModel):
|
||||
IfShowTray: Optional[bool] = Field(default=None, description="是否常态显示托盘图标")
|
||||
IfToTray: Optional[bool] = Field(default=None, description="是否最小化到托盘")
|
||||
|
||||
|
||||
class GlobalConfig_Notify(BaseModel):
|
||||
SendTaskResultTime: Optional[Literal["不推送", "任何时刻", "仅失败时"]] = Field(
|
||||
default=None, description="任务结果推送时机"
|
||||
)
|
||||
IfSendStatistic: Optional[bool] = Field(
|
||||
default=None, description="是否发送统计信息"
|
||||
)
|
||||
IfSendSixStar: Optional[bool] = Field(
|
||||
default=None, description="是否发送公招六星通知"
|
||||
)
|
||||
IfPushPlyer: Optional[bool] = Field(default=None, description="是否推送系统通知")
|
||||
IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
|
||||
SMTPServerAddress: Optional[str] = Field(default=None, description="SMTP服务器地址")
|
||||
AuthorizationCode: Optional[str] = Field(default=None, description="SMTP授权码")
|
||||
FromAddress: Optional[str] = Field(default=None, description="邮件发送地址")
|
||||
ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
|
||||
IfServerChan: Optional[bool] = Field(
|
||||
default=None, description="是否使用ServerChan推送"
|
||||
)
|
||||
ServerChanKey: Optional[str] = Field(default=None, description="ServerChan推送密钥")
|
||||
|
||||
|
||||
class GlobalConfig_Update(BaseModel):
|
||||
IfAutoUpdate: Optional[bool] = Field(default=None, description="是否自动更新")
|
||||
Source: Optional[Literal["GitHub", "MirrorChyan", "AutoSite"]] = Field(
|
||||
default=None, description="更新源: GitHub源, Mirror酱源, 自建源"
|
||||
)
|
||||
ProxyAddress: Optional[str] = Field(default=None, description="网络代理地址")
|
||||
MirrorChyanCDK: Optional[str] = Field(default=None, description="Mirror酱CDK")
|
||||
|
||||
|
||||
class GlobalConfig(BaseModel):
|
||||
Function: Optional[GlobalConfig_Function] = Field(
|
||||
default=None, description="功能相关配置"
|
||||
)
|
||||
Voice: Optional[GlobalConfig_Voice] = Field(
|
||||
default=None, description="语音相关配置"
|
||||
)
|
||||
Start: Optional[GlobalConfig_Start] = Field(
|
||||
default=None, description="启动相关配置"
|
||||
)
|
||||
UI: Optional[GlobalConfig_UI] = Field(default=None, description="界面相关配置")
|
||||
Notify: Optional[GlobalConfig_Notify] = Field(
|
||||
default=None, description="通知相关配置"
|
||||
)
|
||||
Update: Optional[GlobalConfig_Update] = Field(
|
||||
default=None, description="更新相关配置"
|
||||
)
|
||||
|
||||
|
||||
class QueueIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["QueueConfig"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class QueueItemIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["QueueItem"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class TimeSetIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["TimeSet"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class QueueItem_Info(BaseModel):
|
||||
ScriptId: Optional[str] = Field(
|
||||
default=None, description="任务所对应的脚本ID, 为None时表示未选择"
|
||||
)
|
||||
|
||||
|
||||
class QueueItem(BaseModel):
|
||||
Info: Optional[QueueItem_Info] = Field(default=None, description="队列项")
|
||||
|
||||
|
||||
class TimeSet_Info(BaseModel):
|
||||
Enabled: Optional[bool] = Field(default=None, description="是否启用")
|
||||
Time: Optional[str] = Field(default=None, description="时间设置, 格式为HH:MM")
|
||||
|
||||
|
||||
class TimeSet(BaseModel):
|
||||
Info: Optional[TimeSet_Info] = Field(default=None, description="时间项")
|
||||
|
||||
|
||||
class QueueConfig_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="队列名称")
|
||||
TimeEnabled: Optional[bool] = Field(default=None, description="是否启用定时")
|
||||
StartUpEnabled: Optional[bool] = Field(default=None, description="是否启动时运行")
|
||||
AfterAccomplish: Optional[
|
||||
Literal[
|
||||
"NoAction", "KillSelf", "Sleep", "Hibernate", "Shutdown", "ShutdownForce"
|
||||
]
|
||||
] = Field(default=None, description="完成后操作")
|
||||
|
||||
|
||||
class QueueConfig(BaseModel):
|
||||
Info: Optional[QueueConfig_Info] = Field(default=None, description="队列信息")
|
||||
|
||||
|
||||
class ScriptIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["MaaConfig", "GeneralConfig"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class UserIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["MaaUserConfig", "GeneralUserConfig"] = Field(
|
||||
..., description="配置类型"
|
||||
)
|
||||
|
||||
|
||||
class MaaUserConfig_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="用户名")
|
||||
Id: Optional[str] = Field(default=None, description="用户ID")
|
||||
Mode: Optional[Literal["简洁", "详细"]] = Field(
|
||||
default=None, description="用户配置模式"
|
||||
)
|
||||
StageMode: Optional[str] = Field(default=None, description="关卡配置模式")
|
||||
Server: Optional[
|
||||
Literal["Official", "Bilibili", "YoStarEN", "YoStarJP", "YoStarKR", "txwy"]
|
||||
] = Field(default=None, description="服务器")
|
||||
Status: Optional[bool] = Field(default=None, description="用户状态")
|
||||
RemainedDay: Optional[int] = Field(default=None, description="剩余天数")
|
||||
Annihilation: Optional[
|
||||
Literal[
|
||||
"Close",
|
||||
"Annihilation",
|
||||
"Chernobog@Annihilation",
|
||||
"LungmenOutskirts@Annihilation",
|
||||
"LungmenDowntown@Annihilation",
|
||||
]
|
||||
] = Field(default=None, description="剿灭模式")
|
||||
Routine: Optional[bool] = Field(default=None, description="是否启用日常")
|
||||
InfrastMode: Optional[Literal["Normal", "Rotation", "Custom"]] = Field(
|
||||
default=None, description="基建模式"
|
||||
)
|
||||
InfrastPath: Optional[str] = Field(default=None, description="自定义基建文件路径")
|
||||
Password: Optional[str] = Field(default=None, description="密码")
|
||||
Notes: Optional[str] = Field(default=None, description="备注")
|
||||
MedicineNumb: Optional[int] = Field(default=None, description="吃理智药数量")
|
||||
SeriesNumb: Optional[Literal["0", "6", "5", "4", "3", "2", "1", "-1"]] = Field(
|
||||
default=None, description="连战次数"
|
||||
)
|
||||
Stage: Optional[str] = Field(default=None, description="关卡选择")
|
||||
Stage_1: Optional[str] = Field(default=None, description="备选关卡 - 1")
|
||||
Stage_2: Optional[str] = Field(default=None, description="备选关卡 - 2")
|
||||
Stage_3: Optional[str] = Field(default=None, description="备选关卡 - 3")
|
||||
Stage_Remain: Optional[str] = Field(default=None, description="剩余理智关卡")
|
||||
IfSkland: Optional[bool] = Field(default=None, description="是否启用森空岛签到")
|
||||
SklandToken: Optional[str] = Field(default=None, description="SklandToken")
|
||||
|
||||
|
||||
class MaaUserConfig_Data(BaseModel):
|
||||
LastProxyDate: Optional[str] = Field(default=None, description="上次代理日期")
|
||||
LastAnnihilationDate: Optional[str] = Field(
|
||||
default=None, description="上次剿灭日期"
|
||||
)
|
||||
LastSklandDate: Optional[str] = Field(
|
||||
default=None, description="上次森空岛签到日期"
|
||||
)
|
||||
ProxyTimes: Optional[int] = Field(default=None, description="代理次数")
|
||||
IfPassCheck: Optional[bool] = Field(default=None, description="是否通过人工排查")
|
||||
|
||||
|
||||
class MaaUserConfig_Task(BaseModel):
|
||||
IfWakeUp: Optional[bool] = Field(default=None, description="开始唤醒")
|
||||
IfRecruiting: Optional[bool] = Field(default=None, description="自动公招")
|
||||
IfBase: Optional[bool] = Field(default=None, description="基建换班")
|
||||
IfCombat: Optional[bool] = Field(default=None, description="刷理智")
|
||||
IfMall: Optional[bool] = Field(default=None, description="获取信用及购物")
|
||||
IfMission: Optional[bool] = Field(default=None, description="领取奖励")
|
||||
IfAutoRoguelike: Optional[bool] = Field(default=None, description="自动肉鸽")
|
||||
IfReclamation: Optional[bool] = Field(default=None, description="生息演算")
|
||||
|
||||
|
||||
class MaaUserConfig_Notify(BaseModel):
|
||||
Enabled: Optional[bool] = Field(default=None, description="是否启用通知")
|
||||
IfSendStatistic: Optional[bool] = Field(
|
||||
default=None, description="是否发送统计信息"
|
||||
)
|
||||
IfSendSixStar: Optional[bool] = Field(default=None, description="是否发送高资喜报")
|
||||
IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
|
||||
ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
|
||||
IfServerChan: Optional[bool] = Field(
|
||||
default=None, description="是否使用Server酱推送"
|
||||
)
|
||||
ServerChanKey: Optional[str] = Field(default=None, description="ServerChanKey")
|
||||
|
||||
|
||||
class GeneralUserConfig_Notify(BaseModel):
|
||||
Enabled: Optional[bool] = Field(default=None, description="是否启用通知")
|
||||
IfSendStatistic: Optional[bool] = Field(
|
||||
default=None, description="是否发送统计信息"
|
||||
)
|
||||
IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
|
||||
ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
|
||||
IfServerChan: Optional[bool] = Field(
|
||||
default=None, description="是否使用Server酱推送"
|
||||
)
|
||||
ServerChanKey: Optional[str] = Field(default=None, description="ServerChanKey")
|
||||
IfCompanyWebHookBot: Optional[bool] = Field(
|
||||
default=None, description="是否使用Webhook推送"
|
||||
)
|
||||
CompanyWebHookBotUrl: Optional[str] = Field(
|
||||
default=None, description="企微Webhook Bot URL"
|
||||
)
|
||||
|
||||
|
||||
class MaaUserConfig(BaseModel):
|
||||
Info: Optional[MaaUserConfig_Info] = Field(default=None, description="基础信息")
|
||||
Data: Optional[MaaUserConfig_Data] = Field(default=None, description="用户数据")
|
||||
Task: Optional[MaaUserConfig_Task] = Field(default=None, description="任务列表")
|
||||
Notify: Optional[MaaUserConfig_Notify] = Field(default=None, description="单独通知")
|
||||
|
||||
|
||||
class MaaConfig_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="脚本名称")
|
||||
Path: Optional[str] = Field(default=None, description="脚本路径")
|
||||
|
||||
|
||||
class MaaConfig_Run(BaseModel):
|
||||
TaskTransitionMethod: Optional[Literal["NoAction", "ExitGame", "ExitEmulator"]] = (
|
||||
Field(default=None, description="简洁任务间切换方式")
|
||||
)
|
||||
ProxyTimesLimit: Optional[int] = Field(default=None, description="每日代理次数限制")
|
||||
ADBSearchRange: Optional[int] = Field(default=None, description="ADB端口搜索范围")
|
||||
RunTimesLimit: Optional[int] = Field(default=None, description="重试次数限制")
|
||||
AnnihilationTimeLimit: Optional[int] = Field(
|
||||
default=None, description="剿灭超时限制"
|
||||
)
|
||||
RoutineTimeLimit: Optional[int] = Field(default=None, description="日常超时限制")
|
||||
AnnihilationWeeklyLimit: Optional[bool] = Field(
|
||||
default=None, description="剿灭每周仅代理至上限"
|
||||
)
|
||||
|
||||
|
||||
class MaaConfig(BaseModel):
|
||||
Info: Optional[MaaConfig_Info] = Field(default=None, description="脚本基础信息")
|
||||
Run: Optional[MaaConfig_Run] = Field(default=None, description="脚本运行配置")
|
||||
|
||||
|
||||
class GeneralUserConfig_Info(BaseModel):
|
||||
|
||||
Name: Optional[str] = Field(default=None, description="用户名")
|
||||
Status: Optional[bool] = Field(default=None, description="用户状态")
|
||||
RemainedDay: Optional[int] = Field(default=None, description="剩余天数")
|
||||
IfScriptBeforeTask: Optional[bool] = Field(
|
||||
default=None, description="是否在任务前执行脚本"
|
||||
)
|
||||
ScriptBeforeTask: Optional[str] = Field(default=None, description="任务前脚本路径")
|
||||
IfScriptAfterTask: Optional[bool] = Field(
|
||||
default=None, description="是否在任务后执行脚本"
|
||||
)
|
||||
ScriptAfterTask: Optional[str] = Field(default=None, description="任务后脚本路径")
|
||||
Notes: Optional[str] = Field(default=None, description="备注")
|
||||
|
||||
|
||||
class GeneralUserConfig_Data(BaseModel):
|
||||
LastProxyDate: Optional[str] = Field(default=None, description="上次代理日期")
|
||||
ProxyTimes: Optional[int] = Field(default=None, description="代理次数")
|
||||
|
||||
|
||||
class GeneralUserConfig(BaseModel):
|
||||
Info: Optional[GeneralUserConfig_Info] = Field(default=None, description="用户信息")
|
||||
Data: Optional[GeneralUserConfig_Data] = Field(default=None, description="用户数据")
|
||||
Notify: Optional[GeneralUserConfig_Notify] = Field(
|
||||
default=None, description="单独通知"
|
||||
)
|
||||
|
||||
|
||||
class GeneralConfig_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="脚本名称")
|
||||
RootPath: Optional[str] = Field(default=None, description="脚本根目录")
|
||||
|
||||
|
||||
class GeneralConfig_Script(BaseModel):
|
||||
ScriptPath: Optional[str] = Field(default=None, description="脚本可执行文件路径")
|
||||
Arguments: Optional[str] = Field(default=None, description="脚本启动附加命令参数")
|
||||
IfTrackProcess: Optional[bool] = Field(
|
||||
default=None, description="是否追踪脚本子进程"
|
||||
)
|
||||
ConfigPath: Optional[str] = Field(default=None, description="配置文件路径")
|
||||
ConfigPathMode: Optional[Literal["File", "Folder"]] = Field(
|
||||
default=None, description="配置文件类型: 单个文件, 文件夹"
|
||||
)
|
||||
UpdateConfigMode: Optional[Literal["Never", "Success", "Failure", "Always"]] = (
|
||||
Field(
|
||||
default=None,
|
||||
description="更新配置时机, 从不, 仅成功时, 仅失败时, 任务结束时",
|
||||
)
|
||||
)
|
||||
LogPath: Optional[str] = Field(default=None, description="日志文件路径")
|
||||
LogPathFormat: Optional[str] = Field(default=None, description="日志文件名格式")
|
||||
LogTimeStart: Optional[int] = Field(default=None, description="日志时间戳开始位置")
|
||||
LogTimeEnd: Optional[int] = Field(default=None, description="日志时间戳结束位置")
|
||||
LogTimeFormat: Optional[str] = Field(default=None, description="日志时间戳格式")
|
||||
SuccessLog: Optional[str] = Field(default=None, description="成功时日志")
|
||||
ErrorLog: Optional[str] = Field(default=None, description="错误时日志")
|
||||
|
||||
|
||||
class GeneralConfig_Game(BaseModel):
|
||||
Enabled: Optional[bool] = Field(
|
||||
default=None, description="游戏/模拟器相关功能是否启用"
|
||||
)
|
||||
Type: Optional[Literal["Emulator", "Client"]] = Field(
|
||||
default=None, description="类型: 模拟器, PC端"
|
||||
)
|
||||
Path: Optional[str] = Field(default=None, description="游戏/模拟器程序路径")
|
||||
Arguments: Optional[str] = Field(default=None, description="游戏/模拟器启动参数")
|
||||
WaitTime: Optional[int] = Field(default=None, description="游戏/模拟器等待启动时间")
|
||||
IfForceClose: Optional[bool] = Field(
|
||||
default=None, description="是否强制关闭游戏/模拟器进程"
|
||||
)
|
||||
|
||||
|
||||
class GeneralConfig_Run(BaseModel):
|
||||
ProxyTimesLimit: Optional[int] = Field(default=None, description="每日代理次数限制")
|
||||
RunTimesLimit: Optional[int] = Field(default=None, description="重试次数限制")
|
||||
RunTimeLimit: Optional[int] = Field(default=None, description="日志超时限制")
|
||||
|
||||
|
||||
class GeneralConfig(BaseModel):
|
||||
|
||||
Info: Optional[GeneralConfig_Info] = Field(default=None, description="脚本基础信息")
|
||||
Script: Optional[GeneralConfig_Script] = Field(default=None, description="脚本配置")
|
||||
Game: Optional[GeneralConfig_Game] = Field(default=None, description="游戏配置")
|
||||
Run: Optional[GeneralConfig_Run] = Field(default=None, description="运行配置")
|
||||
|
||||
|
||||
class PlanIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["MaaPlanConfig"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class MaaPlanConfig_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="计划表名称")
|
||||
Mode: Optional[Literal["ALL", "Weekly"]] = Field(
|
||||
default=None, description="计划表模式"
|
||||
)
|
||||
|
||||
|
||||
class MaaPlanConfig_Item(BaseModel):
|
||||
MedicineNumb: Optional[int] = Field(default=None, description="吃理智药")
|
||||
SeriesNumb: Optional[Literal["0", "6", "5", "4", "3", "2", "1", "-1"]] = Field(
|
||||
None, description="连战次数"
|
||||
)
|
||||
Stage: Optional[str] = Field(default=None, description="关卡选择")
|
||||
Stage_1: Optional[str] = Field(default=None, description="备选关卡 - 1")
|
||||
Stage_2: Optional[str] = Field(default=None, description="备选关卡 - 2")
|
||||
Stage_3: Optional[str] = Field(default=None, description="备选关卡 - 3")
|
||||
Stage_Remain: Optional[str] = Field(default=None, description="剩余理智关卡")
|
||||
|
||||
|
||||
class MaaPlanConfig(BaseModel):
|
||||
|
||||
Info: Optional[MaaPlanConfig_Info] = Field(default=None, description="基础信息")
|
||||
ALL: Optional[MaaPlanConfig_Item] = Field(default=None, description="全局")
|
||||
Monday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周一")
|
||||
Tuesday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周二")
|
||||
Wednesday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周三")
|
||||
Thursday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周四")
|
||||
Friday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周五")
|
||||
Saturday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周六")
|
||||
Sunday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周日")
|
||||
|
||||
|
||||
class HistoryIndexItem(BaseModel):
|
||||
date: str = Field(..., description="日期")
|
||||
status: Literal["完成", "异常"] = Field(..., description="状态")
|
||||
jsonFile: str = Field(..., description="对应JSON文件")
|
||||
|
||||
|
||||
class HistoryData(BaseModel):
|
||||
index: Optional[List[HistoryIndexItem]] = Field(
|
||||
default=None, description="历史记录索引列表"
|
||||
)
|
||||
recruit_statistics: Optional[Dict[str, int]] = Field(
|
||||
default=None, description="公招统计数据, key为星级, value为对应的公招数量"
|
||||
)
|
||||
drop_statistics: Optional[Dict[str, Dict[str, int]]] = Field(
|
||||
default=None,
|
||||
description="掉落统计数据, 格式为 { '关卡号': { '掉落物': 数量 } }",
|
||||
)
|
||||
error_info: Optional[Dict[str, str]] = Field(
|
||||
default=None, description="报错信息, key为时间戳, value为错误描述"
|
||||
)
|
||||
log_content: Optional[str] = Field(
|
||||
default=None, description="日志内容, 仅在提取单条历史记录数据时返回"
|
||||
)
|
||||
|
||||
|
||||
class ScriptCreateIn(BaseModel):
|
||||
type: Literal["MAA", "General"] = Field(
|
||||
..., description="脚本类型: MAA脚本, 通用脚本"
|
||||
)
|
||||
|
||||
|
||||
class ScriptCreateOut(OutBase):
|
||||
scriptId: str = Field(..., description="新创建的脚本ID")
|
||||
data: Union[MaaConfig, GeneralConfig] = Field(..., description="脚本配置数据")
|
||||
|
||||
|
||||
class ScriptGetIn(BaseModel):
|
||||
scriptId: Optional[str] = Field(
|
||||
default=None, description="脚本ID, 未携带时表示获取所有脚本数据"
|
||||
)
|
||||
|
||||
|
||||
class ScriptGetOut(OutBase):
|
||||
index: List[ScriptIndexItem] = Field(..., description="脚本索引列表")
|
||||
data: Dict[str, Union[MaaConfig, GeneralConfig]] = Field(
|
||||
..., description="脚本数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class ScriptUpdateIn(BaseModel):
|
||||
scriptId: str = Field(..., description="脚本ID")
|
||||
data: Union[MaaConfig, GeneralConfig] = Field(..., description="脚本更新数据")
|
||||
|
||||
|
||||
class ScriptDeleteIn(BaseModel):
|
||||
scriptId: str = Field(..., description="脚本ID")
|
||||
|
||||
|
||||
class ScriptReorderIn(BaseModel):
|
||||
indexList: List[str] = Field(..., description="脚本ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class ScriptFileIn(BaseModel):
|
||||
scriptId: str = Field(..., description="脚本ID")
|
||||
jsonFile: str = Field(..., description="配置文件路径")
|
||||
|
||||
|
||||
class ScriptUrlIn(BaseModel):
|
||||
scriptId: str = Field(..., description="脚本ID")
|
||||
url: str = Field(..., description="配置文件URL")
|
||||
|
||||
|
||||
class ScriptUploadIn(BaseModel):
|
||||
scriptId: str = Field(..., description="脚本ID")
|
||||
config_name: str = Field(..., description="配置名称")
|
||||
author: str = Field(..., description="作者")
|
||||
description: str = Field(..., description="描述")
|
||||
|
||||
|
||||
class UserInBase(BaseModel):
|
||||
scriptId: str = Field(..., description="所属脚本ID")
|
||||
|
||||
|
||||
class UserGetIn(UserInBase):
|
||||
userId: Optional[str] = Field(
|
||||
default=None, description="用户ID, 未携带时表示获取所有用户数据"
|
||||
)
|
||||
|
||||
|
||||
class UserGetOut(OutBase):
|
||||
index: List[UserIndexItem] = Field(..., description="用户索引列表")
|
||||
data: Dict[str, Union[MaaUserConfig, GeneralUserConfig]] = Field(
|
||||
..., description="用户数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class UserCreateOut(OutBase):
|
||||
userId: str = Field(..., description="新创建的用户ID")
|
||||
data: Union[MaaUserConfig, GeneralUserConfig] = Field(
|
||||
..., description="用户配置数据"
|
||||
)
|
||||
|
||||
|
||||
class UserUpdateIn(UserInBase):
|
||||
userId: str = Field(..., description="用户ID")
|
||||
data: Union[MaaUserConfig, GeneralUserConfig] = Field(
|
||||
..., description="用户更新数据"
|
||||
)
|
||||
|
||||
|
||||
class UserDeleteIn(UserInBase):
|
||||
userId: str = Field(..., description="用户ID")
|
||||
|
||||
|
||||
class UserReorderIn(UserInBase):
|
||||
indexList: List[str] = Field(..., description="用户ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class UserSetIn(UserInBase):
|
||||
userId: str = Field(..., description="用户ID")
|
||||
jsonFile: str = Field(..., description="JSON文件路径, 用于导入自定义基建文件")
|
||||
|
||||
|
||||
class WebhookInBase(BaseModel):
|
||||
scriptId: Optional[str] = Field(
|
||||
default=None, description="所属脚本ID, 获取全局设置的Webhook数据时无需携带"
|
||||
)
|
||||
userId: Optional[str] = Field(
|
||||
default=None, description="所属用户ID, 获取全局设置的Webhook数据时无需携带"
|
||||
)
|
||||
|
||||
|
||||
class WebhookGetIn(WebhookInBase):
|
||||
webhookId: Optional[str] = Field(
|
||||
default=None, description="Webhook ID, 未携带时表示获取所有Webhook数据"
|
||||
)
|
||||
|
||||
|
||||
class WebhookGetOut(OutBase):
|
||||
index: List[WebhookIndexItem] = Field(..., description="Webhook索引列表")
|
||||
data: Dict[str, Webhook] = Field(
|
||||
..., description="Webhook数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class WebhookCreateOut(OutBase):
|
||||
webhookId: str = Field(..., description="新创建的Webhook ID")
|
||||
data: Webhook = Field(..., description="Webhook配置数据")
|
||||
|
||||
|
||||
class WebhookUpdateIn(WebhookInBase):
|
||||
webhookId: str = Field(..., description="Webhook ID")
|
||||
data: Webhook = Field(..., description="Webhook更新数据")
|
||||
|
||||
|
||||
class WebhookDeleteIn(WebhookInBase):
|
||||
webhookId: str = Field(..., description="Webhook ID")
|
||||
|
||||
|
||||
class WebhookReorderIn(WebhookInBase):
|
||||
indexList: List[str] = Field(..., description="Webhook ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class WebhookTestIn(WebhookInBase):
|
||||
data: Webhook = Field(..., description="Webhook配置数据")
|
||||
|
||||
|
||||
class PlanCreateIn(BaseModel):
|
||||
type: Literal["MaaPlan"]
|
||||
|
||||
|
||||
class PlanCreateOut(OutBase):
|
||||
planId: str = Field(..., description="新创建的计划ID")
|
||||
data: MaaPlanConfig = Field(..., description="计划配置数据")
|
||||
|
||||
|
||||
class PlanGetIn(BaseModel):
|
||||
planId: Optional[str] = Field(
|
||||
default=None, description="计划ID, 未携带时表示获取所有计划数据"
|
||||
)
|
||||
|
||||
|
||||
class PlanGetOut(OutBase):
|
||||
index: List[PlanIndexItem] = Field(..., description="计划索引列表")
|
||||
data: Dict[str, MaaPlanConfig] = Field(..., description="计划列表或单个计划数据")
|
||||
|
||||
|
||||
class PlanUpdateIn(BaseModel):
|
||||
planId: str = Field(..., description="计划ID")
|
||||
data: MaaPlanConfig = Field(..., description="计划更新数据")
|
||||
|
||||
|
||||
class PlanDeleteIn(BaseModel):
|
||||
planId: str = Field(..., description="计划ID")
|
||||
|
||||
|
||||
class PlanReorderIn(BaseModel):
|
||||
indexList: List[str] = Field(..., description="计划ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class QueueCreateOut(OutBase):
|
||||
queueId: str = Field(..., description="新创建的队列ID")
|
||||
data: QueueConfig = Field(..., description="队列配置数据")
|
||||
|
||||
|
||||
class QueueGetIn(BaseModel):
|
||||
queueId: Optional[str] = Field(
|
||||
default=None, description="队列ID, 未携带时表示获取所有队列数据"
|
||||
)
|
||||
|
||||
|
||||
class QueueGetOut(OutBase):
|
||||
index: List[QueueIndexItem] = Field(..., description="队列索引列表")
|
||||
data: Dict[str, QueueConfig] = Field(
|
||||
..., description="队列数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class QueueUpdateIn(BaseModel):
|
||||
queueId: str = Field(..., description="队列ID")
|
||||
data: QueueConfig = Field(..., description="队列更新数据")
|
||||
|
||||
|
||||
class QueueDeleteIn(BaseModel):
|
||||
queueId: str = Field(..., description="队列ID")
|
||||
|
||||
|
||||
class QueueReorderIn(BaseModel):
|
||||
indexList: List[str] = Field(..., description="按新顺序排列的调度队列UID列表")
|
||||
|
||||
|
||||
class QueueSetInBase(BaseModel):
|
||||
queueId: str = Field(..., description="所属队列ID")
|
||||
|
||||
|
||||
class TimeSetGetIn(QueueSetInBase):
|
||||
timeSetId: Optional[str] = Field(
|
||||
default=None, description="时间设置ID, 未携带时表示获取所有时间设置数据"
|
||||
)
|
||||
|
||||
|
||||
class TimeSetGetOut(OutBase):
|
||||
index: List[TimeSetIndexItem] = Field(..., description="时间设置索引列表")
|
||||
data: Dict[str, TimeSet] = Field(
|
||||
..., description="时间设置数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class TimeSetCreateOut(OutBase):
|
||||
timeSetId: str = Field(..., description="新创建的时间设置ID")
|
||||
data: TimeSet = Field(..., description="时间设置配置数据")
|
||||
|
||||
|
||||
class TimeSetUpdateIn(QueueSetInBase):
|
||||
timeSetId: str = Field(..., description="时间设置ID")
|
||||
data: TimeSet = Field(..., description="时间设置更新数据")
|
||||
|
||||
|
||||
class TimeSetDeleteIn(QueueSetInBase):
|
||||
timeSetId: str = Field(..., description="时间设置ID")
|
||||
|
||||
|
||||
class TimeSetReorderIn(QueueSetInBase):
|
||||
indexList: List[str] = Field(..., description="时间设置ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class QueueItemGetIn(QueueSetInBase):
|
||||
queueItemId: Optional[str] = Field(
|
||||
default=None, description="队列项ID, 未携带时表示获取所有队列项数据"
|
||||
)
|
||||
|
||||
|
||||
class QueueItemGetOut(OutBase):
|
||||
index: List[QueueItemIndexItem] = Field(..., description="队列项索引列表")
|
||||
data: Dict[str, QueueItem] = Field(
|
||||
..., description="队列项数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class QueueItemCreateOut(OutBase):
|
||||
queueItemId: str = Field(..., description="新创建的队列项ID")
|
||||
data: QueueItem = Field(..., description="队列项配置数据")
|
||||
|
||||
|
||||
class QueueItemUpdateIn(QueueSetInBase):
|
||||
queueItemId: str = Field(..., description="队列项ID")
|
||||
data: QueueItem = Field(..., description="队列项更新数据")
|
||||
|
||||
|
||||
class QueueItemDeleteIn(QueueSetInBase):
|
||||
queueItemId: str = Field(..., description="队列项ID")
|
||||
|
||||
|
||||
class QueueItemReorderIn(QueueSetInBase):
|
||||
indexList: List[str] = Field(..., description="队列项ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class DispatchIn(BaseModel):
|
||||
taskId: str = Field(
|
||||
...,
|
||||
description="目标任务ID, 设置类任务可选对应脚本ID或用户ID, 代理类任务可选对应队列ID或脚本ID",
|
||||
)
|
||||
|
||||
|
||||
class TaskCreateIn(DispatchIn):
|
||||
mode: Literal["自动代理", "人工排查", "设置脚本"] = Field(
|
||||
..., description="任务模式"
|
||||
)
|
||||
|
||||
|
||||
class TaskCreateOut(OutBase):
|
||||
websocketId: str = Field(..., description="新创建的任务ID")
|
||||
|
||||
|
||||
class WebSocketMessage(BaseModel):
|
||||
id: str = Field(..., description="消息ID, 为Main时表示消息来自主进程")
|
||||
type: Literal["Update", "Message", "Info", "Signal"] = Field(
|
||||
...,
|
||||
description="消息类型 Update: 更新数据, Message: 请求弹出对话框, Info: 需要在UI显示的消息, Signal: 程序信号",
|
||||
)
|
||||
data: Dict[str, Any] = Field(..., description="消息数据, 具体内容根据type类型而定")
|
||||
|
||||
|
||||
class PowerIn(BaseModel):
|
||||
signal: Literal[
|
||||
"NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf"
|
||||
] = Field(..., description="电源操作信号")
|
||||
|
||||
|
||||
class HistorySearchIn(BaseModel):
|
||||
mode: Literal["按日合并", "按周合并", "按月合并"] = Field(
|
||||
..., description="合并模式"
|
||||
)
|
||||
start_date: str = Field(..., description="开始日期, 格式YYYY-MM-DD")
|
||||
end_date: str = Field(..., description="结束日期, 格式YYYY-MM-DD")
|
||||
|
||||
|
||||
class HistorySearchOut(OutBase):
|
||||
data: Dict[str, Dict[str, HistoryData]] = Field(
|
||||
...,
|
||||
description="历史记录索引数据字典, 格式为 { '日期': { '用户名': [历史记录信息] } }",
|
||||
)
|
||||
|
||||
|
||||
class HistoryDataGetIn(BaseModel):
|
||||
jsonPath: str = Field(..., description="需要提取数据的历史记录JSON文件")
|
||||
|
||||
|
||||
class HistoryDataGetOut(OutBase):
|
||||
data: HistoryData = Field(..., description="历史记录数据")
|
||||
|
||||
|
||||
class SettingGetOut(OutBase):
|
||||
data: GlobalConfig = Field(..., description="全局设置数据")
|
||||
|
||||
|
||||
class SettingUpdateIn(BaseModel):
|
||||
data: GlobalConfig = Field(..., description="全局设置需要更新的数据")
|
||||
|
||||
|
||||
class UpdateCheckIn(BaseModel):
|
||||
current_version: str = Field(..., description="当前前端版本号")
|
||||
if_force: bool = Field(default=False, description="是否强制拉取更新信息")
|
||||
|
||||
|
||||
class UpdateCheckOut(OutBase):
|
||||
if_need_update: bool = Field(..., description="是否需要更新前端")
|
||||
latest_version: str = Field(..., description="最新前端版本号")
|
||||
update_info: Dict[str, List[str]] = Field(..., description="版本更新信息字典")
|
||||
@@ -1,36 +1,31 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA服务包
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__version__ = "5.0.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
from .matomo import Matomo
|
||||
from .notification import Notify
|
||||
from .security import Crypto
|
||||
from .system import System
|
||||
from .update import Updater
|
||||
|
||||
__all__ = ["Notify", "Crypto", "System"]
|
||||
__all__ = ["Matomo", "Notify", "System", "Updater"]
|
||||
|
||||
125
app/services/matomo.py
Normal file
125
app/services/matomo.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import json
|
||||
import uuid
|
||||
import platform
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from app.core import Config
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("信息上报")
|
||||
|
||||
|
||||
class _MatomoHandler:
|
||||
"""Matomo统计上报服务"""
|
||||
|
||||
base_url = "https://statistics.auto-mas.top/matomo.php"
|
||||
site_id = "3"
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self.session = None
|
||||
|
||||
async def _get_session(self):
|
||||
"""获取HTTP会话"""
|
||||
|
||||
if self.session is None or self.session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
self.session = aiohttp.ClientSession(timeout=timeout)
|
||||
return self.session
|
||||
|
||||
async def close(self):
|
||||
"""关闭HTTP会话"""
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
|
||||
def _build_base_params(self, custom_vars: Optional[Dict[str, Any]] = None):
|
||||
"""构建基础参数"""
|
||||
params = {
|
||||
"idsite": self.site_id,
|
||||
"rec": "1",
|
||||
"action_name": "AUTO-MAS后端",
|
||||
"_id": Config.get("Data", "UID")[:16],
|
||||
"uid": Config.get("Data", "UID"),
|
||||
"rand": str(uuid.uuid4().int)[:10],
|
||||
"apiv": "1",
|
||||
"h": time.strftime("%H"),
|
||||
"m": time.strftime("%M"),
|
||||
"s": time.strftime("%S"),
|
||||
"ua": f"AUTO-MAS/{Config.version()} ({platform.system()} {platform.release()})",
|
||||
}
|
||||
|
||||
# 添加自定义变量
|
||||
if custom_vars is not None:
|
||||
cvar = {}
|
||||
for i, (key, value) in enumerate(custom_vars.items(), 1):
|
||||
if i <= 5:
|
||||
cvar[str(i)] = [str(key), str(value)]
|
||||
if cvar:
|
||||
params["_cvar"] = json.dumps(cvar)
|
||||
|
||||
return params
|
||||
|
||||
async def send_event(
|
||||
self,
|
||||
category: str,
|
||||
action: str,
|
||||
name: Optional[str] = None,
|
||||
value: Optional[float] = None,
|
||||
custom_vars: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""发送事件数据到Matomo
|
||||
|
||||
Args:
|
||||
category: 事件类别,如 "Script", "Config", "User"
|
||||
action: 事件动作,如 "Execute", "Update", "Login"
|
||||
name: 事件名称,如具体的脚本名称
|
||||
value: 事件值,如执行时长、文件大小等数值
|
||||
custom_vars: 自定义变量字典
|
||||
"""
|
||||
try:
|
||||
session = await self._get_session()
|
||||
if session is None:
|
||||
return
|
||||
|
||||
params = self._build_base_params(custom_vars)
|
||||
params.update({"e_c": category, "e_a": action, "e_n": name, "e_v": value})
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
async with session.get(self.base_url, params=params) as response:
|
||||
if response.status == 200:
|
||||
logger.debug(f"Matomo事件上报成功: {category}/{action}")
|
||||
else:
|
||||
logger.warning(f"Matomo事件上报失败: {response.status}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Matomo事件上报超时")
|
||||
except Exception as e:
|
||||
logger.error(f"Matomo事件上报错误: {e}")
|
||||
|
||||
|
||||
Matomo = _MatomoHandler()
|
||||
@@ -1,316 +1,431 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA通知服务
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from PySide6.QtCore import Signal
|
||||
import requests
|
||||
import time
|
||||
from loguru import logger
|
||||
from plyer import notification
|
||||
import re
|
||||
import json
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from plyer import notification
|
||||
from email.header import Header
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from app.core import Config
|
||||
from app.services.security import Crypto
|
||||
from app.models.config import Webhook
|
||||
from app.utils import get_logger, ImageUtils
|
||||
|
||||
logger = get_logger("通知服务")
|
||||
|
||||
|
||||
class Notification(QWidget):
|
||||
class Notification:
|
||||
|
||||
push_info_bar = Signal(str, str, str, int)
|
||||
async def push_plyer(self, title: str, message: str, ticker: str, t: int) -> None:
|
||||
"""
|
||||
推送系统通知
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
Parameters
|
||||
----------
|
||||
title: str
|
||||
通知标题
|
||||
message: str
|
||||
通知内容
|
||||
ticker: str
|
||||
通知横幅
|
||||
t: int
|
||||
通知持续时间
|
||||
"""
|
||||
|
||||
def push_plyer(self, title, message, ticker, t):
|
||||
"""推送系统通知"""
|
||||
if not Config.get("Notify", "IfPushPlyer"):
|
||||
return
|
||||
|
||||
if Config.get(Config.notify_IfPushPlyer):
|
||||
logger.info(f"推送系统通知: {title}")
|
||||
|
||||
if notification.notify is not None:
|
||||
notification.notify(
|
||||
title=title,
|
||||
message=message,
|
||||
app_name="AUTO_MAA",
|
||||
app_icon=str(Config.app_path / "resources/icons/AUTO_MAA.ico"),
|
||||
app_name="AUTO-MAS",
|
||||
app_icon=(Path.cwd() / "res/icons/AUTO-MAS.ico").as_posix(),
|
||||
timeout=t,
|
||||
ticker=ticker,
|
||||
toast=True,
|
||||
)
|
||||
else:
|
||||
logger.error("plyer.notification 未正确导入, 无法推送系统通知")
|
||||
|
||||
return True
|
||||
async def send_mail(
|
||||
self, mode: Literal["文本", "网页"], title: str, content: str, to_address: str
|
||||
) -> None:
|
||||
"""
|
||||
推送邮件通知
|
||||
|
||||
def send_mail(self, mode, title, content) -> None:
|
||||
"""推送邮件通知"""
|
||||
Parameters
|
||||
----------
|
||||
mode: Literal["文本", "网页"]
|
||||
邮件内容模式, 支持 "文本" 和 "网页"
|
||||
title: str
|
||||
邮件标题
|
||||
content: str
|
||||
邮件内容
|
||||
to_address: str
|
||||
收件人地址
|
||||
"""
|
||||
|
||||
if Config.get(Config.notify_IfSendMail):
|
||||
if Config.get("Notify", "SMTPServerAddress") == "":
|
||||
raise ValueError("邮件通知的SMTP服务器地址不能为空")
|
||||
if Config.get("Notify", "AuthorizationCode") == "":
|
||||
raise ValueError("邮件通知的授权码不能为空")
|
||||
if not bool(
|
||||
re.match(
|
||||
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
|
||||
Config.get("Notify", "FromAddress"),
|
||||
)
|
||||
):
|
||||
raise ValueError("邮件通知的发送邮箱格式错误或为空")
|
||||
if not bool(
|
||||
re.match(
|
||||
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
|
||||
to_address,
|
||||
)
|
||||
):
|
||||
raise ValueError("邮件通知的接收邮箱格式错误或为空")
|
||||
|
||||
if (
|
||||
Config.get(Config.notify_SMTPServerAddress) == ""
|
||||
or Config.get(Config.notify_AuthorizationCode) == ""
|
||||
or not bool(
|
||||
re.match(
|
||||
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
|
||||
Config.get(Config.notify_FromAddress),
|
||||
)
|
||||
)
|
||||
or not bool(
|
||||
re.match(
|
||||
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
|
||||
Config.get(Config.notify_ToAddress),
|
||||
)
|
||||
)
|
||||
):
|
||||
logger.error(
|
||||
"请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址"
|
||||
)
|
||||
self.push_info_bar.emit(
|
||||
"error",
|
||||
"邮件通知推送异常",
|
||||
"请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址",
|
||||
-1,
|
||||
)
|
||||
return None
|
||||
# 定义邮件正文
|
||||
if mode == "文本":
|
||||
message = MIMEText(content, "plain", "utf-8")
|
||||
elif mode == "网页":
|
||||
message = MIMEMultipart("alternative")
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
Header("AUTO-MAS通知服务", "utf-8").encode(),
|
||||
Config.get("Notify", "FromAddress"),
|
||||
)
|
||||
) # 发件人显示的名字
|
||||
message["To"] = formataddr(
|
||||
(Header("AUTO-MAS用户", "utf-8").encode(), to_address)
|
||||
) # 收件人显示的名字
|
||||
message["Subject"] = str(Header(title, "utf-8"))
|
||||
|
||||
if mode == "网页":
|
||||
message.attach(MIMEText(content, "html", "utf-8"))
|
||||
|
||||
smtpObj = smtplib.SMTP_SSL(Config.get("Notify", "SMTPServerAddress"), 465)
|
||||
smtpObj.login(
|
||||
Config.get("Notify", "FromAddress"),
|
||||
Config.get("Notify", "AuthorizationCode"),
|
||||
)
|
||||
smtpObj.sendmail(
|
||||
Config.get("Notify", "FromAddress"), to_address, message.as_string()
|
||||
)
|
||||
smtpObj.quit()
|
||||
logger.success(f"邮件发送成功: {title}")
|
||||
|
||||
async def ServerChanPush(self, title: str, content: str, send_key: str) -> None:
|
||||
"""
|
||||
使用Server酱推送通知
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title: str
|
||||
通知标题
|
||||
content: str
|
||||
通知内容
|
||||
send_key: str
|
||||
Server酱的SendKey
|
||||
"""
|
||||
|
||||
if send_key == "":
|
||||
raise ValueError("ServerChan SendKey 不能为空")
|
||||
|
||||
# 构造 URL
|
||||
if send_key.startswith("sctp"):
|
||||
match = re.match(r"^sctp(\d+)t", send_key)
|
||||
if match:
|
||||
url = f"https://{match.group(1)}.push.ft07.com/send/{send_key}.send"
|
||||
else:
|
||||
raise ValueError("SendKey 格式不正确 (sctp<int>)")
|
||||
else:
|
||||
url = f"https://sctapi.ftqq.com/{send_key}.send"
|
||||
|
||||
# 请求发送
|
||||
params = {"title": title, "desp": content}
|
||||
headers = {"Content-Type": "application/json;charset=utf-8"}
|
||||
|
||||
response = requests.post(
|
||||
url, json=params, headers=headers, timeout=10, proxies=Config.get_proxies()
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
if result.get("code") == 0:
|
||||
logger.success(f"Server酱推送通知成功: {title}")
|
||||
else:
|
||||
raise Exception(f"ServerChan 推送通知失败: {response.text}")
|
||||
|
||||
async def WebhookPush(self, title: str, content: str, webhook: Webhook) -> None:
|
||||
"""
|
||||
Webhook 推送通知
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title: str
|
||||
通知标题
|
||||
content: str
|
||||
通知内容
|
||||
webhook: Webhook
|
||||
Webhook配置对象
|
||||
"""
|
||||
if not webhook.get("Info", "Enabled"):
|
||||
return
|
||||
|
||||
if webhook.get("Data", "Url") == "":
|
||||
raise ValueError("Webhook URL 不能为空")
|
||||
|
||||
# 解析模板
|
||||
template = (
|
||||
webhook.get("Data", "Template")
|
||||
or '{"title": "{title}", "content": "{content}"}'
|
||||
)
|
||||
|
||||
# 替换模板变量
|
||||
try:
|
||||
|
||||
# 准备模板变量
|
||||
template_vars = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"date": datetime.now().strftime("%Y-%m-%d"),
|
||||
"time": datetime.now().strftime("%H:%M:%S"),
|
||||
}
|
||||
|
||||
logger.debug(f"原始模板: {template}")
|
||||
logger.debug(f"模板变量: {template_vars}")
|
||||
|
||||
# 先尝试作为JSON模板处理
|
||||
try:
|
||||
# 定义邮件正文
|
||||
if mode == "文本":
|
||||
message = MIMEText(content, "plain", "utf-8")
|
||||
elif mode == "网页":
|
||||
message = MIMEMultipart("alternative")
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
Header("AUTO_MAA通知服务", "utf-8").encode(),
|
||||
Config.get(Config.notify_FromAddress),
|
||||
)
|
||||
) # 发件人显示的名字
|
||||
message["To"] = formataddr(
|
||||
(
|
||||
Header("AUTO_MAA用户", "utf-8").encode(),
|
||||
Config.get(Config.notify_ToAddress),
|
||||
)
|
||||
) # 收件人显示的名字
|
||||
message["Subject"] = Header(title, "utf-8")
|
||||
# 解析模板为JSON对象,然后替换其中的变量
|
||||
template_obj = json.loads(template)
|
||||
|
||||
if mode == "网页":
|
||||
message.attach(MIMEText(content, "html", "utf-8"))
|
||||
|
||||
smtpObj = smtplib.SMTP_SSL(
|
||||
Config.get(Config.notify_SMTPServerAddress),
|
||||
465,
|
||||
)
|
||||
smtpObj.login(
|
||||
Config.get(Config.notify_FromAddress),
|
||||
Crypto.win_decryptor(Config.get(Config.notify_AuthorizationCode)),
|
||||
)
|
||||
smtpObj.sendmail(
|
||||
Config.get(Config.notify_FromAddress),
|
||||
Config.get(Config.notify_ToAddress),
|
||||
message.as_string(),
|
||||
)
|
||||
smtpObj.quit()
|
||||
logger.success("邮件发送成功")
|
||||
except Exception as e:
|
||||
logger.error(f"发送邮件时出错:\n{e}")
|
||||
self.push_info_bar.emit("error", "发送邮件时出错", f"{e}", -1)
|
||||
|
||||
def ServerChanPush(self, title, content):
|
||||
"""使用Server酱推送通知(支持 tag 和 channel,避免使用SDK)"""
|
||||
if Config.get(Config.notify_IfServerChan):
|
||||
send_key = Config.get(Config.notify_ServerChanKey)
|
||||
|
||||
if not send_key:
|
||||
logger.error("请正确设置Server酱的SendKey")
|
||||
self.push_info_bar.emit(
|
||||
"error", "Server酱通知推送异常", "请正确设置Server酱的SendKey", -1
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
# 构造 URL
|
||||
if send_key.startswith("sctp"):
|
||||
match = re.match(r"^sctp(\d+)t", send_key)
|
||||
if match:
|
||||
url = f"https://{match.group(1)}.push.ft07.com/send/{send_key}.send"
|
||||
# 递归替换JSON对象中的变量
|
||||
def replace_variables(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: replace_variables(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [replace_variables(item) for item in obj]
|
||||
elif isinstance(obj, str):
|
||||
result = obj
|
||||
for key, value in template_vars.items():
|
||||
result = result.replace(f"{{{key}}}", str(value))
|
||||
return result
|
||||
else:
|
||||
raise ValueError("SendKey 格式错误(sctp)")
|
||||
else:
|
||||
url = f"https://sctapi.ftqq.com/{send_key}.send"
|
||||
return obj
|
||||
|
||||
# 构建 tags 和 channel
|
||||
def is_valid(s):
|
||||
return s == "" or (
|
||||
s == "|".join(s.split("|"))
|
||||
and (s.count("|") == 0 or all(s.split("|")))
|
||||
data = replace_variables(template_obj)
|
||||
logger.debug(f"成功解析JSON模板: {data}")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# 如果不是有效的JSON,作为字符串模板处理
|
||||
logger.debug("模板不是有效JSON,作为字符串模板处理")
|
||||
formatted_template = template
|
||||
for key, value in template_vars.items():
|
||||
# 转义特殊字符以避免JSON解析错误
|
||||
safe_value = (
|
||||
str(value)
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
)
|
||||
formatted_template = formatted_template.replace(
|
||||
f"{{{key}}}", safe_value
|
||||
)
|
||||
|
||||
tags = "|".join(
|
||||
_.strip()
|
||||
for _ in Config.get(Config.notify_ServerChanTag).split("|")
|
||||
)
|
||||
channels = "|".join(
|
||||
_.strip()
|
||||
for _ in Config.get(Config.notify_ServerChanChannel).split("|")
|
||||
)
|
||||
|
||||
options = {}
|
||||
if is_valid(tags):
|
||||
options["tags"] = tags
|
||||
else:
|
||||
logger.warning("Server酱 Tag 配置不正确,将被忽略")
|
||||
self.push_info_bar.emit(
|
||||
"warning",
|
||||
"Server酱通知推送异常",
|
||||
"请正确设置 ServerChan 的 Tag",
|
||||
-1,
|
||||
)
|
||||
|
||||
if is_valid(channels):
|
||||
options["channel"] = channels
|
||||
else:
|
||||
logger.warning("Server酱 Channel 配置不正确,将被忽略")
|
||||
self.push_info_bar.emit(
|
||||
"warning",
|
||||
"Server酱通知推送异常",
|
||||
"请正确设置 ServerChan 的 Channel",
|
||||
-1,
|
||||
)
|
||||
|
||||
# 请求发送
|
||||
params = {"title": title, "desp": content, **options}
|
||||
headers = {"Content-Type": "application/json;charset=utf-8"}
|
||||
|
||||
response = requests.post(url, json=params, headers=headers, timeout=10)
|
||||
result = response.json()
|
||||
|
||||
if result.get("code") == 0:
|
||||
logger.info("Server酱推送通知成功")
|
||||
return True
|
||||
else:
|
||||
error_code = result.get("code", "-1")
|
||||
logger.error(f"Server酱通知推送失败:响应码:{error_code}")
|
||||
self.push_info_bar.emit(
|
||||
"error", "Server酱通知推送失败", f"响应码:{error_code}", -1
|
||||
)
|
||||
return f"Server酱通知推送失败:{error_code}"
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Server酱通知推送异常")
|
||||
self.push_info_bar.emit(
|
||||
"error", "Server酱通知推送异常", f"请检查相关设置,如还有问题可联系开发者", -1
|
||||
)
|
||||
return f"Server酱通知推送异常:{str(e)}"
|
||||
|
||||
def CompanyWebHookBotPush(self, title, content):
|
||||
"""使用企业微信群机器人推送通知"""
|
||||
if Config.get(Config.notify_IfCompanyWebHookBot):
|
||||
|
||||
if Config.get(Config.notify_CompanyWebHookBotUrl) == "":
|
||||
logger.error("请正确设置企业微信群机器人的WebHook地址")
|
||||
self.push_info_bar.emit(
|
||||
"error",
|
||||
"企业微信群机器人通知推送异常",
|
||||
"请正确设置企业微信群机器人的WebHook地址",
|
||||
-1,
|
||||
)
|
||||
return None
|
||||
|
||||
content = f"{title}\n{content}"
|
||||
data = {"msgtype": "text", "text": {"content": content}}
|
||||
# 从远程服务器获取最新主题图像
|
||||
for _ in range(3):
|
||||
# 再次尝试解析为JSON
|
||||
try:
|
||||
response = requests.post(
|
||||
url=Config.get(Config.notify_CompanyWebHookBotUrl),
|
||||
json=data,
|
||||
timeout=10,
|
||||
)
|
||||
info = response.json()
|
||||
break
|
||||
except Exception as e:
|
||||
err = e
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
logger.error(f"推送企业微信群机器人时出错:{err}")
|
||||
self.push_info_bar.emit(
|
||||
"error",
|
||||
"企业微信群机器人通知推送失败",
|
||||
f'使用企业微信群机器人推送通知时出错:{info["errmsg"]}',
|
||||
-1,
|
||||
)
|
||||
return None
|
||||
data = json.loads(formatted_template)
|
||||
logger.debug(f"字符串模板解析为JSON成功: {data}")
|
||||
except json.JSONDecodeError:
|
||||
# 最终作为纯文本发送
|
||||
data = formatted_template
|
||||
logger.debug(f"作为纯文本发送: {data}")
|
||||
|
||||
if info["errcode"] == 0:
|
||||
logger.info("企业微信群机器人推送通知成功")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"企业微信群机器人推送通知失败:{info}")
|
||||
self.push_info_bar.emit(
|
||||
"error",
|
||||
"企业微信群机器人通知推送失败",
|
||||
f'使用企业微信群机器人推送通知时出错:{info["errmsg"]}',
|
||||
-1,
|
||||
)
|
||||
return f'使用企业微信群机器人推送通知时出错:{info["errmsg"]}'
|
||||
except Exception as e:
|
||||
logger.warning(f"模板解析失败,使用默认格式: {e}")
|
||||
data = {"title": title, "content": content}
|
||||
|
||||
def send_test_notification(self):
|
||||
# 准备请求头
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers.update(json.loads(webhook.get("Data", "Headers")))
|
||||
|
||||
if webhook.get("Data", "Method") == "POST":
|
||||
if isinstance(data, dict):
|
||||
response = requests.post(
|
||||
url=webhook.get("Data", "Url"),
|
||||
json=data,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
proxies=Config.get_proxies(),
|
||||
)
|
||||
elif isinstance(data, str):
|
||||
response = requests.post(
|
||||
url=webhook.get("Data", "Url"),
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
proxies=Config.get_proxies(),
|
||||
)
|
||||
elif webhook.get("Data", "Method") == "GET":
|
||||
if isinstance(data, dict):
|
||||
# Flatten params to ensure all values are str or list of str
|
||||
params = {}
|
||||
for k, v in data.items():
|
||||
if isinstance(v, (dict, list)):
|
||||
params[k] = json.dumps(v, ensure_ascii=False)
|
||||
else:
|
||||
params[k] = str(v)
|
||||
else:
|
||||
params = {"message": str(data)}
|
||||
response = requests.get(
|
||||
url=webhook.get("Data", "Url"),
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
proxies=Config.get_proxies(),
|
||||
)
|
||||
|
||||
# 检查响应
|
||||
if response.status_code == 200:
|
||||
logger.success(
|
||||
f"自定义Webhook推送成功: {webhook.get('Info', 'Name')} - {title}"
|
||||
)
|
||||
else:
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
|
||||
async def _WebHookPush(self, title, content, webhook_url) -> None:
|
||||
"""
|
||||
WebHook 推送通知 (即将弃用)
|
||||
|
||||
:param title: 通知标题
|
||||
:param content: 通知内容
|
||||
:param webhook_url: WebHook地址
|
||||
"""
|
||||
|
||||
if not webhook_url:
|
||||
raise ValueError("WebHook 地址不能为空")
|
||||
|
||||
content = f"{title}\n{content}"
|
||||
data = {"msgtype": "text", "text": {"content": content}}
|
||||
|
||||
response = requests.post(
|
||||
url=webhook_url, json=data, timeout=10, proxies=Config.get_proxies()
|
||||
)
|
||||
info = response.json()
|
||||
|
||||
if info["errcode"] == 0:
|
||||
logger.success(f"WebHook 推送通知成功: {title}")
|
||||
else:
|
||||
raise Exception(f"WebHook 推送通知失败: {response.text}")
|
||||
|
||||
async def CompanyWebHookBotPushImage(
|
||||
self, image_path: Path, webhook_url: str
|
||||
) -> None:
|
||||
"""
|
||||
使用企业微信群机器人推送图片通知(等待重新适配)
|
||||
|
||||
:param image_path: 图片文件路径
|
||||
:param webhook_url: 企业微信群机器人的WebHook地址
|
||||
"""
|
||||
|
||||
if not webhook_url:
|
||||
raise ValueError("webhook URL 不能为空")
|
||||
|
||||
# 压缩图片
|
||||
ImageUtils.compress_image_if_needed(image_path)
|
||||
|
||||
# 检查图片是否存在
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"文件未找到: {image_path}")
|
||||
|
||||
# 获取图片base64和md5
|
||||
image_base64 = ImageUtils.get_base64_from_file(str(image_path))
|
||||
image_md5 = ImageUtils.calculate_md5_from_file(str(image_path))
|
||||
|
||||
data = {
|
||||
"msgtype": "image",
|
||||
"image": {"base64": image_base64, "md5": image_md5},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url=webhook_url, json=data, timeout=10, proxies=Config.get_proxies()
|
||||
)
|
||||
info = response.json()
|
||||
|
||||
if info.get("errcode") == 0:
|
||||
logger.success(f"企业微信群机器人推送图片成功: {image_path.name}")
|
||||
else:
|
||||
raise Exception(f"企业微信群机器人推送图片失败: {response.text}")
|
||||
|
||||
async def send_test_notification(self) -> None:
|
||||
"""发送测试通知到所有已启用的通知渠道"""
|
||||
|
||||
logger.info("发送测试通知到所有已启用的通知渠道")
|
||||
|
||||
# 发送系统通知
|
||||
self.push_plyer(
|
||||
await self.push_plyer(
|
||||
"测试通知",
|
||||
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
|
||||
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
|
||||
"测试通知",
|
||||
3,
|
||||
)
|
||||
|
||||
# 发送邮件通知
|
||||
if Config.get(Config.notify_IfSendMail):
|
||||
self.send_mail(
|
||||
if Config.get("Notify", "IfSendMail"):
|
||||
await self.send_mail(
|
||||
"文本",
|
||||
"AUTO_MAA测试通知",
|
||||
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
|
||||
"AUTO-MAS测试通知",
|
||||
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
|
||||
Config.get("Notify", "ToAddress"),
|
||||
)
|
||||
|
||||
# 发送Server酱通知
|
||||
if Config.get(Config.notify_IfServerChan):
|
||||
self.ServerChanPush(
|
||||
"AUTO_MAA测试通知",
|
||||
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
|
||||
if Config.get("Notify", "IfServerChan"):
|
||||
await self.ServerChanPush(
|
||||
"AUTO-MAS测试通知",
|
||||
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
|
||||
Config.get("Notify", "ServerChanKey"),
|
||||
)
|
||||
|
||||
# 发送企业微信机器人通知
|
||||
if Config.get(Config.notify_IfCompanyWebHookBot):
|
||||
self.CompanyWebHookBotPush(
|
||||
"AUTO_MAA测试通知",
|
||||
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
|
||||
# 发送自定义Webhook通知
|
||||
for webhook in Config.Notify_CustomWebhooks.values():
|
||||
await self.WebhookPush(
|
||||
"AUTO-MAS测试通知",
|
||||
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
|
||||
webhook,
|
||||
)
|
||||
|
||||
return True
|
||||
logger.success("测试通知发送完成")
|
||||
|
||||
|
||||
Notify = Notification()
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA安全服务
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
import hashlib
|
||||
import random
|
||||
import secrets
|
||||
import base64
|
||||
import win32crypt
|
||||
from pathlib import Path
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Cipher import PKCS1_OAEP
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
from typing import List, Dict, Union
|
||||
|
||||
from app.core import Config
|
||||
|
||||
|
||||
class CryptoHandler:
|
||||
|
||||
def get_PASSWORD(self, PASSWORD: str) -> None:
|
||||
"""配置管理密钥"""
|
||||
|
||||
# 生成目录
|
||||
Config.key_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成RSA密钥对
|
||||
key = RSA.generate(2048)
|
||||
public_key_local = key.publickey()
|
||||
private_key = key
|
||||
# 保存RSA公钥
|
||||
(Config.app_path / "data/key/public_key.pem").write_bytes(
|
||||
public_key_local.exportKey()
|
||||
)
|
||||
# 生成密钥转换与校验随机盐
|
||||
PASSWORD_salt = secrets.token_hex(random.randint(32, 1024))
|
||||
(Config.app_path / "data/key/PASSWORDsalt.txt").write_text(
|
||||
PASSWORD_salt,
|
||||
encoding="utf-8",
|
||||
)
|
||||
verify_salt = secrets.token_hex(random.randint(32, 1024))
|
||||
(Config.app_path / "data/key/verifysalt.txt").write_text(
|
||||
verify_salt,
|
||||
encoding="utf-8",
|
||||
)
|
||||
# 将管理密钥转化为AES-256密钥
|
||||
AES_password = hashlib.sha256(
|
||||
(PASSWORD + PASSWORD_salt).encode("utf-8")
|
||||
).digest()
|
||||
# 生成AES-256密钥校验哈希值并保存
|
||||
AES_password_verify = hashlib.sha256(
|
||||
AES_password + verify_salt.encode("utf-8")
|
||||
).digest()
|
||||
(Config.app_path / "data/key/AES_password_verify.bin").write_bytes(
|
||||
AES_password_verify
|
||||
)
|
||||
# AES-256加密RSA私钥并保存密文
|
||||
AES_key = AES.new(AES_password, AES.MODE_ECB)
|
||||
private_key_local = AES_key.encrypt(pad(private_key.exportKey(), 32))
|
||||
(Config.app_path / "data/key/private_key.bin").write_bytes(private_key_local)
|
||||
|
||||
def AUTO_encryptor(self, note: str) -> str:
|
||||
"""使用AUTO_MAA的算法加密数据"""
|
||||
|
||||
if note == "":
|
||||
return ""
|
||||
|
||||
# 读取RSA公钥
|
||||
public_key_local = RSA.import_key(
|
||||
(Config.app_path / "data/key/public_key.pem").read_bytes()
|
||||
)
|
||||
# 使用RSA公钥对数据进行加密
|
||||
cipher = PKCS1_OAEP.new(public_key_local)
|
||||
encrypted = cipher.encrypt(note.encode("utf-8"))
|
||||
return base64.b64encode(encrypted).decode("utf-8")
|
||||
|
||||
def AUTO_decryptor(self, note: str, PASSWORD: str) -> str:
|
||||
"""使用AUTO_MAA的算法解密数据"""
|
||||
|
||||
if note == "":
|
||||
return ""
|
||||
|
||||
# 读入RSA私钥密文、盐与校验哈希值
|
||||
private_key_local = (
|
||||
(Config.app_path / "data/key/private_key.bin").read_bytes().strip()
|
||||
)
|
||||
PASSWORD_salt = (
|
||||
(Config.app_path / "data/key/PASSWORDsalt.txt")
|
||||
.read_text(encoding="utf-8")
|
||||
.strip()
|
||||
)
|
||||
verify_salt = (
|
||||
(Config.app_path / "data/key/verifysalt.txt")
|
||||
.read_text(encoding="utf-8")
|
||||
.strip()
|
||||
)
|
||||
AES_password_verify = (
|
||||
(Config.app_path / "data/key/AES_password_verify.bin").read_bytes().strip()
|
||||
)
|
||||
# 将管理密钥转化为AES-256密钥并验证
|
||||
AES_password = hashlib.sha256(
|
||||
(PASSWORD + PASSWORD_salt).encode("utf-8")
|
||||
).digest()
|
||||
AES_password_SHA = hashlib.sha256(
|
||||
AES_password + verify_salt.encode("utf-8")
|
||||
).digest()
|
||||
if AES_password_SHA != AES_password_verify:
|
||||
return "管理密钥错误"
|
||||
else:
|
||||
# AES解密RSA私钥
|
||||
AES_key = AES.new(AES_password, AES.MODE_ECB)
|
||||
private_key_pem = unpad(AES_key.decrypt(private_key_local), 32)
|
||||
private_key = RSA.import_key(private_key_pem)
|
||||
# 使用RSA私钥解密数据
|
||||
decrypter = PKCS1_OAEP.new(private_key)
|
||||
note = decrypter.decrypt(base64.b64decode(note)).decode("utf-8")
|
||||
return note
|
||||
|
||||
def change_PASSWORD(self, PASSWORD_old: str, PASSWORD_new: str) -> None:
|
||||
"""修改管理密钥"""
|
||||
|
||||
for member in Config.member_dict.values():
|
||||
|
||||
# 使用旧管理密钥解密
|
||||
for user in member["UserData"].values():
|
||||
user["Password"] = self.AUTO_decryptor(
|
||||
user["Config"].get(user["Config"].Info_Password), PASSWORD_old
|
||||
)
|
||||
|
||||
self.get_PASSWORD(PASSWORD_new)
|
||||
|
||||
for member in Config.member_dict.values():
|
||||
|
||||
# 使用新管理密钥重新加密
|
||||
for user in member["UserData"].values():
|
||||
user["Config"].set(
|
||||
user["Config"].Info_Password, self.AUTO_encryptor(user["Password"])
|
||||
)
|
||||
user["Password"] = None
|
||||
del user["Password"]
|
||||
|
||||
def win_encryptor(
|
||||
self, note: str, description: str = None, entropy: bytes = None
|
||||
) -> str:
|
||||
"""使用Windows DPAPI加密数据"""
|
||||
|
||||
if note == "":
|
||||
return ""
|
||||
|
||||
encrypted = win32crypt.CryptProtectData(
|
||||
note.encode("utf-8"), description, entropy, None, None, 0
|
||||
)
|
||||
return base64.b64encode(encrypted).decode("utf-8")
|
||||
|
||||
def win_decryptor(self, note: str, entropy: bytes = None) -> str:
|
||||
"""使用Windows DPAPI解密数据"""
|
||||
|
||||
if note == "":
|
||||
return ""
|
||||
|
||||
decrypted = win32crypt.CryptUnprotectData(
|
||||
base64.b64decode(note), entropy, None, None, 0
|
||||
)
|
||||
return decrypted[1].decode("utf-8")
|
||||
|
||||
def search_member(self) -> List[Dict[str, Union[Path, list]]]:
|
||||
"""搜索所有脚本实例及其用户数据库路径"""
|
||||
|
||||
member_list = []
|
||||
|
||||
if (Config.app_path / "config/MaaConfig").exists():
|
||||
for subdir in (Config.app_path / "config/MaaConfig").iterdir():
|
||||
if subdir.is_dir():
|
||||
|
||||
member_list.append({"Path": subdir / "user_data.db"})
|
||||
|
||||
return member_list
|
||||
|
||||
def check_PASSWORD(self, PASSWORD: str) -> bool:
|
||||
"""验证管理密钥"""
|
||||
|
||||
return bool(
|
||||
self.AUTO_decryptor(self.AUTO_encryptor("-"), PASSWORD) != "管理密钥错误"
|
||||
)
|
||||
|
||||
|
||||
Crypto = CryptoHandler()
|
||||
@@ -1,58 +1,58 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA系统服务
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from PySide6.QtWidgets import QApplication
|
||||
import sys
|
||||
import ctypes
|
||||
import asyncio
|
||||
import win32gui
|
||||
import win32process
|
||||
import winreg
|
||||
import psutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import getpass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import WebSocketMessage
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("系统服务")
|
||||
|
||||
|
||||
class _SystemHandler:
|
||||
|
||||
ES_CONTINUOUS = 0x80000000
|
||||
ES_SYSTEM_REQUIRED = 0x00000001
|
||||
countdown = 60
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.power_task: Optional[asyncio.Task] = None
|
||||
|
||||
self.set_Sleep()
|
||||
self.set_SelfStart()
|
||||
|
||||
def set_Sleep(self) -> None:
|
||||
async def set_Sleep(self) -> None:
|
||||
"""同步系统休眠状态"""
|
||||
|
||||
if Config.get(Config.function_IfAllowSleep):
|
||||
if Config.get("Function", "IfAllowSleep"):
|
||||
# 设置系统电源状态
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(
|
||||
self.ES_CONTINUOUS | self.ES_SYSTEM_REQUIRED
|
||||
@@ -61,41 +61,154 @@ class _SystemHandler:
|
||||
# 恢复系统电源状态
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(self.ES_CONTINUOUS)
|
||||
|
||||
def set_SelfStart(self) -> None:
|
||||
async def set_SelfStart(self) -> None:
|
||||
"""同步开机自启"""
|
||||
|
||||
if Config.get(Config.start_IfSelfStart) and not self.is_startup():
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
winreg.KEY_SET_VALUE,
|
||||
winreg.KEY_ALL_ACCESS | winreg.KEY_WRITE | winreg.KEY_CREATE_SUB_KEY,
|
||||
)
|
||||
winreg.SetValueEx(key, "AUTO_MAA", 0, winreg.REG_SZ, Config.app_path_sys)
|
||||
winreg.CloseKey(key)
|
||||
elif not Config.get(Config.start_IfSelfStart) and self.is_startup():
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
winreg.KEY_SET_VALUE,
|
||||
winreg.KEY_ALL_ACCESS | winreg.KEY_WRITE | winreg.KEY_CREATE_SUB_KEY,
|
||||
)
|
||||
winreg.DeleteValue(key, "AUTO_MAA")
|
||||
winreg.CloseKey(key)
|
||||
if Config.get("Start", "IfSelfStart") and not await self.is_startup():
|
||||
|
||||
def set_power(self, mode) -> None:
|
||||
# 创建任务计划
|
||||
try:
|
||||
|
||||
# 获取当前用户和时间
|
||||
current_user = getpass.getuser()
|
||||
current_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
# XML 模板
|
||||
xml_content = f"""<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Date>{current_time}</Date>
|
||||
<Author>{current_user}</Author>
|
||||
<Description>AUTO-MAS自启动服务</Description>
|
||||
<URI>\\AUTO-MAS_AutoStart</URI>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<LogonTrigger>
|
||||
<StartBoundary>{current_time}</StartBoundary>
|
||||
<Enabled>true</Enabled>
|
||||
</LogonTrigger>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>HighestAvailable</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>false</AllowHardTerminate>
|
||||
<StartWhenAvailable>true</StartWhenAvailable>
|
||||
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
||||
<IdleSettings>
|
||||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
||||
<RestartOnIdle>false</RestartOnIdle>
|
||||
</IdleSettings>
|
||||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
||||
<Enabled>true</Enabled>
|
||||
<Hidden>false</Hidden>
|
||||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>7</Priority>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>"{Path.cwd() / 'AUTO-MAS.exe'}"</Command>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>"""
|
||||
|
||||
# 创建临时 XML 文件并执行
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False, encoding="utf-16"
|
||||
) as f:
|
||||
f.write(xml_content)
|
||||
xml_file = f.name
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"schtasks",
|
||||
"/create",
|
||||
"/tn",
|
||||
"AUTO-MAS_AutoStart",
|
||||
"/xml",
|
||||
xml_file,
|
||||
"/f",
|
||||
],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.success(
|
||||
f"程序自启动任务计划已创建: {Path.cwd() / 'AUTO-MAS.exe'}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"程序自启动任务计划创建失败: {result.stderr}")
|
||||
|
||||
finally:
|
||||
# 删除临时文件
|
||||
try:
|
||||
Path(xml_file).unlink()
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"程序自启动任务计划创建失败: {e}")
|
||||
|
||||
elif not Config.get("Start", "IfSelfStart") and await self.is_startup():
|
||||
|
||||
try:
|
||||
|
||||
result = subprocess.run(
|
||||
["schtasks", "/delete", "/tn", "AUTO-MAS_AutoStart", "/f"],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.success("程序自启动任务计划已删除")
|
||||
else:
|
||||
logger.error(f"程序自启动任务计划删除失败: {result.stderr}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"程序自启动任务计划删除失败: {e}")
|
||||
|
||||
async def set_power(
|
||||
self,
|
||||
mode: Literal[
|
||||
"NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf"
|
||||
],
|
||||
) -> None:
|
||||
"""
|
||||
执行系统电源操作
|
||||
|
||||
:param mode: 电源操作
|
||||
"""
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
|
||||
if mode == "None":
|
||||
if mode == "NoAction":
|
||||
|
||||
logger.info("不执行系统电源操作")
|
||||
|
||||
elif mode == "Shutdown":
|
||||
|
||||
await self.kill_emulator_processes()
|
||||
logger.info("执行关机操作")
|
||||
subprocess.run(["shutdown", "/s", "/t", "0"])
|
||||
|
||||
elif mode == "ShutdownForce":
|
||||
logger.info("执行强制关机操作")
|
||||
subprocess.run(["shutdown", "/s", "/t", "0", "/f"])
|
||||
|
||||
elif mode == "Hibernate":
|
||||
|
||||
logger.info("执行休眠操作")
|
||||
@@ -108,14 +221,14 @@ class _SystemHandler:
|
||||
["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"]
|
||||
)
|
||||
|
||||
elif mode == "KillSelf":
|
||||
elif mode == "KillSelf" and Config.server is not None:
|
||||
|
||||
Config.main_window.close()
|
||||
QApplication.quit()
|
||||
logger.info("执行退出主程序操作")
|
||||
Config.server.should_exit = True
|
||||
|
||||
elif sys.platform.startswith("linux"):
|
||||
|
||||
if mode == "None":
|
||||
if mode == "NoAction":
|
||||
|
||||
logger.info("不执行系统电源操作")
|
||||
|
||||
@@ -134,31 +247,87 @@ class _SystemHandler:
|
||||
logger.info("执行睡眠操作")
|
||||
subprocess.run(["systemctl", "suspend"])
|
||||
|
||||
elif mode == "KillSelf":
|
||||
elif mode == "KillSelf" and Config.server is not None:
|
||||
|
||||
Config.main_window.close()
|
||||
QApplication.quit()
|
||||
logger.info("执行退出主程序操作")
|
||||
Config.server.should_exit = True
|
||||
|
||||
def is_startup(self) -> bool:
|
||||
async def _power_task(
|
||||
self,
|
||||
power_sign: Literal[
|
||||
"NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf"
|
||||
],
|
||||
) -> None:
|
||||
"""电源任务"""
|
||||
|
||||
await asyncio.sleep(self.countdown)
|
||||
if power_sign == "KillSelf":
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Main", type="Signal", data={"RequestClose": "请求前端关闭"}
|
||||
).model_dump()
|
||||
)
|
||||
await self.set_power(power_sign)
|
||||
|
||||
async def start_power_task(self):
|
||||
"""开始电源任务"""
|
||||
|
||||
if self.power_task is None or self.power_task.done():
|
||||
self.power_task = asyncio.create_task(self._power_task(Config.power_sign))
|
||||
logger.info(
|
||||
f"电源任务已启动, {self.countdown}秒后执行: {Config.power_sign}"
|
||||
)
|
||||
else:
|
||||
logger.warning("已有电源任务在运行, 请勿重复启动")
|
||||
|
||||
async def cancel_power_task(self):
|
||||
"""取消电源任务"""
|
||||
|
||||
if self.power_task is not None and not self.power_task.done():
|
||||
self.power_task.cancel()
|
||||
try:
|
||||
await self.power_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("电源任务已取消")
|
||||
else:
|
||||
logger.warning("当前无电源任务在运行")
|
||||
raise RuntimeError("当前无电源任务在运行")
|
||||
|
||||
async def kill_emulator_processes(self):
|
||||
"""这里暂时仅支持 MuMu 模拟器"""
|
||||
|
||||
logger.info("正在清除模拟器进程")
|
||||
|
||||
keywords = ["Nemu", "nemu", "emulator", "MuMu"]
|
||||
for proc in psutil.process_iter(["pid", "name"]):
|
||||
try:
|
||||
pname = proc.info["name"].lower()
|
||||
if any(keyword.lower() in pname for keyword in keywords):
|
||||
proc.kill()
|
||||
logger.info(f"已关闭 MuMu 模拟器进程: {proc.info['name']}")
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
|
||||
logger.success("模拟器进程清除完成")
|
||||
|
||||
async def is_startup(self) -> bool:
|
||||
"""判断程序是否已经开机自启"""
|
||||
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
0,
|
||||
winreg.KEY_READ,
|
||||
)
|
||||
|
||||
try:
|
||||
value, _ = winreg.QueryValueEx(key, "AUTO_MAA")
|
||||
winreg.CloseKey(key)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
winreg.CloseKey(key)
|
||||
result = subprocess.run(
|
||||
["schtasks", "/query", "/tn", "AUTO-MAS_AutoStart"],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.exception(f"检查任务计划程序失败: {e}")
|
||||
return False
|
||||
|
||||
def get_window_info(self) -> list:
|
||||
"""获取当前窗口信息"""
|
||||
async def get_window_info(self) -> list:
|
||||
"""获取当前前台窗口信息"""
|
||||
|
||||
def callback(hwnd, window_info):
|
||||
if win32gui.IsWindowVisible(hwnd) and win32gui.GetWindowText(hwnd):
|
||||
@@ -171,10 +340,16 @@ class _SystemHandler:
|
||||
win32gui.EnumWindows(callback, window_info)
|
||||
return window_info
|
||||
|
||||
def kill_process(self, path: Path) -> None:
|
||||
"""根据路径中止进程"""
|
||||
async def kill_process(self, path: Path) -> None:
|
||||
"""
|
||||
根据路径中止进程
|
||||
|
||||
for pid in self.search_pids(path):
|
||||
:param path: 进程路径
|
||||
"""
|
||||
|
||||
logger.info(f"开始中止进程: {path}")
|
||||
|
||||
for pid in await self.search_pids(path):
|
||||
killprocess = subprocess.Popen(
|
||||
f"taskkill /F /T /PID {pid}",
|
||||
shell=True,
|
||||
@@ -182,8 +357,17 @@ class _SystemHandler:
|
||||
)
|
||||
killprocess.wait()
|
||||
|
||||
def search_pids(self, path: Path) -> list:
|
||||
"""根据路径查找进程PID"""
|
||||
logger.success(f"进程已中止: {path}")
|
||||
|
||||
async def search_pids(self, path: Path) -> list:
|
||||
"""
|
||||
根据路径查找进程PID
|
||||
|
||||
:param path: 进程路径
|
||||
:return: 匹配的进程PID列表
|
||||
"""
|
||||
|
||||
logger.info(f"开始查找进程 PID: {path}")
|
||||
|
||||
pids = []
|
||||
for proc in psutil.process_iter(["pid", "exe"]):
|
||||
@@ -191,7 +375,7 @@ class _SystemHandler:
|
||||
if proc.info["exe"] and proc.info["exe"].lower() == str(path).lower():
|
||||
pids.append(proc.info["pid"])
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
# 进程可能在此期间已结束或无法访问,忽略这些异常
|
||||
# 进程可能在此期间已结束或无法访问, 忽略这些异常
|
||||
pass
|
||||
return pids
|
||||
|
||||
|
||||
389
app/services/update.py
Normal file
389
app/services/update.py
Normal file
@@ -0,0 +1,389 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import asyncio
|
||||
import zipfile
|
||||
import requests
|
||||
import subprocess
|
||||
from packaging import version
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import WebSocketMessage
|
||||
from app.utils.constants import MIRROR_ERROR_INFO
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("更新服务")
|
||||
|
||||
|
||||
class _UpdateHandler:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.is_locked: bool = False
|
||||
self.remote_version: Optional[str] = None
|
||||
self.last_check_time: Optional[datetime] = None
|
||||
self.update_version_info: Optional[Dict[str, List[str]]] = None
|
||||
self.mirror_chyan_download_url: Optional[str] = None
|
||||
|
||||
async def check_update(
|
||||
self, current_version: str, if_force: bool = False
|
||||
) -> tuple[bool, str, Dict[str, List[str]]]:
|
||||
|
||||
if (
|
||||
not if_force
|
||||
and self.remote_version is not None
|
||||
and self.last_check_time is not None
|
||||
and self.update_version_info is not None
|
||||
and self.last_check_time > datetime.now() - timedelta(hours=4)
|
||||
):
|
||||
logger.info("四小时内已进行过一次检查, 直接使用缓存的版本更新信息")
|
||||
return (
|
||||
bool(
|
||||
version.parse(self.remote_version) > version.parse(current_version)
|
||||
),
|
||||
self.remote_version,
|
||||
self.update_version_info,
|
||||
)
|
||||
|
||||
logger.info("开始检查更新")
|
||||
|
||||
response = requests.get(
|
||||
f"https://mirrorchyan.com/api/resources/AUTO_MAA/latest?user_agent=AutoMasGui¤t_version={current_version}&cdk={Config.get('Update', 'MirrorChyanCDK')}&channel=stable",
|
||||
timeout=10,
|
||||
proxies=Config.get_proxies(),
|
||||
)
|
||||
if response.status_code == 200:
|
||||
version_info = response.json()
|
||||
else:
|
||||
result = response.json()
|
||||
|
||||
if result["code"] != 0:
|
||||
if result["code"] in MIRROR_ERROR_INFO:
|
||||
raise Exception(
|
||||
f"获取版本信息时出错: {MIRROR_ERROR_INFO[result['code']]}"
|
||||
)
|
||||
else:
|
||||
raise Exception(
|
||||
"获取版本信息时出错: 意料之外的错误, 请及时联系项目组以获取来自 Mirror 酱的技术支持"
|
||||
)
|
||||
|
||||
logger.success("获取版本信息成功")
|
||||
self.last_check_time = datetime.now()
|
||||
|
||||
self.remote_version = version_info["data"]["version_name"]
|
||||
if self.remote_version is None:
|
||||
raise Exception("Mirror 酱未返回版本号, 请稍后重试")
|
||||
if "url" in version_info["data"]:
|
||||
self.mirror_chyan_download_url = version_info["data"]["url"]
|
||||
|
||||
if version.parse(self.remote_version) > version.parse(current_version):
|
||||
|
||||
# 版本更新信息
|
||||
version_info_json: Dict[str, Dict[str, List[str]]] = json.loads(
|
||||
re.sub(
|
||||
r"^<!--\s*(.*?)\s*-->$",
|
||||
r"\1",
|
||||
version_info["data"]["release_note"].splitlines()[0],
|
||||
)
|
||||
)
|
||||
|
||||
self.update_version_info = {}
|
||||
for v_i in [
|
||||
info
|
||||
for ver, info in version_info_json.items()
|
||||
if version.parse(ver) > version.parse(current_version)
|
||||
]:
|
||||
|
||||
for key, value in v_i.items():
|
||||
if key not in self.update_version_info:
|
||||
self.update_version_info[key] = []
|
||||
self.update_version_info[key] += value
|
||||
|
||||
return True, self.remote_version, self.update_version_info
|
||||
|
||||
else:
|
||||
return False, current_version, {}
|
||||
|
||||
async def download_update(self) -> None:
|
||||
|
||||
if self.is_locked:
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={"Failed": "已有更新任务在进行中, 请勿重复操作"},
|
||||
).model_dump()
|
||||
)
|
||||
return None
|
||||
|
||||
self.is_locked = True
|
||||
|
||||
if self.remote_version is None:
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={"Failed": "未检测到可用的远程版本, 请先检查更新"},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
return None
|
||||
|
||||
if (Path.cwd() / f"UpdatePack_{self.remote_version}.zip").exists():
|
||||
logger.info(
|
||||
f"更新包已存在: {Path.cwd() / f'UpdatePack_{self.remote_version}.zip'}"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={
|
||||
"Accomplish": str(
|
||||
Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
|
||||
)
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
return None
|
||||
|
||||
if Config.get("Update", "Source") == "GitHub":
|
||||
|
||||
download_url = f"https://github.com/AUTO-MAS-Project/AUTO-MAS/releases/download/{self.remote_version}/AUTO-MAS_{self.remote_version}.zip"
|
||||
|
||||
elif Config.get("Update", "Source") == "MirrorChyan":
|
||||
|
||||
if self.mirror_chyan_download_url is None:
|
||||
logger.warning("MirrorChyan 未返回下载链接, 使用自建下载站")
|
||||
download_url = f"https://download.auto-mas.top/d/AUTO-MAS/AUTO-MAS_{self.remote_version}.zip"
|
||||
|
||||
else:
|
||||
with requests.get(
|
||||
self.mirror_chyan_download_url,
|
||||
allow_redirects=True,
|
||||
timeout=10,
|
||||
stream=True,
|
||||
proxies=Config.get_proxies(),
|
||||
) as response:
|
||||
if response.status_code == 200:
|
||||
download_url = response.url
|
||||
elif Config.get("Update", "Source") == "AutoSite":
|
||||
download_url = f"https://download.auto-mas.top/d/AUTO-MAS/AUTO-MAS_{self.remote_version}.zip"
|
||||
|
||||
else:
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={
|
||||
"Failed": f"未知的下载源: {Config.get('Update', 'Source')}, 请检查配置文件"
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
return None
|
||||
|
||||
logger.info(f"开始下载: {download_url}")
|
||||
|
||||
check_times = 3
|
||||
while check_times != 0:
|
||||
|
||||
try:
|
||||
# 清理可能存在的临时文件
|
||||
if (Path.cwd() / "download.temp").exists():
|
||||
(Path.cwd() / "download.temp").unlink()
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
response = requests.get(
|
||||
download_url, timeout=10, stream=True, proxies=Config.get_proxies()
|
||||
)
|
||||
|
||||
if response.status_code not in [200, 206]:
|
||||
|
||||
if check_times != -1:
|
||||
check_times -= 1
|
||||
|
||||
logger.warning(
|
||||
f"连接失败: {download_url}, 状态码: {response.status_code}, 剩余重试次数: {check_times}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
logger.info(f"连接成功: {download_url}, 状态码: {response.status_code}")
|
||||
|
||||
file_size = int(response.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
last_download_size = 0
|
||||
speed = 0
|
||||
last_time = time.time()
|
||||
with (Path.cwd() / "download.temp").open(mode="wb") as f:
|
||||
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
|
||||
# 更新指定线程的下载进度, 每秒更新一次
|
||||
if time.time() - last_time >= 1.0:
|
||||
speed = (
|
||||
(downloaded_size - last_download_size)
|
||||
/ (time.time() - last_time)
|
||||
/ 1024
|
||||
)
|
||||
last_download_size = downloaded_size
|
||||
last_time = time.time()
|
||||
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Update",
|
||||
data={
|
||||
"downloaded_size": downloaded_size,
|
||||
"file_size": file_size,
|
||||
"speed": speed,
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
(Path.cwd() / "download.temp").rename(
|
||||
Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
|
||||
)
|
||||
|
||||
logger.success(
|
||||
f"下载完成: {download_url}, 实际下载大小: {downloaded_size} 字节, 耗时: {time.time() - start_time:.2f} 秒, 保存位置: {Path.cwd() / f'UpdatePack_{self.remote_version}.zip'}"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={
|
||||
"Accomplish": str(
|
||||
Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
|
||||
)
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
|
||||
if check_times != -1:
|
||||
check_times -= 1
|
||||
|
||||
logger.info(
|
||||
f"下载出错: {download_url}, 错误信息: {e}, 剩余重试次数: {check_times}"
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
else:
|
||||
|
||||
if (Path.cwd() / "download.temp").exists():
|
||||
(Path.cwd() / "download.temp").unlink()
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={"Failed": f"下载失败: {download_url}"},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
|
||||
async def install_update(self):
|
||||
|
||||
if self.is_locked:
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={"Failed": "已有更新任务在进行中, 请勿重复操作"},
|
||||
).model_dump()
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("开始应用更新")
|
||||
self.is_locked = True
|
||||
|
||||
versions = {
|
||||
version.parse(match.group(1)): f.name
|
||||
for f in Path.cwd().glob("UpdatePack_*.zip")
|
||||
if (match := re.match(r"UpdatePack_(.+)\.zip$", f.name))
|
||||
}
|
||||
logger.info(f"检测到的更新包: {versions.values()}")
|
||||
|
||||
if not versions:
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={"Failed": "未检测到更新包, 请先下载更新"},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
return None
|
||||
|
||||
update_package = Path.cwd() / versions[max(versions)]
|
||||
|
||||
logger.info(f"开始解压: {update_package} 到 {Path.cwd()}")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(update_package, "r") as zip_ref:
|
||||
zip_ref.extractall(Path.cwd())
|
||||
except Exception as e:
|
||||
logger.error(f"解压失败, {type(e).__name__}: {e}")
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Info",
|
||||
data={"Error": f"解压失败, {type(e).__name__}: {e}"},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
return None
|
||||
|
||||
logger.success(f"解压完成: {update_package} 到 {Path.cwd()}")
|
||||
|
||||
logger.info("正在删除临时文件与旧更新包文件")
|
||||
if (Path.cwd() / "changes.json").exists():
|
||||
(Path.cwd() / "changes.json").unlink()
|
||||
for f in versions.values():
|
||||
if (Path.cwd() / f).exists():
|
||||
(Path.cwd() / f).unlink()
|
||||
|
||||
logger.info("启动更新程序")
|
||||
self.is_locked = False
|
||||
subprocess.Popen(
|
||||
[Path.cwd() / "AUTO-MAS-Setup.exe"],
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.DETACHED_PROCESS
|
||||
| subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
|
||||
|
||||
Updater = _UpdateHandler()
|
||||
2086
app/task/MAA.py
Normal file
2086
app/task/MAA.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,32 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA图形化界面包
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__version__ = "5.0.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
from .main_window import AUTO_MAA
|
||||
from .Widget import ProgressRingMessageBox
|
||||
|
||||
__all__ = ["AUTO_MAA", "ProgressRingMessageBox"]
|
||||
from .skland import skland_sign_in
|
||||
from .general import GeneralManager
|
||||
from .MAA import MaaManager
|
||||
|
||||
__all__ = ["skland_sign_in", "GeneralManager", "MaaManager"]
|
||||
1103
app/task/general.py
Normal file
1103
app/task/general.py
Normal file
File diff suppressed because it is too large
Load Diff
266
app/task/skland.py
Normal file
266
app/task/skland.py
Normal file
@@ -0,0 +1,266 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 ClozyA
|
||||
|
||||
# This file incorporates work covered by the following copyright and
|
||||
# permission notice:
|
||||
#
|
||||
# skland-checkin-ghaction Copyright © 2023 Yanstory
|
||||
# https://github.com/Yanstory/skland-checkin-ghaction
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import time
|
||||
import json
|
||||
import hmac
|
||||
import asyncio
|
||||
import hashlib
|
||||
import requests
|
||||
from urllib import parse
|
||||
|
||||
from app.core import Config
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("森空岛签到任务")
|
||||
|
||||
|
||||
async def skland_sign_in(token) -> dict:
|
||||
"""森空岛签到"""
|
||||
|
||||
app_code = "4ca99fa6b56cc2ba"
|
||||
# 用于获取grant code
|
||||
grant_code_url = "https://as.hypergryph.com/user/oauth2/v2/grant"
|
||||
# 用于获取cred
|
||||
cred_code_url = "https://zonai.skland.com/api/v1/user/auth/generate_cred_by_code"
|
||||
# 查询角色绑定
|
||||
binding_url = "https://zonai.skland.com/api/v1/game/player/binding"
|
||||
# 签到接口
|
||||
sign_url = "https://zonai.skland.com/api/v1/game/attendance"
|
||||
|
||||
# 基础请求头
|
||||
header = {
|
||||
"cred": "",
|
||||
"User-Agent": "Skland/1.5.1 (com.hypergryph.skland; build:100501001; Android 34;) Okhttp/4.11.0",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Connection": "close",
|
||||
}
|
||||
header_login = header.copy()
|
||||
header_for_sign = {
|
||||
"platform": "1",
|
||||
"timestamp": "",
|
||||
"dId": "",
|
||||
"vName": "1.5.1",
|
||||
}
|
||||
|
||||
def generate_signature(token_for_sign: str, path, body_or_query):
|
||||
"""
|
||||
生成请求签名
|
||||
|
||||
:param token_for_sign: 用于加密的token
|
||||
:param path: 请求路径(如 /api/v1/game/player/binding)
|
||||
:param body_or_query: GET用query字符串, POST用body字符串
|
||||
:return: (sign, 新的header_for_sign字典)
|
||||
"""
|
||||
|
||||
t = str(int(time.time()) - 2) # 时间戳, -2秒以防服务器时间不一致
|
||||
token_bytes = token_for_sign.encode("utf-8")
|
||||
header_ca = dict(header_for_sign)
|
||||
header_ca["timestamp"] = t
|
||||
header_ca_str = json.dumps(header_ca, separators=(",", ":"))
|
||||
s = path + body_or_query + t + header_ca_str # 拼接原始字符串
|
||||
# HMAC-SHA256 + MD5得到最终sign
|
||||
hex_s = hmac.new(token_bytes, s.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
md5 = hashlib.md5(hex_s.encode("utf-8")).hexdigest()
|
||||
return md5, header_ca
|
||||
|
||||
def get_sign_header(url: str, method, body, old_header, sign_token):
|
||||
"""
|
||||
获取带签名的请求头
|
||||
|
||||
:param url: 请求完整url
|
||||
:param method: 请求方式 GET/POST
|
||||
:param body: POST请求体或GET时为None
|
||||
:param old_header: 原始请求头
|
||||
:param sign_token: 当前会话的签名token
|
||||
:return: 新请求头
|
||||
"""
|
||||
|
||||
h = json.loads(json.dumps(old_header))
|
||||
p = parse.urlparse(url)
|
||||
if method.lower() == "get":
|
||||
sign, header_ca = generate_signature(sign_token, p.path, p.query)
|
||||
else:
|
||||
sign, header_ca = generate_signature(
|
||||
sign_token, p.path, json.dumps(body) if body else ""
|
||||
)
|
||||
h["sign"] = sign
|
||||
for i in header_ca:
|
||||
h[i] = header_ca[i]
|
||||
return h
|
||||
|
||||
def copy_header(cred):
|
||||
"""
|
||||
复制请求头并添加cred
|
||||
|
||||
:param cred: 当前会话的cred
|
||||
:return: 新的请求头
|
||||
"""
|
||||
v = json.loads(json.dumps(header))
|
||||
v["cred"] = cred
|
||||
return v
|
||||
|
||||
async def login_by_token(token_code):
|
||||
"""
|
||||
使用token一步步拿到cred和sign_token
|
||||
|
||||
:param token_code: 你的skyland token
|
||||
:return: (cred, sign_token)
|
||||
"""
|
||||
try:
|
||||
# token为json对象时提取data.content
|
||||
t = json.loads(token_code)
|
||||
token_code = t["data"]["content"]
|
||||
except:
|
||||
pass
|
||||
grant_code = await get_grant_code(token_code)
|
||||
return await get_cred(grant_code)
|
||||
|
||||
async def get_cred(grant):
|
||||
"""
|
||||
通过grant code获取cred和sign_token
|
||||
|
||||
:param grant: grant code
|
||||
:return: (cred, sign_token)
|
||||
"""
|
||||
|
||||
rsp = requests.post(
|
||||
cred_code_url,
|
||||
json={"code": grant, "kind": 1},
|
||||
headers=header_login,
|
||||
proxies=Config.get_proxies(),
|
||||
).json()
|
||||
if rsp["code"] != 0:
|
||||
raise Exception(f"获得cred失败: {rsp.get('message')}")
|
||||
sign_token = rsp["data"]["token"]
|
||||
cred = rsp["data"]["cred"]
|
||||
return cred, sign_token
|
||||
|
||||
async def get_grant_code(token):
|
||||
"""
|
||||
通过token获取grant code
|
||||
|
||||
:param token: 你的skyland token
|
||||
:return: grant code
|
||||
"""
|
||||
rsp = requests.post(
|
||||
grant_code_url,
|
||||
json={"appCode": app_code, "token": token, "type": 0},
|
||||
headers=header_login,
|
||||
proxies=Config.get_proxies(),
|
||||
).json()
|
||||
if rsp["status"] != 0:
|
||||
raise Exception(
|
||||
f"使用token: {token[:3]}******{token[-3:]} 获得认证代码失败: {rsp.get('msg')}"
|
||||
)
|
||||
return rsp["data"]["code"]
|
||||
|
||||
async def get_binding_list(cred, sign_token):
|
||||
"""
|
||||
查询已绑定的角色列表
|
||||
|
||||
:param cred: 当前cred
|
||||
:param sign_token: 当前sign_token
|
||||
:return: 角色列表
|
||||
"""
|
||||
v = []
|
||||
rsp = requests.get(
|
||||
binding_url,
|
||||
headers=get_sign_header(
|
||||
binding_url, "get", None, copy_header(cred), sign_token
|
||||
),
|
||||
proxies=Config.get_proxies(),
|
||||
).json()
|
||||
if rsp["code"] != 0:
|
||||
logger.error(f"请求角色列表出现问题: {rsp['message']}")
|
||||
if rsp.get("message") == "用户未登录":
|
||||
logger.error(f"用户登录可能失效了, 请重新登录!")
|
||||
return v
|
||||
# 只取明日方舟(arknights)的绑定账号
|
||||
for i in rsp["data"]["list"]:
|
||||
if i.get("appCode") != "arknights":
|
||||
continue
|
||||
v.extend(i.get("bindingList"))
|
||||
return v
|
||||
|
||||
async def do_sign(cred, sign_token) -> dict:
|
||||
"""
|
||||
对所有绑定的角色进行签到
|
||||
|
||||
:param cred: 当前cred
|
||||
:param sign_token: 当前sign_token
|
||||
:return: 签到结果字典
|
||||
"""
|
||||
|
||||
characters = await get_binding_list(cred, sign_token)
|
||||
result = {"成功": [], "重复": [], "失败": [], "总计": len(characters)}
|
||||
|
||||
for character in characters:
|
||||
|
||||
body = {
|
||||
"uid": character.get("uid"),
|
||||
"gameId": character.get("channelMasterId"),
|
||||
}
|
||||
rsp = requests.post(
|
||||
sign_url,
|
||||
headers=get_sign_header(
|
||||
sign_url, "post", body, copy_header(cred), sign_token
|
||||
),
|
||||
json=body,
|
||||
proxies=Config.get_proxies(),
|
||||
).json()
|
||||
|
||||
if rsp["code"] != 0:
|
||||
|
||||
result[
|
||||
"重复" if rsp.get("message") == "请勿重复签到!" else "失败"
|
||||
].append(
|
||||
f"{character.get("nickName")}({character.get("channelName")})"
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
result["成功"].append(
|
||||
f"{character.get("nickName")}({character.get("channelName")})"
|
||||
)
|
||||
|
||||
await asyncio.sleep(3)
|
||||
|
||||
return result
|
||||
|
||||
# 主流程
|
||||
try:
|
||||
# 拿到cred和sign_token
|
||||
cred, sign_token = await login_by_token(token)
|
||||
await asyncio.sleep(1)
|
||||
# 依次签到
|
||||
return await do_sign(cred, sign_token)
|
||||
except Exception as e:
|
||||
logger.exception(f"森空岛签到失败: {e}")
|
||||
return {"成功": [], "重复": [], "失败": [], "总计": 0}
|
||||
1317
app/ui/Widget.py
1317
app/ui/Widget.py
File diff suppressed because it is too large
Load Diff
@@ -1,508 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA调度中枢界面
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QStackedWidget,
|
||||
QHBoxLayout,
|
||||
)
|
||||
from qfluentwidgets import (
|
||||
CardWidget,
|
||||
Pivot,
|
||||
ScrollArea,
|
||||
FluentIcon,
|
||||
HeaderCardWidget,
|
||||
FluentIcon,
|
||||
TextBrowser,
|
||||
ComboBox,
|
||||
SubtitleLabel,
|
||||
PushButton,
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
from app.core import Config, TaskManager, Task, MainInfoBar
|
||||
from .Widget import StatefulItemCard, ComboBoxMessageBox
|
||||
|
||||
|
||||
class DispatchCenter(QWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setObjectName("调度中枢")
|
||||
|
||||
self.pivot = Pivot(self)
|
||||
self.stackedWidget = QStackedWidget(self)
|
||||
self.Layout = QVBoxLayout(self)
|
||||
|
||||
self.script_list: Dict[str, DispatchBox] = {}
|
||||
|
||||
dispatch_box = DispatchBox("主调度台", self)
|
||||
self.script_list["主调度台"] = dispatch_box
|
||||
self.stackedWidget.addWidget(self.script_list["主调度台"])
|
||||
self.pivot.addItem(
|
||||
routeKey="主调度台",
|
||||
text="主调度台",
|
||||
onClick=self.update_top_bar,
|
||||
icon=FluentIcon.CAFE,
|
||||
)
|
||||
|
||||
self.Layout.addWidget(self.pivot, 0, Qt.AlignHCenter)
|
||||
self.Layout.addWidget(self.stackedWidget)
|
||||
self.Layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.pivot.currentItemChanged.connect(
|
||||
lambda index: self.stackedWidget.setCurrentWidget(self.script_list[index])
|
||||
)
|
||||
|
||||
def add_board(self, task: Task) -> None:
|
||||
"""添加一个调度台界面"""
|
||||
|
||||
dispatch_box = DispatchBox(task.name, self)
|
||||
|
||||
dispatch_box.top_bar.main_button.clicked.connect(
|
||||
lambda: TaskManager.stop_task(task.name)
|
||||
)
|
||||
|
||||
task.create_task_list.connect(dispatch_box.info.task.create_task)
|
||||
task.create_user_list.connect(dispatch_box.info.user.create_user)
|
||||
task.update_task_list.connect(dispatch_box.info.task.update_task)
|
||||
task.update_user_list.connect(dispatch_box.info.user.update_user)
|
||||
task.update_log_text.connect(dispatch_box.info.log_text.text.setText)
|
||||
task.accomplish.connect(lambda: self.del_board(f"调度台_{task.name}"))
|
||||
|
||||
self.script_list[f"调度台_{task.name}"] = dispatch_box
|
||||
|
||||
self.stackedWidget.addWidget(self.script_list[f"调度台_{task.name}"])
|
||||
|
||||
self.pivot.addItem(routeKey=f"调度台_{task.name}", text=f"调度台 {task.name}")
|
||||
|
||||
def del_board(self, name: str) -> None:
|
||||
"""删除指定子界面"""
|
||||
|
||||
self.pivot.setCurrentItem("主调度台")
|
||||
self.stackedWidget.removeWidget(self.script_list[name])
|
||||
self.script_list[name].deleteLater()
|
||||
self.pivot.removeWidget(name)
|
||||
|
||||
def connect_main_board(self, task: Task) -> None:
|
||||
"""连接主调度台"""
|
||||
|
||||
self.script_list["主调度台"].top_bar.Lable.setText(
|
||||
f"{task.name} - {task.mode.replace("_主调度台","")}模式"
|
||||
)
|
||||
self.script_list["主调度台"].top_bar.Lable.show()
|
||||
self.script_list["主调度台"].top_bar.object.hide()
|
||||
self.script_list["主调度台"].top_bar.mode.hide()
|
||||
self.script_list["主调度台"].top_bar.multi_button.show()
|
||||
self.script_list["主调度台"].top_bar.main_button.clicked.disconnect()
|
||||
self.script_list["主调度台"].top_bar.main_button.setText("中止任务")
|
||||
self.script_list["主调度台"].top_bar.main_button.clicked.connect(
|
||||
lambda: TaskManager.stop_task(task.name)
|
||||
)
|
||||
task.create_task_list.connect(
|
||||
self.script_list["主调度台"].info.task.create_task
|
||||
)
|
||||
task.create_user_list.connect(
|
||||
self.script_list["主调度台"].info.user.create_user
|
||||
)
|
||||
task.update_task_list.connect(
|
||||
self.script_list["主调度台"].info.task.update_task
|
||||
)
|
||||
task.update_user_list.connect(
|
||||
self.script_list["主调度台"].info.user.update_user
|
||||
)
|
||||
task.update_log_text.connect(
|
||||
self.script_list["主调度台"].info.log_text.text.setText
|
||||
)
|
||||
task.accomplish.connect(
|
||||
lambda logs: self.disconnect_main_board(task.name, logs)
|
||||
)
|
||||
|
||||
def disconnect_main_board(self, name: str, logs: list) -> None:
|
||||
"""断开主调度台"""
|
||||
|
||||
self.script_list["主调度台"].top_bar.Lable.hide()
|
||||
self.script_list["主调度台"].top_bar.object.show()
|
||||
self.script_list["主调度台"].top_bar.mode.show()
|
||||
self.script_list["主调度台"].top_bar.multi_button.hide()
|
||||
self.script_list["主调度台"].top_bar.main_button.clicked.disconnect()
|
||||
self.script_list["主调度台"].top_bar.main_button.setText("开始任务")
|
||||
self.script_list["主调度台"].top_bar.main_button.clicked.connect(
|
||||
self.script_list["主调度台"].top_bar.start_main_task
|
||||
)
|
||||
if len(logs) > 0:
|
||||
history = ""
|
||||
for log in logs:
|
||||
history += (
|
||||
f"任务名称:{log[0]},{log[1]["History"].replace("\n","\n ")}\n"
|
||||
)
|
||||
self.script_list["主调度台"].info.log_text.text.setText(history)
|
||||
else:
|
||||
self.script_list["主调度台"].info.log_text.text.setText("没有任务被执行")
|
||||
|
||||
def update_top_bar(self):
|
||||
"""更新顶栏"""
|
||||
|
||||
self.script_list["主调度台"].top_bar.object.clear()
|
||||
|
||||
for name, info in Config.queue_dict.items():
|
||||
self.script_list["主调度台"].top_bar.object.addItem(
|
||||
(
|
||||
"队列"
|
||||
if info["Config"].get(info["Config"].queueSet_Name) == ""
|
||||
else f"队列 - {info["Config"].get(info["Config"].queueSet_Name)}"
|
||||
),
|
||||
userData=name,
|
||||
)
|
||||
|
||||
for name, info in Config.member_dict.items():
|
||||
self.script_list["主调度台"].top_bar.object.addItem(
|
||||
(
|
||||
f"实例 - {info['Type']}"
|
||||
if info["Config"].get(info["Config"].MaaSet_Name) == ""
|
||||
else f"实例 - {info['Type']} - {info["Config"].get(info["Config"].MaaSet_Name)}"
|
||||
),
|
||||
userData=name,
|
||||
)
|
||||
|
||||
if len(Config.queue_dict) == 1:
|
||||
self.script_list["主调度台"].top_bar.object.setCurrentIndex(0)
|
||||
elif len(Config.member_dict) == 1:
|
||||
self.script_list["主调度台"].top_bar.object.setCurrentIndex(
|
||||
len(Config.queue_dict)
|
||||
)
|
||||
else:
|
||||
self.script_list["主调度台"].top_bar.object.setCurrentIndex(-1)
|
||||
|
||||
self.script_list["主调度台"].top_bar.mode.clear()
|
||||
self.script_list["主调度台"].top_bar.mode.addItems(["自动代理", "人工排查"])
|
||||
self.script_list["主调度台"].top_bar.mode.setCurrentIndex(0)
|
||||
|
||||
|
||||
class DispatchBox(QWidget):
|
||||
|
||||
def __init__(self, name: str, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setObjectName(name)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
scrollArea = ScrollArea()
|
||||
scrollArea.setWidgetResizable(True)
|
||||
|
||||
content_widget = QWidget()
|
||||
content_layout = QVBoxLayout(content_widget)
|
||||
|
||||
self.top_bar = self.DispatchTopBar(self, name)
|
||||
self.info = self.DispatchInfoCard(self)
|
||||
|
||||
content_layout.addWidget(self.top_bar)
|
||||
content_layout.addWidget(self.info)
|
||||
|
||||
scrollArea.setWidget(content_widget)
|
||||
|
||||
layout.addWidget(scrollArea)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
class DispatchTopBar(CardWidget):
|
||||
|
||||
def __init__(self, parent=None, name: str = None):
|
||||
super().__init__(parent)
|
||||
|
||||
Layout = QHBoxLayout(self)
|
||||
|
||||
if name == "主调度台":
|
||||
|
||||
self.Lable = SubtitleLabel("", self)
|
||||
self.Lable.hide()
|
||||
self.object = ComboBox()
|
||||
self.object.setPlaceholderText("请选择调度对象")
|
||||
self.mode = ComboBox()
|
||||
self.mode.setPlaceholderText("请选择调度模式")
|
||||
|
||||
self.multi_button = PushButton("添加任务")
|
||||
self.multi_button.clicked.connect(self.start_multi_task)
|
||||
self.main_button = PushButton("开始任务")
|
||||
self.main_button.clicked.connect(self.start_main_task)
|
||||
self.multi_button.hide()
|
||||
|
||||
Layout.addWidget(self.Lable)
|
||||
Layout.addWidget(self.object)
|
||||
Layout.addWidget(self.mode)
|
||||
Layout.addStretch(1)
|
||||
Layout.addWidget(self.multi_button)
|
||||
Layout.addWidget(self.main_button)
|
||||
|
||||
else:
|
||||
|
||||
self.Lable = SubtitleLabel(name, self)
|
||||
self.main_button = PushButton("中止任务")
|
||||
|
||||
Layout.addWidget(self.Lable)
|
||||
Layout.addStretch(1)
|
||||
Layout.addWidget(self.main_button)
|
||||
|
||||
def start_main_task(self):
|
||||
"""开始任务"""
|
||||
|
||||
if self.object.currentIndex() == -1:
|
||||
logger.warning("未选择调度对象")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "未选择调度对象", "请选择后再开始任务", 5000
|
||||
)
|
||||
return None
|
||||
|
||||
if self.mode.currentIndex() == -1:
|
||||
logger.warning("未选择调度模式")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "未选择调度模式", "请选择后再开始任务", 5000
|
||||
)
|
||||
return None
|
||||
|
||||
if self.object.currentData() in Config.running_list:
|
||||
logger.warning(f"任务已存在:{self.object.currentData()}")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "任务已存在", self.object.currentData(), 5000
|
||||
)
|
||||
return None
|
||||
|
||||
if "调度队列" in self.object.currentData():
|
||||
|
||||
logger.info(f"用户添加任务:{self.object.currentData()}")
|
||||
TaskManager.add_task(
|
||||
f"{self.mode.currentText()}_主调度台",
|
||||
self.object.currentData(),
|
||||
Config.queue_dict[self.object.currentData()]["Config"].toDict(),
|
||||
)
|
||||
|
||||
elif "脚本" in self.object.currentData():
|
||||
|
||||
if Config.member_dict[self.object.currentData()]["Type"] == "Maa":
|
||||
|
||||
logger.info(f"用户添加任务:{self.object.currentData()}")
|
||||
TaskManager.add_task(
|
||||
f"{self.mode.currentText()}_主调度台",
|
||||
"自定义队列",
|
||||
{"Queue": {"Member_1": self.object.currentData()}},
|
||||
)
|
||||
|
||||
def start_multi_task(self):
|
||||
"""开始任务"""
|
||||
|
||||
# 获取所有可用的队列和实例
|
||||
text_list = []
|
||||
data_list = []
|
||||
for name, info in Config.queue_dict.items():
|
||||
if name in Config.running_list:
|
||||
continue
|
||||
text_list.append(
|
||||
"队列"
|
||||
if info["Config"].get(info["Config"].queueSet_Name) == ""
|
||||
else f"队列 - {info["Config"].get(info["Config"].queueSet_Name)}"
|
||||
)
|
||||
data_list.append(name)
|
||||
|
||||
for name, info in Config.member_dict.items():
|
||||
if name in Config.running_list:
|
||||
continue
|
||||
text_list.append(
|
||||
f"实例 - {info['Type']}"
|
||||
if info["Config"].get(info["Config"].MaaSet_Name) == ""
|
||||
else f"实例 - {info['Type']} - {info["Config"].get(info["Config"].MaaSet_Name)}"
|
||||
)
|
||||
data_list.append(name)
|
||||
|
||||
choice = ComboBoxMessageBox(
|
||||
self.window(),
|
||||
"选择一个对象以添加相应多开任务",
|
||||
["选择调度对象"],
|
||||
[text_list],
|
||||
[data_list],
|
||||
)
|
||||
|
||||
if choice.exec() and choice.input[0].currentIndex() != -1:
|
||||
|
||||
if choice.input[0].currentData() in Config.running_list:
|
||||
logger.warning(f"任务已存在:{choice.input[0].currentData()}")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "任务已存在", choice.input[0].currentData(), 5000
|
||||
)
|
||||
return None
|
||||
|
||||
if "调度队列" in choice.input[0].currentData():
|
||||
|
||||
logger.info(f"用户添加任务:{choice.input[0].currentData()}")
|
||||
TaskManager.add_task(
|
||||
"自动代理_新调度台",
|
||||
choice.input[0].currentData(),
|
||||
Config.queue_dict[choice.input[0].currentData()][
|
||||
"Config"
|
||||
].toDict(),
|
||||
)
|
||||
|
||||
elif "脚本" in choice.input[0].currentData():
|
||||
|
||||
if (
|
||||
Config.member_dict[choice.input[0].currentData()]["Type"]
|
||||
== "Maa"
|
||||
):
|
||||
|
||||
logger.info(f"用户添加任务:{choice.input[0].currentData()}")
|
||||
TaskManager.add_task(
|
||||
"自动代理_新调度台",
|
||||
f"自定义队列 - {choice.input[0].currentData()}",
|
||||
{"Queue": {"Member_1": choice.input[0].currentData()}},
|
||||
)
|
||||
|
||||
class DispatchInfoCard(HeaderCardWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setTitle("调度信息")
|
||||
|
||||
self.task = self.TaskInfoCard(self)
|
||||
self.user = self.UserInfoCard(self)
|
||||
self.log_text = self.LogCard(self)
|
||||
|
||||
self.viewLayout.addWidget(self.task)
|
||||
self.viewLayout.addWidget(self.user)
|
||||
self.viewLayout.addWidget(self.log_text)
|
||||
|
||||
self.viewLayout.setStretch(0, 1)
|
||||
self.viewLayout.setStretch(1, 1)
|
||||
self.viewLayout.setStretch(2, 5)
|
||||
|
||||
def update_board(self, task_list: list, user_list: list, log: str):
|
||||
"""更新调度信息"""
|
||||
|
||||
self.task.update_task(task_list)
|
||||
self.user.update_user(user_list)
|
||||
self.log_text.text.setText(log)
|
||||
|
||||
class TaskInfoCard(HeaderCardWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setTitle("任务队列")
|
||||
|
||||
self.Layout = QVBoxLayout()
|
||||
self.viewLayout.addLayout(self.Layout)
|
||||
self.viewLayout.setContentsMargins(3, 0, 3, 3)
|
||||
|
||||
self.task_cards: List[StatefulItemCard] = []
|
||||
|
||||
def create_task(self, task_list: list):
|
||||
"""创建任务队列"""
|
||||
|
||||
while self.Layout.count() > 0:
|
||||
item = self.Layout.takeAt(0)
|
||||
if item.spacerItem():
|
||||
self.Layout.removeItem(item.spacerItem())
|
||||
elif item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
self.task_cards = []
|
||||
|
||||
for task in task_list:
|
||||
|
||||
self.task_cards.append(StatefulItemCard(task))
|
||||
self.Layout.addWidget(self.task_cards[-1])
|
||||
|
||||
self.Layout.addStretch(1)
|
||||
|
||||
def update_task(self, task_list: list):
|
||||
"""更新任务队列"""
|
||||
|
||||
for i in range(len(task_list)):
|
||||
|
||||
self.task_cards[i].update_status(task_list[i][1])
|
||||
|
||||
class UserInfoCard(HeaderCardWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setTitle("用户队列")
|
||||
|
||||
self.Layout = QVBoxLayout()
|
||||
self.viewLayout.addLayout(self.Layout)
|
||||
self.viewLayout.setContentsMargins(3, 0, 3, 3)
|
||||
|
||||
self.user_cards: List[StatefulItemCard] = []
|
||||
|
||||
def create_user(self, user_list: list):
|
||||
"""创建用户队列"""
|
||||
|
||||
while self.Layout.count() > 0:
|
||||
item = self.Layout.takeAt(0)
|
||||
if item.spacerItem():
|
||||
self.Layout.removeItem(item.spacerItem())
|
||||
elif item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
self.user_cards = []
|
||||
|
||||
for user in user_list:
|
||||
|
||||
self.user_cards.append(StatefulItemCard(user))
|
||||
self.Layout.addWidget(self.user_cards[-1])
|
||||
|
||||
self.Layout.addStretch(1)
|
||||
|
||||
def update_user(self, user_list: list):
|
||||
"""更新用户队列"""
|
||||
|
||||
for i in range(len(user_list)):
|
||||
|
||||
self.user_cards[i].Label.setText(user_list[i][0])
|
||||
self.user_cards[i].update_status(user_list[i][1])
|
||||
|
||||
class LogCard(HeaderCardWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setTitle("日志")
|
||||
|
||||
self.text = TextBrowser()
|
||||
self.viewLayout.setContentsMargins(3, 0, 3, 3)
|
||||
self.viewLayout.addWidget(self.text)
|
||||
|
||||
self.text.textChanged.connect(self.to_end)
|
||||
|
||||
def to_end(self):
|
||||
"""滚动到底部"""
|
||||
|
||||
self.text.moveCursor(QTextCursor.End)
|
||||
self.text.ensureCursorVisible()
|
||||
@@ -1,592 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA更新器
|
||||
v1.2
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
import requests
|
||||
import subprocess
|
||||
import time
|
||||
import psutil
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout
|
||||
from qfluentwidgets import (
|
||||
ProgressBar,
|
||||
IndeterminateProgressBar,
|
||||
BodyLabel,
|
||||
setTheme,
|
||||
Theme,
|
||||
)
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtCore import QThread, Signal, QTimer, QEventLoop
|
||||
|
||||
from typing import List, Dict, Union
|
||||
|
||||
|
||||
def version_text(version_numb: list) -> str:
|
||||
"""将版本号列表转为可读的文本信息"""
|
||||
|
||||
while len(version_numb) < 4:
|
||||
version_numb.append(0)
|
||||
|
||||
if version_numb[3] == 0:
|
||||
version = f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
|
||||
else:
|
||||
version = (
|
||||
f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
class DownloadProcess(QThread):
|
||||
"""分段下载子线程"""
|
||||
|
||||
progress = Signal(int)
|
||||
accomplish = Signal(float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
start_byte: int,
|
||||
end_byte: int,
|
||||
download_path: Path,
|
||||
check_times: int = -1,
|
||||
) -> None:
|
||||
super(DownloadProcess, self).__init__()
|
||||
|
||||
self.url = url
|
||||
self.start_byte = start_byte
|
||||
self.end_byte = end_byte
|
||||
self.download_path = download_path
|
||||
self.check_times = check_times
|
||||
|
||||
def run(self) -> None:
|
||||
|
||||
# 清理可能存在的临时文件
|
||||
if self.download_path.exists():
|
||||
self.download_path.unlink()
|
||||
|
||||
headers = {"Range": f"bytes={self.start_byte}-{self.end_byte}"}
|
||||
|
||||
while not self.isInterruptionRequested() and self.check_times != 0:
|
||||
|
||||
try:
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
response = requests.get(
|
||||
self.url, headers=headers, timeout=10, stream=True
|
||||
)
|
||||
|
||||
if response.status_code != 206:
|
||||
|
||||
if self.check_times != -1:
|
||||
self.check_times -= 1
|
||||
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
downloaded_size = 0
|
||||
with self.download_path.open(mode="wb") as f:
|
||||
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
|
||||
if self.isInterruptionRequested():
|
||||
break
|
||||
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
|
||||
self.progress.emit(downloaded_size)
|
||||
|
||||
if self.isInterruptionRequested():
|
||||
|
||||
if self.download_path.exists():
|
||||
self.download_path.unlink()
|
||||
self.accomplish.emit(0)
|
||||
|
||||
else:
|
||||
|
||||
self.accomplish.emit(time.time() - start_time)
|
||||
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
|
||||
if self.check_times != -1:
|
||||
self.check_times -= 1
|
||||
time.sleep(1)
|
||||
|
||||
else:
|
||||
|
||||
if self.download_path.exists():
|
||||
self.download_path.unlink()
|
||||
self.accomplish.emit(0)
|
||||
|
||||
|
||||
class ZipExtractProcess(QThread):
|
||||
"""解压子线程"""
|
||||
|
||||
info = Signal(str)
|
||||
accomplish = Signal()
|
||||
|
||||
def __init__(self, name: str, app_path: Path, download_path: Path) -> None:
|
||||
super(ZipExtractProcess, self).__init__()
|
||||
|
||||
self.name = name
|
||||
self.app_path = app_path
|
||||
self.download_path = download_path
|
||||
|
||||
def run(self) -> None:
|
||||
|
||||
try:
|
||||
|
||||
while True:
|
||||
|
||||
if self.isInterruptionRequested():
|
||||
self.download_path.unlink()
|
||||
return None
|
||||
try:
|
||||
with zipfile.ZipFile(self.download_path, "r") as zip_ref:
|
||||
zip_ref.extractall(self.app_path)
|
||||
self.accomplish.emit()
|
||||
break
|
||||
except PermissionError:
|
||||
if self.name == "AUTO_MAA":
|
||||
self.info.emit(f"解压出错:AUTO_MAA正在运行,正在尝试将其关闭")
|
||||
self.kill_process(self.app_path / "AUTO_MAA.exe")
|
||||
else:
|
||||
self.info.emit(f"解压出错:{self.name}正在运行,正在等待其关闭")
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
e = str(e)
|
||||
e = "\n".join([e[_ : _ + 75] for _ in range(0, len(e), 75)])
|
||||
self.info.emit(f"解压更新时出错:\n{e}")
|
||||
return None
|
||||
|
||||
def kill_process(self, path: Path) -> None:
|
||||
"""根据路径中止进程"""
|
||||
|
||||
for pid in self.search_pids(path):
|
||||
killprocess = subprocess.Popen(
|
||||
f"taskkill /F /PID {pid}",
|
||||
shell=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
killprocess.wait()
|
||||
|
||||
def search_pids(self, path: Path) -> list:
|
||||
"""根据路径查找进程PID"""
|
||||
|
||||
pids = []
|
||||
for proc in psutil.process_iter(["pid", "exe"]):
|
||||
try:
|
||||
if proc.info["exe"] and proc.info["exe"].lower() == str(path).lower():
|
||||
pids.append(proc.info["pid"])
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
# 进程可能在此期间已结束或无法访问,忽略这些异常
|
||||
pass
|
||||
return pids
|
||||
|
||||
|
||||
class DownloadManager(QDialog):
|
||||
"""下载管理器"""
|
||||
|
||||
speed_test_accomplish = Signal()
|
||||
download_accomplish = Signal()
|
||||
download_process_clear = Signal()
|
||||
|
||||
isInterruptionRequested = False
|
||||
|
||||
def __init__(self, app_path: Path, name: str, version: list, config: dict) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.app_path = app_path
|
||||
self.name = name
|
||||
self.version = version
|
||||
self.config = config
|
||||
self.download_path = app_path / "DOWNLOAD_TEMP.zip" # 临时下载文件的路径
|
||||
self.download_process_dict: Dict[str, DownloadProcess] = {}
|
||||
self.timer_dict: Dict[str, QTimer] = {}
|
||||
|
||||
self.resize(700, 70)
|
||||
|
||||
setTheme(Theme.AUTO, lazy=True)
|
||||
|
||||
# 创建垂直布局
|
||||
self.Layout = QVBoxLayout(self)
|
||||
|
||||
self.info = BodyLabel("正在初始化", self)
|
||||
self.progress_1 = IndeterminateProgressBar(self)
|
||||
self.progress_2 = ProgressBar(self)
|
||||
|
||||
self.update_progress(0, 0, 0)
|
||||
|
||||
self.Layout.addWidget(self.info)
|
||||
self.Layout.addStretch(1)
|
||||
self.Layout.addWidget(self.progress_1)
|
||||
self.Layout.addWidget(self.progress_2)
|
||||
self.Layout.addStretch(1)
|
||||
|
||||
def run(self) -> None:
|
||||
|
||||
if self.name == "AUTO_MAA":
|
||||
if self.config["mode"] == "Proxy":
|
||||
self.test_speed_task1()
|
||||
self.speed_test_accomplish.connect(self.download_task1)
|
||||
elif self.config["mode"] == "MirrorChyan":
|
||||
self.download_task1()
|
||||
elif self.config["mode"] == "MirrorChyan":
|
||||
self.download_task1()
|
||||
|
||||
def get_download_url(self, mode: str) -> Union[str, Dict[str, str]]:
|
||||
"""获取下载链接"""
|
||||
|
||||
url_dict = {}
|
||||
|
||||
if mode == "测速":
|
||||
|
||||
url_dict["GitHub站"] = (
|
||||
f"https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
|
||||
)
|
||||
url_dict["官方镜像站"] = (
|
||||
f"https://gitee.com/DLmaster_361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
|
||||
)
|
||||
for name, download_url_head in self.config["download_dict"].items():
|
||||
url_dict[name] = (
|
||||
f"{download_url_head}AUTO_MAA_{version_text(self.version)}.zip"
|
||||
)
|
||||
for proxy_url in self.config["proxy_list"]:
|
||||
url_dict[proxy_url] = (
|
||||
f"{proxy_url}https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
|
||||
)
|
||||
return url_dict
|
||||
|
||||
elif mode == "下载":
|
||||
|
||||
if self.name == "AUTO_MAA":
|
||||
|
||||
if self.config["mode"] == "Proxy":
|
||||
|
||||
if "selected" in self.config:
|
||||
selected_url = self.config["selected"]
|
||||
elif "speed_result" in self.config:
|
||||
selected_url = max(
|
||||
self.config["speed_result"],
|
||||
key=self.config["speed_result"].get,
|
||||
)
|
||||
|
||||
if selected_url == "GitHub站":
|
||||
return f"https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
|
||||
elif selected_url == "官方镜像站":
|
||||
return f"https://gitee.com/DLmaster_361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
|
||||
elif selected_url in self.config["download_dict"].keys():
|
||||
return f"{self.config["download_dict"][selected_url]}AUTO_MAA_{version_text(self.version)}.zip"
|
||||
else:
|
||||
return f"{selected_url}https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
|
||||
|
||||
elif self.config["mode"] == "MirrorChyan":
|
||||
|
||||
with requests.get(
|
||||
self.config["url"],
|
||||
allow_redirects=True,
|
||||
timeout=10,
|
||||
stream=True,
|
||||
) as response:
|
||||
if response.status_code == 200:
|
||||
return response.url
|
||||
|
||||
elif self.config["mode"] == "MirrorChyan":
|
||||
|
||||
with requests.get(
|
||||
self.config["url"], allow_redirects=True, timeout=10, stream=True
|
||||
) as response:
|
||||
if response.status_code == 200:
|
||||
return response.url
|
||||
|
||||
def test_speed_task1(self) -> None:
|
||||
|
||||
if self.isInterruptionRequested:
|
||||
return None
|
||||
|
||||
url_dict = self.get_download_url("测速")
|
||||
self.test_speed_result: Dict[str, float] = {}
|
||||
|
||||
for name, url in url_dict.items():
|
||||
|
||||
if self.isInterruptionRequested:
|
||||
break
|
||||
|
||||
# 创建测速线程,下载4MB文件以测试下载速度
|
||||
self.download_process_dict[name] = DownloadProcess(
|
||||
url,
|
||||
0,
|
||||
4194304,
|
||||
self.app_path / f"{name.replace('/','').replace(':','')}.zip",
|
||||
10,
|
||||
)
|
||||
self.test_speed_result[name] = -1
|
||||
self.download_process_dict[name].accomplish.connect(
|
||||
partial(self.test_speed_task2, name)
|
||||
)
|
||||
|
||||
self.download_process_dict[name].start()
|
||||
timer = QTimer(self)
|
||||
timer.setSingleShot(True)
|
||||
timer.timeout.connect(partial(self.kill_speed_test, name))
|
||||
timer.start(30000)
|
||||
self.timer_dict[name] = timer
|
||||
|
||||
self.update_info("正在测速,预计用时30秒")
|
||||
self.update_progress(0, 1, 0)
|
||||
|
||||
def kill_speed_test(self, name: str) -> None:
|
||||
|
||||
if name in self.download_process_dict:
|
||||
self.download_process_dict[name].requestInterruption()
|
||||
|
||||
def test_speed_task2(self, name: str, t: float) -> None:
|
||||
|
||||
# 计算下载速度
|
||||
if self.isInterruptionRequested:
|
||||
self.update_info(f"已中止测速进程:{name}")
|
||||
self.test_speed_result[name] = 0
|
||||
elif t != 0:
|
||||
self.update_info(f"{name}:{ 4 / t:.2f} MB/s")
|
||||
self.test_speed_result[name] = 4 / t
|
||||
else:
|
||||
self.update_info(f"{name}:{ 0:.2f} MB/s")
|
||||
self.test_speed_result[name] = 0
|
||||
self.update_progress(
|
||||
0,
|
||||
len(self.test_speed_result),
|
||||
sum(1 for speed in self.test_speed_result.values() if speed != -1),
|
||||
)
|
||||
|
||||
# 删除临时文件
|
||||
if (self.app_path / f"{name.replace('/','').replace(':','')}.zip").exists():
|
||||
(self.app_path / f"{name.replace('/','').replace(':','')}.zip").unlink()
|
||||
|
||||
# 清理下载线程
|
||||
self.timer_dict[name].stop()
|
||||
self.timer_dict[name].deleteLater()
|
||||
self.timer_dict.pop(name)
|
||||
self.download_process_dict[name].requestInterruption()
|
||||
self.download_process_dict[name].quit()
|
||||
self.download_process_dict[name].wait()
|
||||
self.download_process_dict[name].deleteLater()
|
||||
self.download_process_dict.pop(name)
|
||||
if not self.download_process_dict:
|
||||
self.download_process_clear.emit()
|
||||
|
||||
if any(speed == -1 for _, speed in self.test_speed_result.items()):
|
||||
return None
|
||||
|
||||
# 保存测速结果
|
||||
self.config["speed_result"] = self.test_speed_result
|
||||
|
||||
self.update_info("测速完成!")
|
||||
self.speed_test_accomplish.emit()
|
||||
|
||||
def download_task1(self) -> None:
|
||||
|
||||
if self.isInterruptionRequested:
|
||||
return None
|
||||
|
||||
url = self.get_download_url("下载")
|
||||
self.downloaded_size_list: List[List[int, bool]] = []
|
||||
|
||||
response = requests.head(url, timeout=10)
|
||||
|
||||
self.file_size = int(response.headers.get("content-length", 0))
|
||||
part_size = self.file_size // self.config["thread_numb"]
|
||||
self.downloaded_size = 0
|
||||
self.last_download_size = 0
|
||||
self.last_time = time.time()
|
||||
self.speed = 0
|
||||
|
||||
# 拆分下载任务,启用多线程下载
|
||||
for i in range(self.config["thread_numb"]):
|
||||
|
||||
if self.isInterruptionRequested:
|
||||
break
|
||||
|
||||
# 计算单任务下载范围
|
||||
start_byte = i * part_size
|
||||
end_byte = (
|
||||
(i + 1) * part_size - 1
|
||||
if (i != self.config["thread_numb"] - 1)
|
||||
else self.file_size - 1
|
||||
)
|
||||
|
||||
# 创建下载子线程
|
||||
self.download_process_dict[f"part{i}"] = DownloadProcess(
|
||||
url,
|
||||
start_byte,
|
||||
end_byte,
|
||||
self.download_path.with_suffix(f".part{i}"),
|
||||
1 if self.config["mode"] == "MirrorChyan" else -1,
|
||||
)
|
||||
self.downloaded_size_list.append([0, False])
|
||||
self.download_process_dict[f"part{i}"].progress.connect(
|
||||
partial(self.download_task2, i)
|
||||
)
|
||||
self.download_process_dict[f"part{i}"].accomplish.connect(
|
||||
partial(self.download_task3, i)
|
||||
)
|
||||
self.download_process_dict[f"part{i}"].start()
|
||||
|
||||
def download_task2(self, index: str, current: int) -> None:
|
||||
"""更新下载进度"""
|
||||
|
||||
self.downloaded_size_list[index][0] = current
|
||||
self.downloaded_size = sum([_[0] for _ in self.downloaded_size_list])
|
||||
self.update_progress(0, self.file_size, self.downloaded_size)
|
||||
|
||||
if time.time() - self.last_time >= 1.0:
|
||||
self.speed = (
|
||||
(self.downloaded_size - self.last_download_size)
|
||||
/ (time.time() - self.last_time)
|
||||
/ 1024
|
||||
)
|
||||
self.last_download_size = self.downloaded_size
|
||||
self.last_time = time.time()
|
||||
|
||||
if self.speed >= 1024:
|
||||
self.update_info(
|
||||
f"正在下载:{self.name} 已下载:{self.downloaded_size / 1048576:.2f}/{self.file_size / 1048576:.2f} MB ({self.downloaded_size / self.file_size * 100:.2f}%) 下载速度:{self.speed / 1024:.2f} MB/s",
|
||||
)
|
||||
else:
|
||||
self.update_info(
|
||||
f"正在下载:{self.name} 已下载:{self.downloaded_size / 1048576:.2f}/{self.file_size / 1048576:.2f} MB ({self.downloaded_size / self.file_size * 100:.2f}%) 下载速度:{self.speed:.2f} KB/s",
|
||||
)
|
||||
|
||||
def download_task3(self, index: str, t: float) -> None:
|
||||
|
||||
# 标记下载线程完成
|
||||
self.downloaded_size_list[index][1] = True
|
||||
|
||||
# 清理下载线程
|
||||
self.download_process_dict[f"part{index}"].requestInterruption()
|
||||
self.download_process_dict[f"part{index}"].quit()
|
||||
self.download_process_dict[f"part{index}"].wait()
|
||||
self.download_process_dict[f"part{index}"].deleteLater()
|
||||
self.download_process_dict.pop(f"part{index}")
|
||||
if not self.download_process_dict:
|
||||
self.download_process_clear.emit()
|
||||
|
||||
if (
|
||||
any([not _[1] for _ in self.downloaded_size_list])
|
||||
or self.isInterruptionRequested
|
||||
):
|
||||
return None
|
||||
|
||||
# 合并下载的分段文件
|
||||
with self.download_path.open(mode="wb") as outfile:
|
||||
for i in range(self.config["thread_numb"]):
|
||||
with self.download_path.with_suffix(f".part{i}").open(
|
||||
mode="rb"
|
||||
) as infile:
|
||||
outfile.write(infile.read())
|
||||
self.download_path.with_suffix(f".part{i}").unlink()
|
||||
|
||||
self.update_info("正在解压更新文件")
|
||||
self.update_progress(0, 0, 0)
|
||||
|
||||
# 创建解压线程
|
||||
self.zip_extract = ZipExtractProcess(
|
||||
self.name, self.app_path, self.download_path
|
||||
)
|
||||
self.zip_loop = QEventLoop()
|
||||
self.zip_extract.info.connect(self.update_info)
|
||||
self.zip_extract.accomplish.connect(self.zip_loop.quit)
|
||||
self.zip_extract.start()
|
||||
self.zip_loop.exec()
|
||||
|
||||
self.update_info("正在删除临时文件")
|
||||
self.update_progress(0, 0, 0)
|
||||
if (self.app_path / "changes.json").exists():
|
||||
(self.app_path / "changes.json").unlink()
|
||||
if self.download_path.exists():
|
||||
self.download_path.unlink()
|
||||
|
||||
# 下载完成后打开对应程序
|
||||
if not self.isInterruptionRequested and self.name == "MAA":
|
||||
subprocess.Popen(
|
||||
[self.app_path / "MAA.exe"],
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.DETACHED_PROCESS
|
||||
| subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
if self.name == "AUTO_MAA":
|
||||
self.update_info(f"即将安装{self.name}")
|
||||
else:
|
||||
self.update_info(f"{self.name}下载成功!")
|
||||
self.update_progress(0, 100, 100)
|
||||
self.download_accomplish.emit()
|
||||
|
||||
def update_info(self, text: str) -> None:
|
||||
self.info.setText(text)
|
||||
|
||||
def update_progress(self, begin: int, end: int, current: int) -> None:
|
||||
|
||||
if begin == 0 and end == 0:
|
||||
self.progress_2.setVisible(False)
|
||||
self.progress_1.setVisible(True)
|
||||
else:
|
||||
self.progress_1.setVisible(False)
|
||||
self.progress_2.setVisible(True)
|
||||
self.progress_2.setRange(begin, end)
|
||||
self.progress_2.setValue(current)
|
||||
|
||||
def requestInterruption(self) -> None:
|
||||
|
||||
self.isInterruptionRequested = True
|
||||
|
||||
if hasattr(self, "zip_extract") and self.zip_extract:
|
||||
self.zip_extract.requestInterruption()
|
||||
|
||||
if hasattr(self, "zip_loop") and self.zip_loop:
|
||||
self.zip_loop.quit()
|
||||
|
||||
for process in self.download_process_dict.values():
|
||||
process.requestInterruption()
|
||||
|
||||
if self.download_process_dict:
|
||||
loop = QEventLoop()
|
||||
self.download_process_clear.connect(loop.quit)
|
||||
loop.exec()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent):
|
||||
"""清理残余进程"""
|
||||
|
||||
self.requestInterruption()
|
||||
|
||||
event.accept()
|
||||
@@ -1,388 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA历史记录界面
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
)
|
||||
from qfluentwidgets import (
|
||||
ScrollArea,
|
||||
FluentIcon,
|
||||
HeaderCardWidget,
|
||||
PushButton,
|
||||
TextBrowser,
|
||||
CardWidget,
|
||||
ComboBox,
|
||||
ZhDatePicker,
|
||||
SubtitleLabel,
|
||||
)
|
||||
from PySide6.QtCore import Signal, QDate
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Union, List, Dict
|
||||
|
||||
|
||||
from app.core import Config
|
||||
from .Widget import StatefulItemCard, QuantifiedItemCard, QuickExpandGroupCard
|
||||
|
||||
|
||||
class History(QWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("历史记录")
|
||||
|
||||
content_widget = QWidget()
|
||||
self.content_layout = QVBoxLayout(content_widget)
|
||||
self.history_top_bar = self.HistoryTopBar(self)
|
||||
|
||||
self.history_top_bar.search_history.connect(self.reload_history)
|
||||
|
||||
scrollArea = ScrollArea()
|
||||
scrollArea.setWidgetResizable(True)
|
||||
scrollArea.setWidget(content_widget)
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self.history_top_bar)
|
||||
layout.addWidget(scrollArea)
|
||||
self.setLayout(layout)
|
||||
|
||||
self.history_card_list = []
|
||||
|
||||
def reload_history(self, mode: str, start_date: QDate, end_date: QDate) -> None:
|
||||
"""加载历史记录界面"""
|
||||
|
||||
while self.content_layout.count() > 0:
|
||||
item = self.content_layout.takeAt(0)
|
||||
if item.spacerItem():
|
||||
self.content_layout.removeItem(item.spacerItem())
|
||||
elif item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
self.history_card_list = []
|
||||
|
||||
history_dict = Config.search_history(
|
||||
mode,
|
||||
datetime(start_date.year(), start_date.month(), start_date.day()),
|
||||
datetime(end_date.year(), end_date.month(), end_date.day()),
|
||||
)
|
||||
|
||||
for date, user in history_dict.items():
|
||||
|
||||
self.history_card_list.append(self.HistoryCard(mode, date, user, self))
|
||||
self.content_layout.addWidget(self.history_card_list[-1])
|
||||
|
||||
self.content_layout.addStretch(1)
|
||||
|
||||
class HistoryTopBar(CardWidget):
|
||||
"""历史记录顶部工具栏"""
|
||||
|
||||
search_history = Signal(str, QDate, QDate)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
Layout = QHBoxLayout(self)
|
||||
|
||||
self.lable_1 = SubtitleLabel("查询范围:")
|
||||
self.start_date = ZhDatePicker()
|
||||
self.start_date.setDate(QDate(2019, 5, 1))
|
||||
self.lable_2 = SubtitleLabel("→")
|
||||
self.end_date = ZhDatePicker()
|
||||
server_date = Config.server_date()
|
||||
self.end_date.setDate(
|
||||
QDate(server_date.year, server_date.month, server_date.day)
|
||||
)
|
||||
self.mode = ComboBox()
|
||||
self.mode.setPlaceholderText("请选择查询模式")
|
||||
self.mode.addItems(["按日合并", "按周合并", "按月合并"])
|
||||
|
||||
self.select_month = PushButton(FluentIcon.TAG, "最近一月")
|
||||
self.select_week = PushButton(FluentIcon.TAG, "最近一周")
|
||||
self.search = PushButton(FluentIcon.SEARCH, "查询")
|
||||
self.select_month.clicked.connect(lambda: self.select_date("month"))
|
||||
self.select_week.clicked.connect(lambda: self.select_date("week"))
|
||||
self.search.clicked.connect(
|
||||
lambda: self.search_history.emit(
|
||||
self.mode.currentText(),
|
||||
self.start_date.getDate(),
|
||||
self.end_date.getDate(),
|
||||
)
|
||||
)
|
||||
|
||||
Layout.addWidget(self.lable_1)
|
||||
Layout.addWidget(self.start_date)
|
||||
Layout.addWidget(self.lable_2)
|
||||
Layout.addWidget(self.end_date)
|
||||
Layout.addWidget(self.mode)
|
||||
Layout.addStretch(1)
|
||||
Layout.addWidget(self.select_month)
|
||||
Layout.addWidget(self.select_week)
|
||||
Layout.addWidget(self.search)
|
||||
|
||||
def select_date(self, date: str) -> None:
|
||||
"""选中最近一段时间并启动查询"""
|
||||
|
||||
server_date = Config.server_date()
|
||||
if date == "week":
|
||||
begin_date = server_date - timedelta(weeks=1)
|
||||
elif date == "month":
|
||||
begin_date = server_date - timedelta(days=30)
|
||||
|
||||
self.start_date.setDate(
|
||||
QDate(begin_date.year, begin_date.month, begin_date.day)
|
||||
)
|
||||
self.end_date.setDate(
|
||||
QDate(server_date.year, server_date.month, server_date.day)
|
||||
)
|
||||
|
||||
self.search.clicked.emit()
|
||||
|
||||
class HistoryCard(QuickExpandGroupCard):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: str,
|
||||
date: str,
|
||||
user: Union[List[Path], Dict[str, List[Path]]],
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(
|
||||
FluentIcon.HISTORY, date, f"{date}的历史运行记录与统计信息", parent
|
||||
)
|
||||
|
||||
widget = QWidget()
|
||||
Layout = QVBoxLayout(widget)
|
||||
self.viewLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.viewLayout.setSpacing(0)
|
||||
self.addGroupWidget(widget)
|
||||
|
||||
self.user_history_card_list = []
|
||||
|
||||
if mode == "按日合并":
|
||||
|
||||
for user_path in user:
|
||||
self.user_history_card_list.append(
|
||||
self.UserHistoryCard(mode, user_path.stem, user_path, self)
|
||||
)
|
||||
Layout.addWidget(self.user_history_card_list[-1])
|
||||
|
||||
elif mode in ["按周合并", "按月合并"]:
|
||||
|
||||
for user, info in user.items():
|
||||
self.user_history_card_list.append(
|
||||
self.UserHistoryCard(mode, user, info, self)
|
||||
)
|
||||
Layout.addWidget(self.user_history_card_list[-1])
|
||||
|
||||
class UserHistoryCard(HeaderCardWidget):
|
||||
"""用户历史记录卡片"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: str,
|
||||
name: str,
|
||||
user_history: Union[Path, List[Path]],
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setTitle(name)
|
||||
|
||||
if mode == "按日合并":
|
||||
|
||||
self.user_history_path = user_history
|
||||
self.main_history = Config.load_maa_logs("总览", user_history)
|
||||
|
||||
self.index_card = self.IndexCard(
|
||||
self.main_history["条目索引"], self
|
||||
)
|
||||
self.index_card.index_changed.connect(self.update_info)
|
||||
self.viewLayout.addWidget(self.index_card)
|
||||
|
||||
elif mode in ["按周合并", "按月合并"]:
|
||||
|
||||
history = Config.merge_maa_logs("指定项", user_history)
|
||||
|
||||
self.main_history = {}
|
||||
self.main_history["统计数据"] = {
|
||||
"公招统计": list(history["recruit_statistics"].items())
|
||||
}
|
||||
|
||||
for game_id, drops in history["drop_statistics"].items():
|
||||
self.main_history["统计数据"][f"掉落统计:{game_id}"] = list(
|
||||
drops.items()
|
||||
)
|
||||
|
||||
self.statistics_card = QHBoxLayout()
|
||||
self.log_card = self.LogCard(self)
|
||||
|
||||
self.viewLayout.addLayout(self.statistics_card)
|
||||
self.viewLayout.addWidget(self.log_card)
|
||||
self.viewLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.viewLayout.setSpacing(0)
|
||||
self.viewLayout.setStretch(0, 1)
|
||||
self.viewLayout.setStretch(2, 4)
|
||||
|
||||
self.update_info("数据总览")
|
||||
|
||||
def update_info(self, index: str) -> None:
|
||||
"""更新信息"""
|
||||
|
||||
if index == "数据总览":
|
||||
|
||||
while self.statistics_card.count() > 0:
|
||||
item = self.statistics_card.takeAt(0)
|
||||
if item.spacerItem():
|
||||
self.statistics_card.removeItem(item.spacerItem())
|
||||
elif item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
for name, item_list in self.main_history["统计数据"].items():
|
||||
|
||||
statistics_card = self.StatisticsCard(name, item_list, self)
|
||||
self.statistics_card.addWidget(statistics_card)
|
||||
|
||||
self.log_card.hide()
|
||||
|
||||
else:
|
||||
|
||||
single_history = Config.load_maa_logs(
|
||||
"单项",
|
||||
self.user_history_path.with_suffix("")
|
||||
/ f"{index.replace(":","-")}.json",
|
||||
)
|
||||
|
||||
while self.statistics_card.count() > 0:
|
||||
item = self.statistics_card.takeAt(0)
|
||||
if item.spacerItem():
|
||||
self.statistics_card.removeItem(item.spacerItem())
|
||||
elif item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
for name, item_list in single_history["统计数据"].items():
|
||||
|
||||
statistics_card = self.StatisticsCard(name, item_list, self)
|
||||
self.statistics_card.addWidget(statistics_card)
|
||||
|
||||
self.log_card.text.setText(single_history["日志信息"])
|
||||
self.log_card.open_file.clicked.disconnect()
|
||||
self.log_card.open_file.clicked.connect(
|
||||
lambda: os.startfile(
|
||||
self.user_history_path.with_suffix("")
|
||||
/ f"{index.replace(":","-")}.log"
|
||||
)
|
||||
)
|
||||
self.log_card.open_dir.clicked.disconnect()
|
||||
self.log_card.open_dir.clicked.connect(
|
||||
lambda: subprocess.Popen(
|
||||
[
|
||||
"explorer",
|
||||
"/select,",
|
||||
str(
|
||||
self.user_history_path.with_suffix("")
|
||||
/ f"{index.replace(":","-")}.log"
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
self.log_card.show()
|
||||
|
||||
self.viewLayout.setStretch(1, self.statistics_card.count())
|
||||
|
||||
self.setMinimumHeight(300)
|
||||
|
||||
class IndexCard(HeaderCardWidget):
|
||||
|
||||
index_changed = Signal(str)
|
||||
|
||||
def __init__(self, index_list: list, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setTitle("记录条目")
|
||||
|
||||
self.Layout = QVBoxLayout()
|
||||
self.viewLayout.addLayout(self.Layout)
|
||||
self.viewLayout.setContentsMargins(3, 0, 3, 3)
|
||||
|
||||
self.index_cards: List[StatefulItemCard] = []
|
||||
|
||||
for index in index_list:
|
||||
|
||||
self.index_cards.append(StatefulItemCard(index))
|
||||
self.index_cards[-1].clicked.connect(
|
||||
partial(self.index_changed.emit, index[0])
|
||||
)
|
||||
self.Layout.addWidget(self.index_cards[-1])
|
||||
|
||||
self.Layout.addStretch(1)
|
||||
|
||||
class StatisticsCard(HeaderCardWidget):
|
||||
|
||||
def __init__(self, name: str, item_list: list, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setTitle(name)
|
||||
|
||||
self.Layout = QVBoxLayout()
|
||||
self.viewLayout.addLayout(self.Layout)
|
||||
self.viewLayout.setContentsMargins(3, 0, 3, 3)
|
||||
|
||||
self.item_cards: List[QuantifiedItemCard] = []
|
||||
|
||||
for item in item_list:
|
||||
|
||||
self.item_cards.append(QuantifiedItemCard(item))
|
||||
self.Layout.addWidget(self.item_cards[-1])
|
||||
|
||||
if len(item_list) == 0:
|
||||
self.Layout.addWidget(QuantifiedItemCard(["暂无记录", ""]))
|
||||
|
||||
self.Layout.addStretch(1)
|
||||
|
||||
class LogCard(HeaderCardWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setTitle("日志")
|
||||
|
||||
self.text = TextBrowser(self)
|
||||
self.open_file = PushButton("打开日志文件", self)
|
||||
self.open_file.clicked.connect(lambda: print("打开日志文件"))
|
||||
self.open_dir = PushButton("打开所在目录", self)
|
||||
self.open_dir.clicked.connect(lambda: print("打开所在文件"))
|
||||
|
||||
Layout = QVBoxLayout()
|
||||
h_layout = QHBoxLayout()
|
||||
h_layout.addWidget(self.open_file)
|
||||
h_layout.addWidget(self.open_dir)
|
||||
Layout.addWidget(self.text)
|
||||
Layout.addLayout(h_layout)
|
||||
self.viewLayout.setContentsMargins(3, 0, 3, 3)
|
||||
self.viewLayout.addLayout(Layout)
|
||||
402
app/ui/home.py
402
app/ui/home.py
@@ -1,402 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA主界面
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QSpacerItem,
|
||||
QSizePolicy,
|
||||
QFileDialog,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QSize, QUrl
|
||||
from PySide6.QtGui import QDesktopServices, QColor
|
||||
from qfluentwidgets import (
|
||||
FluentIcon,
|
||||
ScrollArea,
|
||||
SimpleCardWidget,
|
||||
PrimaryToolButton,
|
||||
TextBrowser,
|
||||
)
|
||||
import re
|
||||
import shutil
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.core import Config, MainInfoBar, Network
|
||||
from .Widget import Banner, IconButton
|
||||
|
||||
|
||||
class Home(QWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("主页")
|
||||
|
||||
self.banner = Banner()
|
||||
self.banner_text = TextBrowser()
|
||||
|
||||
widget = QWidget()
|
||||
Layout = QVBoxLayout(widget)
|
||||
|
||||
Layout.addWidget(self.banner)
|
||||
Layout.addWidget(self.banner_text)
|
||||
Layout.setStretch(0, 2)
|
||||
Layout.setStretch(1, 3)
|
||||
|
||||
v_layout = QVBoxLayout(self.banner)
|
||||
v_layout.setContentsMargins(0, 0, 0, 15)
|
||||
v_layout.setSpacing(5)
|
||||
v_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# 空白占位符
|
||||
v_layout.addItem(
|
||||
QSpacerItem(10, 20, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
|
||||
# 顶部部分 (按钮组)
|
||||
h1_layout = QHBoxLayout()
|
||||
h1_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# 左边留白区域
|
||||
h1_layout.addStretch()
|
||||
|
||||
# 按钮组
|
||||
buttonGroup = ButtonGroup()
|
||||
buttonGroup.setMaximumHeight(320)
|
||||
h1_layout.addWidget(buttonGroup)
|
||||
|
||||
# 空白占位符
|
||||
h1_layout.addItem(
|
||||
QSpacerItem(20, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
|
||||
# 将顶部水平布局添加到垂直布局
|
||||
v_layout.addLayout(h1_layout)
|
||||
|
||||
# 中间留白区域
|
||||
v_layout.addItem(
|
||||
QSpacerItem(10, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
v_layout.addStretch()
|
||||
|
||||
# 中间留白区域
|
||||
v_layout.addItem(
|
||||
QSpacerItem(10, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
v_layout.addStretch()
|
||||
|
||||
# 底部部分 (图片切换按钮)
|
||||
h2_layout = QHBoxLayout()
|
||||
h2_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# 左边留白区域
|
||||
h2_layout.addItem(
|
||||
QSpacerItem(20, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
|
||||
# # 公告卡片
|
||||
# noticeCard = NoticeCard()
|
||||
# h2_layout.addWidget(noticeCard)
|
||||
|
||||
h2_layout.addStretch()
|
||||
|
||||
# 自定义图像按钮布局
|
||||
self.imageButton = PrimaryToolButton(FluentIcon.IMAGE_EXPORT)
|
||||
self.imageButton.setFixedSize(56, 56)
|
||||
self.imageButton.setIconSize(QSize(32, 32))
|
||||
self.imageButton.clicked.connect(self.get_home_image)
|
||||
|
||||
v1_layout = QVBoxLayout()
|
||||
v1_layout.addWidget(self.imageButton, alignment=Qt.AlignmentFlag.AlignBottom)
|
||||
|
||||
h2_layout.addLayout(v1_layout)
|
||||
|
||||
# 空白占位符
|
||||
h2_layout.addItem(
|
||||
QSpacerItem(25, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
|
||||
# 将底部水平布局添加到垂直布局
|
||||
v_layout.addLayout(h2_layout)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
scrollArea = ScrollArea()
|
||||
scrollArea.setWidgetResizable(True)
|
||||
scrollArea.setWidget(widget)
|
||||
layout.addWidget(scrollArea)
|
||||
self.setLayout(layout)
|
||||
|
||||
self.set_banner()
|
||||
|
||||
def get_home_image(self) -> None:
|
||||
"""获取主页图片"""
|
||||
|
||||
if Config.get(Config.function_HomeImageMode) == "默认":
|
||||
pass
|
||||
elif Config.get(Config.function_HomeImageMode) == "自定义":
|
||||
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "打开自定义主页图片", "", "图片文件 (*.png *.jpg *.bmp)"
|
||||
)
|
||||
if file_path:
|
||||
|
||||
for file in Config.app_path.glob(
|
||||
"resources/images/Home/BannerCustomize.*"
|
||||
):
|
||||
file.unlink()
|
||||
|
||||
shutil.copy(
|
||||
file_path,
|
||||
Config.app_path
|
||||
/ f"resources/images/Home/BannerCustomize{Path(file_path).suffix}",
|
||||
)
|
||||
|
||||
logger.info(f"自定义主页图片更换成功:{file_path}")
|
||||
MainInfoBar.push_info_bar(
|
||||
"success",
|
||||
"主页图片更换成功",
|
||||
"自定义主页图片更换成功!",
|
||||
3000,
|
||||
)
|
||||
|
||||
else:
|
||||
logger.warning("自定义主页图片更换失败:未选择图片文件")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning",
|
||||
"主页图片更换失败",
|
||||
"未选择图片文件!",
|
||||
5000,
|
||||
)
|
||||
elif Config.get(Config.function_HomeImageMode) == "主题图像":
|
||||
|
||||
# 从远程服务器获取最新主题图像
|
||||
Network.set_info(
|
||||
mode="get",
|
||||
url="https://gitee.com/DLmaster_361/AUTO_MAA/raw/server/theme_image.json",
|
||||
)
|
||||
Network.start()
|
||||
Network.loop.exec()
|
||||
if Network.stutus_code == 200:
|
||||
theme_image = Network.response_json
|
||||
else:
|
||||
logger.warning(f"获取最新主题图像时出错:{Network.error_message}")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning",
|
||||
"获取最新主题图像时出错",
|
||||
f"网络错误:{Network.stutus_code}",
|
||||
5000,
|
||||
)
|
||||
return None
|
||||
|
||||
if (Config.app_path / "resources/theme_image.json").exists():
|
||||
with (Config.app_path / "resources/theme_image.json").open(
|
||||
mode="r", encoding="utf-8"
|
||||
) as f:
|
||||
theme_image_local = json.load(f)
|
||||
time_local = datetime.strptime(
|
||||
theme_image_local["time"], "%Y-%m-%d %H:%M"
|
||||
)
|
||||
else:
|
||||
time_local = datetime.strptime("2000-01-01 00:00", "%Y-%m-%d %H:%M")
|
||||
|
||||
if not (
|
||||
Config.app_path / "resources/images/Home/BannerTheme.jpg"
|
||||
).exists() or (
|
||||
datetime.now()
|
||||
> datetime.strptime(theme_image["time"], "%Y-%m-%d %H:%M")
|
||||
> time_local
|
||||
):
|
||||
|
||||
Network.set_info(
|
||||
mode="get_file",
|
||||
url=theme_image["url"],
|
||||
path=Config.app_path / "resources/images/Home/BannerTheme.jpg",
|
||||
)
|
||||
Network.start()
|
||||
Network.loop.exec()
|
||||
|
||||
if Network.stutus_code == 200:
|
||||
|
||||
with (Config.app_path / "resources/theme_image.json").open(
|
||||
mode="w", encoding="utf-8"
|
||||
) as f:
|
||||
json.dump(theme_image, f, ensure_ascii=False, indent=4)
|
||||
|
||||
logger.success(f"主题图像「{theme_image["name"]}」下载成功")
|
||||
MainInfoBar.push_info_bar(
|
||||
"success",
|
||||
"主题图像下载成功",
|
||||
f"「{theme_image["name"]}」下载成功!",
|
||||
3000,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
logger.warning(f"下载最新主题图像时出错:{Network.error_message}")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning",
|
||||
"下载最新主题图像时出错",
|
||||
f"网络错误:{Network.stutus_code}",
|
||||
5000,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
logger.info("主题图像已是最新")
|
||||
MainInfoBar.push_info_bar(
|
||||
"info",
|
||||
"主题图像已是最新",
|
||||
"主题图像已是最新!",
|
||||
3000,
|
||||
)
|
||||
|
||||
self.set_banner()
|
||||
|
||||
def set_banner(self):
|
||||
"""设置主页图像"""
|
||||
if Config.get(Config.function_HomeImageMode) == "默认":
|
||||
self.banner.set_banner_image(
|
||||
str(Config.app_path / "resources/images/Home/BannerDefault.png")
|
||||
)
|
||||
self.imageButton.hide()
|
||||
self.banner_text.setVisible(False)
|
||||
elif Config.get(Config.function_HomeImageMode) == "自定义":
|
||||
for file in Config.app_path.glob("resources/images/Home/BannerCustomize.*"):
|
||||
self.banner.set_banner_image(str(file))
|
||||
break
|
||||
self.imageButton.show()
|
||||
self.banner_text.setVisible(False)
|
||||
elif Config.get(Config.function_HomeImageMode) == "主题图像":
|
||||
self.banner.set_banner_image(
|
||||
str(Config.app_path / "resources/images/Home/BannerTheme.jpg")
|
||||
)
|
||||
self.imageButton.show()
|
||||
self.banner_text.setVisible(True)
|
||||
|
||||
if (Config.app_path / "resources/theme_image.json").exists():
|
||||
with (Config.app_path / "resources/theme_image.json").open(
|
||||
mode="r", encoding="utf-8"
|
||||
) as f:
|
||||
theme_image = json.load(f)
|
||||
html_content = theme_image["html"]
|
||||
else:
|
||||
html_content = "<h1>主题图像</h1><p>主题图像信息未知</p>"
|
||||
|
||||
self.banner_text.setHtml(re.sub(r"<img[^>]*>", "", html_content))
|
||||
|
||||
|
||||
class ButtonGroup(SimpleCardWidget):
|
||||
"""显示主页和 GitHub 按钮的竖直按钮组"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.setFixedSize(56, 180)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# 创建主页按钮
|
||||
home_button = IconButton(
|
||||
FluentIcon.HOME.icon(color=QColor("#fff")),
|
||||
tip_title="AUTO_MAA官网",
|
||||
tip_content="AUTO_MAA官方文档站",
|
||||
isTooltip=True,
|
||||
)
|
||||
home_button.setIconSize(QSize(32, 32))
|
||||
home_button.clicked.connect(self.open_home)
|
||||
layout.addWidget(home_button)
|
||||
|
||||
# 创建 GitHub 按钮
|
||||
github_button = IconButton(
|
||||
FluentIcon.GITHUB.icon(color=QColor("#fff")),
|
||||
tip_title="Github仓库",
|
||||
tip_content="如果本项目有帮助到您~\n不妨给项目点一个Star⭐",
|
||||
isTooltip=True,
|
||||
)
|
||||
github_button.setIconSize(QSize(32, 32))
|
||||
github_button.clicked.connect(self.open_github)
|
||||
layout.addWidget(github_button)
|
||||
|
||||
# # 创建 文档 按钮
|
||||
# doc_button = IconButton(
|
||||
# FluentIcon.DICTIONARY.icon(color=QColor("#fff")),
|
||||
# tip_title="自助排障文档",
|
||||
# tip_content="点击打开自助排障文档,好孩子都能看懂",
|
||||
# isTooltip=True,
|
||||
# )
|
||||
# doc_button.setIconSize(QSize(32, 32))
|
||||
# doc_button.clicked.connect(self.open_doc)
|
||||
# layout.addWidget(doc_button)
|
||||
|
||||
# 创建 Q群 按钮
|
||||
doc_button = IconButton(
|
||||
FluentIcon.CHAT.icon(color=QColor("#fff")),
|
||||
tip_title="官方社群",
|
||||
tip_content="加入官方群聊【AUTO_MAA绝赞DeBug中!】",
|
||||
isTooltip=True,
|
||||
)
|
||||
doc_button.setIconSize(QSize(32, 32))
|
||||
doc_button.clicked.connect(self.open_chat)
|
||||
layout.addWidget(doc_button)
|
||||
|
||||
# 创建 MirrorChyan 按钮
|
||||
doc_button = IconButton(
|
||||
FluentIcon.SHOPPING_CART.icon(color=QColor("#fff")),
|
||||
tip_title="非官方店铺",
|
||||
tip_content="获取 MirrorChyan CDK,更新快人一步",
|
||||
isTooltip=True,
|
||||
)
|
||||
doc_button.setIconSize(QSize(32, 32))
|
||||
doc_button.clicked.connect(self.open_sales)
|
||||
layout.addWidget(doc_button)
|
||||
|
||||
def _normalBackgroundColor(self):
|
||||
return QColor(0, 0, 0, 96)
|
||||
|
||||
def open_home(self):
|
||||
"""打开主页链接"""
|
||||
QDesktopServices.openUrl(QUrl("https://clozya.github.io/AUTOMAA_docs"))
|
||||
|
||||
def open_github(self):
|
||||
"""打开 GitHub 链接"""
|
||||
QDesktopServices.openUrl(QUrl("https://github.com/DLmaster361/AUTO_MAA"))
|
||||
|
||||
def open_chat(self):
|
||||
"""打开 Q群 链接"""
|
||||
QDesktopServices.openUrl(QUrl("https://qm.qq.com/q/bd9fISNoME"))
|
||||
|
||||
def open_doc(self):
|
||||
"""打开 文档 链接"""
|
||||
QDesktopServices.openUrl(QUrl("https://clozya.github.io/AUTOMAA_docs"))
|
||||
|
||||
def open_sales(self):
|
||||
"""打开 MirrorChyan 链接"""
|
||||
QDesktopServices.openUrl(QUrl("https://mirrorchyan.com/"))
|
||||
@@ -1,442 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA主界面
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from PySide6.QtWidgets import QApplication, QSystemTrayIcon
|
||||
from qfluentwidgets import (
|
||||
Action,
|
||||
SystemTrayMenu,
|
||||
SplashScreen,
|
||||
FluentIcon,
|
||||
setTheme,
|
||||
isDarkTheme,
|
||||
SystemThemeListener,
|
||||
Theme,
|
||||
MSFluentWindow,
|
||||
NavigationItemPosition,
|
||||
)
|
||||
from PySide6.QtGui import QIcon, QCloseEvent
|
||||
from PySide6.QtCore import QTimer
|
||||
from datetime import datetime, timedelta
|
||||
import shutil
|
||||
import darkdetect
|
||||
|
||||
from app.core import Config, TaskManager, MainTimer, MainInfoBar
|
||||
from app.services import Notify, Crypto, System
|
||||
from .home import Home
|
||||
from .member_manager import MemberManager
|
||||
from .queue_manager import QueueManager
|
||||
from .dispatch_center import DispatchCenter
|
||||
from .history import History
|
||||
from .setting import Setting
|
||||
|
||||
|
||||
class AUTO_MAA(MSFluentWindow):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.setWindowIcon(QIcon(str(Config.app_path / "resources/icons/AUTO_MAA.ico")))
|
||||
|
||||
version_numb = list(map(int, Config.VERSION.split(".")))
|
||||
version_text = (
|
||||
f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
|
||||
if version_numb[3] == 0
|
||||
else f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
|
||||
)
|
||||
|
||||
self.setWindowTitle(f"AUTO_MAA - {version_text}")
|
||||
|
||||
self.switch_theme()
|
||||
|
||||
self.splashScreen = SplashScreen(self.windowIcon(), self)
|
||||
self.show_ui("显示主窗口", if_quick=True)
|
||||
|
||||
Config.main_window = self.window()
|
||||
|
||||
# 创建主窗口
|
||||
self.home = Home(self)
|
||||
self.member_manager = MemberManager(self)
|
||||
self.queue_manager = QueueManager(self)
|
||||
self.dispatch_center = DispatchCenter(self)
|
||||
self.history = History(self)
|
||||
self.setting = Setting(self)
|
||||
|
||||
self.addSubInterface(
|
||||
self.home,
|
||||
FluentIcon.HOME,
|
||||
"主页",
|
||||
FluentIcon.HOME,
|
||||
NavigationItemPosition.TOP,
|
||||
)
|
||||
self.addSubInterface(
|
||||
self.member_manager,
|
||||
FluentIcon.ROBOT,
|
||||
"脚本管理",
|
||||
FluentIcon.ROBOT,
|
||||
NavigationItemPosition.TOP,
|
||||
)
|
||||
self.addSubInterface(
|
||||
self.queue_manager,
|
||||
FluentIcon.BOOK_SHELF,
|
||||
"调度队列",
|
||||
FluentIcon.BOOK_SHELF,
|
||||
NavigationItemPosition.TOP,
|
||||
)
|
||||
self.addSubInterface(
|
||||
self.dispatch_center,
|
||||
FluentIcon.IOT,
|
||||
"调度中心",
|
||||
FluentIcon.IOT,
|
||||
NavigationItemPosition.TOP,
|
||||
)
|
||||
self.addSubInterface(
|
||||
self.history,
|
||||
FluentIcon.HISTORY,
|
||||
"历史记录",
|
||||
FluentIcon.HISTORY,
|
||||
NavigationItemPosition.BOTTOM,
|
||||
)
|
||||
self.addSubInterface(
|
||||
self.setting,
|
||||
FluentIcon.SETTING,
|
||||
"设置",
|
||||
FluentIcon.SETTING,
|
||||
NavigationItemPosition.BOTTOM,
|
||||
)
|
||||
self.stackedWidget.currentChanged.connect(
|
||||
lambda index: (
|
||||
self.queue_manager.reload_member_name() if index == 2 else None
|
||||
)
|
||||
)
|
||||
self.stackedWidget.currentChanged.connect(
|
||||
lambda index: (
|
||||
self.dispatch_center.pivot.setCurrentItem("主调度台")
|
||||
if index == 3
|
||||
else None
|
||||
)
|
||||
)
|
||||
self.stackedWidget.currentChanged.connect(
|
||||
lambda index: (
|
||||
self.dispatch_center.update_top_bar() if index == 3 else None
|
||||
)
|
||||
)
|
||||
|
||||
# 创建系统托盘及其菜单
|
||||
self.tray = QSystemTrayIcon(
|
||||
QIcon(str(Config.app_path / "resources/icons/AUTO_MAA.ico")), self
|
||||
)
|
||||
self.tray.setToolTip("AUTO_MAA")
|
||||
self.tray_menu = SystemTrayMenu("AUTO_MAA", self)
|
||||
|
||||
# 显示主界面菜单项
|
||||
self.tray_menu.addAction(
|
||||
Action(
|
||||
FluentIcon.CAFE,
|
||||
"显示主界面",
|
||||
triggered=lambda: self.show_ui("显示主窗口"),
|
||||
)
|
||||
)
|
||||
self.tray_menu.addSeparator()
|
||||
|
||||
# 开始任务菜单项
|
||||
self.tray_menu.addActions(
|
||||
[
|
||||
Action(FluentIcon.PLAY, "运行自动代理", triggered=self.start_main_task),
|
||||
Action(
|
||||
FluentIcon.PAUSE,
|
||||
"中止所有任务",
|
||||
triggered=lambda: TaskManager.stop_task("ALL"),
|
||||
),
|
||||
]
|
||||
)
|
||||
self.tray_menu.addSeparator()
|
||||
|
||||
# 退出主程序菜单项
|
||||
self.tray_menu.addAction(
|
||||
Action(
|
||||
FluentIcon.POWER_BUTTON,
|
||||
"退出主程序",
|
||||
triggered=lambda: System.set_power("KillSelf"),
|
||||
)
|
||||
)
|
||||
|
||||
# 设置托盘菜单
|
||||
self.tray.setContextMenu(self.tray_menu)
|
||||
self.tray.activated.connect(self.on_tray_activated)
|
||||
|
||||
self.set_min_method()
|
||||
|
||||
Config.user_info_changed.connect(self.member_manager.refresh_dashboard)
|
||||
TaskManager.create_gui.connect(self.dispatch_center.add_board)
|
||||
TaskManager.connect_gui.connect(self.dispatch_center.connect_main_board)
|
||||
Notify.push_info_bar.connect(MainInfoBar.push_info_bar)
|
||||
self.setting.ui.card_IfShowTray.checkedChanged.connect(
|
||||
lambda: self.show_ui("配置托盘")
|
||||
)
|
||||
self.setting.ui.card_IfToTray.checkedChanged.connect(self.set_min_method)
|
||||
self.setting.function.card_HomeImageMode.comboBox.currentIndexChanged.connect(
|
||||
lambda index: (
|
||||
self.home.get_home_image() if index == 2 else self.home.set_banner()
|
||||
)
|
||||
)
|
||||
|
||||
self.splashScreen.finish()
|
||||
|
||||
self.themeListener = SystemThemeListener(self)
|
||||
self.themeListener.systemThemeChanged.connect(self.switch_theme)
|
||||
self.themeListener.start()
|
||||
|
||||
def switch_theme(self) -> None:
|
||||
"""切换主题"""
|
||||
|
||||
setTheme(
|
||||
Theme(darkdetect.theme()) if darkdetect.theme() else Theme.LIGHT, lazy=True
|
||||
)
|
||||
QTimer.singleShot(300, lambda: setTheme(Theme.AUTO, lazy=True))
|
||||
|
||||
# 云母特效启用时需要增加重试机制
|
||||
# 云母特效不兼容Win10,如果True则通过云母进行主题转换,False则根据当前主题设置背景颜色
|
||||
if self.isMicaEffectEnabled():
|
||||
QTimer.singleShot(
|
||||
300,
|
||||
lambda: self.windowEffect.setMicaEffect(self.winId(), isDarkTheme()),
|
||||
)
|
||||
|
||||
else:
|
||||
# 根据当前主题设置背景颜色
|
||||
if isDarkTheme():
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
CardWidget {background-color: #313131;}
|
||||
HeaderCardWidget {background-color: #313131;}
|
||||
background-color: #313131;
|
||||
"""
|
||||
)
|
||||
else:
|
||||
self.setStyleSheet("background-color: #ffffff;")
|
||||
|
||||
def start_up_task(self) -> None:
|
||||
"""启动时任务"""
|
||||
|
||||
# 清理旧日志
|
||||
self.clean_old_logs()
|
||||
|
||||
# 清理安装包
|
||||
if (Config.app_path / "AUTO_MAA-Setup.exe").exists():
|
||||
try:
|
||||
(Config.app_path / "AUTO_MAA-Setup.exe").unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 检查密码
|
||||
self.setting.check_PASSWORD()
|
||||
|
||||
# 获取主题图像
|
||||
if Config.get(Config.function_HomeImageMode) == "主题图像":
|
||||
self.home.get_home_image()
|
||||
|
||||
# 直接运行主任务
|
||||
if Config.get(Config.start_IfRunDirectly):
|
||||
|
||||
self.start_main_task()
|
||||
|
||||
# 获取公告
|
||||
self.setting.show_notice(if_first=True)
|
||||
|
||||
# 检查更新
|
||||
if Config.get(Config.update_IfAutoUpdate):
|
||||
self.setting.check_update(if_first=True)
|
||||
|
||||
# 直接最小化
|
||||
if Config.get(Config.start_IfMinimizeDirectly):
|
||||
|
||||
self.titleBar.minBtn.click()
|
||||
|
||||
def set_min_method(self) -> None:
|
||||
"""设置最小化方法"""
|
||||
|
||||
if Config.get(Config.ui_IfToTray):
|
||||
|
||||
self.titleBar.minBtn.clicked.disconnect()
|
||||
self.titleBar.minBtn.clicked.connect(lambda: self.show_ui("隐藏到托盘"))
|
||||
|
||||
else:
|
||||
|
||||
self.titleBar.minBtn.clicked.disconnect()
|
||||
self.titleBar.minBtn.clicked.connect(self.window().showMinimized)
|
||||
|
||||
def on_tray_activated(self, reason):
|
||||
"""双击返回主界面"""
|
||||
if reason == QSystemTrayIcon.DoubleClick:
|
||||
self.show_ui("显示主窗口")
|
||||
|
||||
def clean_old_logs(self):
|
||||
"""
|
||||
删除超过用户设定天数的日志文件(基于目录日期)
|
||||
"""
|
||||
|
||||
if Config.get(Config.function_HistoryRetentionTime) == 0:
|
||||
logger.info("由于用户设置日志永久保留,跳过日志清理")
|
||||
return
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
for date_folder in (Config.app_path / "history").iterdir():
|
||||
if not date_folder.is_dir():
|
||||
continue # 只处理日期文件夹
|
||||
|
||||
try:
|
||||
# 只检查 `YYYY-MM-DD` 格式的文件夹
|
||||
folder_date = datetime.strptime(date_folder.name, "%Y-%m-%d")
|
||||
if datetime.now() - folder_date > timedelta(
|
||||
days=Config.get(Config.function_HistoryRetentionTime)
|
||||
):
|
||||
shutil.rmtree(date_folder, ignore_errors=True)
|
||||
deleted_count += 1
|
||||
logger.info(f"已删除超期日志目录: {date_folder}")
|
||||
except ValueError:
|
||||
logger.warning(f"非日期格式的目录: {date_folder}")
|
||||
|
||||
logger.info(f"清理完成: {deleted_count} 个日期目录")
|
||||
|
||||
def start_main_task(self) -> None:
|
||||
"""启动主任务"""
|
||||
|
||||
if "调度队列_1" in Config.queue_dict:
|
||||
|
||||
logger.info("自动添加任务:调度队列_1")
|
||||
TaskManager.add_task(
|
||||
"自动代理_主调度台",
|
||||
"调度队列_1",
|
||||
Config.queue_dict["调度队列_1"]["Config"].toDict(),
|
||||
)
|
||||
|
||||
elif "脚本_1" in Config.member_dict:
|
||||
|
||||
logger.info("自动添加任务:脚本_1")
|
||||
TaskManager.add_task(
|
||||
"自动代理_主调度台", "自定义队列", {"Queue": {"Member_1": "脚本_1"}}
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
logger.warning("启动主任务失败:未找到有效的主任务配置文件")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "启动主任务失败", "“调度队列_1”与“脚本_1”均不存在", -1
|
||||
)
|
||||
|
||||
def show_ui(self, mode: str, if_quick: bool = False) -> None:
|
||||
"""配置窗口状态"""
|
||||
|
||||
self.switch_theme()
|
||||
|
||||
if mode == "显示主窗口":
|
||||
|
||||
# 配置主窗口
|
||||
if not self.window().isVisible():
|
||||
size = list(
|
||||
map(
|
||||
int,
|
||||
Config.get(Config.ui_size).split("x"),
|
||||
)
|
||||
)
|
||||
location = list(
|
||||
map(
|
||||
int,
|
||||
Config.get(Config.ui_location).split("x"),
|
||||
)
|
||||
)
|
||||
if self.window().isMaximized():
|
||||
self.window().showNormal()
|
||||
self.window().setGeometry(location[0], location[1], size[0], size[1])
|
||||
self.window().show()
|
||||
if not if_quick:
|
||||
if Config.get(Config.ui_maximized):
|
||||
self.titleBar.maxBtn.click()
|
||||
self.show_ui("配置托盘")
|
||||
|
||||
if not any(
|
||||
self.window().geometry().intersects(screen.availableGeometry())
|
||||
for screen in QApplication.screens()
|
||||
):
|
||||
self.window().showNormal()
|
||||
self.window().setGeometry(100, 100, 1200, 700)
|
||||
|
||||
self.window().raise_()
|
||||
self.window().activateWindow()
|
||||
|
||||
elif mode == "配置托盘":
|
||||
|
||||
if Config.get(Config.ui_IfShowTray):
|
||||
self.tray.show()
|
||||
else:
|
||||
self.tray.hide()
|
||||
|
||||
elif mode == "隐藏到托盘":
|
||||
|
||||
# 保存窗口相关属性
|
||||
if not self.window().isMaximized():
|
||||
|
||||
Config.set(
|
||||
Config.ui_size,
|
||||
f"{self.geometry().width()}x{self.geometry().height()}",
|
||||
)
|
||||
Config.set(
|
||||
Config.ui_location,
|
||||
f"{self.geometry().x()}x{self.geometry().y()}",
|
||||
)
|
||||
|
||||
Config.set(Config.ui_maximized, self.window().isMaximized())
|
||||
Config.save()
|
||||
|
||||
# 隐藏主窗口
|
||||
if not if_quick:
|
||||
|
||||
self.window().hide()
|
||||
self.tray.show()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent):
|
||||
"""清理残余进程"""
|
||||
|
||||
self.show_ui("隐藏到托盘", if_quick=True)
|
||||
|
||||
# 清理各功能线程
|
||||
MainTimer.Timer.stop()
|
||||
MainTimer.Timer.deleteLater()
|
||||
MainTimer.LongTimer.stop()
|
||||
MainTimer.LongTimer.deleteLater()
|
||||
TaskManager.stop_task("ALL")
|
||||
|
||||
# 关闭主题监听
|
||||
self.themeListener.terminate()
|
||||
self.themeListener.deleteLater()
|
||||
|
||||
logger.info("AUTO_MAA主程序关闭")
|
||||
logger.info("----------------END----------------")
|
||||
|
||||
event.accept()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,710 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA调度队列界面
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QStackedWidget,
|
||||
QHBoxLayout,
|
||||
)
|
||||
from qfluentwidgets import (
|
||||
Action,
|
||||
Pivot,
|
||||
ScrollArea,
|
||||
FluentIcon,
|
||||
MessageBox,
|
||||
HeaderCardWidget,
|
||||
CommandBar,
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from typing import List
|
||||
|
||||
from app.core import QueueConfig, Config, MainInfoBar
|
||||
from .Widget import (
|
||||
SwitchSettingCard,
|
||||
ComboBoxSettingCard,
|
||||
LineEditSettingCard,
|
||||
TimeEditSettingCard,
|
||||
NoOptionComboBoxSettingCard,
|
||||
HistoryCard,
|
||||
)
|
||||
|
||||
|
||||
class QueueManager(QWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setObjectName("调度队列")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.tools = CommandBar()
|
||||
|
||||
self.queue_manager = self.QueueSettingBox(self)
|
||||
|
||||
# 逐个添加动作
|
||||
self.tools.addActions(
|
||||
[
|
||||
Action(
|
||||
FluentIcon.ADD_TO, "新建调度队列", triggered=self.add_setting_box
|
||||
),
|
||||
Action(
|
||||
FluentIcon.REMOVE_FROM,
|
||||
"删除调度队列",
|
||||
triggered=self.del_setting_box,
|
||||
),
|
||||
]
|
||||
)
|
||||
self.tools.addSeparator()
|
||||
self.tools.addActions(
|
||||
[
|
||||
Action(
|
||||
FluentIcon.LEFT_ARROW, "向左移动", triggered=self.left_setting_box
|
||||
),
|
||||
Action(
|
||||
FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_setting_box
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
layout.addWidget(self.tools)
|
||||
layout.addWidget(self.queue_manager)
|
||||
|
||||
def add_setting_box(self):
|
||||
"""添加一个调度队列"""
|
||||
|
||||
index = len(Config.queue_dict) + 1
|
||||
|
||||
queue_config = QueueConfig()
|
||||
queue_config.load(
|
||||
Config.app_path / f"config/QueueConfig/调度队列_{index}.json", queue_config
|
||||
)
|
||||
queue_config.save()
|
||||
|
||||
Config.queue_dict[f"调度队列_{index}"] = {
|
||||
"Path": Config.app_path / f"config/QueueConfig/调度队列_{index}.json",
|
||||
"Config": queue_config,
|
||||
}
|
||||
|
||||
self.queue_manager.add_QueueSettingBox(index)
|
||||
self.queue_manager.switch_SettingBox(index)
|
||||
|
||||
logger.success(f"调度队列_{index} 添加成功")
|
||||
MainInfoBar.push_info_bar("success", "操作成功", f"添加 调度队列_{index}", 3000)
|
||||
|
||||
def del_setting_box(self):
|
||||
"""删除一个调度队列实例"""
|
||||
|
||||
name = self.queue_manager.pivot.currentRouteKey()
|
||||
|
||||
if name == None:
|
||||
logger.warning("未选择调度队列")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "未选择调度队列", "请先选择一个调度队列", 5000
|
||||
)
|
||||
return None
|
||||
|
||||
if name in Config.running_list:
|
||||
logger.warning("调度队列正在运行")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "调度队列正在运行", "请先停止调度队列", 5000
|
||||
)
|
||||
return None
|
||||
|
||||
choice = MessageBox(
|
||||
"确认",
|
||||
f"确定要删除 {name} 吗?",
|
||||
self.window(),
|
||||
)
|
||||
if choice.exec():
|
||||
|
||||
self.queue_manager.clear_SettingBox()
|
||||
|
||||
Config.queue_dict[name]["Path"].unlink()
|
||||
for i in range(int(name[5:]) + 1, len(Config.queue_dict) + 1):
|
||||
if Config.queue_dict[f"调度队列_{i}"]["Path"].exists():
|
||||
Config.queue_dict[f"调度队列_{i}"]["Path"].rename(
|
||||
Config.queue_dict[f"调度队列_{i}"]["Path"].with_name(
|
||||
f"调度队列_{i-1}.json"
|
||||
)
|
||||
)
|
||||
|
||||
self.queue_manager.show_SettingBox(max(int(name[5:]) - 1, 1))
|
||||
|
||||
logger.success(f"{name} 删除成功")
|
||||
MainInfoBar.push_info_bar("success", "操作成功", f"删除 {name}", 3000)
|
||||
|
||||
def left_setting_box(self):
|
||||
"""向左移动调度队列实例"""
|
||||
|
||||
name = self.queue_manager.pivot.currentRouteKey()
|
||||
|
||||
if name == None:
|
||||
logger.warning("未选择调度队列")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "未选择调度队列", "请先选择一个调度队列", 5000
|
||||
)
|
||||
return None
|
||||
|
||||
index = int(name[5:])
|
||||
|
||||
if index == 1:
|
||||
logger.warning("向左移动调度队列时已到达最左端")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "已经是第一个调度队列", "无法向左移动", 5000
|
||||
)
|
||||
return None
|
||||
|
||||
if name in Config.running_list or f"调度队列_{index-1}" in Config.running_list:
|
||||
logger.warning("相关调度队列正在运行")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "相关调度队列正在运行", "请先停止调度队列", 5000
|
||||
)
|
||||
return None
|
||||
|
||||
self.queue_manager.clear_SettingBox()
|
||||
|
||||
Config.queue_dict[name]["Path"].rename(
|
||||
Config.queue_dict[name]["Path"].with_name("调度队列_0.json")
|
||||
)
|
||||
Config.queue_dict[f"调度队列_{index-1}"]["Path"].rename(
|
||||
Config.queue_dict[name]["Path"]
|
||||
)
|
||||
Config.queue_dict[name]["Path"].with_name("调度队列_0.json").rename(
|
||||
Config.queue_dict[f"调度队列_{index-1}"]["Path"]
|
||||
)
|
||||
|
||||
self.queue_manager.show_SettingBox(index - 1)
|
||||
|
||||
logger.success(f"{name} 左移成功")
|
||||
MainInfoBar.push_info_bar("success", "操作成功", f"左移 {name}", 3000)
|
||||
|
||||
def right_setting_box(self):
|
||||
"""向右移动调度队列实例"""
|
||||
|
||||
name = self.queue_manager.pivot.currentRouteKey()
|
||||
|
||||
if name == None:
|
||||
logger.warning("未选择调度队列")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "未选择调度队列", "请先选择一个调度队列", 5000
|
||||
)
|
||||
return None
|
||||
|
||||
index = int(name[5:])
|
||||
|
||||
if index == len(Config.queue_dict):
|
||||
logger.warning("向右移动调度队列时已到达最右端")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "已经是最后一个调度队列", "无法向右移动", 5000
|
||||
)
|
||||
return None
|
||||
|
||||
if name in Config.running_list or f"调度队列_{index+1}" in Config.running_list:
|
||||
logger.warning("相关调度队列正在运行")
|
||||
MainInfoBar.push_info_bar(
|
||||
"warning", "相关调度队列正在运行", "请先停止调度队列", 5000
|
||||
)
|
||||
return None
|
||||
|
||||
self.queue_manager.clear_SettingBox()
|
||||
|
||||
Config.queue_dict[name]["Path"].rename(
|
||||
Config.queue_dict[name]["Path"].with_name("调度队列_0.json")
|
||||
)
|
||||
Config.queue_dict[f"调度队列_{index+1}"]["Path"].rename(
|
||||
Config.queue_dict[name]["Path"]
|
||||
)
|
||||
Config.queue_dict[name]["Path"].with_name("调度队列_0.json").rename(
|
||||
Config.queue_dict[f"调度队列_{index+1}"]["Path"]
|
||||
)
|
||||
|
||||
self.queue_manager.show_SettingBox(index + 1)
|
||||
|
||||
logger.success(f"{name} 右移成功")
|
||||
MainInfoBar.push_info_bar("success", "操作成功", f"右移 {name}", 3000)
|
||||
|
||||
def reload_member_name(self):
|
||||
"""刷新调度队列成员"""
|
||||
|
||||
member_list = [
|
||||
["禁用"] + [_ for _ in Config.member_dict.keys()],
|
||||
["未启用"]
|
||||
+ [
|
||||
(
|
||||
k
|
||||
if v["Config"].get(v["Config"].MaaSet_Name) == ""
|
||||
else f"{k} - {v["Config"].get(v["Config"].MaaSet_Name)}"
|
||||
)
|
||||
for k, v in Config.member_dict.items()
|
||||
],
|
||||
]
|
||||
for script in self.queue_manager.script_list:
|
||||
|
||||
script.task.card_Member_1.reLoadOptions(
|
||||
value=member_list[0], texts=member_list[1]
|
||||
)
|
||||
script.task.card_Member_2.reLoadOptions(
|
||||
value=member_list[0], texts=member_list[1]
|
||||
)
|
||||
script.task.card_Member_3.reLoadOptions(
|
||||
value=member_list[0], texts=member_list[1]
|
||||
)
|
||||
script.task.card_Member_4.reLoadOptions(
|
||||
value=member_list[0], texts=member_list[1]
|
||||
)
|
||||
script.task.card_Member_5.reLoadOptions(
|
||||
value=member_list[0], texts=member_list[1]
|
||||
)
|
||||
script.task.card_Member_6.reLoadOptions(
|
||||
value=member_list[0], texts=member_list[1]
|
||||
)
|
||||
script.task.card_Member_7.reLoadOptions(
|
||||
value=member_list[0], texts=member_list[1]
|
||||
)
|
||||
script.task.card_Member_8.reLoadOptions(
|
||||
value=member_list[0], texts=member_list[1]
|
||||
)
|
||||
script.task.card_Member_9.reLoadOptions(
|
||||
value=member_list[0], texts=member_list[1]
|
||||
)
|
||||
script.task.card_Member_10.reLoadOptions(
|
||||
value=member_list[0], texts=member_list[1]
|
||||
)
|
||||
|
||||
class QueueSettingBox(QWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setObjectName("调度队列管理")
|
||||
|
||||
self.pivot = Pivot(self)
|
||||
self.stackedWidget = QStackedWidget(self)
|
||||
self.Layout = QVBoxLayout(self)
|
||||
|
||||
self.script_list: List[
|
||||
QueueManager.QueueSettingBox.QueueMemberSettingBox
|
||||
] = []
|
||||
|
||||
self.Layout.addWidget(self.pivot, 0, Qt.AlignHCenter)
|
||||
self.Layout.addWidget(self.stackedWidget)
|
||||
self.Layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.pivot.currentItemChanged.connect(
|
||||
lambda index: self.switch_SettingBox(
|
||||
int(index[5:]), if_change_pivot=False
|
||||
)
|
||||
)
|
||||
|
||||
self.show_SettingBox(1)
|
||||
|
||||
def show_SettingBox(self, index) -> None:
|
||||
"""加载所有子界面"""
|
||||
|
||||
Config.search_queue()
|
||||
|
||||
for name in Config.queue_dict.keys():
|
||||
self.add_QueueSettingBox(int(name[5:]))
|
||||
|
||||
self.switch_SettingBox(index)
|
||||
|
||||
def switch_SettingBox(self, index: int, if_change_pivot: bool = True) -> None:
|
||||
"""切换到指定的子界面"""
|
||||
|
||||
if len(Config.queue_dict) == 0:
|
||||
return None
|
||||
|
||||
if index > len(Config.queue_dict):
|
||||
return None
|
||||
|
||||
if if_change_pivot:
|
||||
self.pivot.setCurrentItem(self.script_list[index - 1].objectName())
|
||||
self.stackedWidget.setCurrentWidget(self.script_list[index - 1])
|
||||
|
||||
def clear_SettingBox(self) -> None:
|
||||
"""清空所有子界面"""
|
||||
|
||||
for sub_interface in self.script_list:
|
||||
self.stackedWidget.removeWidget(sub_interface)
|
||||
sub_interface.deleteLater()
|
||||
self.script_list.clear()
|
||||
self.pivot.clear()
|
||||
|
||||
def add_QueueSettingBox(self, uid: int) -> None:
|
||||
"""添加一个调度队列设置界面"""
|
||||
|
||||
maa_setting_box = self.QueueMemberSettingBox(uid, self)
|
||||
|
||||
self.script_list.append(maa_setting_box)
|
||||
|
||||
self.stackedWidget.addWidget(self.script_list[-1])
|
||||
|
||||
self.pivot.addItem(routeKey=f"调度队列_{uid}", text=f"调度队列 {uid}")
|
||||
|
||||
class QueueMemberSettingBox(QWidget):
|
||||
|
||||
def __init__(self, uid: int, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setObjectName(f"调度队列_{uid}")
|
||||
self.config = Config.queue_dict[f"调度队列_{uid}"]["Config"]
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
scrollArea = ScrollArea()
|
||||
scrollArea.setWidgetResizable(True)
|
||||
|
||||
content_widget = QWidget()
|
||||
content_layout = QVBoxLayout(content_widget)
|
||||
|
||||
self.queue_set = self.QueueSetSettingCard(self.config, self)
|
||||
self.time = self.TimeSettingCard(self.config, self)
|
||||
self.task = self.TaskSettingCard(self.config, self)
|
||||
self.history = HistoryCard(
|
||||
qconfig=self.config,
|
||||
configItem=self.config.Data_LastProxyHistory,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
content_layout.addWidget(self.queue_set)
|
||||
content_layout.addWidget(self.time)
|
||||
content_layout.addWidget(self.task)
|
||||
content_layout.addWidget(self.history)
|
||||
content_layout.addStretch(1)
|
||||
|
||||
scrollArea.setWidget(content_widget)
|
||||
|
||||
layout.addWidget(scrollArea)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
class QueueSetSettingCard(HeaderCardWidget):
|
||||
|
||||
def __init__(self, config: QueueConfig, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setTitle("队列设置")
|
||||
self.config = config
|
||||
|
||||
self.card_Name = LineEditSettingCard(
|
||||
icon=FluentIcon.EDIT,
|
||||
title="调度队列名称",
|
||||
content="用于标识调度队列的名称",
|
||||
text="请输入调度队列名称",
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queueSet_Name,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Enable = SwitchSettingCard(
|
||||
icon=FluentIcon.HOME,
|
||||
title="状态",
|
||||
content="调度队列状态,仅启用时会执行定时任务",
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queueSet_Enabled,
|
||||
parent=self,
|
||||
)
|
||||
self.card_AfterAccomplish = ComboBoxSettingCard(
|
||||
icon=FluentIcon.POWER_BUTTON,
|
||||
title="调度队列结束后",
|
||||
content="选择调度队列结束后的操作",
|
||||
texts=[
|
||||
"无动作",
|
||||
"退出AUTO_MAA",
|
||||
"睡眠(win系统需禁用休眠)",
|
||||
"休眠",
|
||||
"关机",
|
||||
],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queueSet_AfterAccomplish,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
Layout = QVBoxLayout()
|
||||
Layout.addWidget(self.card_Name)
|
||||
Layout.addWidget(self.card_Enable)
|
||||
Layout.addWidget(self.card_AfterAccomplish)
|
||||
|
||||
self.viewLayout.addLayout(Layout)
|
||||
|
||||
class TimeSettingCard(HeaderCardWidget):
|
||||
|
||||
def __init__(self, config: QueueConfig, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setTitle("定时设置")
|
||||
self.config = config
|
||||
|
||||
widget_1 = QWidget()
|
||||
Layout_1 = QVBoxLayout(widget_1)
|
||||
widget_2 = QWidget()
|
||||
Layout_2 = QVBoxLayout(widget_2)
|
||||
Layout = QHBoxLayout()
|
||||
|
||||
self.card_Time_0 = TimeEditSettingCard(
|
||||
icon=FluentIcon.STOP_WATCH,
|
||||
title="定时 1",
|
||||
content=None,
|
||||
qconfig=self.config,
|
||||
configItem_bool=self.config.time_TimeEnabled_0,
|
||||
configItem_time=self.config.time_TimeSet_0,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Time_1 = TimeEditSettingCard(
|
||||
icon=FluentIcon.STOP_WATCH,
|
||||
title="定时 2",
|
||||
content=None,
|
||||
qconfig=self.config,
|
||||
configItem_bool=self.config.time_TimeEnabled_1,
|
||||
configItem_time=self.config.time_TimeSet_1,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Time_2 = TimeEditSettingCard(
|
||||
icon=FluentIcon.STOP_WATCH,
|
||||
title="定时 3",
|
||||
content=None,
|
||||
qconfig=self.config,
|
||||
configItem_bool=self.config.time_TimeEnabled_2,
|
||||
configItem_time=self.config.time_TimeSet_2,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Time_3 = TimeEditSettingCard(
|
||||
icon=FluentIcon.STOP_WATCH,
|
||||
title="定时 4",
|
||||
content=None,
|
||||
qconfig=self.config,
|
||||
configItem_bool=self.config.time_TimeEnabled_3,
|
||||
configItem_time=self.config.time_TimeSet_3,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Time_4 = TimeEditSettingCard(
|
||||
icon=FluentIcon.STOP_WATCH,
|
||||
title="定时 5",
|
||||
content=None,
|
||||
qconfig=self.config,
|
||||
configItem_bool=self.config.time_TimeEnabled_4,
|
||||
configItem_time=self.config.time_TimeSet_4,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Time_5 = TimeEditSettingCard(
|
||||
icon=FluentIcon.STOP_WATCH,
|
||||
title="定时 6",
|
||||
content=None,
|
||||
qconfig=self.config,
|
||||
configItem_bool=self.config.time_TimeEnabled_5,
|
||||
configItem_time=self.config.time_TimeSet_5,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Time_6 = TimeEditSettingCard(
|
||||
icon=FluentIcon.STOP_WATCH,
|
||||
title="定时 7",
|
||||
content=None,
|
||||
qconfig=self.config,
|
||||
configItem_bool=self.config.time_TimeEnabled_6,
|
||||
configItem_time=self.config.time_TimeSet_6,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Time_7 = TimeEditSettingCard(
|
||||
icon=FluentIcon.STOP_WATCH,
|
||||
title="定时 8",
|
||||
content=None,
|
||||
qconfig=self.config,
|
||||
configItem_bool=self.config.time_TimeEnabled_7,
|
||||
configItem_time=self.config.time_TimeSet_7,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Time_8 = TimeEditSettingCard(
|
||||
icon=FluentIcon.STOP_WATCH,
|
||||
title="定时 9",
|
||||
content=None,
|
||||
qconfig=self.config,
|
||||
configItem_bool=self.config.time_TimeEnabled_8,
|
||||
configItem_time=self.config.time_TimeSet_8,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Time_9 = TimeEditSettingCard(
|
||||
icon=FluentIcon.STOP_WATCH,
|
||||
title="定时 10",
|
||||
content=None,
|
||||
qconfig=self.config,
|
||||
configItem_bool=self.config.time_TimeEnabled_9,
|
||||
configItem_time=self.config.time_TimeSet_9,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
Layout_1.addWidget(self.card_Time_0)
|
||||
Layout_1.addWidget(self.card_Time_1)
|
||||
Layout_1.addWidget(self.card_Time_2)
|
||||
Layout_1.addWidget(self.card_Time_3)
|
||||
Layout_1.addWidget(self.card_Time_4)
|
||||
Layout_2.addWidget(self.card_Time_5)
|
||||
Layout_2.addWidget(self.card_Time_6)
|
||||
Layout_2.addWidget(self.card_Time_7)
|
||||
Layout_2.addWidget(self.card_Time_8)
|
||||
Layout_2.addWidget(self.card_Time_9)
|
||||
Layout.addWidget(widget_1)
|
||||
Layout.addWidget(widget_2)
|
||||
|
||||
self.viewLayout.addLayout(Layout)
|
||||
|
||||
class TaskSettingCard(HeaderCardWidget):
|
||||
|
||||
def __init__(self, config: QueueConfig, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setTitle("任务队列")
|
||||
self.config = config
|
||||
|
||||
member_list = [
|
||||
["禁用"] + [_ for _ in Config.member_dict.keys()],
|
||||
["未启用"]
|
||||
+ [
|
||||
(
|
||||
k
|
||||
if v["Config"].get(v["Config"].MaaSet_Name) == ""
|
||||
else f"{k} - {v["Config"].get(v["Config"].MaaSet_Name)}"
|
||||
)
|
||||
for k, v in Config.member_dict.items()
|
||||
],
|
||||
]
|
||||
|
||||
self.card_Member_1 = NoOptionComboBoxSettingCard(
|
||||
icon=FluentIcon.APPLICATION,
|
||||
title="任务实例 1",
|
||||
content="第一个调起的脚本任务实例",
|
||||
value=member_list[0],
|
||||
texts=member_list[1],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queue_Member_1,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Member_2 = NoOptionComboBoxSettingCard(
|
||||
icon=FluentIcon.APPLICATION,
|
||||
title="任务实例 2",
|
||||
content="第二个调起的脚本任务实例",
|
||||
value=member_list[0],
|
||||
texts=member_list[1],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queue_Member_2,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Member_3 = NoOptionComboBoxSettingCard(
|
||||
icon=FluentIcon.APPLICATION,
|
||||
title="任务实例 3",
|
||||
content="第三个调起的脚本任务实例",
|
||||
value=member_list[0],
|
||||
texts=member_list[1],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queue_Member_3,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Member_4 = NoOptionComboBoxSettingCard(
|
||||
icon=FluentIcon.APPLICATION,
|
||||
title="任务实例 4",
|
||||
content="第四个调起的脚本任务实例",
|
||||
value=member_list[0],
|
||||
texts=member_list[1],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queue_Member_4,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Member_5 = NoOptionComboBoxSettingCard(
|
||||
icon=FluentIcon.APPLICATION,
|
||||
title="任务实例 5",
|
||||
content="第五个调起的脚本任务实例",
|
||||
value=member_list[0],
|
||||
texts=member_list[1],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queue_Member_5,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Member_6 = NoOptionComboBoxSettingCard(
|
||||
icon=FluentIcon.APPLICATION,
|
||||
title="任务实例 6",
|
||||
content="第六个调起的脚本任务实例",
|
||||
value=member_list[0],
|
||||
texts=member_list[1],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queue_Member_6,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Member_7 = NoOptionComboBoxSettingCard(
|
||||
icon=FluentIcon.APPLICATION,
|
||||
title="任务实例 7",
|
||||
content="第七个调起的脚本任务实例",
|
||||
value=member_list[0],
|
||||
texts=member_list[1],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queue_Member_7,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Member_8 = NoOptionComboBoxSettingCard(
|
||||
icon=FluentIcon.APPLICATION,
|
||||
title="任务实例 8",
|
||||
content="第八个调起的脚本任务实例",
|
||||
value=member_list[0],
|
||||
texts=member_list[1],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queue_Member_8,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Member_9 = NoOptionComboBoxSettingCard(
|
||||
icon=FluentIcon.APPLICATION,
|
||||
title="任务实例 9",
|
||||
content="第九个调起的脚本任务实例",
|
||||
value=member_list[0],
|
||||
texts=member_list[1],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queue_Member_9,
|
||||
parent=self,
|
||||
)
|
||||
self.card_Member_10 = NoOptionComboBoxSettingCard(
|
||||
icon=FluentIcon.APPLICATION,
|
||||
title="任务实例 10",
|
||||
content="第十个调起的脚本任务实例",
|
||||
value=member_list[0],
|
||||
texts=member_list[1],
|
||||
qconfig=self.config,
|
||||
configItem=self.config.queue_Member_10,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
Layout = QVBoxLayout()
|
||||
Layout.addWidget(self.card_Member_1)
|
||||
Layout.addWidget(self.card_Member_2)
|
||||
Layout.addWidget(self.card_Member_3)
|
||||
Layout.addWidget(self.card_Member_4)
|
||||
Layout.addWidget(self.card_Member_5)
|
||||
Layout.addWidget(self.card_Member_6)
|
||||
Layout.addWidget(self.card_Member_7)
|
||||
Layout.addWidget(self.card_Member_8)
|
||||
Layout.addWidget(self.card_Member_9)
|
||||
Layout.addWidget(self.card_Member_10)
|
||||
|
||||
self.viewLayout.addLayout(Layout)
|
||||
1165
app/ui/setting.py
1165
app/ui/setting.py
File diff suppressed because it is too large
Load Diff
@@ -1,88 +0,0 @@
|
||||
; Script generated by the Inno Setup Script Wizard.
|
||||
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
|
||||
|
||||
#define MyAppName "AUTO_MAA"
|
||||
#define MyAppVersion ""
|
||||
#define MyAppPublisher "AUTO_MAA Team"
|
||||
#define MyAppURL "https://doc.automaa.xyz/"
|
||||
#define MyAppExeName "AUTO_MAA.exe"
|
||||
#define MyAppPath ""
|
||||
#define OutputDir ""
|
||||
|
||||
[Setup]
|
||||
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
|
||||
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
|
||||
AppId={{D116A92A-E174-4699-B777-61C5FD837B19}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppVerName={#MyAppName}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
AppUpdatesURL={#MyAppURL}
|
||||
DefaultDirName=D:\{#MyAppName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run
|
||||
; on anything but x64 and Windows 11 on Arm.
|
||||
ArchitecturesAllowed=x64compatible
|
||||
; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the
|
||||
; install be done in "64-bit mode" on x64 or Windows 11 on Arm,
|
||||
; meaning it should use the native 64-bit Program Files directory and
|
||||
; the 64-bit view of the registry.
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
DisableProgramGroupPage=yes
|
||||
LicenseFile={#MyAppPath}\LICENSE
|
||||
; Remove the following line to run in administrative install mode (install for all users).
|
||||
PrivilegesRequired=lowest
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=AUTO_MAA-Setup
|
||||
SetupIconFile={#MyAppPath}\resources\icons\AUTO_MAA.ico
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
|
||||
[Languages]
|
||||
Name: "Chinese"; MessagesFile: "{#MyAppPath}\resources\docs\ChineseSimplified.isl"
|
||||
Name: "English"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "{#MyAppPath}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#MyAppPath}\app\*"; DestDir: "{app}\app"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "{#MyAppPath}\resources\*"; DestDir: "{app}\resources"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "{#MyAppPath}\main.py"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#MyAppPath}\requirements.txt"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#MyAppPath}\README.md"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#MyAppPath}\LICENSE"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall
|
||||
|
||||
[Code]
|
||||
var
|
||||
DeleteDataQuestion: Boolean;
|
||||
|
||||
function InitializeUninstall: Boolean;
|
||||
begin
|
||||
DeleteDataQuestion := MsgBox('您确认要完全移除 AUTO_MAA 的所有用户数据文件与子组件吗?', mbConfirmation, MB_YESNO) = IDYES;
|
||||
Result := True;
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
begin
|
||||
if CurUninstallStep = usPostUninstall then
|
||||
begin
|
||||
DelTree(ExpandConstant('{app}\app'), True, True, True);
|
||||
DelTree(ExpandConstant('{app}\resources'), True, True, True);
|
||||
if DeleteDataQuestion then
|
||||
begin
|
||||
DelTree(ExpandConstant('{app}'), True, True, True);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
88
app/utils/ImageUtils.py
Normal file
88
app/utils/ImageUtils.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2025 ClozyA
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class ImageUtils:
|
||||
@staticmethod
|
||||
def get_base64_from_file(image_path):
|
||||
"""从本地文件读取并返回base64编码字符串"""
|
||||
with open(image_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def calculate_md5_from_file(image_path):
|
||||
"""从本地文件读取并返回md5值(hex字符串)"""
|
||||
with open(image_path, "rb") as f:
|
||||
return hashlib.md5(f.read()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def calculate_md5_from_base64(base64_content):
|
||||
"""从base64字符串计算md5"""
|
||||
image_data = base64.b64decode(base64_content)
|
||||
return hashlib.md5(image_data).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def compress_image_if_needed(image_path: Path, max_size_mb=2) -> Path:
|
||||
"""
|
||||
如果图片大于max_size_mb, 则压缩并覆盖原文件, 返回原始路径(Path对象)
|
||||
"""
|
||||
|
||||
RESAMPLE = Image.Resampling.LANCZOS # Pillow 9.1.0及以后
|
||||
|
||||
max_size = max_size_mb * 1024 * 1024
|
||||
if image_path.stat().st_size <= max_size:
|
||||
return image_path
|
||||
|
||||
img = Image.open(image_path)
|
||||
suffix = image_path.suffix.lower()
|
||||
quality = 90 if suffix in [".jpg", ".jpeg"] else None
|
||||
step = 5
|
||||
|
||||
if quality is not None:
|
||||
while True:
|
||||
img.save(image_path, quality=quality, optimize=True)
|
||||
if image_path.stat().st_size <= max_size or quality <= 10:
|
||||
break
|
||||
quality -= step
|
||||
elif suffix == ".png":
|
||||
width, height = img.size
|
||||
while True:
|
||||
img.save(image_path, optimize=True)
|
||||
if (
|
||||
image_path.stat().st_size <= max_size
|
||||
or width <= 200
|
||||
or height <= 200
|
||||
):
|
||||
break
|
||||
width = int(width * 0.95)
|
||||
height = int(height * 0.95)
|
||||
img = img.resize((width, height), RESAMPLE)
|
||||
else:
|
||||
raise ValueError("仅支持JPG/JPEG和PNG格式图片的压缩")
|
||||
|
||||
return image_path
|
||||
156
app/utils/LogMonitor.py
Normal file
156
app/utils/LogMonitor.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import asyncio
|
||||
import aiofiles
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, List, Awaitable
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger("日志监控器")
|
||||
|
||||
TIME_FIELDS = {
|
||||
"%Y": "year",
|
||||
"%m": "month",
|
||||
"%d": "day",
|
||||
"%H": "hour",
|
||||
"%M": "minute",
|
||||
"%S": "second",
|
||||
"%f": "microsecond",
|
||||
}
|
||||
"""时间字段映射表"""
|
||||
|
||||
|
||||
def strptime(date_string: str, format: str, default_date: datetime) -> datetime:
|
||||
"""根据指定格式解析日期字符串"""
|
||||
|
||||
date = datetime.strptime(date_string, format)
|
||||
|
||||
# 构建参数字典
|
||||
datetime_kwargs = {}
|
||||
for format_code, field_name in TIME_FIELDS.items():
|
||||
if format_code in format:
|
||||
datetime_kwargs[field_name] = getattr(date, field_name)
|
||||
else:
|
||||
datetime_kwargs[field_name] = getattr(default_date, field_name)
|
||||
|
||||
return datetime(**datetime_kwargs)
|
||||
|
||||
|
||||
class LogMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
time_stamp_range: tuple[int, int],
|
||||
time_format: str,
|
||||
callback: Callable[[List[str]], Awaitable[None]],
|
||||
encoding: str = "utf-8",
|
||||
):
|
||||
self.time_stamp_range = time_stamp_range
|
||||
self.time_format = time_format
|
||||
self.callback = callback
|
||||
self.encoding = encoding
|
||||
self.log_file_path: Optional[Path] = None
|
||||
self.log_start_time: datetime = datetime.now()
|
||||
self.last_callback_time: datetime = datetime.now()
|
||||
self.log_contents: List[str] = []
|
||||
self.task: Optional[asyncio.Task] = None
|
||||
self.__is_running = False
|
||||
|
||||
async def monitor_log(self):
|
||||
"""监控日志文件的主循环"""
|
||||
if self.log_file_path is None or not self.log_file_path.exists():
|
||||
raise ValueError("日志文件路径未设置或文件不存在")
|
||||
|
||||
logger.info(f"开始监控日志文件: {self.log_file_path}")
|
||||
|
||||
while self.__is_running:
|
||||
logger.debug("正在检查日志文件...")
|
||||
log_contents = []
|
||||
if_log_start = False
|
||||
|
||||
# 检查文件是否仍然存在
|
||||
if not self.log_file_path.exists():
|
||||
logger.warning(f"日志文件不存在: {self.log_file_path}")
|
||||
continue
|
||||
|
||||
# 尝试读取文件
|
||||
try:
|
||||
async with aiofiles.open(
|
||||
self.log_file_path, "r", encoding=self.encoding
|
||||
) as f:
|
||||
async for line in f:
|
||||
if not if_log_start:
|
||||
try:
|
||||
entry_time = strptime(
|
||||
line[
|
||||
self.time_stamp_range[
|
||||
0
|
||||
] : self.time_stamp_range[1]
|
||||
],
|
||||
self.time_format,
|
||||
self.last_callback_time,
|
||||
)
|
||||
if entry_time > self.log_start_time:
|
||||
if_log_start = True
|
||||
log_contents.append(line)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
else:
|
||||
log_contents.append(line)
|
||||
|
||||
except (FileNotFoundError, PermissionError) as e:
|
||||
logger.warning(f"文件访问错误: {e}")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
except UnicodeDecodeError as e:
|
||||
logger.error(f"文件编码错误: {e}")
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
|
||||
# 调用回调
|
||||
if (
|
||||
log_contents != self.log_contents
|
||||
or datetime.now() - self.last_callback_time > timedelta(minutes=1)
|
||||
):
|
||||
self.log_contents = log_contents
|
||||
self.last_callback_time = datetime.now()
|
||||
|
||||
# 安全调用回调函数
|
||||
try:
|
||||
await self.callback(log_contents)
|
||||
except Exception as e:
|
||||
logger.error(f"回调函数执行失败: {e}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def start(self, log_file_path: Path, start_time: datetime) -> None:
|
||||
"""启动监控"""
|
||||
|
||||
if log_file_path.is_dir():
|
||||
raise ValueError(f"日志文件不能是目录: {log_file_path}")
|
||||
|
||||
if self.task is not None and not self.task.done():
|
||||
await self.stop()
|
||||
|
||||
self.__is_running = True
|
||||
self.log_contents = []
|
||||
self.log_file_path = log_file_path
|
||||
self.log_start_time = start_time
|
||||
self.task = asyncio.create_task(self.monitor_log())
|
||||
logger.info(f"日志监控已启动: {self.log_file_path}")
|
||||
|
||||
async def stop(self):
|
||||
"""停止监控"""
|
||||
|
||||
logger.info("请求取消日志监控任务")
|
||||
|
||||
if self.task is not None and not self.task.done():
|
||||
self.task.cancel()
|
||||
|
||||
try:
|
||||
await self.task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("日志监控任务已中止")
|
||||
|
||||
logger.success("日志监控任务已停止")
|
||||
self.task = None
|
||||
156
app/utils/ProcessManager.py
Normal file
156
app/utils/ProcessManager.py
Normal file
@@ -0,0 +1,156 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import asyncio
|
||||
import psutil
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
"""进程监视器类, 用于跟踪主进程及其所有子进程的状态"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.main_pid = None
|
||||
self.tracked_pids = set()
|
||||
self.check_task = None
|
||||
self.track_end_time = datetime.now()
|
||||
|
||||
async def open_process(
|
||||
self, path: Path, args: list = [], tracking_time: int = 60
|
||||
) -> None:
|
||||
"""
|
||||
启动一个新进程并返回其pid, 并开始监视该进程
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path: 可执行文件的路径
|
||||
args: 启动参数列表
|
||||
tracking_time: 子进程追踪持续时间(秒)
|
||||
"""
|
||||
|
||||
process = subprocess.Popen(
|
||||
[path, *args],
|
||||
cwd=path.parent,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
await self.start_monitoring(process.pid, tracking_time)
|
||||
|
||||
async def start_monitoring(self, pid: int, tracking_time: int = 60) -> None:
|
||||
"""
|
||||
启动进程监视器, 跟踪指定的主进程及其子进程
|
||||
|
||||
:param pid: 被监视进程的PID
|
||||
:param tracking_time: 子进程追踪持续时间(秒)
|
||||
"""
|
||||
|
||||
await self.clear()
|
||||
|
||||
self.main_pid = pid
|
||||
self.tracking_time = tracking_time
|
||||
|
||||
# 扫描并记录所有相关进程
|
||||
try:
|
||||
# 获取主进程
|
||||
main_proc = psutil.Process(self.main_pid)
|
||||
self.tracked_pids.add(self.main_pid)
|
||||
|
||||
# 递归获取所有子进程
|
||||
if tracking_time:
|
||||
for child in main_proc.children(recursive=True):
|
||||
self.tracked_pids.add(child.pid)
|
||||
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
|
||||
# 启动持续追踪任务
|
||||
if tracking_time > 0:
|
||||
self.track_end_time = datetime.now() + timedelta(seconds=tracking_time)
|
||||
self.check_task = asyncio.create_task(self.track_processes())
|
||||
|
||||
async def track_processes(self) -> None:
|
||||
"""更新子进程列表"""
|
||||
|
||||
while datetime.now() < self.track_end_time:
|
||||
current_pids = set(self.tracked_pids)
|
||||
for pid in current_pids:
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
for child in proc.children():
|
||||
if child.pid not in self.tracked_pids:
|
||||
# 新发现的子进程
|
||||
self.tracked_pids.add(child.pid)
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def is_running(self) -> bool:
|
||||
"""检查所有跟踪的进程是否还在运行"""
|
||||
|
||||
for pid in self.tracked_pids:
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
if proc.is_running():
|
||||
return True
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
async def kill(self, if_force: bool = False) -> None:
|
||||
"""停止监视器并中止所有跟踪的进程"""
|
||||
|
||||
for pid in self.tracked_pids:
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
if if_force:
|
||||
kill_process = subprocess.Popen(
|
||||
["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
kill_process.wait()
|
||||
proc.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
|
||||
await self.clear()
|
||||
|
||||
async def clear(self) -> None:
|
||||
"""清空跟踪的进程列表"""
|
||||
|
||||
if self.check_task is not None and not self.check_task.done():
|
||||
self.check_task.cancel()
|
||||
|
||||
try:
|
||||
await self.check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.main_pid = None
|
||||
self.tracked_pids.clear()
|
||||
@@ -1,32 +1,44 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA工具包
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__version__ = "5.0.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
__all__ = []
|
||||
|
||||
from .constants import *
|
||||
from .logger import get_logger
|
||||
from .ImageUtils import ImageUtils
|
||||
from .LogMonitor import LogMonitor, strptime
|
||||
from .ProcessManager import ProcessManager
|
||||
from .security import dpapi_encrypt, dpapi_decrypt
|
||||
|
||||
__all__ = [
|
||||
"constants",
|
||||
"get_logger",
|
||||
"ImageUtils",
|
||||
"LogMonitor",
|
||||
"ProcessManager",
|
||||
"dpapi_encrypt",
|
||||
"dpapi_decrypt",
|
||||
"strptime",
|
||||
]
|
||||
|
||||
292
app/utils/constants.py
Normal file
292
app/utils/constants.py
Normal file
@@ -0,0 +1,292 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 ClozyA
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
TYPE_BOOK = {"MaaConfig": "MAA", "GeneralConfig": "通用"}
|
||||
"""配置类型映射表"""
|
||||
|
||||
RESOURCE_STAGE_INFO = [
|
||||
{"value": "-", "text": "当前/上次", "days": [1, 2, 3, 4, 5, 6, 7]},
|
||||
{"value": "1-7", "text": "1-7", "days": [1, 2, 3, 4, 5, 6, 7]},
|
||||
{"value": "R8-11", "text": "R8-11", "days": [1, 2, 3, 4, 5, 6, 7]},
|
||||
{"value": "12-17-HARD", "text": "12-17-HARD", "days": [1, 2, 3, 4, 5, 6, 7]},
|
||||
{"value": "LS-6", "text": "经验-6/5", "days": [1, 2, 3, 4, 5, 6, 7]},
|
||||
{"value": "CE-6", "text": "龙门币-6/5", "days": [2, 4, 6, 7]},
|
||||
{"value": "AP-5", "text": "红票-5", "days": [1, 4, 6, 7]},
|
||||
{"value": "CA-5", "text": "技能-5", "days": [2, 3, 5, 7]},
|
||||
{"value": "SK-5", "text": "碳-5", "days": [1, 3, 5, 6]},
|
||||
{"value": "PR-A-1", "text": "奶/盾芯片", "days": [1, 4, 5, 7]},
|
||||
{"value": "PR-A-2", "text": "奶/盾芯片组", "days": [1, 4, 5, 7]},
|
||||
{"value": "PR-B-1", "text": "术/狙芯片", "days": [1, 2, 5, 6]},
|
||||
{"value": "PR-B-2", "text": "术/狙芯片组", "days": [1, 2, 5, 6]},
|
||||
{"value": "PR-C-1", "text": "先/辅芯片", "days": [3, 4, 6, 7]},
|
||||
{"value": "PR-C-2", "text": "先/辅芯片组", "days": [3, 4, 6, 7]},
|
||||
{"value": "PR-D-1", "text": "近/特芯片", "days": [2, 3, 6, 7]},
|
||||
{"value": "PR-D-2", "text": "近/特芯片组", "days": [2, 3, 6, 7]},
|
||||
]
|
||||
"""常规资源关信息"""
|
||||
|
||||
|
||||
RESOURCE_STAGE_DATE_TEXT = {
|
||||
"LS-6": "经验-6/5 | 常驻开放",
|
||||
"CE-6": "龙门币-6/5 | 二四六日开放",
|
||||
"AP-5": "红票-5 | 一四六日开放",
|
||||
"CA-5": "技能-5 | 二三五日开放",
|
||||
"SK-5": "碳-5 | 一三五六开放",
|
||||
"PR-A-1": "奶/盾芯片 | 一四五日开放",
|
||||
"PR-A-2": "奶/盾芯片组 | 一四五日开放",
|
||||
"PR-B-1": "术/狙芯片 | 一二五六日开放",
|
||||
"PR-B-2": "术/狙芯片组 | 一二五六日开放",
|
||||
"PR-C-1": "先/辅芯片 | 三四六日开放",
|
||||
"PR-C-2": "先/辅芯片组 | 三四六日开放",
|
||||
"PR-D-1": "近/特芯片 | 二三六日开放",
|
||||
"PR-D-2": "近/特芯片组 | 二三六日开放",
|
||||
}
|
||||
"""常规资源关开放日文本映射"""
|
||||
|
||||
|
||||
RESOURCE_STAGE_DROP_INFO = {
|
||||
"CE-6": {
|
||||
"Display": "CE-6",
|
||||
"Value": "CE-6",
|
||||
"Drop": "4001",
|
||||
"DropName": "龙门币",
|
||||
"Activity": {"Tip": "二四六日", "StageName": "资源关卡"},
|
||||
},
|
||||
"AP-5": {
|
||||
"Display": "AP-5",
|
||||
"Value": "AP-5",
|
||||
"Drop": "4006",
|
||||
"DropName": "采购凭证",
|
||||
"Activity": {"Tip": "一四六日", "StageName": "资源关卡"},
|
||||
},
|
||||
"CA-5": {
|
||||
"Display": "CA-5",
|
||||
"Value": "CA-5",
|
||||
"Drop": "3303",
|
||||
"DropName": "技巧概要",
|
||||
"Activity": {"Tip": "二三五日", "StageName": "资源关卡"},
|
||||
},
|
||||
"LS-6": {
|
||||
"Display": "LS-6",
|
||||
"Value": "LS-6",
|
||||
"Drop": "2004",
|
||||
"DropName": "作战记录",
|
||||
"Activity": {"Tip": "常驻开放", "StageName": "资源关卡"},
|
||||
},
|
||||
"SK-5": {
|
||||
"Display": "SK-5",
|
||||
"Value": "SK-5",
|
||||
"Drop": "3114",
|
||||
"DropName": "碳素组",
|
||||
"Activity": {"Tip": "一三五六", "StageName": "资源关卡"},
|
||||
},
|
||||
"PR-A-1": {
|
||||
"Display": "PR-A",
|
||||
"Value": "PR-A",
|
||||
"Drop": "PR-A",
|
||||
"DropName": "奶/盾芯片",
|
||||
"Activity": {"Tip": "一四五日", "StageName": "资源关卡"},
|
||||
},
|
||||
"PR-B-1": {
|
||||
"Display": "PR-B",
|
||||
"Value": "PR-B",
|
||||
"Drop": "PR-B",
|
||||
"DropName": "术/狙芯片",
|
||||
"Activity": {"Tip": "一二五六", "StageName": "资源关卡"},
|
||||
},
|
||||
"PR-C-1": {
|
||||
"Display": "PR-C",
|
||||
"Value": "PR-C",
|
||||
"Drop": "PR-C",
|
||||
"DropName": "先/辅芯片",
|
||||
"Activity": {"Tip": "三四六日", "StageName": "资源关卡"},
|
||||
},
|
||||
"PR-D-1": {
|
||||
"Display": "PR-D",
|
||||
"Value": "PR-D",
|
||||
"Drop": "PR-D",
|
||||
"DropName": "近/特芯片",
|
||||
"Activity": {"Tip": "二三六日", "StageName": "资源关卡"},
|
||||
},
|
||||
}
|
||||
"""常规资源关掉落信息"""
|
||||
|
||||
MATERIALS_MAP = {
|
||||
"4001": "龙门币",
|
||||
"4006": "采购凭证",
|
||||
"2004": "高级作战记录",
|
||||
"2003": "中级作战记录",
|
||||
"2002": "初级作战记录",
|
||||
"2001": "基础作战记录",
|
||||
"3303": "技巧概要·卷3",
|
||||
"3302": "技巧概要·卷2",
|
||||
"3301": "技巧概要·卷1",
|
||||
"30165": "重相位对映体",
|
||||
"30155": "烧结核凝晶",
|
||||
"30145": "晶体电子单元",
|
||||
"30135": "D32钢",
|
||||
"30125": "双极纳米片",
|
||||
"30115": "聚合剂",
|
||||
"31094": "手性屈光体",
|
||||
"31093": "类凝结核",
|
||||
"31084": "环烃预制体",
|
||||
"31083": "环烃聚质",
|
||||
"31074": "固化纤维板",
|
||||
"31073": "褐素纤维",
|
||||
"31064": "转质盐聚块",
|
||||
"31063": "转质盐组",
|
||||
"31054": "切削原液",
|
||||
"31053": "化合切削液",
|
||||
"31044": "精炼溶剂",
|
||||
"31043": "半自然溶剂",
|
||||
"31034": "晶体电路",
|
||||
"31033": "晶体元件",
|
||||
"31024": "炽合金块",
|
||||
"31023": "炽合金",
|
||||
"31014": "聚合凝胶",
|
||||
"31013": "凝胶",
|
||||
"30074": "白马醇",
|
||||
"30073": "扭转醇",
|
||||
"30084": "三水锰矿",
|
||||
"30083": "轻锰矿",
|
||||
"30094": "五水研磨石",
|
||||
"30093": "研磨石",
|
||||
"30104": "RMA70-24",
|
||||
"30103": "RMA70-12",
|
||||
"30014": "提纯源岩",
|
||||
"30013": "固源岩组",
|
||||
"30012": "固源岩",
|
||||
"30011": "源岩",
|
||||
"30064": "改量装置",
|
||||
"30063": "全新装置",
|
||||
"30062": "装置",
|
||||
"30061": "破损装置",
|
||||
"30034": "聚酸酯块",
|
||||
"30033": "聚酸酯组",
|
||||
"30032": "聚酸酯",
|
||||
"30031": "酯原料",
|
||||
"30024": "糖聚块",
|
||||
"30023": "糖组",
|
||||
"30022": "糖",
|
||||
"30021": "代糖",
|
||||
"30044": "异铁块",
|
||||
"30043": "异铁组",
|
||||
"30042": "异铁",
|
||||
"30041": "异铁碎片",
|
||||
"30054": "酮阵列",
|
||||
"30053": "酮凝集组",
|
||||
"30052": "酮凝集",
|
||||
"30051": "双酮",
|
||||
"3114": "碳素组",
|
||||
"3113": "碳素",
|
||||
"3112": "碳",
|
||||
"3213": "先锋双芯片",
|
||||
"3223": "近卫双芯片",
|
||||
"3233": "重装双芯片",
|
||||
"3243": "狙击双芯片",
|
||||
"3253": "术师双芯片",
|
||||
"3263": "医疗双芯片",
|
||||
"3273": "辅助双芯片",
|
||||
"3283": "特种双芯片",
|
||||
"3212": "先锋芯片组",
|
||||
"3222": "近卫芯片组",
|
||||
"3232": "重装芯片组",
|
||||
"3242": "狙击芯片组",
|
||||
"3252": "术师芯片组",
|
||||
"3262": "医疗芯片组",
|
||||
"3272": "辅助芯片组",
|
||||
"3282": "特种芯片组",
|
||||
"3211": "先锋芯片",
|
||||
"3221": "近卫芯片",
|
||||
"3231": "重装芯片",
|
||||
"3241": "狙击芯片",
|
||||
"3251": "术师芯片",
|
||||
"3261": "医疗芯片",
|
||||
"3271": "辅助芯片",
|
||||
"3281": "特种芯片",
|
||||
"PR-A": "医疗/重装芯片",
|
||||
"PR-B": "术师/狙击芯片",
|
||||
"PR-C": "先锋/辅助芯片",
|
||||
"PR-D": "近卫/特种芯片",
|
||||
}
|
||||
"""掉落物索引表"""
|
||||
|
||||
POWER_SIGN_MAP = {
|
||||
"NoAction": "无动作",
|
||||
"Shutdown": "关机",
|
||||
"ShutdownForce": "强制关机",
|
||||
"Hibernate": "休眠",
|
||||
"Sleep": "睡眠",
|
||||
"KillSelf": "退出程序",
|
||||
}
|
||||
"""电源操作类型索引表"""
|
||||
|
||||
RESERVED_NAMES = {
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
"COM1",
|
||||
"COM2",
|
||||
"COM3",
|
||||
"COM4",
|
||||
"COM5",
|
||||
"COM6",
|
||||
"COM7",
|
||||
"COM8",
|
||||
"COM9",
|
||||
"LPT1",
|
||||
"LPT2",
|
||||
"LPT3",
|
||||
"LPT4",
|
||||
"LPT5",
|
||||
"LPT6",
|
||||
"LPT7",
|
||||
"LPT8",
|
||||
"LPT9",
|
||||
}
|
||||
"""Windows保留名称列表"""
|
||||
|
||||
ILLEGAL_CHARS = set('<>:"/\\|?*')
|
||||
"""文件名非法字符集合"""
|
||||
|
||||
MIRROR_ERROR_INFO = {
|
||||
1001: "获取版本信息的URL参数不正确",
|
||||
7001: "填入的 CDK 已过期",
|
||||
7002: "填入的 CDK 错误",
|
||||
7003: "填入的 CDK 今日下载次数已达上限",
|
||||
7004: "填入的 CDK 类型和待下载的资源不匹配",
|
||||
7005: "填入的 CDK 已被封禁",
|
||||
8001: "对应架构和系统下的资源不存在",
|
||||
8002: "错误的系统参数",
|
||||
8003: "错误的架构参数",
|
||||
8004: "错误的更新通道参数",
|
||||
1: "未知错误类型",
|
||||
}
|
||||
"""MirrorChyan错误代码映射表"""
|
||||
|
||||
DEFAULT_DATETIME = datetime.strptime("2000-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
"""默认日期时间"""
|
||||
5
app/utils/device_manager/__init__.py
Normal file
5
app/utils/device_manager/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .mumu import MumuManager
|
||||
from .ldplayer import LDManager
|
||||
from .utils import BaseDevice, DeviceStatus
|
||||
|
||||
__all__ = ["MumuManager", "LDManager", "BaseDevice", "DeviceStatus"]
|
||||
492
app/utils/device_manager/general.py
Normal file
492
app/utils/device_manager/general.py
Normal file
@@ -0,0 +1,492 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import asyncio
|
||||
import psutil
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from app.utils.device_manager.utils import BaseDevice, DeviceStatus
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
"""进程监视器类, 用于跟踪主进程及其所有子进程的状态"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.main_pid = None
|
||||
self.tracked_pids = set()
|
||||
self.check_task = None
|
||||
self.track_end_time = datetime.now()
|
||||
|
||||
async def open_process(
|
||||
self, path: Path, args: list = [], tracking_time: int = 60
|
||||
) -> None:
|
||||
"""
|
||||
启动一个新进程并返回其pid, 并开始监视该进程
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path: 可执行文件的路径
|
||||
args: 启动参数列表
|
||||
tracking_time: 子进程追踪持续时间(秒)
|
||||
"""
|
||||
|
||||
process = subprocess.Popen(
|
||||
[path, *args],
|
||||
cwd=path.parent,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
await self.start_monitoring(process.pid, tracking_time)
|
||||
|
||||
async def start_monitoring(self, pid: int, tracking_time: int = 60) -> None:
|
||||
"""
|
||||
启动进程监视器, 跟踪指定的主进程及其子进程
|
||||
|
||||
:param pid: 被监视进程的PID
|
||||
:param tracking_time: 子进程追踪持续时间(秒)
|
||||
"""
|
||||
|
||||
await self.clear()
|
||||
|
||||
self.main_pid = pid
|
||||
self.tracking_time = tracking_time
|
||||
|
||||
# 扫描并记录所有相关进程
|
||||
try:
|
||||
# 获取主进程
|
||||
main_proc = psutil.Process(self.main_pid)
|
||||
self.tracked_pids.add(self.main_pid)
|
||||
|
||||
# 递归获取所有子进程
|
||||
if tracking_time:
|
||||
for child in main_proc.children(recursive=True):
|
||||
self.tracked_pids.add(child.pid)
|
||||
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
|
||||
# 启动持续追踪任务
|
||||
if tracking_time > 0:
|
||||
self.track_end_time = datetime.now() + timedelta(seconds=tracking_time)
|
||||
self.check_task = asyncio.create_task(self.track_processes())
|
||||
|
||||
async def track_processes(self) -> None:
|
||||
"""更新子进程列表"""
|
||||
|
||||
while datetime.now() < self.track_end_time:
|
||||
current_pids = set(self.tracked_pids)
|
||||
for pid in current_pids:
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
for child in proc.children():
|
||||
if child.pid not in self.tracked_pids:
|
||||
# 新发现的子进程
|
||||
self.tracked_pids.add(child.pid)
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def is_running(self) -> bool:
|
||||
"""检查所有跟踪的进程是否还在运行"""
|
||||
|
||||
for pid in self.tracked_pids:
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
if proc.is_running():
|
||||
return True
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
async def kill(self, if_force: bool = False) -> None:
|
||||
"""停止监视器并中止所有跟踪的进程"""
|
||||
|
||||
for pid in self.tracked_pids:
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
if if_force:
|
||||
kill_process = subprocess.Popen(
|
||||
["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
kill_process.wait()
|
||||
proc.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
|
||||
await self.clear()
|
||||
|
||||
async def clear(self) -> None:
|
||||
"""清空跟踪的进程列表"""
|
||||
|
||||
if self.check_task is not None and not self.check_task.done():
|
||||
self.check_task.cancel()
|
||||
|
||||
try:
|
||||
await self.check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.main_pid = None
|
||||
self.tracked_pids.clear()
|
||||
|
||||
|
||||
class GeneralDeviceManager(BaseDevice):
|
||||
"""
|
||||
通用设备管理器,基于BaseDevice和ProcessManager实现
|
||||
用于管理一般应用程序进程
|
||||
"""
|
||||
|
||||
def __init__(self, executable_path: str, name: str = "通用设备"):
|
||||
"""
|
||||
初始化通用设备管理器
|
||||
|
||||
Args:
|
||||
executable_path (str): 可执行文件的绝对路径
|
||||
name (str): 设备管理器名称
|
||||
"""
|
||||
self.executable_path = Path(executable_path)
|
||||
self.name = name
|
||||
self.logger = get_logger(f"{name}管理器")
|
||||
|
||||
# 进程管理实例字典,以idx为键
|
||||
self.process_managers: Dict[str, ProcessManager] = {}
|
||||
|
||||
# 设备信息存储
|
||||
self.device_info: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# 默认等待时间
|
||||
self.wait_time = 60
|
||||
|
||||
if not self.executable_path.exists():
|
||||
raise FileNotFoundError(f"可执行文件不存在: {executable_path}")
|
||||
|
||||
async def start(self, idx: str, package_name: str = "") -> tuple[bool, int, dict]:
|
||||
"""
|
||||
启动设备
|
||||
|
||||
Args:
|
||||
idx: 设备ID
|
||||
package_name: 包名(可选)
|
||||
|
||||
Returns:
|
||||
tuple[bool, int, dict]: (是否成功, 状态码, 启动信息)
|
||||
"""
|
||||
try:
|
||||
# 检查是否已经在运行
|
||||
current_status = await self.get_status(idx)
|
||||
if current_status in [DeviceStatus.ONLINE, DeviceStatus.STARTING]:
|
||||
self.logger.warning(f"设备{idx}已经在运行,状态: {current_status}")
|
||||
return False, current_status, {}
|
||||
|
||||
# 创建进程管理器
|
||||
if idx not in self.process_managers:
|
||||
self.process_managers[idx] = ProcessManager()
|
||||
|
||||
# 准备启动参数
|
||||
args = []
|
||||
if package_name:
|
||||
args.extend(["-pkg", package_name])
|
||||
|
||||
# 启动进程
|
||||
await self.process_managers[idx].open_process(
|
||||
self.executable_path, args, tracking_time=self.wait_time
|
||||
)
|
||||
|
||||
# 等待进程启动
|
||||
start_time = datetime.now()
|
||||
timeout = timedelta(seconds=self.wait_time)
|
||||
|
||||
while datetime.now() - start_time < timeout:
|
||||
if await self.process_managers[idx].is_running():
|
||||
self.device_info[idx] = {
|
||||
"title": f"{self.name}_{idx}",
|
||||
"status": str(DeviceStatus.ONLINE),
|
||||
"pid": self.process_managers[idx].main_pid,
|
||||
"start_time": start_time.isoformat(),
|
||||
}
|
||||
|
||||
self.logger.info(f"设备{idx}启动成功")
|
||||
return True, DeviceStatus.ONLINE, self.device_info[idx]
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
self.logger.error(f"设备{idx}启动超时")
|
||||
return False, DeviceStatus.ERROR, {}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"启动设备{idx}失败: {str(e)}")
|
||||
return False, DeviceStatus.ERROR, {}
|
||||
|
||||
async def close(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
关闭设备或服务
|
||||
|
||||
Args:
|
||||
idx: 设备ID
|
||||
|
||||
Returns:
|
||||
tuple[bool, int]: (是否成功, 状态码)
|
||||
"""
|
||||
try:
|
||||
if idx not in self.process_managers:
|
||||
self.logger.warning(f"设备{idx}的进程管理器不存在")
|
||||
return False, DeviceStatus.NOT_FOUND
|
||||
|
||||
# 检查进程是否在运行
|
||||
if not await self.process_managers[idx].is_running():
|
||||
self.logger.info(f"设备{idx}进程已经停止")
|
||||
return True, DeviceStatus.OFFLINE
|
||||
|
||||
# 终止进程
|
||||
await self.process_managers[idx].kill(if_force=False)
|
||||
|
||||
# 等待进程完全停止
|
||||
stop_time = datetime.now()
|
||||
timeout = timedelta(seconds=10) # 10秒超时
|
||||
|
||||
while datetime.now() - stop_time < timeout:
|
||||
if not await self.process_managers[idx].is_running():
|
||||
# 清理设备信息
|
||||
if idx in self.device_info:
|
||||
del self.device_info[idx]
|
||||
|
||||
self.logger.info(f"设备{idx}已成功关闭")
|
||||
return True, DeviceStatus.OFFLINE
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 强制终止
|
||||
self.logger.warning(f"设备{idx}未能正常关闭,尝试强制终止")
|
||||
await self.process_managers[idx].kill(if_force=True)
|
||||
|
||||
if idx in self.device_info:
|
||||
del self.device_info[idx]
|
||||
|
||||
return True, DeviceStatus.OFFLINE
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"关闭设备{idx}失败: {str(e)}")
|
||||
return False, DeviceStatus.ERROR
|
||||
|
||||
async def get_status(self, idx: str) -> int:
|
||||
"""
|
||||
获取指定设备当前状态
|
||||
|
||||
Args:
|
||||
idx: 设备ID
|
||||
|
||||
Returns:
|
||||
int: 状态码
|
||||
"""
|
||||
try:
|
||||
if idx not in self.process_managers:
|
||||
return DeviceStatus.OFFLINE
|
||||
|
||||
if await self.process_managers[idx].is_running():
|
||||
return DeviceStatus.ONLINE
|
||||
else:
|
||||
return DeviceStatus.OFFLINE
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"获取设备{idx}状态失败: {str(e)}")
|
||||
return DeviceStatus.ERROR
|
||||
|
||||
async def hide_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
隐藏设备窗口
|
||||
|
||||
Args:
|
||||
idx: 设备ID
|
||||
|
||||
Returns:
|
||||
tuple[bool, int]: (是否成功, 状态码)
|
||||
"""
|
||||
try:
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
|
||||
if (
|
||||
idx not in self.process_managers
|
||||
or not self.process_managers[idx].main_pid
|
||||
):
|
||||
return False, DeviceStatus.NOT_FOUND
|
||||
|
||||
# 窗口隐藏功能(简化实现)
|
||||
# 注意:完整的窗口隐藏功能需要更复杂的Windows API调用
|
||||
self.logger.info(f"设备{idx}窗口隐藏请求已处理(简化实现)")
|
||||
return True, DeviceStatus.ONLINE
|
||||
|
||||
self.logger.info(f"设备{idx}窗口已隐藏")
|
||||
return True, DeviceStatus.ONLINE
|
||||
|
||||
except ImportError:
|
||||
self.logger.warning("隐藏窗口功能需要pywin32库")
|
||||
return False, DeviceStatus.ERROR
|
||||
except Exception as e:
|
||||
self.logger.error(f"隐藏设备{idx}窗口失败: {str(e)}")
|
||||
return False, DeviceStatus.ERROR
|
||||
|
||||
async def show_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
显示设备窗口
|
||||
|
||||
Args:
|
||||
idx: 设备ID
|
||||
|
||||
Returns:
|
||||
tuple[bool, int]: (是否成功, 状态码)
|
||||
"""
|
||||
try:
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
|
||||
if (
|
||||
idx not in self.process_managers
|
||||
or not self.process_managers[idx].main_pid
|
||||
):
|
||||
return False, DeviceStatus.NOT_FOUND
|
||||
|
||||
# 窗口显示功能(简化实现)
|
||||
# 注意:完整的窗口显示功能需要更复杂的Windows API调用
|
||||
self.logger.info(f"设备{idx}窗口显示请求已处理(简化实现)")
|
||||
return True, DeviceStatus.ONLINE
|
||||
|
||||
self.logger.info(f"设备{idx}窗口已显示")
|
||||
return True, DeviceStatus.ONLINE
|
||||
|
||||
except ImportError:
|
||||
self.logger.warning("显示窗口功能需要pywin32库")
|
||||
return False, DeviceStatus.ERROR
|
||||
except Exception as e:
|
||||
self.logger.error(f"显示设备{idx}窗口失败: {str(e)}")
|
||||
return False, DeviceStatus.ERROR
|
||||
|
||||
async def get_all_info(self) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
获取所有设备信息
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, str]]: 设备信息字典
|
||||
结构示例:
|
||||
{
|
||||
"0": {
|
||||
"title": "设备名称",
|
||||
"status": "1"
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = {}
|
||||
|
||||
for idx in list(self.process_managers.keys()):
|
||||
try:
|
||||
status = await self.get_status(idx)
|
||||
|
||||
if idx in self.device_info:
|
||||
title = self.device_info[idx].get("title", f"{self.name}_{idx}")
|
||||
else:
|
||||
title = f"{self.name}_{idx}"
|
||||
|
||||
result[idx] = {"title": title, "status": str(status)}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"获取设备{idx}信息失败: {str(e)}")
|
||||
result[idx] = {
|
||||
"title": f"{self.name}_{idx}",
|
||||
"status": str(DeviceStatus.ERROR),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""
|
||||
清理所有资源
|
||||
"""
|
||||
self.logger.info("开始清理设备管理器资源")
|
||||
|
||||
for idx, pm in list(self.process_managers.items()):
|
||||
try:
|
||||
if await pm.is_running():
|
||||
await pm.kill(if_force=True)
|
||||
await pm.clear()
|
||||
except Exception as e:
|
||||
self.logger.error(f"清理设备{idx}资源失败: {str(e)}")
|
||||
|
||||
self.process_managers.clear()
|
||||
self.device_info.clear()
|
||||
|
||||
self.logger.info("设备管理器资源清理完成")
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数,确保资源被正确释放"""
|
||||
try:
|
||||
# 注意:析构函数中不能使用async/await
|
||||
# 这里只是标记,实际清理需要显式调用cleanup()
|
||||
if hasattr(self, "process_managers") and self.process_managers:
|
||||
self.logger.warning("设备管理器未正确清理,请显式调用cleanup()方法")
|
||||
except: # noqa: E722
|
||||
pass
|
||||
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
|
||||
async def main():
|
||||
# 创建通用设备管理器
|
||||
manager = GeneralDeviceManager(
|
||||
executable_path=r"C:\Windows\System32\notepad.exe", name="记事本"
|
||||
)
|
||||
|
||||
try:
|
||||
# 启动设备
|
||||
success, status, info = await manager.start("0")
|
||||
print(f"启动结果: {success}, 状态: {status}, 信息: {info}")
|
||||
|
||||
if success:
|
||||
# 获取所有设备信息
|
||||
all_info = await manager.get_all_info()
|
||||
print(f"所有设备信息: {all_info}")
|
||||
|
||||
# 等待5秒
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# 关闭设备
|
||||
close_success, close_status = await manager.close("0")
|
||||
print(f"关闭结果: {close_success}, 状态: {close_status}")
|
||||
|
||||
finally:
|
||||
# 清理资源
|
||||
await manager.cleanup()
|
||||
|
||||
# 运行示例
|
||||
asyncio.run(main())
|
||||
278
app/utils/device_manager/keyboard_utils.py
Normal file
278
app/utils/device_manager/keyboard_utils.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
键盘工具模块
|
||||
|
||||
提供虚拟键码到keyboard库按键名称的转换功能,以及相关的键盘操作辅助函数。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import keyboard
|
||||
from typing import List
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("键盘工具")
|
||||
|
||||
|
||||
def vk_code_to_key_name(vk_code: int) -> str:
|
||||
"""
|
||||
将Windows虚拟键码转换为keyboard库识别的按键名称
|
||||
|
||||
Args:
|
||||
vk_code (int): Windows虚拟键码
|
||||
|
||||
Returns:
|
||||
str: keyboard库识别的按键名称
|
||||
|
||||
Examples:
|
||||
>>> vk_code_to_key_name(0x1B)
|
||||
'esc'
|
||||
>>> vk_code_to_key_name(0x70)
|
||||
'f1'
|
||||
>>> vk_code_to_key_name(0x41)
|
||||
'a'
|
||||
"""
|
||||
# Windows虚拟键码到keyboard库按键名称的映射
|
||||
vk_mapping = {
|
||||
# 常用功能键
|
||||
0x1B: "esc", # VK_ESCAPE
|
||||
0x0D: "enter", # VK_RETURN
|
||||
0x20: "space", # VK_SPACE
|
||||
0x08: "backspace", # VK_BACK
|
||||
0x09: "tab", # VK_TAB
|
||||
0x2E: "delete", # VK_DELETE
|
||||
0x24: "home", # VK_HOME
|
||||
0x23: "end", # VK_END
|
||||
0x21: "page up", # VK_PRIOR
|
||||
0x22: "page down", # VK_NEXT
|
||||
0x2D: "insert", # VK_INSERT
|
||||
# 修饰键
|
||||
0x10: "shift", # VK_SHIFT
|
||||
0x11: "ctrl", # VK_CONTROL
|
||||
0x12: "alt", # VK_MENU
|
||||
0x5B: "left windows", # VK_LWIN
|
||||
0x5C: "right windows", # VK_RWIN
|
||||
0x5D: "apps", # VK_APPS (右键菜单键)
|
||||
# 方向键
|
||||
0x25: "left", # VK_LEFT
|
||||
0x26: "up", # VK_UP
|
||||
0x27: "right", # VK_RIGHT
|
||||
0x28: "down", # VK_DOWN
|
||||
# 功能键 F1-F12
|
||||
0x70: "f1",
|
||||
0x71: "f2",
|
||||
0x72: "f3",
|
||||
0x73: "f4",
|
||||
0x74: "f5",
|
||||
0x75: "f6",
|
||||
0x76: "f7",
|
||||
0x77: "f8",
|
||||
0x78: "f9",
|
||||
0x79: "f10",
|
||||
0x7A: "f11",
|
||||
0x7B: "f12",
|
||||
# 数字键 0-9
|
||||
0x30: "0",
|
||||
0x31: "1",
|
||||
0x32: "2",
|
||||
0x33: "3",
|
||||
0x34: "4",
|
||||
0x35: "5",
|
||||
0x36: "6",
|
||||
0x37: "7",
|
||||
0x38: "8",
|
||||
0x39: "9",
|
||||
# 字母键 A-Z
|
||||
0x41: "a",
|
||||
0x42: "b",
|
||||
0x43: "c",
|
||||
0x44: "d",
|
||||
0x45: "e",
|
||||
0x46: "f",
|
||||
0x47: "g",
|
||||
0x48: "h",
|
||||
0x49: "i",
|
||||
0x4A: "j",
|
||||
0x4B: "k",
|
||||
0x4C: "l",
|
||||
0x4D: "m",
|
||||
0x4E: "n",
|
||||
0x4F: "o",
|
||||
0x50: "p",
|
||||
0x51: "q",
|
||||
0x52: "r",
|
||||
0x53: "s",
|
||||
0x54: "t",
|
||||
0x55: "u",
|
||||
0x56: "v",
|
||||
0x57: "w",
|
||||
0x58: "x",
|
||||
0x59: "y",
|
||||
0x5A: "z",
|
||||
# 数字小键盘
|
||||
0x60: "num 0",
|
||||
0x61: "num 1",
|
||||
0x62: "num 2",
|
||||
0x63: "num 3",
|
||||
0x64: "num 4",
|
||||
0x65: "num 5",
|
||||
0x66: "num 6",
|
||||
0x67: "num 7",
|
||||
0x68: "num 8",
|
||||
0x69: "num 9",
|
||||
0x6A: "num *",
|
||||
0x6B: "num +",
|
||||
0x6D: "num -",
|
||||
0x6E: "num .",
|
||||
0x6F: "num /",
|
||||
0x90: "num lock",
|
||||
# 标点符号和特殊键
|
||||
0xBA: ";", # VK_OEM_1 (;:)
|
||||
0xBB: "=", # VK_OEM_PLUS (=+)
|
||||
0xBC: ",", # VK_OEM_COMMA (,<)
|
||||
0xBD: "-", # VK_OEM_MINUS (-_)
|
||||
0xBE: ".", # VK_OEM_PERIOD (.>)
|
||||
0xBF: "/", # VK_OEM_2 (/?)
|
||||
0xC0: "`", # VK_OEM_3 (`~)
|
||||
0xDB: "[", # VK_OEM_4 ([{)
|
||||
0xDC: "\\", # VK_OEM_5 (\|)
|
||||
0xDD: "]", # VK_OEM_6 (]})
|
||||
0xDE: "'", # VK_OEM_7 ('")
|
||||
# 系统键
|
||||
0x14: "caps lock", # VK_CAPITAL
|
||||
0x91: "scroll lock", # VK_SCROLL
|
||||
0x13: "pause", # VK_PAUSE
|
||||
0x2C: "print screen", # VK_SNAPSHOT
|
||||
# 媒体键
|
||||
0xA0: "left shift", # VK_LSHIFT
|
||||
0xA1: "right shift", # VK_RSHIFT
|
||||
0xA2: "left ctrl", # VK_LCONTROL
|
||||
0xA3: "right ctrl", # VK_RCONTROL
|
||||
0xA4: "left alt", # VK_LMENU
|
||||
0xA5: "right alt", # VK_RMENU
|
||||
}
|
||||
|
||||
return vk_mapping.get(vk_code, f"vk_{vk_code}")
|
||||
|
||||
|
||||
def vk_codes_to_key_names(vk_codes: List[int]) -> List[str]:
|
||||
"""
|
||||
将多个虚拟键码转换为keyboard库识别的按键名称列表
|
||||
|
||||
Args:
|
||||
vk_codes (List[int]): Windows虚拟键码列表
|
||||
|
||||
Returns:
|
||||
List[str]: keyboard库识别的按键名称列表
|
||||
|
||||
Examples:
|
||||
>>> vk_codes_to_key_names([0x11, 0x12, 0x44])
|
||||
['ctrl', 'alt', 'd']
|
||||
"""
|
||||
return [vk_code_to_key_name(vk) for vk in vk_codes]
|
||||
|
||||
|
||||
async def send_key_combination(key_names: List[str], hold_time: float = 0.05) -> bool:
|
||||
"""
|
||||
发送按键组合
|
||||
|
||||
Args:
|
||||
key_names (List[str]): 按键名称列表
|
||||
hold_time (float): 按键保持时间(秒),默认 0.05 秒
|
||||
|
||||
Returns:
|
||||
bool: 操作是否成功
|
||||
|
||||
Examples:
|
||||
>>> await send_key_combination(['ctrl', 'alt', 'd'])
|
||||
True
|
||||
>>> await send_key_combination(['f1'])
|
||||
True
|
||||
"""
|
||||
try:
|
||||
if not key_names:
|
||||
logger.warning("按键名称列表为空")
|
||||
return False
|
||||
|
||||
if len(key_names) == 1:
|
||||
# 单个按键
|
||||
keyboard.press_and_release(key_names[0])
|
||||
logger.debug(f"发送单个按键: {key_names[0]}")
|
||||
else:
|
||||
# 组合键:按下所有键,然后释放
|
||||
logger.debug(f"发送组合键: {'+'.join(key_names)}")
|
||||
for key in key_names:
|
||||
keyboard.press(key)
|
||||
await asyncio.sleep(hold_time) # 保持按键状态
|
||||
for key in reversed(key_names):
|
||||
keyboard.release(key)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送按键组合失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def send_vk_combination(vk_codes: List[int], hold_time: float = 0.05) -> bool:
|
||||
"""
|
||||
发送虚拟键码组合
|
||||
|
||||
Args:
|
||||
vk_codes (List[int]): Windows虚拟键码列表
|
||||
hold_time (float): 按键保持时间(秒),默认 0.05 秒
|
||||
|
||||
Returns:
|
||||
bool: 操作是否成功
|
||||
|
||||
Examples:
|
||||
>>> await send_vk_combination([0x11, 0x12, 0x44]) # Ctrl+Alt+D
|
||||
True
|
||||
>>> await send_vk_combination([0x70]) # F1
|
||||
True
|
||||
"""
|
||||
try:
|
||||
key_names = vk_codes_to_key_names(vk_codes)
|
||||
return await send_key_combination(key_names, hold_time)
|
||||
except Exception as e:
|
||||
logger.error(f"发送虚拟键码组合失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_common_boss_keys() -> dict[str, List[int]]:
|
||||
"""
|
||||
获取常见的BOSS键组合
|
||||
|
||||
Returns:
|
||||
dict[str, List[int]]: 常见BOSS键组合的名称和对应的虚拟键码
|
||||
"""
|
||||
return {
|
||||
"alt_tab": [0x12, 0x09], # Alt+Tab
|
||||
"ctrl_alt_d": [0x11, 0x12, 0x44], # Ctrl+Alt+D
|
||||
"win_d": [0x5B, 0x44], # Win+D (显示桌面)
|
||||
"win_m": [0x5B, 0x4D], # Win+M (最小化所有窗口)
|
||||
"f1": [0x70], # F1
|
||||
"f2": [0x71], # F2
|
||||
"f3": [0x72], # F3
|
||||
"f4": [0x73], # F4
|
||||
"alt_f4": [0x12, 0x73], # Alt+F4 (关闭窗口)
|
||||
"ctrl_shift_esc": [0x11, 0x10, 0x1B], # Ctrl+Shift+Esc (任务管理器)
|
||||
}
|
||||
|
||||
|
||||
def describe_vk_combination(vk_codes: List[int]) -> str:
|
||||
"""
|
||||
描述虚拟键码组合
|
||||
|
||||
Args:
|
||||
vk_codes (List[int]): Windows虚拟键码列表
|
||||
|
||||
Returns:
|
||||
str: 组合键的描述字符串
|
||||
|
||||
Examples:
|
||||
>>> describe_vk_combination([0x11, 0x12, 0x44])
|
||||
'Ctrl+Alt+D'
|
||||
>>> describe_vk_combination([0x70])
|
||||
'F1'
|
||||
"""
|
||||
key_names = vk_codes_to_key_names(vk_codes)
|
||||
return "+".join(name.title() for name in key_names)
|
||||
329
app/utils/device_manager/ldplayer.py
Normal file
329
app/utils/device_manager/ldplayer.py
Normal file
@@ -0,0 +1,329 @@
|
||||
import asyncio
|
||||
from typing import Literal
|
||||
from app.utils.device_manager.utils import BaseDevice, ExeRunner, DeviceStatus
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.device_manager.keyboard_utils import (
|
||||
vk_codes_to_key_names,
|
||||
send_key_combination,
|
||||
)
|
||||
import psutil
|
||||
from pydantic import BaseModel
|
||||
import win32gui
|
||||
|
||||
|
||||
class EmulatorInfo(BaseModel):
|
||||
idx: int
|
||||
title: str
|
||||
top_hwnd: int
|
||||
bind_hwnd: int
|
||||
in_android: int
|
||||
pid: int
|
||||
vbox_pid: int
|
||||
width: int
|
||||
height: int
|
||||
density: int
|
||||
|
||||
|
||||
class LDManager(BaseDevice):
|
||||
"""
|
||||
基于dnconsole.exe的模拟器管理
|
||||
|
||||
!需要管理员权限
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, exe_path: str) -> None:
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
exe_path (str): dnconsole.exe的绝对路径
|
||||
"""
|
||||
self.runner = ExeRunner(exe_path, "gbk")
|
||||
self.logger = get_logger("雷电模拟器管理器")
|
||||
self.wait_time = 60 # 配置获取 后续改一下 单位为s
|
||||
|
||||
async def start(self, idx: str, package_name="") -> tuple[bool, int, dict]:
|
||||
"""
|
||||
启动指定模拟器
|
||||
Returns:
|
||||
tuple[bool, int, str]: 是否成功, 当前状态码, ADB端口信息
|
||||
"""
|
||||
OK, info, status = await self.get_device_info(idx)
|
||||
if status != DeviceStatus.OFFLINE:
|
||||
self.logger.error(
|
||||
f"模拟器{idx}未处于关闭状态,当前状态码: {status}, 需求状态码: {DeviceStatus.OFFLINE}"
|
||||
)
|
||||
return False, status, {}
|
||||
if package_name:
|
||||
result = self.runner.run(
|
||||
"launch",
|
||||
"--index",
|
||||
idx,
|
||||
"--packagename",
|
||||
f'"{package_name}"',
|
||||
)
|
||||
else:
|
||||
result = self.runner.run(
|
||||
"launch",
|
||||
"--index",
|
||||
idx,
|
||||
)
|
||||
# 参考命令 dnconsole.exe launch --index 0
|
||||
self.logger.debug(f"启动结果:{result}")
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"命令执行失败: {result}")
|
||||
|
||||
i = 0
|
||||
while i < self.wait_time * 10:
|
||||
OK, info, status = await self.get_device_info(idx)
|
||||
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
|
||||
self.logger.error(f"模拟器{idx}启动失败,状态码: {status}")
|
||||
return False, status, {}
|
||||
if status == DeviceStatus.ONLINE:
|
||||
self.logger.debug(info)
|
||||
if OK and isinstance(info, EmulatorInfo):
|
||||
pid: int = info.vbox_pid
|
||||
adb_port = ""
|
||||
adb_host_ip = await self.get_adb_ports(pid)
|
||||
print(adb_host_ip)
|
||||
if adb_host_ip:
|
||||
return (
|
||||
True,
|
||||
status,
|
||||
{"adb_port": adb_port, "adb_host_ip": adb_host_ip},
|
||||
)
|
||||
|
||||
return True, status, {}
|
||||
await asyncio.sleep(0.1)
|
||||
i += 1
|
||||
return False, DeviceStatus.UNKNOWN, {}
|
||||
|
||||
async def close(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
关闭指定模拟器
|
||||
Returns:
|
||||
- tuple[bool, int]: 是否成功, 当前状态码
|
||||
|
||||
参考命令行:dnconsole.exe quit --index 0
|
||||
"""
|
||||
OK, info, status = await self.get_device_info(idx)
|
||||
if status != DeviceStatus.ONLINE and status != DeviceStatus.STARTING:
|
||||
return False, DeviceStatus.NOT_FOUND
|
||||
result = self.runner.run(
|
||||
"quit",
|
||||
"--index",
|
||||
idx,
|
||||
)
|
||||
# 参考命令 dnconsole.exe quit --index 0
|
||||
if result.returncode != 0:
|
||||
return True, DeviceStatus.OFFLINE
|
||||
i = 0
|
||||
while i < self.wait_time * 10:
|
||||
OK, info, status = await self.get_device_info(idx)
|
||||
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
|
||||
return False, status
|
||||
if status == DeviceStatus.OFFLINE:
|
||||
return True, DeviceStatus.OFFLINE
|
||||
await asyncio.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
return False, DeviceStatus.UNKNOWN
|
||||
|
||||
async def get_status(self, idx: str) -> int:
|
||||
"""
|
||||
获取指定模拟器当前状态
|
||||
返回值: 状态码
|
||||
"""
|
||||
_, _, status = await self.get_device_info(idx)
|
||||
return status
|
||||
|
||||
async def get_device_info(
|
||||
self,
|
||||
idx: str,
|
||||
data: dict[int, EmulatorInfo] | None = None,
|
||||
) -> tuple[Literal[True], EmulatorInfo, int] | tuple[Literal[False], dict, int]:
|
||||
"""
|
||||
获取指定模拟器的信息和状态
|
||||
Returns:
|
||||
- tuple[bool, EmulatorInfo | dict, int]: 是否成功, 模拟器信息或空字典, 状态码
|
||||
|
||||
参考命令行:dnconsole.exe list2
|
||||
"""
|
||||
if not data:
|
||||
result = await self._get_all_info()
|
||||
else:
|
||||
result = data
|
||||
|
||||
try:
|
||||
emulator_info = result.get(int(idx))
|
||||
print(emulator_info)
|
||||
if not emulator_info:
|
||||
self.logger.error(f"未找到模拟器{idx}的信息")
|
||||
return False, {}, DeviceStatus.UNKNOWN
|
||||
|
||||
self.logger.debug(f"获取模拟器{idx}信息: {emulator_info}")
|
||||
|
||||
# 计算状态码
|
||||
if emulator_info.in_android == 1:
|
||||
status = DeviceStatus.ONLINE
|
||||
elif emulator_info.in_android == 2:
|
||||
if emulator_info.vbox_pid > 0:
|
||||
status = DeviceStatus.STARTING
|
||||
# 雷电启动后, vbox_pid为-1, 目前不知道有什么区别
|
||||
else:
|
||||
status = DeviceStatus.STARTING
|
||||
elif emulator_info.in_android == 0:
|
||||
status = DeviceStatus.OFFLINE
|
||||
else:
|
||||
status = DeviceStatus.UNKNOWN
|
||||
|
||||
self.logger.debug(f"获取模拟器{idx}状态: {status}")
|
||||
return True, emulator_info, status
|
||||
except: # noqa: E722
|
||||
self.logger.error(f"获取模拟器{idx}信息失败")
|
||||
return False, {}, DeviceStatus.UNKNOWN
|
||||
|
||||
async def _get_all_info(self) -> dict[int, EmulatorInfo]:
|
||||
result = self.runner.run("list2")
|
||||
# self.logger.debug(f"全部信息{result.stdout.strip()}")
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"命令执行失败: {result}")
|
||||
|
||||
emulators: dict[int, EmulatorInfo] = {}
|
||||
data = result.stdout.strip()
|
||||
|
||||
for line in data.strip().splitlines():
|
||||
parts = line.strip().split(",")
|
||||
if len(parts) != 10:
|
||||
raise ValueError(f"数据格式错误: {line}")
|
||||
try:
|
||||
info = EmulatorInfo(
|
||||
idx=int(parts[0]),
|
||||
title=parts[1],
|
||||
top_hwnd=int(parts[2]),
|
||||
bind_hwnd=int(parts[3]),
|
||||
in_android=int(parts[4]),
|
||||
pid=int(parts[5]),
|
||||
vbox_pid=int(parts[6]),
|
||||
width=int(parts[7]),
|
||||
height=int(parts[8]),
|
||||
density=int(parts[9]),
|
||||
)
|
||||
emulators[info.idx] = info
|
||||
except Exception as e:
|
||||
self.logger.warning(f"解析失败: {line}, 错误: {e}")
|
||||
pass
|
||||
return emulators
|
||||
|
||||
# ?wk雷电你都返回了什么啊
|
||||
|
||||
async def get_all_info(self) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
解析_emulator_info字典,提取idx和title,便于前端显示
|
||||
"""
|
||||
raw_data = await self._get_all_info()
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for info in raw_data.values():
|
||||
OK, device_info, status = await self.get_device_info(
|
||||
str(info.idx), raw_data
|
||||
)
|
||||
result[str(info.idx)] = {"title": info.title, "status": str(status)}
|
||||
return result
|
||||
|
||||
async def send_boss_key(
|
||||
self,
|
||||
boss_keys: list[int],
|
||||
result: EmulatorInfo,
|
||||
is_show: bool = False,
|
||||
# True: 显示, False: 隐藏
|
||||
) -> bool:
|
||||
"""
|
||||
发送BOSS键
|
||||
|
||||
Args:
|
||||
idx (str): 模拟器索引
|
||||
boss_keys (list[int]): BOSS键的虚拟键码列表
|
||||
result (EmulatorInfo): 模拟器信息
|
||||
is_show (bool, optional): 将要隐藏或显示窗口,默认为 False(隐藏)。
|
||||
"""
|
||||
hwnd = result.top_hwnd
|
||||
try:
|
||||
# 使用键盘工具发送按键组合
|
||||
success = await send_key_combination(vk_codes_to_key_names(boss_keys))
|
||||
if not success:
|
||||
return False
|
||||
|
||||
# 等待系统处理
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 检查窗口可见性是否符合预期
|
||||
current_visible = win32gui.IsWindowVisible(hwnd)
|
||||
expected_visible = is_show
|
||||
|
||||
if current_visible == expected_visible:
|
||||
return True
|
||||
else:
|
||||
# 如果第一次没有成功,再试一次
|
||||
success = await send_key_combination(vk_codes_to_key_names(boss_keys))
|
||||
if not success:
|
||||
return False
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
current_visible = win32gui.IsWindowVisible(hwnd)
|
||||
return current_visible == expected_visible
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"发送BOSS键失败: {e}")
|
||||
return False
|
||||
|
||||
async def hide_device(
|
||||
self,
|
||||
idx: str,
|
||||
boss_keys: list[int] = [],
|
||||
) -> tuple[bool, int]:
|
||||
"""隐藏设备窗口"""
|
||||
OK, result, status = await self.get_device_info(idx)
|
||||
if not OK or not isinstance(result, EmulatorInfo):
|
||||
return False, DeviceStatus.UNKNOWN
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
|
||||
return await self.send_boss_key(boss_keys, result, False), status
|
||||
|
||||
async def show_device(
|
||||
self,
|
||||
idx: str,
|
||||
boss_keys: list[int] = [],
|
||||
) -> tuple[bool, int]:
|
||||
"""显示设备窗口"""
|
||||
OK, result, status = await self.get_device_info(idx)
|
||||
if not OK or not isinstance(result, EmulatorInfo):
|
||||
return False, DeviceStatus.UNKNOWN
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
|
||||
return await self.send_boss_key(boss_keys, result, True), status
|
||||
|
||||
async def get_adb_ports(self, pid: int) -> int:
|
||||
"""使用psutil获取adb端口"""
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
connections = process.connections(kind="inet")
|
||||
for conn in connections:
|
||||
if conn.status == psutil.CONN_LISTEN and conn.laddr.port != 2222:
|
||||
return conn.laddr.port
|
||||
return 0 # 如果没有找到合适的端口,返回0
|
||||
except: # noqa: E722
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
MANAGER_PATH = (
|
||||
r"C:\leidian\LDPlayer9\dnconsole.exe" # 替换为实际的dnconsole.exe路径
|
||||
)
|
||||
idx = "0" # 替换为实际存在的模拟器实例索
|
||||
|
||||
manager = LDManager(MANAGER_PATH)
|
||||
# asyncio.run(manager._get_all_info())
|
||||
a = asyncio.run(manager.start("0"))
|
||||
print(a)
|
||||
220
app/utils/device_manager/mumu.py
Normal file
220
app/utils/device_manager/mumu.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import asyncio
|
||||
import json
|
||||
from app.utils.device_manager.utils import BaseDevice, ExeRunner, DeviceStatus
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
|
||||
class MumuManager(BaseDevice):
|
||||
"""
|
||||
基于MuMuManager.exe的模拟器管理
|
||||
"""
|
||||
|
||||
def __init__(self, exe_path: str) -> None:
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
exe_path (str): MuMuManager.exe的绝对路径
|
||||
"""
|
||||
self.runner = ExeRunner(exe_path, "utf-8")
|
||||
self.logger = get_logger("MuMu管理器")
|
||||
self.wait_time = 60 # 配置获取 后续改一下 单位为s
|
||||
|
||||
async def start(self, idx: str, package_name="") -> tuple[bool, int, dict]:
|
||||
"""
|
||||
启动指定模拟器
|
||||
Returns:
|
||||
tuple[bool, int, str]: 是否成功, 当前状态码, ADB端口信息
|
||||
"""
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.OFFLINE:
|
||||
self.logger.error(
|
||||
f"模拟器{idx}未处于关闭状态,当前状态码: {status}, 需求状态码: {DeviceStatus.OFFLINE}"
|
||||
)
|
||||
return False, status, {}
|
||||
if package_name:
|
||||
result = self.runner.run(
|
||||
"control",
|
||||
"-v",
|
||||
idx,
|
||||
"launch",
|
||||
"-pkg",
|
||||
package_name,
|
||||
)
|
||||
else:
|
||||
result = self.runner.run(
|
||||
"control",
|
||||
"-v",
|
||||
idx,
|
||||
"launch",
|
||||
)
|
||||
# 参考命令 MuMuManager.exe control -v 2 launch
|
||||
self.logger.debug(f"启动结果:{result}")
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"命令执行失败: {result}")
|
||||
|
||||
i = 0
|
||||
while i < self.wait_time * 10:
|
||||
status = await self.get_status(idx)
|
||||
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
|
||||
self.logger.error(f"模拟器{idx}启动失败,状态码: {status}")
|
||||
return False, status, {}
|
||||
if status == DeviceStatus.ONLINE:
|
||||
OK, info = await self.get_device_info(idx)
|
||||
self.logger.debug(info)
|
||||
if OK:
|
||||
data = json.loads(info)
|
||||
adb_port = data.get("adb_port")
|
||||
adb_host_ip = data.get("adb_host_ip")
|
||||
if adb_port and adb_host_ip:
|
||||
return (
|
||||
True,
|
||||
status,
|
||||
{"adb_port": adb_port, "adb_host_ip": adb_host_ip},
|
||||
)
|
||||
|
||||
return True, status, {}
|
||||
await asyncio.sleep(0.1)
|
||||
i += 1
|
||||
return False, DeviceStatus.UNKNOWN, {}
|
||||
|
||||
async def close(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
关闭指定模拟器
|
||||
Returns:
|
||||
tuple[bool, int]: 是否成功, 当前状态码
|
||||
"""
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.ONLINE and status != DeviceStatus.STARTING:
|
||||
return False, DeviceStatus.NOT_FOUND
|
||||
result = self.runner.run(
|
||||
"control",
|
||||
"-v",
|
||||
idx,
|
||||
"shutdown",
|
||||
)
|
||||
# 参考命令 MuMuManager.exe control -v 2 shutdown
|
||||
if result.returncode != 0:
|
||||
return True, DeviceStatus.OFFLINE
|
||||
i = 0
|
||||
while i < self.wait_time * 10:
|
||||
status = await self.get_status(idx)
|
||||
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
|
||||
return False, status
|
||||
if status == DeviceStatus.OFFLINE:
|
||||
return True, DeviceStatus.OFFLINE
|
||||
await asyncio.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
return False, DeviceStatus.UNKNOWN
|
||||
|
||||
async def get_status(self, idx: str, data: str | None = None) -> int:
|
||||
if not data:
|
||||
OK, result_str = await self.get_device_info(idx)
|
||||
self.logger.debug(f"获取状态结果{result_str}")
|
||||
else:
|
||||
OK, result_str = True, data
|
||||
|
||||
try:
|
||||
result_json = json.loads(result_str)
|
||||
|
||||
if OK:
|
||||
if result_json["is_android_started"]:
|
||||
return DeviceStatus.STARTING
|
||||
elif result_json["is_process_started"]:
|
||||
return DeviceStatus.ONLINE
|
||||
else:
|
||||
return DeviceStatus.OFFLINE
|
||||
|
||||
else:
|
||||
if result_json["errmsg"] == "unknown error":
|
||||
return DeviceStatus.UNKNOWN
|
||||
else:
|
||||
return DeviceStatus.ERROR
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"JSON解析错误: {e}")
|
||||
return DeviceStatus.UNKNOWN
|
||||
|
||||
async def get_device_info(self, idx: str) -> tuple[bool, str]:
|
||||
result = self.runner.run(
|
||||
"info",
|
||||
"-v",
|
||||
idx,
|
||||
)
|
||||
self.logger.debug(f"获取模拟器{idx}信息: {result}")
|
||||
if result.returncode != 0:
|
||||
return False, result.stdout.strip()
|
||||
else:
|
||||
return True, result.stdout.strip()
|
||||
|
||||
async def _get_all_info(self) -> str:
|
||||
result = self.runner.run(
|
||||
"info",
|
||||
"-v",
|
||||
"all",
|
||||
)
|
||||
# self.logger.debug(f"result{result.stdout.strip()}")
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"命令执行失败: {result}")
|
||||
return result.stdout.strip()
|
||||
|
||||
async def get_all_info(self) -> dict[str, dict[str, str]]:
|
||||
json_data = await self._get_all_info()
|
||||
data = json.loads(json_data)
|
||||
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
|
||||
if not data:
|
||||
return result
|
||||
|
||||
if isinstance(data, dict) and "index" in data and "name" in data:
|
||||
index = data["index"]
|
||||
name = data["name"]
|
||||
status = self.get_status(index, json_data)
|
||||
result[index] = {
|
||||
"title": name,
|
||||
"status": str(status),
|
||||
}
|
||||
|
||||
elif isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict) and "index" in value and "name" in value:
|
||||
index = value["index"]
|
||||
name = value["name"]
|
||||
status = await self.get_status(index)
|
||||
result[index] = {
|
||||
"title": name,
|
||||
"status": str(status),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def hide_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""隐藏设备窗口"""
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
result = self.runner.run(
|
||||
"control",
|
||||
"-v",
|
||||
idx,
|
||||
"hide_window",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False, status
|
||||
return True, DeviceStatus.ONLINE
|
||||
|
||||
async def show_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""显示设备窗口"""
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
result = self.runner.run(
|
||||
"control",
|
||||
"-v",
|
||||
idx,
|
||||
"show_window",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False, status
|
||||
return True, DeviceStatus.ONLINE
|
||||
104
app/utils/device_manager/utils.py
Normal file
104
app/utils/device_manager/utils.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import subprocess
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class DeviceStatus(IntEnum):
|
||||
ONLINE = 0
|
||||
"""设备在线"""
|
||||
OFFLINE = 1
|
||||
"""设备离线"""
|
||||
STARTING = 2
|
||||
"""设备开启中"""
|
||||
CLOSEING = 3
|
||||
"""设备关闭中"""
|
||||
ERROR = 4
|
||||
"""错误"""
|
||||
NOT_FOUND = 5
|
||||
"""未找到设备"""
|
||||
UNKNOWN = 10
|
||||
|
||||
|
||||
class ExeRunner:
|
||||
def __init__(self, exe_path, encoding) -> None:
|
||||
"""
|
||||
指定 exe 路径
|
||||
!请传入绝对路径,使用/分隔路径
|
||||
"""
|
||||
if not os.path.isfile(exe_path):
|
||||
raise FileNotFoundError(f"找不到文件: {exe_path}")
|
||||
self.exe_path = os.path.abspath(exe_path) # 转为绝对路径
|
||||
self.encoding = encoding
|
||||
|
||||
def run(self, *args) -> subprocess.CompletedProcess[str]:
|
||||
"""
|
||||
执行命令,返回结果
|
||||
"""
|
||||
cmd = [self.exe_path] + list(args)
|
||||
print(f"执行: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding=self.encoding,
|
||||
errors="replace",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class BaseDevice(ABC):
|
||||
@abstractmethod
|
||||
async def start(self, idx: str, package_name: str) -> tuple[bool, int, dict]:
|
||||
"""
|
||||
启动设备
|
||||
返回值: (是否成功, 状态码, 启动信息)
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
关闭设备或服务
|
||||
返回值: (是否成功, 状态码)
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_status(self, idx: str) -> int:
|
||||
"""
|
||||
获取指定模拟器当前状态
|
||||
返回值: 状态码
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def hide_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
隐藏设备窗口
|
||||
返回值: (是否成功, 状态码)
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def show_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
显示设备窗口
|
||||
返回值: (是否成功, 状态码)
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_all_info(self) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
获取设备信息
|
||||
返回值: 设备字典
|
||||
结构示例:
|
||||
{
|
||||
"0":{
|
||||
"title": 模拟器名字,
|
||||
"status": "1"
|
||||
}
|
||||
}
|
||||
"""
|
||||
...
|
||||
70
app/utils/logger.py
Normal file
70
app/utils/logger.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from loguru import logger as _logger
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
(Path.cwd() / "debug").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
_logger.remove()
|
||||
|
||||
|
||||
_logger.add(
|
||||
sink=Path.cwd() / "debug/app.log",
|
||||
level="INFO",
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{extra[module]}</cyan> | <level>{message}</level>",
|
||||
enqueue=True,
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
rotation="1 week",
|
||||
retention="1 month",
|
||||
compression="zip",
|
||||
)
|
||||
|
||||
_logger.add(
|
||||
sink=sys.stderr,
|
||||
level="DEBUG",
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{extra[module]}</cyan> | <level>{message}</level>",
|
||||
enqueue=True,
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
colorize=True,
|
||||
)
|
||||
|
||||
|
||||
_logger = _logger.patch(lambda record: record["extra"].setdefault("module", "未知模块"))
|
||||
|
||||
|
||||
def get_logger(module_name: str):
|
||||
"""
|
||||
获取一个绑定 module 名的日志器
|
||||
|
||||
:param module_name: 模块名称, 如 "用户管理"
|
||||
:return: 绑定后的 logger
|
||||
"""
|
||||
return _logger.bind(module=module_name)
|
||||
|
||||
|
||||
__all__ = ["get_logger"]
|
||||
@@ -1,144 +0,0 @@
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO_MAA is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO_MAA is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA打包程序
|
||||
v4.3
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def version_text(version_numb: list) -> str:
|
||||
"""将版本号列表转为可读的文本信息"""
|
||||
|
||||
while len(version_numb) < 4:
|
||||
version_numb.append(0)
|
||||
|
||||
if version_numb[3] == 0:
|
||||
version = f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
|
||||
else:
|
||||
version = (
|
||||
f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def version_info_markdown(info: dict) -> str:
|
||||
"""将版本信息字典转为markdown信息"""
|
||||
|
||||
version_info = ""
|
||||
for key, value in info.items():
|
||||
version_info += f"## {key}\n"
|
||||
for v in value:
|
||||
version_info += f"- {v}\n"
|
||||
return version_info
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
root_path = Path(sys.argv[0]).resolve().parent
|
||||
|
||||
with (root_path / "resources/version.json").open(mode="r", encoding="utf-8") as f:
|
||||
version = json.load(f)
|
||||
|
||||
main_version_numb = list(map(int, version["main_version"].split(".")))
|
||||
updater_version_numb = list(map(int, version["updater_version"].split(".")))
|
||||
|
||||
print("Packaging AUTO_MAA main program ...")
|
||||
|
||||
os.system(
|
||||
"powershell -Command python -m nuitka --standalone --onefile --mingw64"
|
||||
" --enable-plugins=pyside6 --windows-console-mode=disable"
|
||||
" --onefile-tempdir-spec='{TEMP}\\AUTO_MAA'"
|
||||
" --windows-icon-from-ico=resources\\icons\\AUTO_MAA.ico"
|
||||
" --company-name='AUTO_MAA Team' --product-name=AUTO_MAA"
|
||||
f" --file-version={version["main_version"]}"
|
||||
f" --product-version={version["main_version"]}"
|
||||
" --file-description='AUTO_MAA Component'"
|
||||
" --copyright='Copyright © 2024-2025 DLmaster361'"
|
||||
" --assume-yes-for-downloads --output-filename=AUTO_MAA"
|
||||
" --remove-output main.py"
|
||||
)
|
||||
|
||||
print("AUTO_MAA main program packaging completed !")
|
||||
|
||||
print("start to create setup program ...")
|
||||
|
||||
(root_path / "AUTO_MAA").mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(root_path / "AUTO_MAA.exe", root_path / "AUTO_MAA/")
|
||||
shutil.copytree(root_path / "app", root_path / "AUTO_MAA/app")
|
||||
shutil.copytree(root_path / "resources", root_path / "AUTO_MAA/resources")
|
||||
shutil.copy(root_path / "main.py", root_path / "AUTO_MAA/")
|
||||
shutil.copy(root_path / "requirements.txt", root_path / "AUTO_MAA/")
|
||||
shutil.copy(root_path / "README.md", root_path / "AUTO_MAA/")
|
||||
shutil.copy(root_path / "LICENSE", root_path / "AUTO_MAA/")
|
||||
|
||||
with (root_path / "app/utils/AUTO_MAA.iss").open(mode="r", encoding="utf-8") as f:
|
||||
iss = f.read()
|
||||
iss = (
|
||||
iss.replace(
|
||||
'#define MyAppVersion ""',
|
||||
f'#define MyAppVersion "{version["main_version"]}"',
|
||||
)
|
||||
.replace(
|
||||
'#define MyAppPath ""', f'#define MyAppPath "{root_path / "AUTO_MAA"}"'
|
||||
)
|
||||
.replace('#define OutputDir ""', f'#define OutputDir "{root_path}"')
|
||||
)
|
||||
with (root_path / "AUTO_MAA.iss").open(mode="w", encoding="utf-8") as f:
|
||||
f.write(iss)
|
||||
|
||||
os.system(f'ISCC "{root_path / "AUTO_MAA.iss"}"')
|
||||
|
||||
(root_path / "AUTO_MAA_Setup").mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(root_path / "AUTO_MAA-Setup.exe", root_path / "AUTO_MAA_Setup")
|
||||
|
||||
shutil.make_archive(
|
||||
base_name=root_path / f"AUTO_MAA_{version_text(main_version_numb)}",
|
||||
format="zip",
|
||||
root_dir=root_path / "AUTO_MAA",
|
||||
base_dir=".",
|
||||
)
|
||||
|
||||
print("setup program created !")
|
||||
|
||||
(root_path / "AUTO_MAA.iss").unlink(missing_ok=True)
|
||||
shutil.rmtree(root_path / "AUTO_MAA")
|
||||
shutil.rmtree(root_path / "AUTO_MAA_Setup")
|
||||
|
||||
all_version_info = {}
|
||||
for v_i in version["version_info"].values():
|
||||
for key, value in v_i.items():
|
||||
if key in all_version_info:
|
||||
all_version_info[key] += value.copy()
|
||||
else:
|
||||
all_version_info[key] = value.copy()
|
||||
|
||||
(root_path / "version_info.txt").write_text(
|
||||
f"{version_text(main_version_numb)}\n{version_text(updater_version_numb)}\n<!--{json.dumps(version["version_info"], ensure_ascii=False)}-->\n{version_info_markdown(all_version_info)}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
70
app/utils/security.py
Normal file
70
app/utils/security.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License,
|
||||
# or (at your option) any later version.
|
||||
|
||||
# AUTO-MAS is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
||||
# the GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import base64
|
||||
import win32crypt
|
||||
|
||||
|
||||
def dpapi_encrypt(
|
||||
note: str, description: None | str = None, entropy: None | bytes = None
|
||||
) -> str:
|
||||
"""
|
||||
使用Windows DPAPI加密数据
|
||||
|
||||
:param note: 数据明文
|
||||
:type note: str
|
||||
:param description: 描述信息
|
||||
:type description: str
|
||||
:param entropy: 随机熵
|
||||
:type entropy: bytes
|
||||
:return: 加密后的数据
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
if note == "":
|
||||
return ""
|
||||
|
||||
encrypted = win32crypt.CryptProtectData(
|
||||
note.encode("utf-8"), description, entropy, None, None, 0
|
||||
)
|
||||
return base64.b64encode(encrypted).decode("utf-8")
|
||||
|
||||
|
||||
def dpapi_decrypt(note: str, entropy: None | bytes = None) -> str:
|
||||
"""
|
||||
使用Windows DPAPI解密数据
|
||||
|
||||
:param note: 数据密文
|
||||
:type note: str
|
||||
:param entropy: 随机熵
|
||||
:type entropy: bytes
|
||||
:return: 解密后的明文
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
if note == "":
|
||||
return ""
|
||||
|
||||
decrypted = win32crypt.CryptUnprotectData(
|
||||
base64.b64decode(note), entropy, None, None, 0
|
||||
)
|
||||
return decrypted[1].decode("utf-8")
|
||||
411
docs/Backend_Task_Scheduling_and_WebSocket_Messages.md
Normal file
411
docs/Backend_Task_Scheduling_and_WebSocket_Messages.md
Normal file
@@ -0,0 +1,411 @@
|
||||
# AUTO-MAS 后端任务调度逻辑与WebSocket消息格式说明
|
||||
|
||||
## 1. 任务调度架构概览
|
||||
|
||||
AUTO-MAS 后端采用基于 AsyncIO 的异步任务调度系统,主要由以下核心组件构成:
|
||||
|
||||
### 1.1 核心组件
|
||||
|
||||
- **TaskManager**: 任务调度器,负责任务的创建、运行、停止和清理
|
||||
- **Broadcast**: 消息广播系统,负责在不同组件间传递消息
|
||||
- **WebSocket**: 与前端的实时通信通道
|
||||
- **Config**: 配置管理系统,包含脚本配置、队列配置等
|
||||
|
||||
### 1.2 任务类型
|
||||
|
||||
系统支持三种主要任务模式:
|
||||
|
||||
1. **设置脚本** - 直接执行单个脚本配置
|
||||
2. **自动代理** - 按队列顺序自动执行多个脚本
|
||||
3. **人工排查** - 手动排查和执行任务
|
||||
|
||||
## 2. 任务调度流程
|
||||
|
||||
### 2.1 任务创建流程
|
||||
|
||||
```
|
||||
前端请求 → API接口 → TaskManager.add_task() → 任务验证 → 创建异步任务 → 返回任务ID
|
||||
```
|
||||
|
||||
**具体步骤:**
|
||||
|
||||
1. **任务验证**: 根据模式和UID验证任务配置是否存在
|
||||
2. **重复检查**: 确保相同任务未在运行中
|
||||
3. **任务创建**: 使用`asyncio.create_task()`创建异步任务
|
||||
4. **回调设置**: 添加任务完成回调用于清理工作
|
||||
|
||||
### 2.2 任务执行流程
|
||||
|
||||
#### 设置脚本模式
|
||||
```
|
||||
获取脚本配置 → 确定脚本类型(MAA/General) → 创建对应Manager → 执行任务
|
||||
```
|
||||
|
||||
#### 自动代理模式
|
||||
```
|
||||
获取队列配置 → 构建任务列表 → 逐个执行脚本 → 更新状态 → 发送完成信号
|
||||
```
|
||||
|
||||
### 2.3 任务状态管理
|
||||
|
||||
- **等待**: 任务已加入队列但未开始执行
|
||||
- **运行**: 任务正在执行中
|
||||
- **跳过**: 任务因重复或其他原因被跳过
|
||||
- **完成**: 任务执行完毕
|
||||
|
||||
## 3. WebSocket 消息系统
|
||||
|
||||
### 3.1 消息基础结构
|
||||
|
||||
所有WebSocket消息都遵循统一的JSON格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "消息ID或任务ID",
|
||||
"type": "消息类型",
|
||||
"data": {
|
||||
"具体数据": "根据类型而定"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 消息类型详解
|
||||
|
||||
#### 3.2.1 Update 类型 - 数据更新
|
||||
|
||||
**用途**: 通知前端更新界面数据,"user_list"仅给出当前处于`运行`状态的脚本的用户列表值
|
||||
|
||||
**常见数据格式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"type": "Update",
|
||||
"data": {
|
||||
"user_list": [
|
||||
{
|
||||
"name": "用户名",
|
||||
"status": "运行状态",
|
||||
"config": "配置信息"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"type": "Update",
|
||||
"data": {
|
||||
"task_dict": [
|
||||
{
|
||||
"script_id": "脚本ID",
|
||||
"status": "等待/运行/完成/跳过",
|
||||
"name": "脚本名称",
|
||||
"user_list": [
|
||||
{
|
||||
"name": "用户名",
|
||||
"status": "运行状态",
|
||||
"config": "配置信息"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"type": "Update",
|
||||
"data": {
|
||||
"task_list": [
|
||||
{
|
||||
"script_id": "脚本ID",
|
||||
"status": "等待/运行/完成/跳过",
|
||||
"name": "脚本名称"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"type": "Update",
|
||||
"data": {
|
||||
"log": "任务执行日志内容"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 Info 类型 - 信息显示
|
||||
|
||||
**用途**: 向前端发送需要显示的信息,包括普通信息、警告和错误
|
||||
|
||||
**数据格式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"type": "Info",
|
||||
"data": {
|
||||
"Error": "错误信息内容"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"type": "Info",
|
||||
"data": {
|
||||
"Warning": "警告信息内容"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"type": "Info",
|
||||
"data": {
|
||||
"Info": "普通信息内容"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.3 Message 类型 - 对话框请求
|
||||
|
||||
**用途**: 请求前端弹出对话框显示重要信息
|
||||
|
||||
**数据格式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"type": "Message",
|
||||
"data": {
|
||||
"title": "对话框标题",
|
||||
"content": "对话框内容",
|
||||
"type": "info/warning/error"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.4 Signal 类型 - 程序信号
|
||||
|
||||
**用途**: 发送程序控制信号和状态通知
|
||||
|
||||
**常见信号:**
|
||||
|
||||
**任务完成信号:**
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"type": "Signal",
|
||||
"data": {
|
||||
"Accomplish": "任务完成后调度台显示的日志内容"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**电源操作信号:**
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"type": "Signal",
|
||||
"data": {
|
||||
"power": "NoAction/KillSelf/Sleep/Hibernate/Shutdown/ShutdownForce",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**心跳信号:**
|
||||
```json
|
||||
{
|
||||
"id": "Main",
|
||||
"type": "Signal",
|
||||
"data": {
|
||||
"Ping": "无描述"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "Main",
|
||||
"type": "Signal",
|
||||
"data": {
|
||||
"Pong": "无描述"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 任务管理器详细说明
|
||||
|
||||
### 4.1 TaskManager 核心方法
|
||||
|
||||
#### add_task(mode: str, uid: str)
|
||||
- **功能**: 添加新任务到调度队列
|
||||
- **参数**:
|
||||
- `mode`: 任务模式 ("设置脚本", "自动代理", "人工排查")
|
||||
- `uid`: 任务唯一标识符
|
||||
- **返回**: 任务UUID
|
||||
|
||||
#### stop_task(task_id: str)
|
||||
- **功能**: 停止指定任务
|
||||
- **参数**:
|
||||
- `task_id`: 任务ID,支持 "ALL" 停止所有任务
|
||||
|
||||
#### run_task(mode: str, task_id: UUID, actual_id: Optional[UUID])
|
||||
- **功能**: 执行具体任务逻辑
|
||||
- **流程**: 根据模式选择相应的执行策略
|
||||
|
||||
### 4.2 任务执行器
|
||||
|
||||
#### GeneralManager
|
||||
- **用途**: 处理通用脚本任务
|
||||
- **特点**: 支持自定义脚本路径和参数
|
||||
- **配置**: 基于 GeneralConfig 和 GeneralUserConfig
|
||||
|
||||
#### MaaManager
|
||||
- **用途**: 处理MAA (明日方舟助手) 专用任务
|
||||
- **特点**: 支持模拟器控制、ADB连接、游戏自动化
|
||||
- **配置**: 基于 MaaConfig 和 MaaUserConfig
|
||||
|
||||
## 5. 消息广播系统
|
||||
|
||||
### 5.1 Broadcast 机制
|
||||
|
||||
- **设计模式**: 发布-订阅模式
|
||||
- **功能**: 实现组件间解耦的消息传递
|
||||
- **特点**: 支持多个订阅者同时接收消息
|
||||
|
||||
### 5.2 消息流向
|
||||
|
||||
```
|
||||
任务执行器 → Broadcast → WebSocket → 前端界面
|
||||
↓
|
||||
其他订阅者
|
||||
```
|
||||
|
||||
## 6. 配置管理系统
|
||||
|
||||
### 6.1 配置类型
|
||||
|
||||
- **ScriptConfig**: 脚本配置,包含MAA和General两种类型
|
||||
- **QueueConfig**: 队列配置,定义自动代理任务的执行顺序
|
||||
- **GlobalConfig**: 全局系统配置
|
||||
|
||||
### 6.2 配置操作
|
||||
|
||||
- **锁机制**: 防止配置在使用时被修改
|
||||
- **实时更新**: 支持动态加载配置变更
|
||||
- **类型验证**: 确保配置数据的正确性
|
||||
|
||||
## 7. API 接口说明
|
||||
|
||||
### 7.1 任务控制接口
|
||||
|
||||
**创建任务**
|
||||
- **端点**: `POST /api/dispatch/start`
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"mode": "自动代理|人工排查|设置脚本",
|
||||
"taskId": "目标任务ID"
|
||||
}
|
||||
```
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"status": "success",
|
||||
"message": "操作成功",
|
||||
"websocketId": "新任务ID"
|
||||
}
|
||||
```
|
||||
|
||||
**停止任务**
|
||||
- **端点**: `POST /api/dispatch/stop`
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"taskId": "要停止的任务ID"
|
||||
}
|
||||
```
|
||||
|
||||
**电源操作**
|
||||
- **端点**: `POST /api/dispatch/power`
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"signal": "NoAction|Shutdown|ShutdownForce|Hibernate|Sleep|KillSelf"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 WebSocket 连接
|
||||
|
||||
**端点**: `WS /api/core/ws`
|
||||
|
||||
**连接特性**:
|
||||
- 同时只允许一个WebSocket连接
|
||||
- 自动心跳检测 (15秒超时)
|
||||
- 连接断开时自动清理资源
|
||||
|
||||
## 8. 错误处理机制
|
||||
|
||||
### 8.1 异常类型
|
||||
|
||||
- **ValueError**: 配置验证失败
|
||||
- **RuntimeError**: 任务状态冲突
|
||||
- **TimeoutError**: 操作超时
|
||||
- **ConnectionError**: 连接相关错误
|
||||
|
||||
### 8.2 错误响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "相关任务ID",
|
||||
"type": "Info",
|
||||
"data": {
|
||||
"Error": "具体错误描述"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 9. 性能和监控
|
||||
|
||||
### 9.1 日志系统
|
||||
|
||||
- **分层日志**: 按模块划分日志记录器
|
||||
- **实时监控**: 支持日志实时推送到前端
|
||||
- **文件轮转**: 自动管理日志文件大小
|
||||
|
||||
### 9.2 资源管理
|
||||
|
||||
- **进程管理**: 自动清理子进程
|
||||
- **内存监控**: 防止内存泄漏
|
||||
- **连接池**: 复用数据库和网络连接
|
||||
|
||||
## 10. 安全考虑
|
||||
|
||||
### 10.1 输入验证
|
||||
|
||||
- **参数校验**: 使用 Pydantic 模型验证
|
||||
- **路径安全**: 防止路径遍历攻击
|
||||
- **命令注入**: 严格控制执行的命令参数
|
||||
|
||||
### 10.2 权限控制
|
||||
|
||||
- **单一连接**: 限制WebSocket连接数量
|
||||
- **操作限制**: 防止重复或冲突操作
|
||||
- **资源保护**: 防止资源滥用
|
||||
|
||||
---
|
||||
|
||||
*此文档基于 AUTO-MAS v5.0.0 版本编写,详细的API文档和配置说明请参考相关配置文件和源代码注释。*
|
||||
126
docs/TaskManager_WebSocket_Implementation.md
Normal file
126
docs/TaskManager_WebSocket_Implementation.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# TaskManager WebSocket消息处理功能实现
|
||||
|
||||
## 功能概述
|
||||
|
||||
根据后端TaskManager的WebSocket消息机制,实现了前端对ID为"TaskManager"的WebSocket消息的完整处理逻辑。
|
||||
|
||||
## 后端TaskManager消息分析
|
||||
|
||||
### 消息格式
|
||||
```json
|
||||
{
|
||||
"id": "TaskManager",
|
||||
"type": "Signal",
|
||||
"data": {
|
||||
"newTask": "任务UUID"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 触发时机
|
||||
当后端启动时运行的队列开始执行时,TaskManager会发送此消息通知前端有新任务被自动创建。
|
||||
|
||||
## 前端实现
|
||||
|
||||
### 1. WebSocket订阅机制
|
||||
在`useSchedulerLogic.ts`中添加了以下功能:
|
||||
|
||||
- **subscribeToTaskManager()**: 订阅ID为"TaskManager"的WebSocket消息
|
||||
- **handleTaskManagerMessage()**: 处理TaskManager发送的消息
|
||||
- **createSchedulerTabForTask()**: 根据任务ID自动创建调度台
|
||||
|
||||
### 2. 自动调度台创建逻辑
|
||||
|
||||
当收到`newTask`信号时,系统会:
|
||||
|
||||
1. **检查重复**: 验证是否已存在相同websocketId的调度台
|
||||
2. **创建调度台**: 自动创建新的调度台标签页
|
||||
3. **设置状态**: 直接将调度台状态设置为"运行"
|
||||
4. **建立连接**: 立即订阅该任务的WebSocket消息
|
||||
5. **用户提示**: 显示成功创建的消息提示
|
||||
|
||||
### 3. 调度台特性
|
||||
|
||||
自动创建的调度台具有以下特性:
|
||||
- 标题格式:`自动调度台{编号}`
|
||||
- 初始状态:`运行`
|
||||
- 可关闭:`true`(但运行时不可删除)
|
||||
- 自动订阅:立即开始接收任务消息
|
||||
|
||||
### 4. 生命周期管理
|
||||
|
||||
- **初始化**: 在组件挂载时调用`initialize()`订阅TaskManager消息
|
||||
- **清理**: 在组件卸载时取消TaskManager订阅
|
||||
- **任务结束**: 复用现有的任务结束处理逻辑
|
||||
|
||||
## 代码修改点
|
||||
|
||||
### 1. useSchedulerLogic.ts
|
||||
```typescript
|
||||
// 新增TaskManager消息订阅
|
||||
const subscribeToTaskManager = () => {
|
||||
ws.subscribe('TaskManager', {
|
||||
onMessage: (message) => handleTaskManagerMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
// 新增TaskManager消息处理
|
||||
const handleTaskManagerMessage = (wsMessage: any) => {
|
||||
if (type === 'Signal' && data && data.newTask) {
|
||||
createSchedulerTabForTask(data.newTask)
|
||||
}
|
||||
}
|
||||
|
||||
// 新增自动调度台创建
|
||||
const createSchedulerTabForTask = (taskId: string) => {
|
||||
// 创建运行状态的调度台并立即订阅
|
||||
}
|
||||
```
|
||||
|
||||
### 2. index.vue
|
||||
```typescript
|
||||
// 生命周期中添加初始化调用
|
||||
onMounted(() => {
|
||||
initialize() // 订阅TaskManager消息
|
||||
loadTaskOptions()
|
||||
})
|
||||
```
|
||||
|
||||
## 功能特点
|
||||
|
||||
### 1. 无缝集成
|
||||
- 完全复用现有的调度台逻辑和UI组件
|
||||
- 与手动创建的调度台行为一致
|
||||
- 支持所有现有功能(日志显示、任务总览、消息处理等)
|
||||
|
||||
### 2. 状态同步
|
||||
- 调度台状态与后端任务状态严格同步
|
||||
- 支持任务完成后的自动状态更新
|
||||
- 正确处理WebSocket连接的建立和清理
|
||||
|
||||
### 3. 用户体验
|
||||
- 自动切换到新创建的调度台
|
||||
- 提供清晰的成功提示
|
||||
- 防止重复创建相同任务的调度台
|
||||
|
||||
### 4. 错误处理
|
||||
- 检查消息格式的有效性
|
||||
- 防止重复订阅和创建
|
||||
- 优雅处理异常情况
|
||||
|
||||
## 测试验证
|
||||
|
||||
功能实现后需要验证以下场景:
|
||||
|
||||
1. **启动时队列**: 后端启动时运行的队列应自动创建调度台
|
||||
2. **消息接收**: 调度台应正确接收和显示任务消息
|
||||
3. **状态更新**: 任务状态变化应正确反映在UI上
|
||||
4. **任务结束**: 任务完成后应正确清理资源
|
||||
5. **重复处理**: 相同任务不应创建多个调度台
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 完全向后兼容现有功能
|
||||
- 不影响手动创建的调度台
|
||||
- 保持现有的WebSocket消息处理机制
|
||||
- 复用所有现有的UI组件和样式
|
||||
427
docs/useWebSocket_API_Reference.md
Normal file
427
docs/useWebSocket_API_Reference.md
Normal file
@@ -0,0 +1,427 @@
|
||||
# useWebSocket API 参考文档
|
||||
|
||||
## 概述
|
||||
|
||||
`useWebSocket()` 组合式函数提供了完整的 WebSocket 通信接口,包含消息订阅、连接管理、状态监控等核心功能。
|
||||
|
||||
## 导出函数详解
|
||||
|
||||
### 1. subscribe() - 订阅消息
|
||||
|
||||
```typescript
|
||||
const subscribe = (
|
||||
filter: SubscriptionFilter,
|
||||
handler: (message: WebSocketBaseMessage) => void
|
||||
): string
|
||||
```
|
||||
|
||||
#### 参数说明
|
||||
|
||||
**filter: SubscriptionFilter**
|
||||
```typescript
|
||||
interface SubscriptionFilter {
|
||||
type?: string // 消息类型过滤器(可选)
|
||||
id?: string // 消息ID过滤器(可选)
|
||||
needCache?: boolean // 是否启用缓存回放(可选)
|
||||
}
|
||||
```
|
||||
|
||||
**handler: Function**
|
||||
- 消息处理回调函数
|
||||
- 参数: `message: WebSocketBaseMessage`
|
||||
- 无返回值
|
||||
|
||||
#### 返回值
|
||||
- `string`: 唯一的订阅ID,用于后续取消订阅
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```typescript
|
||||
// 订阅所有消息
|
||||
const allMsgSub = subscribe({}, (msg) => {
|
||||
console.log('收到消息:', msg)
|
||||
})
|
||||
|
||||
// 订阅特定类型消息
|
||||
const taskSub = subscribe(
|
||||
{ type: 'TaskUpdate', needCache: true },
|
||||
(msg) => {
|
||||
console.log('任务更新:', msg.data)
|
||||
}
|
||||
)
|
||||
|
||||
// 订阅特定ID消息
|
||||
const specificSub = subscribe(
|
||||
{ id: 'TaskManager' },
|
||||
(msg) => {
|
||||
console.log('任务管理器消息:', msg)
|
||||
}
|
||||
)
|
||||
|
||||
// 精确订阅(同时匹配type和id)
|
||||
const preciseSub = subscribe(
|
||||
{ type: 'Progress', id: 'task_001', needCache: true },
|
||||
(msg) => {
|
||||
console.log('特定任务进度:', msg.data)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### 特殊功能
|
||||
- **自动回放**: 如果 `needCache: true`,会立即回放匹配的历史消息
|
||||
- **过滤优先级**: type + id > type > id > 全部
|
||||
- **引用计数**: 多个订阅共享缓存,自动管理内存
|
||||
|
||||
---
|
||||
|
||||
### 2. unsubscribe() - 取消订阅
|
||||
|
||||
```typescript
|
||||
const unsubscribe = (subscriptionId: string): void
|
||||
```
|
||||
|
||||
#### 参数说明
|
||||
|
||||
**subscriptionId: string**
|
||||
- 由 `subscribe()` 返回的订阅ID
|
||||
- 必须是有效的订阅ID
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```typescript
|
||||
const subId = subscribe({ type: 'TaskUpdate' }, handleTaskUpdate)
|
||||
|
||||
// 取消订阅
|
||||
unsubscribe(subId)
|
||||
|
||||
// Vue 组件中的最佳实践
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
const setupSubscription = () => {
|
||||
const subId = subscribe({ type: 'TaskUpdate' }, handleTaskUpdate)
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribe(subId)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 自动清理
|
||||
- 自动减少缓存引用计数
|
||||
- 引用计数为0时清理相关缓存
|
||||
- 不会影响其他订阅者
|
||||
|
||||
---
|
||||
|
||||
### 3. sendRaw() - 发送消息
|
||||
|
||||
```typescript
|
||||
const sendRaw = (type: string, data?: any, id?: string): void
|
||||
```
|
||||
|
||||
#### 参数说明
|
||||
|
||||
**type: string** (必需)
|
||||
- 消息类型标识
|
||||
- 后端用于路由消息
|
||||
|
||||
**data: any** (可选)
|
||||
- 消息负载数据
|
||||
- 可以是任何可序列化的对象
|
||||
|
||||
**id: string** (可选)
|
||||
- 消息标识符
|
||||
- 用于消息跟踪和响应匹配
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```typescript
|
||||
// 发送简单消息
|
||||
sendRaw('Hello')
|
||||
|
||||
// 发送带数据的消息
|
||||
sendRaw('TaskStart', {
|
||||
taskId: '12345',
|
||||
config: { timeout: 30000 }
|
||||
})
|
||||
|
||||
// 发送带ID的消息(便于追踪响应)
|
||||
sendRaw('GetTaskStatus', { taskId: '12345' }, 'query_001')
|
||||
|
||||
// 发送控制信号
|
||||
sendRaw('Signal', {
|
||||
command: 'pause',
|
||||
reason: '用户手动暂停'
|
||||
}, 'TaskManager')
|
||||
```
|
||||
|
||||
#### 发送条件
|
||||
- 仅在 WebSocket 连接为 `OPEN` 状态时发送
|
||||
- 连接异常时静默失败(不抛出异常)
|
||||
- 自动JSON序列化
|
||||
|
||||
---
|
||||
|
||||
### 4. getConnectionInfo() - 获取连接信息
|
||||
|
||||
```typescript
|
||||
const getConnectionInfo = (): ConnectionInfo
|
||||
```
|
||||
|
||||
#### 返回值类型
|
||||
|
||||
```typescript
|
||||
interface ConnectionInfo {
|
||||
connectionId: string // 连接唯一标识
|
||||
status: WebSocketStatus // 当前连接状态
|
||||
subscriberCount: number // 当前订阅者数量
|
||||
moduleLoadCount: number // 模块加载计数
|
||||
wsReadyState: number | null // WebSocket原生状态
|
||||
isConnecting: boolean // 是否正在连接
|
||||
hasHeartbeat: boolean // 是否启用心跳
|
||||
hasEverConnected: boolean // 是否曾经连接成功
|
||||
reconnectAttempts: number // 重连尝试次数
|
||||
isPersistentMode: boolean // 是否持久化模式
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```typescript
|
||||
const info = getConnectionInfo()
|
||||
|
||||
console.log('连接ID:', info.connectionId)
|
||||
console.log('连接状态:', info.status)
|
||||
console.log('订阅者数量:', info.subscriberCount)
|
||||
|
||||
// 检查连接是否健康
|
||||
const isHealthy = info.status === '已连接' &&
|
||||
info.hasHeartbeat &&
|
||||
info.wsReadyState === WebSocket.OPEN
|
||||
|
||||
// 监控重连情况
|
||||
if (info.reconnectAttempts > 0) {
|
||||
console.log(`已重连 ${info.reconnectAttempts} 次`)
|
||||
}
|
||||
```
|
||||
|
||||
#### 调试用途
|
||||
- 诊断连接问题
|
||||
- 监控连接质量
|
||||
- 统计使用情况
|
||||
|
||||
---
|
||||
|
||||
### 5. status - 连接状态
|
||||
|
||||
```typescript
|
||||
const status: Ref<WebSocketStatus>
|
||||
```
|
||||
|
||||
#### 状态类型
|
||||
|
||||
```typescript
|
||||
type WebSocketStatus = '连接中' | '已连接' | '已断开' | '连接错误'
|
||||
```
|
||||
|
||||
#### 状态说明
|
||||
|
||||
| 状态 | 描述 | 触发条件 |
|
||||
|------|------|----------|
|
||||
| `'连接中'` | 正在建立连接 | WebSocket.CONNECTING |
|
||||
| `'已连接'` | 连接成功 | WebSocket.OPEN |
|
||||
| `'已断开'` | 连接断开 | WebSocket.CLOSED |
|
||||
| `'连接错误'` | 连接异常 | WebSocket.onerror |
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```typescript
|
||||
import { watch } from 'vue'
|
||||
|
||||
const { status } = useWebSocket()
|
||||
|
||||
// 监听状态变化
|
||||
watch(status, (newStatus) => {
|
||||
console.log('连接状态变化:', newStatus)
|
||||
|
||||
switch (newStatus) {
|
||||
case '已连接':
|
||||
console.log('✅ WebSocket 连接成功')
|
||||
break
|
||||
case '已断开':
|
||||
console.log('❌ WebSocket 连接断开')
|
||||
break
|
||||
case '连接错误':
|
||||
console.log('⚠️ WebSocket 连接错误')
|
||||
break
|
||||
case '连接中':
|
||||
console.log('🔄 WebSocket 连接中...')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// 在模板中显示状态
|
||||
// <div>连接状态: {{ status }}</div>
|
||||
```
|
||||
|
||||
#### 响应式特性
|
||||
- Vue 响应式 Ref 对象
|
||||
- 自动更新 UI
|
||||
- 可用于计算属性和监听器
|
||||
|
||||
---
|
||||
|
||||
### 6. backendStatus - 后端状态
|
||||
|
||||
```typescript
|
||||
const backendStatus: Ref<BackendStatus>
|
||||
```
|
||||
|
||||
#### 状态类型
|
||||
|
||||
```typescript
|
||||
type BackendStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
|
||||
```
|
||||
|
||||
#### 状态说明
|
||||
|
||||
| 状态 | 描述 | 含义 |
|
||||
|------|------|------|
|
||||
| `'unknown'` | 未知状态 | 初始状态,尚未检测 |
|
||||
| `'starting'` | 启动中 | 后端服务正在启动 |
|
||||
| `'running'` | 运行中 | 后端服务正常运行 |
|
||||
| `'stopped'` | 已停止 | 后端服务已停止 |
|
||||
| `'error'` | 错误状态 | 后端服务异常 |
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```typescript
|
||||
const { backendStatus, restartBackend } = useWebSocket()
|
||||
|
||||
// 监听后端状态
|
||||
watch(backendStatus, (newStatus) => {
|
||||
console.log('后端状态:', newStatus)
|
||||
|
||||
switch (newStatus) {
|
||||
case 'running':
|
||||
console.log('✅ 后端服务运行正常')
|
||||
break
|
||||
case 'stopped':
|
||||
console.log('⏹️ 后端服务已停止')
|
||||
break
|
||||
case 'error':
|
||||
console.log('❌ 后端服务异常')
|
||||
// 可以提示用户或自动重启
|
||||
break
|
||||
case 'starting':
|
||||
console.log('🚀 后端服务启动中...')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// 根据状态显示不同UI
|
||||
const statusColor = computed(() => {
|
||||
switch (backendStatus.value) {
|
||||
case 'running': return 'green'
|
||||
case 'error': return 'red'
|
||||
case 'starting': return 'orange'
|
||||
default: return 'gray'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 自动管理
|
||||
- 每3秒自动检测一次
|
||||
- 异常时自动尝试重启(最多3次)
|
||||
- 集成 Electron 进程管理
|
||||
|
||||
---
|
||||
|
||||
## 完整使用示例
|
||||
|
||||
```typescript
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const {
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
sendRaw,
|
||||
getConnectionInfo,
|
||||
status,
|
||||
backendStatus
|
||||
} = useWebSocket()
|
||||
|
||||
let taskSubscription: string
|
||||
let systemSubscription: string
|
||||
|
||||
onMounted(() => {
|
||||
// 订阅任务消息
|
||||
taskSubscription = subscribe(
|
||||
{ type: 'TaskUpdate', needCache: true },
|
||||
(message) => {
|
||||
console.log('任务更新:', message.data)
|
||||
}
|
||||
)
|
||||
|
||||
// 订阅系统消息
|
||||
systemSubscription = subscribe(
|
||||
{ id: 'System' },
|
||||
(message) => {
|
||||
console.log('系统消息:', message)
|
||||
}
|
||||
)
|
||||
|
||||
// 发送初始化消息
|
||||
sendRaw('ClientReady', {
|
||||
timestamp: Date.now()
|
||||
}, 'System')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理订阅
|
||||
if (taskSubscription) unsubscribe(taskSubscription)
|
||||
if (systemSubscription) unsubscribe(systemSubscription)
|
||||
})
|
||||
|
||||
// 监听连接状态
|
||||
watch([status, backendStatus], ([wsStatus, beStatus]) => {
|
||||
console.log(`WS: ${wsStatus}, Backend: ${beStatus}`)
|
||||
})
|
||||
|
||||
// 获取连接信息
|
||||
const connectionInfo = getConnectionInfo()
|
||||
|
||||
return {
|
||||
status,
|
||||
backendStatus,
|
||||
connectionInfo,
|
||||
sendMessage: (type: string, data: any) => sendRaw(type, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 订阅管理
|
||||
- 总是在组件卸载时取消订阅
|
||||
- 使用 `needCache: true` 确保不丢失消息
|
||||
- 避免重复订阅相同的消息类型
|
||||
|
||||
### 2. 错误处理
|
||||
- 监听连接状态变化
|
||||
- 根据后端状态调整UI显示
|
||||
- 实现重连提示和手动重启
|
||||
|
||||
### 3. 性能优化
|
||||
- 精确的过滤条件减少不必要的处理
|
||||
- 合理使用缓存避免消息丢失
|
||||
- 及时取消不需要的订阅
|
||||
|
||||
### 4. 调试技巧
|
||||
- 使用 `getConnectionInfo()` 诊断问题
|
||||
- 开发环境下查看控制台日志
|
||||
- 监控订阅者数量避免内存泄漏
|
||||
336
docs/useWebSocket_Analysis.md
Normal file
336
docs/useWebSocket_Analysis.md
Normal file
@@ -0,0 +1,336 @@
|
||||
## 核心架构设计
|
||||
|
||||
### 1. 全局持久化存储
|
||||
|
||||
```typescript
|
||||
const WS_STORAGE_KEY = Symbol.for('GLOBAL_WEBSOCKET_PERSISTENT')
|
||||
```
|
||||
|
||||
- 使用 `Symbol.for()` 确保全局唯一性
|
||||
- 存储在 `window` 对象上,实现跨组件共享
|
||||
- 避免多次实例化,确保连接唯一性
|
||||
|
||||
### 2. 状态管理结构
|
||||
|
||||
```typescript
|
||||
interface GlobalWSStorage {
|
||||
wsRef: WebSocket | null // WebSocket 实例
|
||||
status: Ref<WebSocketStatus> // 连接状态
|
||||
subscriptions: Ref<Map<string, WebSocketSubscription>> // 订阅管理
|
||||
cacheMarkers: Ref<Map<string, CacheMarker>> // 缓存标记
|
||||
cachedMessages: Ref<Array<CachedMessage>> // 消息缓存
|
||||
// ... 其他状态
|
||||
}
|
||||
```
|
||||
|
||||
## 核心功能模块
|
||||
|
||||
### 1. 配置管理
|
||||
|
||||
```typescript
|
||||
const BASE_WS_URL = 'ws://localhost:36163/api/core/ws'
|
||||
const HEARTBEAT_INTERVAL = 15000 // 心跳间隔
|
||||
const HEARTBEAT_TIMEOUT = 5000 // 心跳超时
|
||||
const BACKEND_CHECK_INTERVAL = 3000 // 后端检查间隔
|
||||
const MAX_RESTART_ATTEMPTS = 3 // 最大重启尝试次数
|
||||
const RESTART_DELAY = 2000 // 重启延迟
|
||||
const MAX_QUEUE_SIZE = 50 // 最大队列大小
|
||||
const MESSAGE_TTL = 60000 // 消息过期时间
|
||||
```
|
||||
|
||||
**要点**:
|
||||
- 所有时间配置使用毫秒为单位
|
||||
- 可根据网络环境调整超时时间
|
||||
- 队列大小限制防止内存泄漏
|
||||
|
||||
### 2. 消息订阅系统
|
||||
|
||||
#### 订阅过滤器
|
||||
```typescript
|
||||
interface SubscriptionFilter {
|
||||
type?: string // 消息类型过滤
|
||||
id?: string // 消息ID过滤
|
||||
needCache?: boolean // 是否需要缓存
|
||||
}
|
||||
```
|
||||
|
||||
#### 订阅机制
|
||||
```typescript
|
||||
export const subscribe = (
|
||||
filter: SubscriptionFilter,
|
||||
handler: (message: WebSocketBaseMessage) => void
|
||||
): string => {
|
||||
// 1. 生成唯一订阅ID
|
||||
// 2. 创建订阅记录
|
||||
// 3. 添加缓存标记
|
||||
// 4. 回放匹配的缓存消息
|
||||
}
|
||||
```
|
||||
|
||||
**要点**:
|
||||
- 支持按 `type` 和 `id` 的组合过滤
|
||||
- 自动回放缓存消息,确保不丢失历史数据
|
||||
- 返回订阅ID用于后续取消订阅
|
||||
|
||||
### 3. 智能缓存系统
|
||||
|
||||
#### 缓存标记机制
|
||||
```typescript
|
||||
interface CacheMarker {
|
||||
type?: string
|
||||
id?: string
|
||||
refCount: number // 引用计数
|
||||
}
|
||||
```
|
||||
|
||||
#### 缓存策略
|
||||
- **引用计数**: 订阅时 +1,取消订阅时 -1
|
||||
- **自动清理**: 引用计数为 0 时删除标记
|
||||
- **TTL机制**: 消息超过 60 秒自动过期
|
||||
- **大小限制**: 每个队列最多保留 50 条消息
|
||||
|
||||
|
||||
### 4. 心跳检测机制
|
||||
|
||||
```typescript
|
||||
const startGlobalHeartbeat = (ws: WebSocket) => {
|
||||
global.heartbeatTimer = window.setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const pingTime = Date.now()
|
||||
global.lastPingTime = pingTime
|
||||
ws.send(JSON.stringify({
|
||||
type: 'Signal',
|
||||
data: { Ping: pingTime, connectionId: global.connectionId }
|
||||
}))
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL)
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 5. 后端服务监控
|
||||
|
||||
#### 状态检测
|
||||
```typescript
|
||||
type BackendStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
|
||||
```
|
||||
|
||||
#### 自动重启逻辑
|
||||
```typescript
|
||||
const restartBackend = async (): Promise<boolean> => {
|
||||
// 1. 防重入检查
|
||||
// 2. 递增重启计数
|
||||
// 3. 调用 Electron API 启动后端
|
||||
// 4. 更新状态
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 6. 连接控制机制
|
||||
|
||||
#### 连接权限控制
|
||||
```typescript
|
||||
const allowedConnectionReasons = ['后端启动后连接', '后端重启后重连']
|
||||
const checkConnectionPermission = () => getGlobalStorage().allowNewConnection
|
||||
```
|
||||
|
||||
#### 连接锁机制
|
||||
```typescript
|
||||
let isGlobalConnectingLock = false
|
||||
const acquireConnectionLock = () => {
|
||||
if (isGlobalConnectingLock) return false
|
||||
isGlobalConnectingLock = true
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
**AI 开发要点**:
|
||||
- 防止并发连接导致的竞态条件
|
||||
- 只允许特定原因的连接请求
|
||||
- 确保全局唯一连接
|
||||
|
||||
## 消息流处理
|
||||
|
||||
### 1. 消息匹配算法
|
||||
|
||||
```typescript
|
||||
const messageMatchesFilter = (message: WebSocketBaseMessage, filter: SubscriptionFilter): boolean => {
|
||||
// 如果都不指定,匹配所有消息
|
||||
if (!filter.type && !filter.id) return true
|
||||
|
||||
// 如果只指定type
|
||||
if (filter.type && !filter.id) return message.type === filter.type
|
||||
|
||||
// 如果只指定id
|
||||
if (!filter.type && filter.id) return message.id === filter.id
|
||||
|
||||
// 如果同时指定type和id,必须都匹配
|
||||
return message.type === filter.type && message.id === filter.id
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 消息分发流程
|
||||
|
||||
```
|
||||
WebSocket 接收消息
|
||||
↓
|
||||
JSON 解析
|
||||
↓
|
||||
遍历所有订阅者
|
||||
↓
|
||||
匹配过滤条件
|
||||
↓
|
||||
调用处理器函数
|
||||
↓
|
||||
检查是否需要缓存
|
||||
↓
|
||||
添加到缓存队列
|
||||
```
|
||||
|
||||
## 外部接口设计
|
||||
|
||||
### 1. 主要导出函数
|
||||
|
||||
```typescript
|
||||
export function useWebSocket() {
|
||||
return {
|
||||
subscribe, // 订阅消息
|
||||
unsubscribe, // 取消订阅
|
||||
sendRaw, // 发送消息
|
||||
getConnectionInfo, // 获取连接信息
|
||||
status, // 连接状态
|
||||
backendStatus, // 后端状态
|
||||
restartBackend, // 重启后端
|
||||
getBackendStatus, // 获取后端状态
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 特殊接口
|
||||
|
||||
```typescript
|
||||
export const connectAfterBackendStart = async (): Promise<boolean>
|
||||
```
|
||||
- 后端启动后的连接入口
|
||||
- 启动后端监控
|
||||
- 设置连接权限
|
||||
|
||||
## 错误处理策略
|
||||
|
||||
### 1. 连接错误处理
|
||||
- WebSocket 连接失败时设置状态为 '连接错误'
|
||||
- 通过后端监控检测服务状态
|
||||
- 自动重启后端服务
|
||||
|
||||
### 2. 消息处理错误
|
||||
- 订阅处理器异常时记录警告但不中断其他订阅者
|
||||
- JSON 解析失败时静默忽略
|
||||
- 发送消息失败时静默处理
|
||||
|
||||
### 3. 后端故障处理
|
||||
```typescript
|
||||
const handleBackendFailure = async () => {
|
||||
if (global.backendRestartAttempts >= MAX_RESTART_ATTEMPTS) {
|
||||
// 显示错误对话框,提示重启应用
|
||||
Modal.error({
|
||||
title: '后端服务异常',
|
||||
content: '后端服务多次重启失败,请重启整个应用程序。'
|
||||
})
|
||||
return
|
||||
}
|
||||
// 自动重启逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 调试和监控
|
||||
|
||||
### 1. 调试模式
|
||||
```typescript
|
||||
const DEBUG = process.env.NODE_ENV === 'development'
|
||||
const log = (...args: any[]) => {
|
||||
if (DEBUG) console.log('[WebSocket]', ...args)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 连接信息监控
|
||||
```typescript
|
||||
const getConnectionInfo = () => ({
|
||||
connectionId: global.connectionId,
|
||||
status: global.status.value,
|
||||
subscriberCount: global.subscriptions.value.size,
|
||||
moduleLoadCount: global.moduleLoadCount,
|
||||
wsReadyState: global.wsRef ? global.wsRef.readyState : null,
|
||||
isConnecting: global.isConnecting,
|
||||
hasHeartbeat: !!global.heartbeatTimer,
|
||||
hasEverConnected: global.hasEverConnected,
|
||||
reconnectAttempts: global.reconnectAttempts,
|
||||
isPersistentMode: true,
|
||||
})
|
||||
```
|
||||
|
||||
## AI 开发建议
|
||||
|
||||
### 1. 使用模式
|
||||
```typescript
|
||||
// 在 Vue 组件中使用
|
||||
const { subscribe, unsubscribe, sendRaw, status } = useWebSocket()
|
||||
|
||||
// 订阅特定类型消息
|
||||
const subId = subscribe(
|
||||
{ type: 'TaskUpdate', needCache: true },
|
||||
(message) => {
|
||||
console.log('收到任务更新:', message.data)
|
||||
}
|
||||
)
|
||||
|
||||
// 组件卸载时取消订阅
|
||||
onUnmounted(() => {
|
||||
unsubscribe(subId)
|
||||
})
|
||||
```
|
||||
|
||||
### 2. 扩展建议
|
||||
- 添加消息重试机制
|
||||
- 实现消息优先级队列
|
||||
- 支持消息压缩
|
||||
- 添加连接质量监控
|
||||
|
||||
### 3. 性能优化点
|
||||
- 使用 `Object.freeze()` 冻结配置对象
|
||||
- 考虑使用 Web Worker 处理大量消息
|
||||
- 实现消息批处理机制
|
||||
- 添加消息去重功能
|
||||
|
||||
### 4. 安全考虑
|
||||
- 验证消息来源
|
||||
- 实现消息签名机制
|
||||
- 添加连接认证
|
||||
- 防止消息注入攻击
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 1. 外部依赖
|
||||
- `vue`: 响应式系统和组合式API
|
||||
- `ant-design-vue`: UI组件库(Modal)
|
||||
- `schedulerHandlers`: 默认消息处理器
|
||||
|
||||
### 2. 运行时依赖
|
||||
- `window.electronAPI`: Electron主进程通信
|
||||
- WebSocket API: 浏览器原生支持
|
||||
|
||||
## 总结
|
||||
|
||||
这个 WebSocket 组合式函数是一个功能完整、设计精良的实时通信解决方案。它不仅解决了基本的 WebSocket 连接问题,还提供了高级功能如智能缓存、自动重连、后端监控等。
|
||||
|
||||
**核心优势**:
|
||||
1. 全局持久化连接,避免重复建立
|
||||
2. 智能订阅系统,支持精确过滤
|
||||
3. 自动缓存回放,确保数据完整性
|
||||
4. 完善的错误处理和自动恢复
|
||||
5. 详细的调试和监控信息
|
||||
|
||||
**适用场景**:
|
||||
- 实时数据展示
|
||||
- 任务状态监控
|
||||
- 系统通知推送
|
||||
- 双向通信应用
|
||||
2
frontend/.env
Normal file
2
frontend/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_APP_ENV='prod'
|
||||
VITE_APP_VERSION='1.0.1'
|
||||
2
frontend/.env.development
Normal file
2
frontend/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_APP_ENV='dev'
|
||||
VITE_APP_VERSION='0.9.0'
|
||||
28
frontend/.gitignore
vendored
Normal file
28
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.kiro
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
dist-electron
|
||||
.yarn
|
||||
.yarnrc.yml
|
||||
3
frontend/.prettierignore
Normal file
3
frontend/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
dist-electron
|
||||
9
frontend/.prettierrc
Normal file
9
frontend/.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
1
frontend/.yarnrc.yml
Normal file
1
frontend/.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
60
frontend/README.md
Normal file
60
frontend/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# AUTO-MAS Frontend
|
||||
|
||||
基于 Vue 3 + TypeScript + Ant Design Vue + Electron 的桌面应用程序。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🎨 使用 Ant Design Vue 组件库
|
||||
- 🌙 支持深色模式(跟随系统/深色/浅色)
|
||||
- 🎨 支持多种主题色切换
|
||||
- 📱 响应式侧边栏布局
|
||||
- 🔧 内置开发者工具
|
||||
- ⚡ 基于 Vite 的快速开发体验
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # 组件
|
||||
│ └── AppLayout.vue # 主布局组件
|
||||
├── views/ # 页面
|
||||
│ ├── Home.vue # 主页
|
||||
│ ├── Scripts.vue # 脚本管理
|
||||
│ ├── Plans.vue # 计划管理
|
||||
│ ├── Queue.vue # 调度队列
|
||||
│ ├── Scheduler.vue # 调度中心
|
||||
│ ├── History.vue # 历史记录
|
||||
│ └── Settings.vue # 设置页面
|
||||
├── router/ # 路由配置
|
||||
├── composables/ # 组合式函数
|
||||
│ └── useTheme.ts # 主题管理
|
||||
└── main.ts # 应用入口
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
### 安装依赖
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
直接打开electron窗口
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### 构建
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端框架**: Vue 3 + TypeScript
|
||||
- **UI 组件库**: Ant Design Vue 4.x
|
||||
- **图标**: @ant-design/icons-vue
|
||||
- **路由**: Vue Router 4
|
||||
- **构建工具**: Vite
|
||||
- **桌面端**: Electron
|
||||
- **包管理**: Yarn
|
||||
1404
frontend/electron/main.ts
Normal file
1404
frontend/electron/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
83
frontend/electron/preload.ts
Normal file
83
frontend/electron/preload.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('预加载脚本已加载')
|
||||
})
|
||||
|
||||
// 暴露安全的 API 给渲染进程
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
openDevTools: () => ipcRenderer.invoke('open-dev-tools'),
|
||||
selectFolder: () => ipcRenderer.invoke('select-folder'),
|
||||
selectFile: (filters?: any[]) => ipcRenderer.invoke('select-file', filters),
|
||||
openUrl: (url: string) => ipcRenderer.invoke('open-url', url),
|
||||
|
||||
// 窗口控制
|
||||
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
|
||||
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
|
||||
windowClose: () => ipcRenderer.invoke('window-close'),
|
||||
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
||||
appQuit: () => ipcRenderer.invoke('app-quit'),
|
||||
|
||||
// 进程管理
|
||||
getRelatedProcesses: () => ipcRenderer.invoke('get-related-processes'),
|
||||
killAllProcesses: () => ipcRenderer.invoke('kill-all-processes'),
|
||||
forceExit: () => ipcRenderer.invoke('force-exit'),
|
||||
|
||||
// 初始化相关API
|
||||
checkEnvironment: () => ipcRenderer.invoke('check-environment'),
|
||||
checkCriticalFiles: () => ipcRenderer.invoke('check-critical-files'),
|
||||
downloadPython: (mirror?: string) => ipcRenderer.invoke('download-python', mirror),
|
||||
installPip: () => ipcRenderer.invoke('install-pip'),
|
||||
downloadGit: () => ipcRenderer.invoke('download-git'),
|
||||
checkGitUpdate: () => ipcRenderer.invoke('check-git-update'),
|
||||
installDependencies: (mirror?: string) => ipcRenderer.invoke('install-dependencies', mirror),
|
||||
cloneBackend: (repoUrl?: string) => ipcRenderer.invoke('clone-backend', repoUrl),
|
||||
updateBackend: (repoUrl?: string) => ipcRenderer.invoke('update-backend', repoUrl),
|
||||
startBackend: () => ipcRenderer.invoke('start-backend'),
|
||||
stopBackend: () => ipcRenderer.invoke('stop-backend'),
|
||||
|
||||
// 管理员权限相关
|
||||
checkAdmin: () => ipcRenderer.invoke('check-admin'),
|
||||
restartAsAdmin: () => ipcRenderer.invoke('restart-as-admin'),
|
||||
|
||||
// 配置文件操作
|
||||
saveConfig: (config: any) => ipcRenderer.invoke('save-config', config),
|
||||
loadConfig: () => ipcRenderer.invoke('load-config'),
|
||||
resetConfig: () => ipcRenderer.invoke('reset-config'),
|
||||
|
||||
// 托盘设置实时更新
|
||||
updateTraySettings: (uiSettings: any) => ipcRenderer.invoke('update-tray-settings', uiSettings),
|
||||
|
||||
// 日志文件操作
|
||||
getLogPath: () => ipcRenderer.invoke('get-log-path'),
|
||||
getLogFiles: () => ipcRenderer.invoke('get-log-files'),
|
||||
getLogs: (lines?: number, fileName?: string) => ipcRenderer.invoke('get-logs', lines, fileName),
|
||||
clearLogs: (fileName?: string) => ipcRenderer.invoke('clear-logs', fileName),
|
||||
cleanOldLogs: (daysToKeep?: number) => ipcRenderer.invoke('clean-old-logs', daysToKeep),
|
||||
|
||||
// 保留原有方法以兼容现有代码
|
||||
saveLogsToFile: (logs: string) => ipcRenderer.invoke('save-logs-to-file', logs),
|
||||
loadLogsFromFile: () => ipcRenderer.invoke('load-logs-from-file'),
|
||||
|
||||
// 文件系统操作
|
||||
openFile: (filePath: string) => ipcRenderer.invoke('open-file', filePath),
|
||||
showItemInFolder: (filePath: string) => ipcRenderer.invoke('show-item-in-folder', filePath),
|
||||
|
||||
// 对话框相关
|
||||
showQuestionDialog: (questionData: any) => ipcRenderer.invoke('show-question-dialog', questionData),
|
||||
dialogResponse: (messageId: string, choice: boolean) => ipcRenderer.invoke('dialog-response', messageId, choice),
|
||||
resizeDialogWindow: (height: number) => ipcRenderer.invoke('resize-dialog-window', height),
|
||||
moveWindow: (deltaX: number, deltaY: number) => ipcRenderer.invoke('move-window', deltaX, deltaY),
|
||||
|
||||
// 主题信息获取
|
||||
getThemeInfo: () => ipcRenderer.invoke('get-theme-info'),
|
||||
getTheme: () => ipcRenderer.invoke('get-theme'),
|
||||
|
||||
// 监听下载进度
|
||||
onDownloadProgress: (callback: (progress: any) => void) => {
|
||||
ipcRenderer.on('download-progress', (_, progress) => callback(progress))
|
||||
},
|
||||
removeDownloadProgressListener: () => {
|
||||
ipcRenderer.removeAllListeners('download-progress')
|
||||
},
|
||||
})
|
||||
62
frontend/electron/services/downloadService.ts
Normal file
62
frontend/electron/services/downloadService.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as https from 'https'
|
||||
import * as fs from 'fs'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import * as http from 'http'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
export function setMainWindow(window: BrowserWindow) {
|
||||
mainWindow = window
|
||||
}
|
||||
|
||||
export function downloadFile(url: string, outputPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`开始下载文件: ${url}`)
|
||||
console.log(`保存路径: ${outputPath}`)
|
||||
|
||||
const file = fs.createWriteStream(outputPath)
|
||||
// 创建HTTP客户端,兼容https和http
|
||||
const client = url.startsWith('https') ? https : http
|
||||
|
||||
client
|
||||
.get(url, response => {
|
||||
const totalSize = parseInt(response.headers['content-length'] || '0', 10)
|
||||
let downloadedSize = 0
|
||||
|
||||
console.log(`文件大小: ${totalSize} bytes`)
|
||||
|
||||
response.pipe(file)
|
||||
|
||||
response.on('data', chunk => {
|
||||
downloadedSize += chunk.length
|
||||
const progress = totalSize ? Math.round((downloadedSize / totalSize) * 100) : 0
|
||||
|
||||
console.log(`下载进度: ${progress}% (${downloadedSize}/${totalSize})`)
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
progress,
|
||||
status: 'downloading',
|
||||
message: `下载中... ${progress}%`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
console.log(`文件下载完成: ${outputPath}`)
|
||||
resolve()
|
||||
})
|
||||
|
||||
file.on('error', err => {
|
||||
console.error(`文件写入错误: ${err.message}`)
|
||||
fs.unlink(outputPath, () => {}) // 删除不完整的文件
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
.on('error', err => {
|
||||
console.error(`下载错误: ${err.message}`)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
34
frontend/electron/services/environmentService.ts
Normal file
34
frontend/electron/services/environmentService.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import { app } from 'electron'
|
||||
|
||||
// 获取应用根目录
|
||||
export function getAppRoot(): string {
|
||||
return process.env.NODE_ENV === 'development' ? process.cwd() : path.dirname(app.getPath('exe'))
|
||||
}
|
||||
|
||||
// 检查环境
|
||||
export function checkEnvironment(appRoot: string) {
|
||||
const environmentPath = path.join(appRoot, 'environment')
|
||||
const pythonPath = path.join(environmentPath, 'python')
|
||||
const gitPath = path.join(environmentPath, 'git')
|
||||
const backendPath = path.join(appRoot, 'backend')
|
||||
const requirementsPath = path.join(backendPath, 'requirements.txt')
|
||||
|
||||
const pythonExists = fs.existsSync(pythonPath)
|
||||
const gitExists = fs.existsSync(gitPath)
|
||||
const backendExists = fs.existsSync(backendPath)
|
||||
|
||||
// 检查依赖是否已安装(简单检查是否存在site-packages目录)
|
||||
const sitePackagesPath = path.join(pythonPath, 'Lib', 'site-packages')
|
||||
const dependenciesInstalled =
|
||||
fs.existsSync(sitePackagesPath) && fs.readdirSync(sitePackagesPath).length > 10
|
||||
|
||||
return {
|
||||
pythonExists,
|
||||
gitExists,
|
||||
backendExists,
|
||||
dependenciesInstalled,
|
||||
isInitialized: pythonExists && gitExists && backendExists && dependenciesInstalled,
|
||||
}
|
||||
}
|
||||
636
frontend/electron/services/gitService.ts
Normal file
636
frontend/electron/services/gitService.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import { spawn } from 'child_process'
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { downloadFile } from './downloadService'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
export function setMainWindow(window: BrowserWindow) {
|
||||
mainWindow = window
|
||||
}
|
||||
|
||||
const gitDownloadUrl = 'https://download.auto-mas.top/d/AUTO_MAS/git.zip'
|
||||
|
||||
// 获取应用版本号
|
||||
function getAppVersion(appRoot: string): string {
|
||||
console.log('=== 开始获取应用版本号 ===')
|
||||
console.log(`应用根目录: ${appRoot}`)
|
||||
|
||||
try {
|
||||
// 方法1: 从 Electron app 获取版本号(打包后可用)
|
||||
try {
|
||||
const appVersion = app.getVersion()
|
||||
if (appVersion && appVersion !== '1.0.0') { // 避免使用默认版本
|
||||
console.log(`✅ 从 app.getVersion() 获取版本号: ${appVersion}`)
|
||||
return appVersion
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ app.getVersion() 获取失败:', error)
|
||||
}
|
||||
|
||||
// 方法2: 从预设的环境变量获取(如果在构建时注入了)
|
||||
if (process.env.VITE_APP_VERSION) {
|
||||
console.log(`✅ 从环境变量获取版本号: ${process.env.VITE_APP_VERSION}`)
|
||||
return process.env.VITE_APP_VERSION
|
||||
}
|
||||
|
||||
// 方法3: 开发环境下从 package.json 获取
|
||||
const packageJsonPath = path.join(appRoot, 'frontend', 'package.json')
|
||||
console.log(`尝试读取前端package.json: ${packageJsonPath}`)
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
||||
const version = packageJson.version || '获取版本失败!'
|
||||
console.log(`✅ 从前端package.json获取版本号: ${version}`)
|
||||
return version
|
||||
}
|
||||
|
||||
console.log('⚠️ 前端package.json不存在,尝试读取根目录package.json')
|
||||
|
||||
// 方法4: 从根目录 package.json 获取(开发环境)
|
||||
const currentPackageJsonPath = path.join(appRoot, 'package.json')
|
||||
console.log(`尝试读取根目录package.json: ${currentPackageJsonPath}`)
|
||||
|
||||
if (fs.existsSync(currentPackageJsonPath)) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(currentPackageJsonPath, 'utf8'))
|
||||
const version = packageJson.version || '获取版本失败!'
|
||||
console.log(`✅ 从根目录package.json获取版本号: ${version}`)
|
||||
return version
|
||||
}
|
||||
|
||||
console.log('❌ 未找到任何版本信息源')
|
||||
return '获取版本失败!'
|
||||
} catch (error) {
|
||||
console.error('❌ 获取版本号失败:', error)
|
||||
return '获取版本失败!'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查分支是否存在
|
||||
async function checkBranchExists(
|
||||
gitPath: string,
|
||||
gitEnv: any,
|
||||
repoUrl: string,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
console.log(`=== 检查分支是否存在: ${branchName} ===`)
|
||||
console.log(`Git路径: ${gitPath}`)
|
||||
console.log(`仓库URL: ${repoUrl}`)
|
||||
|
||||
try {
|
||||
return new Promise<boolean>(resolve => {
|
||||
const proc = spawn(gitPath, ['ls-remote', '--heads', repoUrl, branchName], {
|
||||
stdio: 'pipe',
|
||||
env: gitEnv,
|
||||
})
|
||||
|
||||
let output = ''
|
||||
let errorOutput = ''
|
||||
|
||||
proc.stdout?.on('data', data => {
|
||||
const chunk = data.toString()
|
||||
output += chunk
|
||||
console.log(`git ls-remote stdout: ${chunk.trim()}`)
|
||||
})
|
||||
|
||||
proc.stderr?.on('data', data => {
|
||||
const chunk = data.toString()
|
||||
errorOutput += chunk
|
||||
console.log(`git ls-remote stderr: ${chunk.trim()}`)
|
||||
})
|
||||
|
||||
proc.on('close', code => {
|
||||
console.log(`git ls-remote 退出码: ${code}`)
|
||||
// 如果输出包含分支名,说明分支存在
|
||||
const branchExists = output.includes(`refs/heads/${branchName}`)
|
||||
console.log(`分支 ${branchName} ${branchExists ? '✅ 存在' : '❌ 不存在'}`)
|
||||
if (errorOutput) {
|
||||
console.log(`错误输出: ${errorOutput}`)
|
||||
}
|
||||
resolve(branchExists)
|
||||
})
|
||||
|
||||
proc.on('error', error => {
|
||||
console.error(`git ls-remote 进程错误:`, error)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`❌ 检查分支 ${branchName} 时出错:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 递归复制目录,包括文件和隐藏文件
|
||||
function copyDirSync(src: string, dest: string) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true })
|
||||
}
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name)
|
||||
const destPath = path.join(dest, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
copyDirSync(srcPath, destPath)
|
||||
} else {
|
||||
// 直接覆盖写,不需要先删除
|
||||
fs.copyFileSync(srcPath, destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取Git环境变量配置
|
||||
function getGitEnvironment(appRoot: string) {
|
||||
const gitDir = path.join(appRoot, 'environment', 'git')
|
||||
const binPath = path.join(gitDir, 'bin')
|
||||
const mingw64BinPath = path.join(gitDir, 'mingw64', 'bin')
|
||||
const gitCorePath = path.join(gitDir, 'mingw64', 'libexec', 'git-core')
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
// 修复remote-https问题的关键:确保所有Git相关路径都在PATH中
|
||||
PATH: `${binPath};${mingw64BinPath};${gitCorePath};${process.env.PATH}`,
|
||||
GIT_EXEC_PATH: gitCorePath,
|
||||
GIT_TEMPLATE_DIR: path.join(gitDir, 'mingw64', 'share', 'git-core', 'templates'),
|
||||
HOME: process.env.USERPROFILE || process.env.HOME,
|
||||
// // SSL证书路径
|
||||
// GIT_SSL_CAINFO: path.join(gitDir, 'mingw64', 'ssl', 'certs', 'ca-bundle.crt'),
|
||||
// 禁用系统Git配置
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
// 禁用交互式认证
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_ASKPASS: '',
|
||||
// // 修复remote-https问题的关键环境变量
|
||||
// CURL_CA_BUNDLE: path.join(gitDir, 'mingw64', 'ssl', 'certs', 'ca-bundle.crt'),
|
||||
// 确保Git能找到所有必要的程序
|
||||
GIT_HTTP_LOW_SPEED_LIMIT: '0',
|
||||
GIT_HTTP_LOW_SPEED_TIME: '0',
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否为Git仓库
|
||||
function isGitRepository(dirPath: string): boolean {
|
||||
const gitDir = path.join(dirPath, '.git')
|
||||
return fs.existsSync(gitDir)
|
||||
}
|
||||
|
||||
// 下载Git
|
||||
export async function downloadGit(appRoot: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const environmentPath = path.join(appRoot, 'environment')
|
||||
const gitPath = path.join(environmentPath, 'git')
|
||||
|
||||
if (!fs.existsSync(environmentPath)) {
|
||||
fs.mkdirSync(environmentPath, { recursive: true })
|
||||
}
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'git',
|
||||
progress: 0,
|
||||
status: 'downloading',
|
||||
message: '开始下载Git...',
|
||||
})
|
||||
}
|
||||
|
||||
// 使用自定义Git压缩包
|
||||
const zipPath = path.join(environmentPath, 'git.zip')
|
||||
await downloadFile(gitDownloadUrl, zipPath)
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'git',
|
||||
progress: 100,
|
||||
status: 'extracting',
|
||||
message: '正在解压Git...',
|
||||
})
|
||||
}
|
||||
|
||||
// 解压Git到临时目录,然后移动到正确位置
|
||||
console.log(`开始解压Git到: ${gitPath}`)
|
||||
|
||||
// 创建临时解压目录
|
||||
const tempExtractPath = path.join(environmentPath, 'git_temp')
|
||||
if (!fs.existsSync(tempExtractPath)) {
|
||||
fs.mkdirSync(tempExtractPath, { recursive: true })
|
||||
console.log(`创建临时解压目录: ${tempExtractPath}`)
|
||||
}
|
||||
|
||||
// 解压到临时目录
|
||||
const zip = new AdmZip(zipPath)
|
||||
zip.extractAllTo(tempExtractPath, true)
|
||||
console.log(`Git解压到临时目录: ${tempExtractPath}`)
|
||||
|
||||
// 检查解压后的目录结构
|
||||
const tempContents = fs.readdirSync(tempExtractPath)
|
||||
console.log(`临时目录内容:`, tempContents)
|
||||
|
||||
// 如果解压后有git子目录,则从git子目录移动内容
|
||||
let sourceDir = tempExtractPath
|
||||
if (tempContents.length === 1 && tempContents[0] === 'git') {
|
||||
sourceDir = path.join(tempExtractPath, 'git')
|
||||
console.log(`检测到git子目录,使用源目录: ${sourceDir}`)
|
||||
}
|
||||
|
||||
// 确保目标Git目录存在
|
||||
if (!fs.existsSync(gitPath)) {
|
||||
fs.mkdirSync(gitPath, { recursive: true })
|
||||
console.log(`创建Git目录: ${gitPath}`)
|
||||
}
|
||||
|
||||
// 移动文件到最终目录
|
||||
const sourceContents = fs.readdirSync(sourceDir)
|
||||
for (const item of sourceContents) {
|
||||
const sourcePath = path.join(sourceDir, item)
|
||||
const targetPath = path.join(gitPath, item)
|
||||
|
||||
// 如果目标已存在,先删除
|
||||
if (fs.existsSync(targetPath)) {
|
||||
if (fs.statSync(targetPath).isDirectory()) {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true })
|
||||
} else {
|
||||
fs.unlinkSync(targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 移动文件或目录
|
||||
fs.renameSync(sourcePath, targetPath)
|
||||
console.log(`移动: ${sourcePath} -> ${targetPath}`)
|
||||
}
|
||||
|
||||
// 清理临时目录
|
||||
fs.rmSync(tempExtractPath, { recursive: true, force: true })
|
||||
console.log(`清理临时目录: ${tempExtractPath}`)
|
||||
|
||||
console.log(`Git解压完成到: ${gitPath}`)
|
||||
|
||||
// 删除zip文件
|
||||
fs.unlinkSync(zipPath)
|
||||
console.log(`删除临时文件: ${zipPath}`)
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'git',
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
message: 'Git安装完成',
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'git',
|
||||
progress: 0,
|
||||
status: 'error',
|
||||
message: `Git下载失败: ${errorMessage}`,
|
||||
})
|
||||
}
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
// 克隆后端代码(替换原有核心逻辑)
|
||||
export async function cloneBackend(
|
||||
appRoot: string,
|
||||
repoUrl = 'https://github.com/AUTO-MAS-Project/AUTO-MAS.git'
|
||||
): Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
}> {
|
||||
console.log('=== 开始克隆/更新后端代码 ===')
|
||||
console.log(`应用根目录: ${appRoot}`)
|
||||
console.log(`仓库URL: ${repoUrl}`)
|
||||
|
||||
try {
|
||||
const backendPath = appRoot
|
||||
const gitPath = path.join(appRoot, 'environment', 'git', 'bin', 'git.exe')
|
||||
|
||||
console.log(`Git可执行文件路径: ${gitPath}`)
|
||||
console.log(`后端代码路径: ${backendPath}`)
|
||||
|
||||
if (!fs.existsSync(gitPath)) {
|
||||
const error = `Git可执行文件不存在: ${gitPath}`
|
||||
console.error(`❌ ${error}`)
|
||||
throw new Error(error)
|
||||
}
|
||||
|
||||
console.log('✅ Git可执行文件存在')
|
||||
const gitEnv = getGitEnvironment(appRoot)
|
||||
console.log('✅ Git环境变量配置完成')
|
||||
|
||||
// 检查 git 是否可用
|
||||
console.log('=== 检查Git是否可用 ===')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(gitPath, ['--version'], { env: gitEnv })
|
||||
|
||||
proc.stdout?.on('data', data => {
|
||||
console.log(`git --version output: ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
proc.stderr?.on('data', data => {
|
||||
console.log(`git --version error: ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
proc.on('close', code => {
|
||||
console.log(`git --version 退出码: ${code}`)
|
||||
if (code === 0) {
|
||||
console.log('✅ Git可用')
|
||||
resolve()
|
||||
} else {
|
||||
console.error('❌ Git无法正常运行')
|
||||
reject(new Error('git 无法正常运行'))
|
||||
}
|
||||
})
|
||||
|
||||
proc.on('error', error => {
|
||||
console.error('❌ Git进程启动失败:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
// 获取版本号并确定目标分支
|
||||
const version = getAppVersion(appRoot)
|
||||
console.log(`=== 分支选择逻辑 ===`)
|
||||
console.log(`当前应用版本: ${version}`)
|
||||
|
||||
let targetBranch = 'feature/refactor' // 默认分支
|
||||
console.log(`默认分支: ${targetBranch}`)
|
||||
|
||||
if (version !== '获取版本失败!') {
|
||||
// 检查版本对应的分支是否存在
|
||||
console.log(`开始检查版本分支是否存在...`)
|
||||
const versionBranchExists = await checkBranchExists(gitPath, gitEnv, repoUrl, version)
|
||||
if (versionBranchExists) {
|
||||
targetBranch = version
|
||||
console.log(`🎯 将使用版本分支: ${targetBranch}`)
|
||||
} else {
|
||||
console.log(`⚠️ 版本分支 ${version} 不存在,使用默认分支: ${targetBranch}`)
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ 版本号获取失败,使用默认分支: feature/refactor')
|
||||
}
|
||||
|
||||
console.log(`=== 最终选择分支: ${targetBranch} ===`)
|
||||
|
||||
// 检查是否为Git仓库
|
||||
const isRepo = isGitRepository(backendPath)
|
||||
console.log(`检查是否为Git仓库: ${isRepo ? '✅ 是' : '❌ 否'}`)
|
||||
|
||||
// ==== 下面是关键逻辑 ====
|
||||
if (isRepo) {
|
||||
console.log('=== 更新现有Git仓库 ===')
|
||||
|
||||
// 已是 git 仓库,先更新远程URL为镜像站,然后 pull
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'backend',
|
||||
progress: 0,
|
||||
status: 'downloading',
|
||||
message: `正在更新后端代码(分支: ${targetBranch})...`,
|
||||
})
|
||||
}
|
||||
|
||||
// 更新远程URL为镜像站URL,避免直接访问GitHub
|
||||
console.log(`📡 更新远程URL为镜像站: ${repoUrl}`)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(gitPath, ['remote', 'set-url', 'origin', repoUrl], {
|
||||
stdio: 'pipe',
|
||||
env: gitEnv,
|
||||
cwd: backendPath,
|
||||
})
|
||||
proc.stdout?.on('data', d => console.log('git remote set-url stdout:', d.toString().trim()))
|
||||
proc.stderr?.on('data', d => console.log('git remote set-url stderr:', d.toString().trim()))
|
||||
proc.on('close', code => {
|
||||
console.log(`git remote set-url 退出码: ${code}`)
|
||||
if (code === 0) {
|
||||
console.log('✅ 远程URL更新成功')
|
||||
resolve()
|
||||
} else {
|
||||
console.error('❌ 远程URL更新失败')
|
||||
reject(new Error(`git remote set-url失败,退出码: ${code}`))
|
||||
}
|
||||
})
|
||||
proc.on('error', error => {
|
||||
console.error('❌ git remote set-url 进程错误:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
// 获取目标分支信息(显式 fetch 目标分支)
|
||||
console.log(`📥 显式获取远程分支: ${targetBranch} ...`)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(gitPath, ['fetch', 'origin', targetBranch], {
|
||||
stdio: 'pipe',
|
||||
env: gitEnv,
|
||||
cwd: backendPath,
|
||||
})
|
||||
proc.stdout?.on('data', d => console.log('git fetch stdout:', d.toString().trim()))
|
||||
proc.stderr?.on('data', d => console.log('git fetch stderr:', d.toString().trim()))
|
||||
proc.on('close', code => {
|
||||
console.log(`git fetch origin ${targetBranch} 退出码: ${code}`)
|
||||
if (code === 0) {
|
||||
console.log(`✅ 成功获取远程分支: ${targetBranch}`)
|
||||
resolve()
|
||||
} else {
|
||||
console.error(`❌ 获取远程分支失败: ${targetBranch}`)
|
||||
reject(new Error(`git fetch origin ${targetBranch} 失败,退出码: ${code}`))
|
||||
}
|
||||
})
|
||||
proc.on('error', error => {
|
||||
console.error('❌ git fetch 进程错误:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
// 切换到目标分支
|
||||
console.log(`🔀 切换到目标分支: ${targetBranch}`)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(gitPath, ['checkout', '-B', targetBranch, `origin/${targetBranch}`], {
|
||||
stdio: 'pipe',
|
||||
env: gitEnv,
|
||||
cwd: backendPath,
|
||||
})
|
||||
proc.stdout?.on('data', d => console.log('git checkout stdout:', d.toString().trim()))
|
||||
proc.stderr?.on('data', d => console.log('git checkout stderr:', d.toString().trim()))
|
||||
proc.on('close', code => {
|
||||
console.log(`git checkout 退出码: ${code}`)
|
||||
if (code === 0) {
|
||||
console.log(`✅ 成功切换到分支: ${targetBranch}`)
|
||||
resolve()
|
||||
} else {
|
||||
console.error(`❌ 切换分支失败: ${targetBranch}`)
|
||||
reject(new Error(`git checkout失败,退出码: ${code}`))
|
||||
}
|
||||
})
|
||||
proc.on('error', error => {
|
||||
console.error('❌ git checkout 进程错误:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
// 执行pull操作
|
||||
console.log('🔄 强制同步到远程分支最新提交...')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(gitPath, ['reset', '--hard', `origin/${targetBranch}`], {
|
||||
stdio: 'pipe',
|
||||
env: gitEnv,
|
||||
cwd: backendPath,
|
||||
})
|
||||
proc.stdout?.on('data', d => console.log('git reset stdout:', d.toString().trim()))
|
||||
proc.stderr?.on('data', d => console.log('git reset stderr:', d.toString().trim()))
|
||||
proc.on('close', code => {
|
||||
console.log(`git reset --hard 退出码: ${code}`)
|
||||
if (code === 0) {
|
||||
console.log('✅ 代码已强制更新到远程最新版本')
|
||||
resolve()
|
||||
} else {
|
||||
console.error('❌ 代码重置失败')
|
||||
reject(new Error(`git reset --hard 失败,退出码: ${code}`))
|
||||
}
|
||||
})
|
||||
proc.on('error', error => {
|
||||
console.error('❌ git reset 进程错误:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'backend',
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
message: `后端代码更新完成(分支: ${targetBranch})`,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`✅ 后端代码更新完成(分支: ${targetBranch})`)
|
||||
} else {
|
||||
console.log('=== 克隆新的Git仓库 ===')
|
||||
|
||||
// 不是 git 仓库,clone 到 tmp,再拷贝出来
|
||||
const tmpDir = path.join(appRoot, 'git_tmp')
|
||||
console.log(`临时目录: ${tmpDir}`)
|
||||
|
||||
if (fs.existsSync(tmpDir)) {
|
||||
console.log('🗑️ 清理现有临时目录...')
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('📁 创建临时目录...')
|
||||
fs.mkdirSync(tmpDir, { recursive: true })
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'backend',
|
||||
progress: 0,
|
||||
status: 'downloading',
|
||||
message: `正在克隆后端代码(分支: ${targetBranch})...`,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`📥 开始克隆代码到临时目录...`)
|
||||
console.log(`克隆参数: --single-branch --depth 1 --branch ${targetBranch}`)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
gitPath,
|
||||
[
|
||||
'clone',
|
||||
'--progress',
|
||||
'--verbose',
|
||||
'--single-branch',
|
||||
'--depth',
|
||||
'1',
|
||||
'--branch',
|
||||
targetBranch,
|
||||
repoUrl,
|
||||
tmpDir,
|
||||
],
|
||||
{
|
||||
stdio: 'pipe',
|
||||
env: gitEnv,
|
||||
cwd: appRoot,
|
||||
}
|
||||
)
|
||||
proc.stdout?.on('data', d => console.log('git clone stdout:', d.toString().trim()))
|
||||
proc.stderr?.on('data', d => console.log('git clone stderr:', d.toString().trim()))
|
||||
proc.on('close', code => {
|
||||
console.log(`git clone 退出码: ${code}`)
|
||||
if (code === 0) {
|
||||
console.log('✅ 代码克隆成功')
|
||||
resolve()
|
||||
} else {
|
||||
console.error('❌ 代码克隆失败')
|
||||
reject(new Error(`git clone失败,退出码: ${code}`))
|
||||
}
|
||||
})
|
||||
proc.on('error', error => {
|
||||
console.error('❌ git clone 进程错误:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
// 复制所有文件到 backendPath(appRoot),包含 .git
|
||||
console.log('📋 复制文件到目标目录...')
|
||||
const tmpFiles = fs.readdirSync(tmpDir)
|
||||
console.log(`临时目录中的文件: ${tmpFiles.join(', ')}`)
|
||||
|
||||
for (const file of tmpFiles) {
|
||||
const src = path.join(tmpDir, file)
|
||||
const dst = path.join(backendPath, file)
|
||||
|
||||
console.log(`复制: ${file}`)
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
console.log(` - 删除现有文件/目录: ${dst}`)
|
||||
if (fs.statSync(dst).isDirectory()) fs.rmSync(dst, { recursive: true, force: true })
|
||||
else fs.unlinkSync(dst)
|
||||
}
|
||||
|
||||
if (fs.statSync(src).isDirectory()) {
|
||||
console.log(` - 复制目录: ${src} -> ${dst}`)
|
||||
copyDirSync(src, dst)
|
||||
} else {
|
||||
console.log(` - 复制文件: ${src} -> ${dst}`)
|
||||
fs.copyFileSync(src, dst)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🗑️ 清理临时目录...')
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'backend',
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
message: `后端代码克隆完成(分支: ${targetBranch})`,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`✅ 后端代码克隆完成(分支: ${targetBranch})`)
|
||||
}
|
||||
|
||||
console.log('=== 后端代码获取操作完成 ===')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error('❌ 获取后端代码失败:', errorMessage)
|
||||
console.error('错误堆栈:', error instanceof Error ? error.stack : 'N/A')
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'backend',
|
||||
progress: 0,
|
||||
status: 'error',
|
||||
message: `后端代码获取失败: ${errorMessage}`,
|
||||
})
|
||||
}
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
168
frontend/electron/services/logService.ts
Normal file
168
frontend/electron/services/logService.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import log from 'electron-log'
|
||||
import * as path from 'path'
|
||||
import { getAppRoot } from './environmentService'
|
||||
|
||||
// 移除ANSI颜色转义字符的函数
|
||||
function stripAnsiColors(text: string): string {
|
||||
// 匹配ANSI转义序列的正则表达式 - 更完整的模式
|
||||
const ansiRegex = /\x1b\[[0-9;]*[mGKHF]|\x1b\[[\d;]*[A-Za-z]/g
|
||||
return text.replace(ansiRegex, '')
|
||||
}
|
||||
|
||||
// 获取应用安装目录下的日志路径
|
||||
function getLogDirectory(): string {
|
||||
const appRoot = getAppRoot()
|
||||
return path.join(appRoot, 'logs')
|
||||
}
|
||||
|
||||
// 获取当前日期的日志文件名 - 使用ISO 8601格式
|
||||
function getTodayLogFileName(): string {
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
return `frontendlog-${year}-${month}-${day}.log`
|
||||
}
|
||||
|
||||
// 配置日志系统
|
||||
export function setupLogger() {
|
||||
// 设置日志文件路径到软件安装目录
|
||||
const logPath = getLogDirectory()
|
||||
|
||||
// 确保日志目录存在
|
||||
const fs = require('fs')
|
||||
if (!fs.existsSync(logPath)) {
|
||||
fs.mkdirSync(logPath, { recursive: true })
|
||||
}
|
||||
|
||||
// 配置日志格式
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'
|
||||
log.transports.console.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'
|
||||
|
||||
// 设置主进程日志文件路径和名称 - 按日期分文件
|
||||
log.transports.file.resolvePathFn = () => {
|
||||
const fileName = getTodayLogFileName()
|
||||
return path.join(logPath, fileName)
|
||||
}
|
||||
|
||||
// 设置日志级别
|
||||
log.transports.file.level = 'debug'
|
||||
log.transports.console.level = 'debug'
|
||||
|
||||
// 设置文件大小限制 (50MB,因为按日期分文件,可以设置更大)
|
||||
log.transports.file.maxSize = 50 * 1024 * 1024
|
||||
|
||||
// 禁用自动归档,因为我们按日期分文件
|
||||
log.transports.file.archiveLog = () => {
|
||||
/* do nothing */
|
||||
};
|
||||
|
||||
// 捕获未处理的异常和Promise拒绝
|
||||
log.catchErrors({
|
||||
showDialog: false,
|
||||
onError: (options: any) => {
|
||||
log.error('未处理的错误:', options.error)
|
||||
log.error('版本信息:', options.versions)
|
||||
log.error('进程类型:', options.processType)
|
||||
},
|
||||
})
|
||||
|
||||
// 重写console方法,将所有控制台输出重定向到日志
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
debug: console.debug,
|
||||
}
|
||||
|
||||
console.log = (...args) => {
|
||||
log.info(...args)
|
||||
originalConsole.log(...args)
|
||||
}
|
||||
|
||||
console.error = (...args) => {
|
||||
log.error(...args)
|
||||
originalConsole.error(...args)
|
||||
}
|
||||
|
||||
console.warn = (...args) => {
|
||||
log.warn(...args)
|
||||
originalConsole.warn(...args)
|
||||
}
|
||||
|
||||
console.info = (...args) => {
|
||||
log.info(...args)
|
||||
originalConsole.info(...args)
|
||||
}
|
||||
|
||||
console.debug = (...args) => {
|
||||
log.debug(...args)
|
||||
originalConsole.debug(...args)
|
||||
}
|
||||
|
||||
log.info('日志系统初始化完成')
|
||||
log.info(`日志文件路径: ${path.join(logPath, getTodayLogFileName())}`)
|
||||
|
||||
return log
|
||||
}
|
||||
|
||||
// 导出日志实例和工具函数
|
||||
export { log, stripAnsiColors }
|
||||
|
||||
// 获取当前日志文件路径
|
||||
export function getLogPath(): string {
|
||||
return path.join(getLogDirectory(), getTodayLogFileName())
|
||||
}
|
||||
|
||||
// 获取所有日志文件列表
|
||||
export function getLogFiles(): string[] {
|
||||
const fs = require('fs')
|
||||
const logDir = getLogDirectory()
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(logDir)
|
||||
return files
|
||||
.filter((file: string) => file.match(/^frontendlog-\d{4}-\d{2}-\d{2}\.log$/))
|
||||
.sort()
|
||||
.reverse() // 最新的在前面
|
||||
}
|
||||
|
||||
// 清理旧日志文件
|
||||
export function cleanOldLogs(daysToKeep: number = 7) {
|
||||
const fs = require('fs')
|
||||
const logDir = getLogDirectory()
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
return
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(logDir)
|
||||
const now = new Date()
|
||||
const cutoffDate = new Date(now.getTime() - daysToKeep * 24 * 60 * 60 * 1000)
|
||||
|
||||
// 格式化截止日期为YYYY-MM-DD
|
||||
const cutoffDateStr = cutoffDate.getFullYear() + '-' +
|
||||
String(cutoffDate.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(cutoffDate.getDate()).padStart(2, '0')
|
||||
|
||||
files.forEach((file: string) => {
|
||||
// 匹配日志文件名格式 frontendlog-YYYY-MM-DD.log
|
||||
const match = file.match(/^frontendlog-(\d{4}-\d{2}-\d{2})\.log$/)
|
||||
if (match) {
|
||||
const fileDateStr = match[1]
|
||||
if (fileDateStr < cutoffDateStr) {
|
||||
const filePath = path.join(logDir, file)
|
||||
try {
|
||||
fs.unlinkSync(filePath)
|
||||
log.info(`已删除旧日志文件: ${file}`)
|
||||
} catch (error) {
|
||||
log.error(`删除旧日志文件失败: ${file}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
642
frontend/electron/services/pythonService.ts
Normal file
642
frontend/electron/services/pythonService.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import { spawn } from 'child_process'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { downloadFile } from './downloadService'
|
||||
import { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { log, stripAnsiColors } from './logService'
|
||||
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
export function setMainWindow(window: BrowserWindow) {
|
||||
mainWindow = window
|
||||
}
|
||||
|
||||
// Python镜像源URL映射
|
||||
const pythonMirrorUrls = {
|
||||
official: 'https://www.python.org/ftp/python/3.12.0/python-3.12.0-embed-amd64.zip',
|
||||
tsinghua: 'https://mirrors.tuna.tsinghua.edu.cn/python/3.12.0/python-3.12.0-embed-amd64.zip',
|
||||
ustc: 'https://mirrors.ustc.edu.cn/python/3.12.0/python-3.12.0-embed-amd64.zip',
|
||||
huawei:
|
||||
'https://mirrors.huaweicloud.com/repository/toolkit/python/3.12.0/python-3.12.0-embed-amd64.zip',
|
||||
aliyun: 'https://mirrors.aliyun.com/python-release/windows/python-3.12.0-embed-amd64.zip',
|
||||
}
|
||||
|
||||
// 检查pip是否已安装
|
||||
function isPipInstalled(pythonPath: string): boolean {
|
||||
const scriptsPath = path.join(pythonPath, 'Scripts')
|
||||
const pipExePath = path.join(scriptsPath, 'pip.exe')
|
||||
const pip3ExePath = path.join(scriptsPath, 'pip3.exe')
|
||||
|
||||
console.log(`检查pip安装状态:`)
|
||||
console.log(`Scripts目录: ${scriptsPath}`)
|
||||
console.log(`pip.exe路径: ${pipExePath}`)
|
||||
console.log(`pip3.exe路径: ${pip3ExePath}`)
|
||||
|
||||
const scriptsExists = fs.existsSync(scriptsPath)
|
||||
const pipExists = fs.existsSync(pipExePath)
|
||||
const pip3Exists = fs.existsSync(pip3ExePath)
|
||||
|
||||
console.log(`Scripts目录存在: ${scriptsExists}`)
|
||||
console.log(`pip.exe存在: ${pipExists}`)
|
||||
console.log(`pip3.exe存在: ${pip3Exists}`)
|
||||
|
||||
return scriptsExists && (pipExists || pip3Exists)
|
||||
}
|
||||
|
||||
// 安装pip
|
||||
async function installPip(pythonPath: string, appRoot: string): Promise<void> {
|
||||
console.log('开始检查pip安装状态...')
|
||||
|
||||
const pythonExe = path.join(pythonPath, 'python.exe')
|
||||
|
||||
// 检查Python可执行文件是否存在
|
||||
if (!fs.existsSync(pythonExe)) {
|
||||
throw new Error(`Python可执行文件不存在: ${pythonExe}`)
|
||||
}
|
||||
|
||||
// 检查pip是否已安装
|
||||
if (isPipInstalled(pythonPath)) {
|
||||
console.log('pip已经安装,跳过安装步骤')
|
||||
console.log('检测到pip.exe文件存在,认为pip安装成功')
|
||||
console.log('pip检查完成')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('pip未安装,开始安装...')
|
||||
|
||||
const getPipPath = path.join(pythonPath, 'get-pip.py')
|
||||
const getPipUrl = 'https://download.auto-mas.top/d/AUTO_MAS/get-pip.py'
|
||||
|
||||
console.log(`Python可执行文件路径: ${pythonExe}`)
|
||||
console.log(`get-pip.py下载URL: ${getPipUrl}`)
|
||||
console.log(`get-pip.py保存路径: ${getPipPath}`)
|
||||
|
||||
// 下载get-pip.py
|
||||
console.log('开始下载get-pip.py...')
|
||||
try {
|
||||
await downloadFile(getPipUrl, getPipPath)
|
||||
console.log('get-pip.py下载完成')
|
||||
|
||||
// 检查下载的文件大小
|
||||
const stats = fs.statSync(getPipPath)
|
||||
console.log(`get-pip.py文件大小: ${stats.size} bytes`)
|
||||
|
||||
if (stats.size < 10000) {
|
||||
// 如果文件小于10KB,可能是无效文件
|
||||
throw new Error(`get-pip.py文件大小异常: ${stats.size} bytes,可能下载失败`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载get-pip.py失败:', error)
|
||||
throw new Error(`下载get-pip.py失败: ${error}`)
|
||||
}
|
||||
|
||||
// 执行pip安装
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
console.log('执行pip安装命令...')
|
||||
|
||||
const process = spawn(pythonExe, [getPipPath], {
|
||||
cwd: pythonPath,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
process.stdout?.on('data', data => {
|
||||
const output = stripAnsiColors(data.toString())
|
||||
log.info('pip安装输出:', output)
|
||||
})
|
||||
|
||||
process.stderr?.on('data', data => {
|
||||
const errorOutput = stripAnsiColors(data.toString())
|
||||
log.warn('pip安装错误输出:', errorOutput)
|
||||
})
|
||||
|
||||
process.on('close', code => {
|
||||
console.log(`pip安装完成,退出码: ${code}`)
|
||||
if (code === 0) {
|
||||
console.log('pip安装成功')
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`pip安装失败,退出码: ${code}`))
|
||||
}
|
||||
})
|
||||
|
||||
process.on('error', error => {
|
||||
console.error('pip安装进程错误:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
// 验证pip是否安装成功
|
||||
console.log('验证pip安装...')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const verifyProcess = spawn(pythonExe, ['-m', 'pip', '--version'], {
|
||||
cwd: pythonPath,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
verifyProcess.stdout?.on('data', data => {
|
||||
const output = stripAnsiColors(data.toString())
|
||||
log.info('pip版本信息:', output)
|
||||
})
|
||||
|
||||
verifyProcess.stderr?.on('data', data => {
|
||||
const errorOutput = stripAnsiColors(data.toString())
|
||||
log.warn('pip版本检查错误:', errorOutput)
|
||||
})
|
||||
|
||||
verifyProcess.on('close', code => {
|
||||
if (code === 0) {
|
||||
console.log('pip验证成功')
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`pip验证失败,退出码: ${code}`))
|
||||
}
|
||||
})
|
||||
|
||||
verifyProcess.on('error', error => {
|
||||
console.error('pip验证进程错误:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
// 清理临时文件
|
||||
console.log('清理临时文件...')
|
||||
try {
|
||||
if (fs.existsSync(getPipPath)) {
|
||||
fs.unlinkSync(getPipPath)
|
||||
console.log('get-pip.py临时文件已删除')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('清理get-pip.py文件时出错:', error)
|
||||
}
|
||||
|
||||
console.log('pip安装和验证完成')
|
||||
}
|
||||
|
||||
// 下载Python
|
||||
export async function downloadPython(
|
||||
appRoot: string,
|
||||
mirror = 'ustc'
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const environmentPath = path.join(appRoot, 'environment')
|
||||
const pythonPath = path.join(environmentPath, 'python')
|
||||
|
||||
// 确保environment目录存在
|
||||
if (!fs.existsSync(environmentPath)) {
|
||||
fs.mkdirSync(environmentPath, { recursive: true })
|
||||
}
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'python',
|
||||
progress: 0,
|
||||
status: 'downloading',
|
||||
message: '开始下载Python...',
|
||||
})
|
||||
}
|
||||
|
||||
// 根据选择的镜像源获取下载链接
|
||||
const pythonUrl =
|
||||
pythonMirrorUrls[mirror as keyof typeof pythonMirrorUrls] || pythonMirrorUrls.ustc
|
||||
const zipPath = path.join(environmentPath, 'python.zip')
|
||||
|
||||
await downloadFile(pythonUrl, zipPath)
|
||||
|
||||
// 检查下载的Python文件大小
|
||||
const stats = fs.statSync(zipPath)
|
||||
console.log(
|
||||
`Python压缩包大小: ${stats.size} bytes (${(stats.size / 1024 / 1024).toFixed(2)} MB)`
|
||||
)
|
||||
|
||||
// Python 3.12.0嵌入式版本应该大约30MB,如果小于5MB可能是无效文件
|
||||
if (stats.size < 5 * 1024 * 1024) {
|
||||
// 5MB
|
||||
fs.unlinkSync(zipPath) // 删除无效文件
|
||||
throw new Error(
|
||||
`Python下载文件大小异常: ${stats.size} bytes (${(stats.size / 1024).toFixed(2)} KB)。可能是对应镜像站不可用。请选择任意一个其他镜像源进行下载!`
|
||||
)
|
||||
}
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'python',
|
||||
progress: 100,
|
||||
status: 'extracting',
|
||||
message: '正在解压Python...',
|
||||
})
|
||||
}
|
||||
|
||||
// 解压Python到指定目录
|
||||
console.log(`开始解压Python到: ${pythonPath}`)
|
||||
|
||||
// 确保Python目录存在
|
||||
if (!fs.existsSync(pythonPath)) {
|
||||
fs.mkdirSync(pythonPath, { recursive: true })
|
||||
console.log(`创建Python目录: ${pythonPath}`)
|
||||
}
|
||||
|
||||
const zip = new AdmZip(zipPath)
|
||||
zip.extractAllTo(pythonPath, true)
|
||||
console.log(`Python解压完成到: ${pythonPath}`)
|
||||
|
||||
// 删除zip文件
|
||||
fs.unlinkSync(zipPath)
|
||||
console.log(`删除临时文件: ${zipPath}`)
|
||||
|
||||
// 启用 site-packages 支持
|
||||
const pthFile = path.join(pythonPath, 'python312._pth')
|
||||
if (fs.existsSync(pthFile)) {
|
||||
let content = fs.readFileSync(pthFile, 'utf-8')
|
||||
content = content.replace(/^#import site/m, 'import site')
|
||||
fs.writeFileSync(pthFile, content, 'utf-8')
|
||||
console.log('已启用 site-packages 支持')
|
||||
}
|
||||
|
||||
// 安装pip
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'python',
|
||||
progress: 80,
|
||||
status: 'installing',
|
||||
message: '正在安装pip...',
|
||||
})
|
||||
}
|
||||
|
||||
await installPip(pythonPath, appRoot)
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'python',
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
message: 'Python和pip安装完成',
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'python',
|
||||
progress: 0,
|
||||
status: 'error',
|
||||
message: `Python下载失败: ${errorMessage}`,
|
||||
})
|
||||
}
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
// pip镜像源URL映射
|
||||
const pipMirrorUrls = {
|
||||
official: 'https://pypi.org/simple/',
|
||||
tsinghua: 'https://pypi.tuna.tsinghua.edu.cn/simple/',
|
||||
ustc: 'https://pypi.mirrors.ustc.edu.cn/simple/',
|
||||
aliyun: 'https://mirrors.aliyun.com/pypi/simple/',
|
||||
douban: 'https://pypi.douban.com/simple/',
|
||||
}
|
||||
|
||||
// 安装Python依赖
|
||||
export async function installDependencies(
|
||||
appRoot: string,
|
||||
mirror = 'tsinghua'
|
||||
): Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const pythonPath = path.join(appRoot, 'environment', 'python', 'python.exe')
|
||||
const backendPath = path.join(appRoot)
|
||||
const requirementsPath = path.join(appRoot, 'requirements.txt')
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(pythonPath)) {
|
||||
throw new Error('Python可执行文件不存在')
|
||||
}
|
||||
if (!fs.existsSync(requirementsPath)) {
|
||||
throw new Error('requirements.txt文件不存在')
|
||||
}
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'dependencies',
|
||||
progress: 0,
|
||||
status: 'downloading',
|
||||
message: '正在安装Python依赖包...',
|
||||
})
|
||||
}
|
||||
|
||||
// 获取pip镜像源URL
|
||||
const pipMirrorUrl =
|
||||
pipMirrorUrls[mirror as keyof typeof pipMirrorUrls] || pipMirrorUrls.tsinghua
|
||||
|
||||
// 使用Scripts文件夹中的pip.exe
|
||||
const pythonDir = path.join(appRoot, 'environment', 'python')
|
||||
const pipExePath = path.join(pythonDir, 'Scripts', 'pip.exe')
|
||||
|
||||
console.log(`开始安装Python依赖`)
|
||||
console.log(`Python目录: ${pythonDir}`)
|
||||
console.log(`pip.exe路径: ${pipExePath}`)
|
||||
console.log(`requirements.txt路径: ${requirementsPath}`)
|
||||
console.log(`pip镜像源: ${pipMirrorUrl}`)
|
||||
|
||||
// 检查pip.exe是否存在
|
||||
if (!fs.existsSync(pipExePath)) {
|
||||
throw new Error(`pip.exe不存在: ${pipExePath}`)
|
||||
}
|
||||
|
||||
// 安装依赖 - 直接使用pip.exe而不是python -m pip
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const process = spawn(
|
||||
pipExePath,
|
||||
[
|
||||
'install',
|
||||
'-r',
|
||||
requirementsPath,
|
||||
'-i',
|
||||
pipMirrorUrl,
|
||||
'--trusted-host',
|
||||
new URL(pipMirrorUrl).hostname,
|
||||
],
|
||||
{
|
||||
cwd: backendPath,
|
||||
stdio: 'pipe',
|
||||
}
|
||||
)
|
||||
|
||||
process.stdout?.on('data', data => {
|
||||
const output = stripAnsiColors(data.toString())
|
||||
log.info('Pip output:', output)
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'dependencies',
|
||||
progress: 50,
|
||||
status: 'downloading',
|
||||
message: '正在安装依赖包...',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
process.stderr?.on('data', data => {
|
||||
const errorOutput = stripAnsiColors(data.toString())
|
||||
log.error('Pip error:', errorOutput)
|
||||
})
|
||||
|
||||
process.on('close', code => {
|
||||
console.log(`pip安装完成,退出码: ${code}`)
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`依赖安装失败,退出码: ${code}`))
|
||||
}
|
||||
})
|
||||
|
||||
process.on('error', error => {
|
||||
console.error('pip进程错误:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'dependencies',
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
message: 'Python依赖安装完成',
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'dependencies',
|
||||
progress: 0,
|
||||
status: 'error',
|
||||
message: `依赖安装失败: ${errorMessage}`,
|
||||
})
|
||||
}
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
// 导出pip安装函数
|
||||
export async function installPipPackage(
|
||||
appRoot: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const pythonPath = path.join(appRoot, 'environment', 'python')
|
||||
|
||||
if (!fs.existsSync(pythonPath)) {
|
||||
throw new Error('Python环境不存在,请先安装Python')
|
||||
}
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'pip',
|
||||
progress: 0,
|
||||
status: 'installing',
|
||||
message: '正在安装pip...',
|
||||
})
|
||||
}
|
||||
|
||||
await installPip(pythonPath, appRoot)
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'pip',
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
message: 'pip安装完成',
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', {
|
||||
type: 'pip',
|
||||
progress: 0,
|
||||
status: 'error',
|
||||
message: `pip安装失败: ${errorMessage}`,
|
||||
})
|
||||
}
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
// 启动后端
|
||||
let backendProc: ChildProcessWithoutNullStreams | null = null
|
||||
|
||||
/**
|
||||
* 启动后端
|
||||
* @param appRoot 项目根目录
|
||||
* @param timeoutMs 等待启动超时(默认 30 秒)
|
||||
*/
|
||||
export async function startBackend(appRoot: string, timeoutMs = 30_000) {
|
||||
try {
|
||||
// 如果已经在运行,直接返回
|
||||
if (backendProc && !backendProc.killed && backendProc.exitCode == null) {
|
||||
console.log('[Backend] 已在运行, PID =', backendProc.pid)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const pythonExe = path.join(appRoot, 'environment', 'python', 'python.exe')
|
||||
const mainPy = path.join(appRoot, 'main.py')
|
||||
|
||||
if (!fs.existsSync(pythonExe)) {
|
||||
throw new Error(`Python可执行文件不存在: ${pythonExe}`)
|
||||
}
|
||||
if (!fs.existsSync(mainPy)) {
|
||||
throw new Error(`后端主文件不存在: ${mainPy}`)
|
||||
}
|
||||
|
||||
console.log(`[Backend] spawn "${pythonExe}" "${mainPy}" (cwd=${appRoot})`)
|
||||
|
||||
backendProc = spawn(pythonExe, [mainPy], {
|
||||
cwd: appRoot,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
||||
})
|
||||
|
||||
backendProc.stdout.setEncoding('utf8')
|
||||
backendProc.stderr.setEncoding('utf8')
|
||||
|
||||
backendProc.stdout.on('data', d => {
|
||||
const line = stripAnsiColors(d.toString().trim())
|
||||
if (line) log.info('[Backend]', line)
|
||||
})
|
||||
backendProc.stderr.on('data', d => {
|
||||
const line = stripAnsiColors(d.toString().trim())
|
||||
if (line) log.info('[Backend]', line)
|
||||
})
|
||||
|
||||
backendProc.once('exit', (code, signal) => {
|
||||
console.log('[Backend] 退出', { code, signal })
|
||||
backendProc = null
|
||||
})
|
||||
backendProc.once('error', e => {
|
||||
console.error('[Backend] 进程错误:', e)
|
||||
})
|
||||
|
||||
// 等待启动成功(匹配 Uvicorn 的输出)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
reject(new Error('后端启动超时'))
|
||||
}
|
||||
}, timeoutMs)
|
||||
|
||||
const checkReady = (buf: Buffer | string) => {
|
||||
if (settled) return
|
||||
const s = buf.toString()
|
||||
if (/Uvicorn running|http:\/\/0\.0\.0\.0:\d+/.test(s)) {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
backendProc!.stdout.on('data', checkReady)
|
||||
backendProc!.stderr.on('data', checkReady)
|
||||
|
||||
backendProc!.once('exit', (code, sig) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(new Error(`后端提前退出: code=${code}, signal=${sig ?? ''}`))
|
||||
}
|
||||
})
|
||||
backendProc!.once('error', err => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
console.log('[Backend] 启动成功, PID =', backendProc.pid)
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
console.error('[Backend] 启动失败:', e)
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止后端进程(如果没启动就直接返回成功) */
|
||||
export async function stopBackend() {
|
||||
if (!backendProc || backendProc.killed) {
|
||||
console.log('[Backend] 未运行,无需停止')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const pid = backendProc.pid
|
||||
console.log('[Backend] 正在停止后端服务, PID =', pid)
|
||||
|
||||
return new Promise<{ success: boolean; error?: string }>(resolve => {
|
||||
// 设置超时,确保不会无限等待
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('[Backend] 停止超时,强制结束进程')
|
||||
try {
|
||||
if (backendProc && !backendProc.killed) {
|
||||
// 在 Windows 上使用 taskkill 强制结束进程树
|
||||
if (process.platform === 'win32') {
|
||||
const { exec } = require('child_process')
|
||||
exec(`taskkill /f /t /pid ${pid}`, (error: any) => {
|
||||
if (error) {
|
||||
console.error('[Backend] taskkill 失败:', error)
|
||||
} else {
|
||||
console.log('[Backend] 进程树已强制结束')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
backendProc.kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Backend] 强制结束失败:', e)
|
||||
}
|
||||
backendProc = null
|
||||
resolve({ success: true })
|
||||
}, 2000) // 2秒超时
|
||||
|
||||
// 清监听,避免重复日志
|
||||
backendProc?.stdout?.removeAllListeners('data')
|
||||
backendProc?.stderr?.removeAllListeners('data')
|
||||
|
||||
backendProc!.once('exit', (code, signal) => {
|
||||
clearTimeout(timeout)
|
||||
console.log('[Backend] 已退出', { code, signal })
|
||||
backendProc = null
|
||||
resolve({ success: true })
|
||||
})
|
||||
|
||||
backendProc!.once('error', err => {
|
||||
clearTimeout(timeout)
|
||||
console.error('[Backend] 停止时出错:', err)
|
||||
backendProc = null
|
||||
resolve({ success: false, error: err instanceof Error ? err.message : String(err) })
|
||||
})
|
||||
|
||||
try {
|
||||
// 首先尝试优雅关闭
|
||||
backendProc!.kill('SIGTERM')
|
||||
console.log('[Backend] 已发送 SIGTERM 信号')
|
||||
} catch (e) {
|
||||
clearTimeout(timeout)
|
||||
console.error('[Backend] kill 调用失败:', e)
|
||||
backendProc = null
|
||||
resolve({ success: false, error: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
})
|
||||
}
|
||||
125
frontend/electron/utils/processManager.ts
Normal file
125
frontend/electron/utils/processManager.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { exec } from 'child_process'
|
||||
import * as path from 'path'
|
||||
import { getAppRoot } from '../services/environmentService'
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number
|
||||
name: string
|
||||
commandLine: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有相关的进程信息
|
||||
*/
|
||||
export async function getRelatedProcesses(): Promise<ProcessInfo[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== 'win32') {
|
||||
resolve([])
|
||||
return
|
||||
}
|
||||
|
||||
const appRoot = getAppRoot()
|
||||
const pythonExePath = path.join(appRoot, 'environment', 'python', 'python.exe')
|
||||
|
||||
// 使用 wmic 获取详细的进程信息
|
||||
const cmd = `wmic process where "Name='python.exe' or Name='AUTO-MAS.exe' or CommandLine like '%main.py%'" get ProcessId,Name,CommandLine /format:csv`
|
||||
|
||||
exec(cmd, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error('获取进程信息失败:', error)
|
||||
resolve([])
|
||||
return
|
||||
}
|
||||
|
||||
const processes: ProcessInfo[] = []
|
||||
const lines = stdout.split('\n').filter(line => line.trim() && !line.startsWith('Node'))
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(',')
|
||||
if (parts.length >= 4) {
|
||||
const commandLine = parts[1] || ''
|
||||
const name = parts[2] || ''
|
||||
const pid = parseInt(parts[3]) || 0
|
||||
|
||||
if (pid > 0 && (
|
||||
commandLine.includes(pythonExePath) ||
|
||||
commandLine.includes('main.py') ||
|
||||
name === 'AUTO-MAS.exe'
|
||||
)) {
|
||||
processes.push({ pid, name, commandLine })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve(processes)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制结束指定的进程
|
||||
*/
|
||||
export async function killProcess(pid: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== 'win32') {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
|
||||
exec(`taskkill /f /t /pid ${pid}`, (error) => {
|
||||
if (error) {
|
||||
console.error(`结束进程 ${pid} 失败:`, error.message)
|
||||
resolve(false)
|
||||
} else {
|
||||
console.log(`进程 ${pid} 已结束`)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制结束所有相关进程
|
||||
*/
|
||||
export async function killAllRelatedProcesses(): Promise<void> {
|
||||
console.log('开始清理所有相关进程...')
|
||||
|
||||
const processes = await getRelatedProcesses()
|
||||
console.log(`找到 ${processes.length} 个相关进程:`)
|
||||
|
||||
for (const proc of processes) {
|
||||
console.log(`- PID: ${proc.pid}, Name: ${proc.name}, CMD: ${proc.commandLine.substring(0, 100)}...`)
|
||||
}
|
||||
|
||||
// 并行结束所有进程
|
||||
const killPromises = processes.map(proc => killProcess(proc.pid))
|
||||
await Promise.all(killPromises)
|
||||
|
||||
console.log('进程清理完成')
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待进程结束
|
||||
*/
|
||||
export async function waitForProcessExit(pid: number, timeoutMs: number = 5000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
const checkProcess = () => {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
|
||||
exec(`tasklist /fi "PID eq ${pid}"`, (error, stdout) => {
|
||||
if (error || !stdout.includes(pid.toString())) {
|
||||
resolve(true)
|
||||
} else {
|
||||
setTimeout(checkProcess, 100)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
checkProcess()
|
||||
})
|
||||
}
|
||||
34
frontend/eslint.config.js
Normal file
34
frontend/eslint.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const vue = require('eslint-plugin-vue');
|
||||
const ts = require('@typescript-eslint/eslint-plugin');
|
||||
const tsParser = require('@typescript-eslint/parser');
|
||||
const prettier = require('eslint-plugin-prettier');
|
||||
|
||||
module.exports = [
|
||||
// 推荐的 vue3 配置
|
||||
vue.configs['vue3-recommended'],
|
||||
// 推荐的 ts 配置
|
||||
ts.configs.recommended,
|
||||
// 推荐的 prettier 配置
|
||||
prettier.configs.recommended,
|
||||
// 自定义规则和文件范围
|
||||
{
|
||||
files: ['**/*.js', '**/*.ts', '**/*.vue'],
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 2021,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: {
|
||||
vue,
|
||||
'@typescript-eslint': ts,
|
||||
prettier,
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
// 如果你希望 prettier 报错,取消注释下面一行
|
||||
// 'prettier/prettier': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AUTO-MAS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
116
frontend/package.json
Normal file
116
frontend/package.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "v5.0.0-alpha.3",
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"yarn watch:main\" \"yarn electron-dev\"",
|
||||
"watch:main": "tsc -p tsconfig.electron.json --watch",
|
||||
"electron-dev": "wait-on http://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .",
|
||||
"build:main": "tsc -p tsconfig.electron.json",
|
||||
"build": "vite build && yarn build:main && electron-builder",
|
||||
"web": "vite",
|
||||
"release": "vite build && yarn build:main && electron-builder --win --publish always"
|
||||
},
|
||||
"build": {
|
||||
"asar": true,
|
||||
"asarUnpack": [],
|
||||
"extraMetadata": {
|
||||
"env": "prod"
|
||||
},
|
||||
"appId": "top.auto-mas.frontend",
|
||||
"productName": "AUTO-MAS",
|
||||
"files": [
|
||||
"dist/**",
|
||||
"dist-electron/**",
|
||||
"public/**",
|
||||
"!src/**",
|
||||
"!**/*.map"
|
||||
],
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "AUTO-MAS-Project",
|
||||
"repo": "AUTO-MAS"
|
||||
}
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "src/assets",
|
||||
"to": "assets",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"requestedExecutionLevel": "requireAdministrator",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "public/AUTO-MAS.ico",
|
||||
"artifactName": "AUTO-MAS-Setup-${version}-${arch}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"shortcutName": "AUTO-MAS",
|
||||
"differentialPackage": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"ant-design-vue": "4.x",
|
||||
"axios": "^1.11.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"electron-log": "^5.4.3",
|
||||
"form-data": "^4.0.4",
|
||||
"markdown-it": "^14.1.0",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "4",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.17.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"concurrently": "^9.2.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"electron": "^37.2.5",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-vue": "^10.4.0",
|
||||
"openapi-typescript-codegen": "^0.29.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.0.4",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^2.2.12",
|
||||
"wait-on": "^8.0.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/node": "22.17.1"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1"
|
||||
}
|
||||
BIN
frontend/public/AUTO-MAS.ico
Normal file
BIN
frontend/public/AUTO-MAS.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 450 KiB |
509
frontend/public/dialog.html
Normal file
509
frontend/public/dialog.html
Normal file
@@ -0,0 +1,509 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>操作确认</title>
|
||||
<style>
|
||||
:root {
|
||||
/* 亮色模式变量 */
|
||||
--primary-color: #1677ff;
|
||||
--primary-hover: #4096ff;
|
||||
--primary-active: #0958d9;
|
||||
--danger-color: #ff4d4f;
|
||||
--danger-hover: #ff7875;
|
||||
--danger-active: #d9363e;
|
||||
--success-color: #52c41a;
|
||||
--warning-color: #faad14;
|
||||
|
||||
--text-primary: #262626;
|
||||
--text-secondary: #595959;
|
||||
--text-tertiary: #8c8c8c;
|
||||
--text-disabled: #bfbfbf;
|
||||
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #fafafa;
|
||||
--bg-tertiary: #f5f5f5;
|
||||
--bg-quaternary: #f0f0f0;
|
||||
|
||||
--border-primary: #d9d9d9;
|
||||
--border-secondary: #f0f0f0;
|
||||
--border-hover: #4096ff;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
/* 暗色模式变量 */
|
||||
[data-theme="dark"] {
|
||||
--primary-color: #1668dc;
|
||||
--primary-hover: #3c89e8;
|
||||
--primary-active: #1554ad;
|
||||
--danger-color: #ff4d4f;
|
||||
--danger-hover: #ff7875;
|
||||
--danger-active: #d9363e;
|
||||
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c9cdd4;
|
||||
--text-tertiary: #a6adb4;
|
||||
--text-disabled: #6c757d;
|
||||
|
||||
--bg-primary: #1f1f1f;
|
||||
--bg-secondary: #2a2a2a;
|
||||
--bg-tertiary: #373737;
|
||||
--bg-quaternary: #404040;
|
||||
|
||||
--border-primary: #434343;
|
||||
--border-secondary: #303030;
|
||||
--border-hover: #3c89e8;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.15), 0 1px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px 0 rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.2), 0 2px 4px -1px rgba(0, 0, 0, 0.15);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.25), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
font-size: 14px;
|
||||
line-height: 1.5715;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-primary);
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
padding: 12px 16px 8px 16px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: move;
|
||||
-webkit-app-region: drag;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
text-align: center;
|
||||
transition: color 0.2s ease;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
padding: 12px 16px 12px 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
padding: 6px 12px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
min-width: 60px;
|
||||
outline: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.5715;
|
||||
}
|
||||
|
||||
.dialog-button:hover {
|
||||
background: var(--bg-quaternary);
|
||||
border-color: var(--border-hover);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.dialog-button:focus {
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.2);
|
||||
border-color: var(--border-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialog-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
.dialog-button.primary {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
border-color: var(--primary-color);
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.dialog-button.primary:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dialog-button.primary:active {
|
||||
background: var(--primary-active);
|
||||
border-color: var(--primary-active);
|
||||
}
|
||||
|
||||
.dialog-button.danger {
|
||||
background: var(--danger-color);
|
||||
color: #fff;
|
||||
border-color: var(--danger-color);
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.dialog-button.danger:hover {
|
||||
background: var(--danger-hover);
|
||||
border-color: var(--danger-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dialog-button.danger:active {
|
||||
background: var(--danger-active);
|
||||
border-color: var(--danger-active);
|
||||
}
|
||||
|
||||
/* 键盘导航样式 */
|
||||
.dialog-button:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.dialog-container {
|
||||
animation: dialogFadeIn 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
}
|
||||
|
||||
@keyframes dialogFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 350px) {
|
||||
.dialog-header {
|
||||
padding: 8px 12px 6px 12px;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
padding: 8px 12px 8px 12px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dialog-container" role="dialog" aria-modal="true" aria-labelledby="dialog-title" aria-describedby="dialog-message">
|
||||
<div class="dialog-header">
|
||||
<h3 class="dialog-title" id="dialog-title">操作确认</h3>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<p class="dialog-message" id="dialog-message">是否要执行此操作?</p>
|
||||
</div>
|
||||
<div class="dialog-actions" id="dialog-actions" role="group" aria-label="对话框操作按钮">
|
||||
<!-- 按钮将通过 JavaScript 动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 主题管理
|
||||
const ThemeManager = {
|
||||
init() {
|
||||
this.applyTheme();
|
||||
this.listenForThemeChanges();
|
||||
},
|
||||
|
||||
applyTheme() {
|
||||
// 优先从 Electron 主进程获取软件内主题状态
|
||||
if (window.electronAPI && window.electronAPI.getTheme) {
|
||||
window.electronAPI.getTheme().then(theme => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}).catch(() => {
|
||||
// 如果获取失败,使用系统主题
|
||||
this.useSystemTheme();
|
||||
});
|
||||
} else {
|
||||
this.useSystemTheme();
|
||||
}
|
||||
},
|
||||
|
||||
useSystemTheme() {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||
},
|
||||
|
||||
listenForThemeChanges() {
|
||||
// 监听系统主题变化
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
// 如果软件主题配置为系统跟随,则更新主题
|
||||
if (!window.electronAPI || !window.electronAPI.getTheme) {
|
||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||
} else {
|
||||
// 重新获取软件内主题状态
|
||||
this.applyTheme();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 Electron 主题变化
|
||||
if (window.electronAPI && window.electronAPI.onThemeChanged) {
|
||||
window.electronAPI.onThemeChanged((theme) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 拖拽管理
|
||||
const DragManager = {
|
||||
isDragging: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
|
||||
init() {
|
||||
const header = document.querySelector('.dialog-header');
|
||||
if (!header) return;
|
||||
|
||||
header.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
document.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||
|
||||
// 防止文本选择
|
||||
header.addEventListener('selectstart', (e) => e.preventDefault());
|
||||
},
|
||||
|
||||
handleMouseDown(e) {
|
||||
// 只有在头部区域才能拖拽
|
||||
if (!e.target.closest('.dialog-header')) return;
|
||||
|
||||
this.isDragging = true;
|
||||
this.startX = e.clientX;
|
||||
this.startY = e.clientY;
|
||||
|
||||
const container = document.querySelector('.dialog-container');
|
||||
container.style.transition = 'none';
|
||||
|
||||
e.preventDefault();
|
||||
},
|
||||
|
||||
handleMouseMove(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const deltaX = e.clientX - this.startX;
|
||||
const deltaY = e.clientY - this.startY;
|
||||
|
||||
// 通知 Electron 移动窗口
|
||||
if (window.electronAPI && window.electronAPI.moveWindow) {
|
||||
window.electronAPI.moveWindow(deltaX, deltaY);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
},
|
||||
|
||||
handleMouseUp(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
this.isDragging = false;
|
||||
|
||||
const container = document.querySelector('.dialog-container');
|
||||
container.style.transition = '';
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// 窗口加载完成后的初始化
|
||||
window.addEventListener('load', () => {
|
||||
// 初始化主题管理
|
||||
ThemeManager.init();
|
||||
});
|
||||
|
||||
// 获取传递的参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const data = JSON.parse(decodeURIComponent(urlParams.get('data') || '{}'));
|
||||
|
||||
// 设置对话框内容
|
||||
document.getElementById('dialog-title').textContent = data.title || '操作确认';
|
||||
document.getElementById('dialog-message').textContent = data.message || '是否要执行此操作?';
|
||||
|
||||
// 创建按钮
|
||||
const actionsContainer = document.getElementById('dialog-actions');
|
||||
const options = data.options || ['确定', '取消'];
|
||||
|
||||
options.forEach((option, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'dialog-button';
|
||||
button.textContent = option;
|
||||
|
||||
// 根据按钮文本设置样式
|
||||
if (option.includes('确定') || option.includes('是') || option.includes('同意')) {
|
||||
button.className += ' primary';
|
||||
} else if (option.includes('删除') || option.includes('危险')) {
|
||||
button.className += ' danger';
|
||||
}
|
||||
|
||||
// 绑定点击事件
|
||||
button.addEventListener('click', () => {
|
||||
// 添加点击动画
|
||||
button.style.transform = 'scale(0.98)';
|
||||
setTimeout(() => {
|
||||
button.style.transform = '';
|
||||
}, 100);
|
||||
|
||||
// 发送结果到主进程
|
||||
if (window.electronAPI && window.electronAPI.dialogResponse) {
|
||||
const choice = index === 0; // 第一个选项为 true
|
||||
window.electronAPI.dialogResponse(data.messageId, choice);
|
||||
}
|
||||
});
|
||||
|
||||
actionsContainer.appendChild(button);
|
||||
});
|
||||
|
||||
// 自动聚焦第一个按钮
|
||||
setTimeout(() => {
|
||||
const firstButton = actionsContainer.querySelector('.dialog-button');
|
||||
if (firstButton) {
|
||||
firstButton.focus();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// 键盘事件处理
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
// ESC 键相当于取消
|
||||
if (window.electronAPI && window.electronAPI.dialogResponse) {
|
||||
window.electronAPI.dialogResponse(data.messageId, false);
|
||||
}
|
||||
} else if (event.key === 'Enter') {
|
||||
// Enter 键相当于确定
|
||||
const focusedButton = document.activeElement;
|
||||
if (focusedButton && focusedButton.classList.contains('dialog-button')) {
|
||||
focusedButton.click();
|
||||
} else {
|
||||
// 如果没有聚焦按钮,默认点击第一个
|
||||
const firstButton = actionsContainer.querySelector('.dialog-button');
|
||||
if (firstButton) {
|
||||
firstButton.click();
|
||||
}
|
||||
}
|
||||
} else if (event.key === 'Tab') {
|
||||
// Tab 键在按钮间切换
|
||||
const buttons = Array.from(actionsContainer.querySelectorAll('.dialog-button'));
|
||||
const currentIndex = buttons.indexOf(document.activeElement);
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Shift+Tab 向前切换
|
||||
const prevIndex = currentIndex <= 0 ? buttons.length - 1 : currentIndex - 1;
|
||||
buttons[prevIndex].focus();
|
||||
} else {
|
||||
// Tab 向后切换
|
||||
const nextIndex = currentIndex >= buttons.length - 1 ? 0 : currentIndex + 1;
|
||||
buttons[nextIndex].focus();
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// 窗口加载完成后的初始化
|
||||
window.addEventListener('load', () => {
|
||||
// 初始化主题管理
|
||||
ThemeManager.init();
|
||||
|
||||
// 初始化拖拽管理
|
||||
DragManager.init();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
98
frontend/src/App.vue
Normal file
98
frontend/src/App.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ConfigProvider } from 'ant-design-vue'
|
||||
import { useTheme } from './composables/useTheme.ts'
|
||||
import { useUpdateModal } from './composables/useUpdateChecker.ts'
|
||||
import AppLayout from './components/AppLayout.vue'
|
||||
import TitleBar from './components/TitleBar.vue'
|
||||
import UpdateModal from './components/UpdateModal.vue'
|
||||
import DevDebugPanel from './components/DevDebugPanel.vue'
|
||||
import GlobalPowerCountdown from './components/GlobalPowerCountdown.vue'
|
||||
import WebSocketMessageListener from './components/WebSocketMessageListener.vue'
|
||||
import WebSocketDebugPanel from './components/WebSocketDebugPanel.vue'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
import { logger } from '@/utils/logger'
|
||||
|
||||
const route = useRoute()
|
||||
const { antdTheme, initTheme } = useTheme()
|
||||
const { updateVisible, updateData, onUpdateConfirmed } = useUpdateModal()
|
||||
|
||||
// 判断是否为初始化页面
|
||||
const isInitializationPage = computed(() => route.name === 'Initialization')
|
||||
|
||||
onMounted(() => {
|
||||
logger.info('App组件已挂载')
|
||||
initTheme()
|
||||
logger.info('主题初始化完成')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider :theme="antdTheme" :locale="zhCN">
|
||||
<!-- 初始化页面使用带标题栏的全屏布局 -->
|
||||
<div v-if="isInitializationPage" class="initialization-container">
|
||||
<TitleBar />
|
||||
<div class="initialization-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 其他页面使用带标题栏的应用布局 -->
|
||||
<div v-else class="app-container">
|
||||
<TitleBar />
|
||||
<AppLayout />
|
||||
</div>
|
||||
|
||||
<!-- 全局更新模态框 -->
|
||||
<UpdateModal
|
||||
v-model:visible="updateVisible"
|
||||
:update-data="updateData"
|
||||
@confirmed="onUpdateConfirmed"
|
||||
/>
|
||||
|
||||
<!-- 开发环境调试面板 -->
|
||||
<DevDebugPanel />
|
||||
|
||||
<!-- 全局电源倒计时弹窗 -->
|
||||
<GlobalPowerCountdown />
|
||||
|
||||
<!-- WebSocket 消息监听组件 -->
|
||||
<WebSocketMessageListener />
|
||||
|
||||
<!-- WebSocket 调试面板 (仅开发环境) -->
|
||||
<WebSocketDebugPanel v-if="$route.query.debug === 'true'" />
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.initialization-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.initialization-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
/* 隐藏滚动条但保留滚动功能 */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
/* 隐藏 Webkit 浏览器的滚动条 */
|
||||
.initialization-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
25
frontend/src/api/core/ApiError.ts
Normal file
25
frontend/src/api/core/ApiError.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { ApiRequestOptions } from './ApiRequestOptions';
|
||||
import type { ApiResult } from './ApiResult';
|
||||
|
||||
export class ApiError extends Error {
|
||||
public readonly url: string;
|
||||
public readonly status: number;
|
||||
public readonly statusText: string;
|
||||
public readonly body: any;
|
||||
public readonly request: ApiRequestOptions;
|
||||
|
||||
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
|
||||
super(message);
|
||||
|
||||
this.name = 'ApiError';
|
||||
this.url = response.url;
|
||||
this.status = response.status;
|
||||
this.statusText = response.statusText;
|
||||
this.body = response.body;
|
||||
this.request = request;
|
||||
}
|
||||
}
|
||||
17
frontend/src/api/core/ApiRequestOptions.ts
Normal file
17
frontend/src/api/core/ApiRequestOptions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type ApiRequestOptions = {
|
||||
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
|
||||
readonly url: string;
|
||||
readonly path?: Record<string, any>;
|
||||
readonly cookies?: Record<string, any>;
|
||||
readonly headers?: Record<string, any>;
|
||||
readonly query?: Record<string, any>;
|
||||
readonly formData?: Record<string, any>;
|
||||
readonly body?: any;
|
||||
readonly mediaType?: string;
|
||||
readonly responseHeader?: string;
|
||||
readonly errors?: Record<number, string>;
|
||||
};
|
||||
11
frontend/src/api/core/ApiResult.ts
Normal file
11
frontend/src/api/core/ApiResult.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type ApiResult = {
|
||||
readonly url: string;
|
||||
readonly ok: boolean;
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly body: any;
|
||||
};
|
||||
131
frontend/src/api/core/CancelablePromise.ts
Normal file
131
frontend/src/api/core/CancelablePromise.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export class CancelError extends Error {
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CancelError';
|
||||
}
|
||||
|
||||
public get isCancelled(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface OnCancel {
|
||||
readonly isResolved: boolean;
|
||||
readonly isRejected: boolean;
|
||||
readonly isCancelled: boolean;
|
||||
|
||||
(cancelHandler: () => void): void;
|
||||
}
|
||||
|
||||
export class CancelablePromise<T> implements Promise<T> {
|
||||
#isResolved: boolean;
|
||||
#isRejected: boolean;
|
||||
#isCancelled: boolean;
|
||||
readonly #cancelHandlers: (() => void)[];
|
||||
readonly #promise: Promise<T>;
|
||||
#resolve?: (value: T | PromiseLike<T>) => void;
|
||||
#reject?: (reason?: any) => void;
|
||||
|
||||
constructor(
|
||||
executor: (
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (reason?: any) => void,
|
||||
onCancel: OnCancel
|
||||
) => void
|
||||
) {
|
||||
this.#isResolved = false;
|
||||
this.#isRejected = false;
|
||||
this.#isCancelled = false;
|
||||
this.#cancelHandlers = [];
|
||||
this.#promise = new Promise<T>((resolve, reject) => {
|
||||
this.#resolve = resolve;
|
||||
this.#reject = reject;
|
||||
|
||||
const onResolve = (value: T | PromiseLike<T>): void => {
|
||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
||||
return;
|
||||
}
|
||||
this.#isResolved = true;
|
||||
if (this.#resolve) this.#resolve(value);
|
||||
};
|
||||
|
||||
const onReject = (reason?: any): void => {
|
||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
||||
return;
|
||||
}
|
||||
this.#isRejected = true;
|
||||
if (this.#reject) this.#reject(reason);
|
||||
};
|
||||
|
||||
const onCancel = (cancelHandler: () => void): void => {
|
||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
||||
return;
|
||||
}
|
||||
this.#cancelHandlers.push(cancelHandler);
|
||||
};
|
||||
|
||||
Object.defineProperty(onCancel, 'isResolved', {
|
||||
get: (): boolean => this.#isResolved,
|
||||
});
|
||||
|
||||
Object.defineProperty(onCancel, 'isRejected', {
|
||||
get: (): boolean => this.#isRejected,
|
||||
});
|
||||
|
||||
Object.defineProperty(onCancel, 'isCancelled', {
|
||||
get: (): boolean => this.#isCancelled,
|
||||
});
|
||||
|
||||
return executor(onResolve, onReject, onCancel as OnCancel);
|
||||
});
|
||||
}
|
||||
|
||||
get [Symbol.toStringTag]() {
|
||||
return "Cancellable Promise";
|
||||
}
|
||||
|
||||
public then<TResult1 = T, TResult2 = never>(
|
||||
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return this.#promise.then(onFulfilled, onRejected);
|
||||
}
|
||||
|
||||
public catch<TResult = never>(
|
||||
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
|
||||
): Promise<T | TResult> {
|
||||
return this.#promise.catch(onRejected);
|
||||
}
|
||||
|
||||
public finally(onFinally?: (() => void) | null): Promise<T> {
|
||||
return this.#promise.finally(onFinally);
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
||||
return;
|
||||
}
|
||||
this.#isCancelled = true;
|
||||
if (this.#cancelHandlers.length) {
|
||||
try {
|
||||
for (const cancelHandler of this.#cancelHandlers) {
|
||||
cancelHandler();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Cancellation threw an error', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.#cancelHandlers.length = 0;
|
||||
if (this.#reject) this.#reject(new CancelError('Request aborted'));
|
||||
}
|
||||
|
||||
public get isCancelled(): boolean {
|
||||
return this.#isCancelled;
|
||||
}
|
||||
}
|
||||
32
frontend/src/api/core/OpenAPI.ts
Normal file
32
frontend/src/api/core/OpenAPI.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { ApiRequestOptions } from './ApiRequestOptions';
|
||||
|
||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
|
||||
type Headers = Record<string, string>;
|
||||
|
||||
export type OpenAPIConfig = {
|
||||
BASE: string;
|
||||
VERSION: string;
|
||||
WITH_CREDENTIALS: boolean;
|
||||
CREDENTIALS: 'include' | 'omit' | 'same-origin';
|
||||
TOKEN?: string | Resolver<string> | undefined;
|
||||
USERNAME?: string | Resolver<string> | undefined;
|
||||
PASSWORD?: string | Resolver<string> | undefined;
|
||||
HEADERS?: Headers | Resolver<Headers> | undefined;
|
||||
ENCODE_PATH?: ((path: string) => string) | undefined;
|
||||
};
|
||||
|
||||
export const OpenAPI: OpenAPIConfig = {
|
||||
BASE: '',
|
||||
VERSION: '1.0.0',
|
||||
WITH_CREDENTIALS: false,
|
||||
CREDENTIALS: 'include',
|
||||
TOKEN: undefined,
|
||||
USERNAME: undefined,
|
||||
PASSWORD: undefined,
|
||||
HEADERS: undefined,
|
||||
ENCODE_PATH: undefined,
|
||||
};
|
||||
323
frontend/src/api/core/request.ts
Normal file
323
frontend/src/api/core/request.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import axios from 'axios';
|
||||
import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
|
||||
import FormData from 'form-data';
|
||||
|
||||
import { ApiError } from './ApiError';
|
||||
import type { ApiRequestOptions } from './ApiRequestOptions';
|
||||
import type { ApiResult } from './ApiResult';
|
||||
import { CancelablePromise } from './CancelablePromise';
|
||||
import type { OnCancel } from './CancelablePromise';
|
||||
import type { OpenAPIConfig } from './OpenAPI';
|
||||
|
||||
export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => {
|
||||
return value !== undefined && value !== null;
|
||||
};
|
||||
|
||||
export const isString = (value: any): value is string => {
|
||||
return typeof value === 'string';
|
||||
};
|
||||
|
||||
export const isStringWithValue = (value: any): value is string => {
|
||||
return isString(value) && value !== '';
|
||||
};
|
||||
|
||||
export const isBlob = (value: any): value is Blob => {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
typeof value.type === 'string' &&
|
||||
typeof value.stream === 'function' &&
|
||||
typeof value.arrayBuffer === 'function' &&
|
||||
typeof value.constructor === 'function' &&
|
||||
typeof value.constructor.name === 'string' &&
|
||||
/^(Blob|File)$/.test(value.constructor.name) &&
|
||||
/^(Blob|File)$/.test(value[Symbol.toStringTag])
|
||||
);
|
||||
};
|
||||
|
||||
export const isFormData = (value: any): value is FormData => {
|
||||
return value instanceof FormData;
|
||||
};
|
||||
|
||||
export const isSuccess = (status: number): boolean => {
|
||||
return status >= 200 && status < 300;
|
||||
};
|
||||
|
||||
export const base64 = (str: string): string => {
|
||||
try {
|
||||
return btoa(str);
|
||||
} catch (err) {
|
||||
// @ts-ignore
|
||||
return Buffer.from(str).toString('base64');
|
||||
}
|
||||
};
|
||||
|
||||
export const getQueryString = (params: Record<string, any>): string => {
|
||||
const qs: string[] = [];
|
||||
|
||||
const append = (key: string, value: any) => {
|
||||
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||
};
|
||||
|
||||
const process = (key: string, value: any) => {
|
||||
if (isDefined(value)) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => {
|
||||
process(key, v);
|
||||
});
|
||||
} else if (typeof value === 'object') {
|
||||
Object.entries(value).forEach(([k, v]) => {
|
||||
process(`${key}[${k}]`, v);
|
||||
});
|
||||
} else {
|
||||
append(key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
process(key, value);
|
||||
});
|
||||
|
||||
if (qs.length > 0) {
|
||||
return `?${qs.join('&')}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
|
||||
const encoder = config.ENCODE_PATH || encodeURI;
|
||||
|
||||
const path = options.url
|
||||
.replace('{api-version}', config.VERSION)
|
||||
.replace(/{(.*?)}/g, (substring: string, group: string) => {
|
||||
if (options.path?.hasOwnProperty(group)) {
|
||||
return encoder(String(options.path[group]));
|
||||
}
|
||||
return substring;
|
||||
});
|
||||
|
||||
const url = `${config.BASE}${path}`;
|
||||
if (options.query) {
|
||||
return `${url}${getQueryString(options.query)}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
|
||||
if (options.formData) {
|
||||
const formData = new FormData();
|
||||
|
||||
const process = (key: string, value: any) => {
|
||||
if (isString(value) || isBlob(value)) {
|
||||
formData.append(key, value);
|
||||
} else {
|
||||
formData.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
Object.entries(options.formData)
|
||||
.filter(([_, value]) => isDefined(value))
|
||||
.forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => process(key, v));
|
||||
} else {
|
||||
process(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return formData;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
|
||||
|
||||
export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => {
|
||||
if (typeof resolver === 'function') {
|
||||
return (resolver as Resolver<T>)(options);
|
||||
}
|
||||
return resolver;
|
||||
};
|
||||
|
||||
export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => {
|
||||
const [token, username, password, additionalHeaders] = await Promise.all([
|
||||
resolve(options, config.TOKEN),
|
||||
resolve(options, config.USERNAME),
|
||||
resolve(options, config.PASSWORD),
|
||||
resolve(options, config.HEADERS),
|
||||
]);
|
||||
|
||||
const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {}
|
||||
|
||||
const headers = Object.entries({
|
||||
Accept: 'application/json',
|
||||
...additionalHeaders,
|
||||
...options.headers,
|
||||
...formHeaders,
|
||||
})
|
||||
.filter(([_, value]) => isDefined(value))
|
||||
.reduce((headers, [key, value]) => ({
|
||||
...headers,
|
||||
[key]: String(value),
|
||||
}), {} as Record<string, string>);
|
||||
|
||||
if (isStringWithValue(token)) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (isStringWithValue(username) && isStringWithValue(password)) {
|
||||
const credentials = base64(`${username}:${password}`);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
if (options.body !== undefined) {
|
||||
if (options.mediaType) {
|
||||
headers['Content-Type'] = options.mediaType;
|
||||
} else if (isBlob(options.body)) {
|
||||
headers['Content-Type'] = options.body.type || 'application/octet-stream';
|
||||
} else if (isString(options.body)) {
|
||||
headers['Content-Type'] = 'text/plain';
|
||||
} else if (!isFormData(options.body)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const getRequestBody = (options: ApiRequestOptions): any => {
|
||||
if (options.body) {
|
||||
return options.body;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const sendRequest = async <T>(
|
||||
config: OpenAPIConfig,
|
||||
options: ApiRequestOptions,
|
||||
url: string,
|
||||
body: any,
|
||||
formData: FormData | undefined,
|
||||
headers: Record<string, string>,
|
||||
onCancel: OnCancel,
|
||||
axiosClient: AxiosInstance
|
||||
): Promise<AxiosResponse<T>> => {
|
||||
const source = axios.CancelToken.source();
|
||||
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
url,
|
||||
headers,
|
||||
data: body ?? formData,
|
||||
method: options.method,
|
||||
withCredentials: config.WITH_CREDENTIALS,
|
||||
withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false,
|
||||
cancelToken: source.token,
|
||||
};
|
||||
|
||||
onCancel(() => source.cancel('The user aborted a request.'));
|
||||
|
||||
try {
|
||||
return await axiosClient.request(requestConfig);
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<T>;
|
||||
if (axiosError.response) {
|
||||
return axiosError.response;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => {
|
||||
if (responseHeader) {
|
||||
const content = response.headers[responseHeader];
|
||||
if (isString(content)) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getResponseBody = (response: AxiosResponse<any>): any => {
|
||||
if (response.status !== 204) {
|
||||
return response.data;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
|
||||
const errors: Record<number, string> = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
500: 'Internal Server Error',
|
||||
502: 'Bad Gateway',
|
||||
503: 'Service Unavailable',
|
||||
...options.errors,
|
||||
}
|
||||
|
||||
const error = errors[result.status];
|
||||
if (error) {
|
||||
throw new ApiError(options, result, error);
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
const errorStatus = result.status ?? 'unknown';
|
||||
const errorStatusText = result.statusText ?? 'unknown';
|
||||
const errorBody = (() => {
|
||||
try {
|
||||
return JSON.stringify(result.body, null, 2);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
throw new ApiError(options, result,
|
||||
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Request method
|
||||
* @param config The OpenAPI configuration object
|
||||
* @param options The request options from the service
|
||||
* @param axiosClient The axios client instance to use
|
||||
* @returns CancelablePromise<T>
|
||||
* @throws ApiError
|
||||
*/
|
||||
export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => {
|
||||
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
||||
try {
|
||||
const url = getUrl(config, options);
|
||||
const formData = getFormData(options);
|
||||
const body = getRequestBody(options);
|
||||
const headers = await getHeaders(config, options, formData);
|
||||
|
||||
if (!onCancel.isCancelled) {
|
||||
const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient);
|
||||
const responseBody = getResponseBody(response);
|
||||
const responseHeader = getResponseHeader(response, options.responseHeader);
|
||||
|
||||
const result: ApiResult = {
|
||||
url,
|
||||
ok: isSuccess(response.status),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: responseHeader ?? responseBody,
|
||||
};
|
||||
|
||||
catchErrorCodes(options, result);
|
||||
|
||||
resolve(result.body);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
120
frontend/src/api/index.ts
Normal file
120
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export { ApiError } from './core/ApiError';
|
||||
export { CancelablePromise, CancelError } from './core/CancelablePromise';
|
||||
export { OpenAPI } from './core/OpenAPI';
|
||||
export type { OpenAPIConfig } from './core/OpenAPI';
|
||||
|
||||
export type { ComboBoxItem } from './models/ComboBoxItem';
|
||||
export type { ComboBoxOut } from './models/ComboBoxOut';
|
||||
export type { CustomWebhook } from './models/CustomWebhook';
|
||||
export type { DispatchIn } from './models/DispatchIn';
|
||||
export type { GeneralConfig } from './models/GeneralConfig';
|
||||
export type { GeneralConfig_Game } from './models/GeneralConfig_Game';
|
||||
export type { GeneralConfig_Info } from './models/GeneralConfig_Info';
|
||||
export type { GeneralConfig_Run } from './models/GeneralConfig_Run';
|
||||
export type { GeneralConfig_Script } from './models/GeneralConfig_Script';
|
||||
export type { GeneralUserConfig_Data } from './models/GeneralUserConfig_Data';
|
||||
export type { GeneralUserConfig_Info } from './models/GeneralUserConfig_Info';
|
||||
export type { GeneralUserConfig_Input } from './models/GeneralUserConfig_Input';
|
||||
export type { GeneralUserConfig_Output } from './models/GeneralUserConfig_Output';
|
||||
export { GetStageIn } from './models/GetStageIn';
|
||||
export type { GlobalConfig_Function } from './models/GlobalConfig_Function';
|
||||
export type { GlobalConfig_Input } from './models/GlobalConfig_Input';
|
||||
export type { GlobalConfig_Notify } from './models/GlobalConfig_Notify';
|
||||
export type { GlobalConfig_Output } from './models/GlobalConfig_Output';
|
||||
export type { GlobalConfig_Start } from './models/GlobalConfig_Start';
|
||||
export type { GlobalConfig_UI } from './models/GlobalConfig_UI';
|
||||
export type { GlobalConfig_Update } from './models/GlobalConfig_Update';
|
||||
export type { GlobalConfig_Voice } from './models/GlobalConfig_Voice';
|
||||
export type { HistoryData } from './models/HistoryData';
|
||||
export type { HistoryDataGetIn } from './models/HistoryDataGetIn';
|
||||
export type { HistoryDataGetOut } from './models/HistoryDataGetOut';
|
||||
export { HistoryIndexItem } from './models/HistoryIndexItem';
|
||||
export { HistorySearchIn } from './models/HistorySearchIn';
|
||||
export type { HistorySearchOut } from './models/HistorySearchOut';
|
||||
export type { HTTPValidationError } from './models/HTTPValidationError';
|
||||
export type { InfoOut } from './models/InfoOut';
|
||||
export type { MaaConfig } from './models/MaaConfig';
|
||||
export type { MaaConfig_Info } from './models/MaaConfig_Info';
|
||||
export type { MaaConfig_Run } from './models/MaaConfig_Run';
|
||||
export type { MaaPlanConfig } from './models/MaaPlanConfig';
|
||||
export type { MaaPlanConfig_Info } from './models/MaaPlanConfig_Info';
|
||||
export type { MaaPlanConfig_Item } from './models/MaaPlanConfig_Item';
|
||||
export type { MaaUserConfig_Data } from './models/MaaUserConfig_Data';
|
||||
export type { MaaUserConfig_Info } from './models/MaaUserConfig_Info';
|
||||
export type { MaaUserConfig_Input } from './models/MaaUserConfig_Input';
|
||||
export type { MaaUserConfig_Output } from './models/MaaUserConfig_Output';
|
||||
export type { MaaUserConfig_Task } from './models/MaaUserConfig_Task';
|
||||
export type { NoticeOut } from './models/NoticeOut';
|
||||
export type { OutBase } from './models/OutBase';
|
||||
export type { PlanCreateIn } from './models/PlanCreateIn';
|
||||
export type { PlanCreateOut } from './models/PlanCreateOut';
|
||||
export type { PlanDeleteIn } from './models/PlanDeleteIn';
|
||||
export type { PlanGetIn } from './models/PlanGetIn';
|
||||
export type { PlanGetOut } from './models/PlanGetOut';
|
||||
export type { PlanIndexItem } from './models/PlanIndexItem';
|
||||
export type { PlanReorderIn } from './models/PlanReorderIn';
|
||||
export type { PlanUpdateIn } from './models/PlanUpdateIn';
|
||||
export { PowerIn } from './models/PowerIn';
|
||||
export type { QueueConfig } from './models/QueueConfig';
|
||||
export type { QueueConfig_Info } from './models/QueueConfig_Info';
|
||||
export type { QueueCreateOut } from './models/QueueCreateOut';
|
||||
export type { QueueDeleteIn } from './models/QueueDeleteIn';
|
||||
export type { QueueGetIn } from './models/QueueGetIn';
|
||||
export type { QueueGetOut } from './models/QueueGetOut';
|
||||
export type { QueueIndexItem } from './models/QueueIndexItem';
|
||||
export type { QueueItem } from './models/QueueItem';
|
||||
export type { QueueItem_Info } from './models/QueueItem_Info';
|
||||
export type { QueueItemCreateOut } from './models/QueueItemCreateOut';
|
||||
export type { QueueItemDeleteIn } from './models/QueueItemDeleteIn';
|
||||
export type { QueueItemGetIn } from './models/QueueItemGetIn';
|
||||
export type { QueueItemGetOut } from './models/QueueItemGetOut';
|
||||
export type { QueueItemIndexItem } from './models/QueueItemIndexItem';
|
||||
export type { QueueItemReorderIn } from './models/QueueItemReorderIn';
|
||||
export type { QueueItemUpdateIn } from './models/QueueItemUpdateIn';
|
||||
export type { QueueReorderIn } from './models/QueueReorderIn';
|
||||
export type { QueueSetInBase } from './models/QueueSetInBase';
|
||||
export type { QueueUpdateIn } from './models/QueueUpdateIn';
|
||||
export { ScriptCreateIn } from './models/ScriptCreateIn';
|
||||
export type { ScriptCreateOut } from './models/ScriptCreateOut';
|
||||
export type { ScriptDeleteIn } from './models/ScriptDeleteIn';
|
||||
export type { ScriptFileIn } from './models/ScriptFileIn';
|
||||
export type { ScriptGetIn } from './models/ScriptGetIn';
|
||||
export type { ScriptGetOut } from './models/ScriptGetOut';
|
||||
export { ScriptIndexItem } from './models/ScriptIndexItem';
|
||||
export type { ScriptReorderIn } from './models/ScriptReorderIn';
|
||||
export type { ScriptUpdateIn } from './models/ScriptUpdateIn';
|
||||
export type { ScriptUploadIn } from './models/ScriptUploadIn';
|
||||
export type { ScriptUrlIn } from './models/ScriptUrlIn';
|
||||
export type { SettingGetOut } from './models/SettingGetOut';
|
||||
export type { SettingUpdateIn } from './models/SettingUpdateIn';
|
||||
export { TaskCreateIn } from './models/TaskCreateIn';
|
||||
export type { TaskCreateOut } from './models/TaskCreateOut';
|
||||
export type { TimeSet } from './models/TimeSet';
|
||||
export type { TimeSet_Info } from './models/TimeSet_Info';
|
||||
export type { TimeSetCreateOut } from './models/TimeSetCreateOut';
|
||||
export type { TimeSetDeleteIn } from './models/TimeSetDeleteIn';
|
||||
export type { TimeSetGetIn } from './models/TimeSetGetIn';
|
||||
export type { TimeSetGetOut } from './models/TimeSetGetOut';
|
||||
export type { TimeSetIndexItem } from './models/TimeSetIndexItem';
|
||||
export type { TimeSetReorderIn } from './models/TimeSetReorderIn';
|
||||
export type { TimeSetUpdateIn } from './models/TimeSetUpdateIn';
|
||||
export type { UpdateCheckIn } from './models/UpdateCheckIn';
|
||||
export type { UpdateCheckOut } from './models/UpdateCheckOut';
|
||||
export type { UserConfig_Notify } from './models/UserConfig_Notify';
|
||||
export type { UserCreateOut } from './models/UserCreateOut';
|
||||
export type { UserDeleteIn } from './models/UserDeleteIn';
|
||||
export type { UserGetIn } from './models/UserGetIn';
|
||||
export type { UserGetOut } from './models/UserGetOut';
|
||||
export type { UserInBase } from './models/UserInBase';
|
||||
export { UserIndexItem } from './models/UserIndexItem';
|
||||
export type { UserReorderIn } from './models/UserReorderIn';
|
||||
export type { UserSetIn } from './models/UserSetIn';
|
||||
export type { UserUpdateIn } from './models/UserUpdateIn';
|
||||
export type { ValidationError } from './models/ValidationError';
|
||||
export type { VersionOut } from './models/VersionOut';
|
||||
|
||||
export { Service } from './services/Service';
|
||||
100
frontend/src/api/mirrors.ts
Normal file
100
frontend/src/api/mirrors.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 镜像源 API 接口
|
||||
* 用于从后端获取最新的镜像源配置
|
||||
*/
|
||||
|
||||
import { OpenAPI } from '@/api'
|
||||
import type { MirrorConfig } from '@/config/mirrors'
|
||||
|
||||
export interface MirrorApiResponse {
|
||||
git?: MirrorConfig[]
|
||||
python?: MirrorConfig[]
|
||||
pip?: MirrorConfig[]
|
||||
apiEndpoints?: {
|
||||
local?: string
|
||||
production?: string
|
||||
proxy?: string
|
||||
}
|
||||
downloadLinks?: {
|
||||
[category: string]: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取镜像源配置
|
||||
*/
|
||||
export async function fetchMirrorConfig(): Promise<MirrorApiResponse> {
|
||||
try {
|
||||
const response = await fetch(`${OpenAPI.BASE}/api/mirrors`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.warn('获取镜像源配置失败,使用默认配置:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试镜像源连通性
|
||||
*/
|
||||
export async function testMirrorConnectivity(url: string, timeout: number = 5000): Promise<{
|
||||
success: boolean
|
||||
speed: number
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
cache: 'no-cache',
|
||||
mode: 'no-cors' // 避免 CORS 问题
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
const speed = Date.now() - startTime
|
||||
|
||||
return {
|
||||
success: true,
|
||||
speed
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
speed: 9999,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量测试镜像源
|
||||
*/
|
||||
export async function batchTestMirrors(mirrors: MirrorConfig[]): Promise<MirrorConfig[]> {
|
||||
const promises = mirrors.map(async (mirror) => {
|
||||
const result = await testMirrorConnectivity(mirror.url)
|
||||
return {
|
||||
...mirror,
|
||||
speed: result.speed
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
|
||||
// 按速度排序
|
||||
return results.sort((a, b) => (a.speed || 9999) - (b.speed || 9999))
|
||||
}
|
||||
15
frontend/src/api/models/ComboBoxItem.ts
Normal file
15
frontend/src/api/models/ComboBoxItem.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type ComboBoxItem = {
|
||||
/**
|
||||
* 展示值
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* 实际值
|
||||
*/
|
||||
value: (string | null);
|
||||
};
|
||||
|
||||
24
frontend/src/api/models/ComboBoxOut.ts
Normal file
24
frontend/src/api/models/ComboBoxOut.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { ComboBoxItem } from './ComboBoxItem';
|
||||
export type ComboBoxOut = {
|
||||
/**
|
||||
* 状态码
|
||||
*/
|
||||
code?: number;
|
||||
/**
|
||||
* 操作状态
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* 操作消息
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* 下拉框选项
|
||||
*/
|
||||
data: Array<ComboBoxItem>;
|
||||
};
|
||||
|
||||
35
frontend/src/api/models/CustomWebhook.ts
Normal file
35
frontend/src/api/models/CustomWebhook.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type CustomWebhook = {
|
||||
/**
|
||||
* Webhook唯一标识
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Webhook名称
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Webhook URL
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* 消息模板
|
||||
*/
|
||||
template: string;
|
||||
/**
|
||||
* 是否启用
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* 自定义请求头
|
||||
*/
|
||||
headers?: (Record<string, string> | null);
|
||||
/**
|
||||
* 请求方法
|
||||
*/
|
||||
method?: ('POST' | 'GET' | null);
|
||||
};
|
||||
|
||||
11
frontend/src/api/models/DispatchIn.ts
Normal file
11
frontend/src/api/models/DispatchIn.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DispatchIn = {
|
||||
/**
|
||||
* 目标任务ID, 设置类任务可选对应脚本ID或用户ID, 代理类任务可选对应队列ID或脚本ID
|
||||
*/
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
27
frontend/src/api/models/GeneralConfig.ts
Normal file
27
frontend/src/api/models/GeneralConfig.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { GeneralConfig_Game } from './GeneralConfig_Game';
|
||||
import type { GeneralConfig_Info } from './GeneralConfig_Info';
|
||||
import type { GeneralConfig_Run } from './GeneralConfig_Run';
|
||||
import type { GeneralConfig_Script } from './GeneralConfig_Script';
|
||||
export type GeneralConfig = {
|
||||
/**
|
||||
* 脚本基础信息
|
||||
*/
|
||||
Info?: (GeneralConfig_Info | null);
|
||||
/**
|
||||
* 脚本配置
|
||||
*/
|
||||
Script?: (GeneralConfig_Script | null);
|
||||
/**
|
||||
* 游戏配置
|
||||
*/
|
||||
Game?: (GeneralConfig_Game | null);
|
||||
/**
|
||||
* 运行配置
|
||||
*/
|
||||
Run?: (GeneralConfig_Run | null);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user