mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-07 13:30:23 +00:00
Compare commits
900 Commits
v4.5.4
...
test-pr-is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f6101f95 | ||
|
|
018e8aa4f0 | ||
|
|
1ad700b935 | ||
|
|
68c8b984ad | ||
|
|
8eb1aa2fb4 | ||
|
|
2d3f4e696b | ||
|
|
b241881c74 | ||
|
|
aecf33f4dc | ||
|
|
dd4374389b | ||
|
|
100efb03ab | ||
|
|
ce9482f19d | ||
|
|
4e37b002f9 | ||
|
|
7e7262415b | ||
|
|
3365211507 | ||
|
|
05b38825c0 | ||
|
|
95f4a4d37e | ||
|
|
cd495fc7a0 | ||
|
|
656279d74b | ||
|
|
377c780d1a | ||
|
|
aefa8985b1 | ||
|
|
b034940dfd | ||
|
|
cb8e10cc7e | ||
|
|
afed164ba1 | ||
|
|
a34a86288b | ||
|
|
50bcd71144 | ||
|
|
fa3a229827 | ||
|
|
e56b912bbd | ||
|
|
da0dd01460 | ||
|
|
578dda2f17 | ||
|
|
649165bf00 | ||
|
|
c4f7107038 | ||
|
|
7f81bf45ee | ||
|
|
7e6035d98b | ||
|
|
2405cb03d8 | ||
|
|
32d3ff6998 | ||
|
|
84f0e0f9a0 | ||
|
|
8697061a90 | ||
|
|
872a3e0100 | ||
|
|
4fcbdc4d89 | ||
|
|
176af14915 | ||
|
|
81cf1fd98e | ||
|
|
5189099146 | ||
|
|
7fc17d45ba | ||
|
|
c54f74609e | ||
|
|
a2d7ac4878 | ||
|
|
fd0afa3b25 | ||
|
|
7685cc3dfc | ||
|
|
f9c0b9d106 | ||
|
|
d31f0a45b4 | ||
|
|
7c701781a1 | ||
|
|
3c612e03ff | ||
|
|
f27db01145 | ||
|
|
ae97cfba03 | ||
|
|
162ddc1bf5 | ||
|
|
afb6ef421a | ||
|
|
173a165c4b | ||
|
|
d525f9b03d | ||
|
|
f2ba789cc0 | ||
|
|
2cdc9bdc09 | ||
|
|
c123b34d5f | ||
|
|
d25b43ebf2 | ||
|
|
8fe4a9e6ac | ||
|
|
09da80aad5 | ||
|
|
3d3f718fd5 | ||
|
|
6068abdec0 | ||
|
|
3957d7af5a | ||
|
|
a2837974fe | ||
|
|
6f8edfe570 | ||
|
|
0b655db4dd | ||
|
|
d800466a30 | ||
|
|
fa80441e36 | ||
|
|
1990761ad6 | ||
|
|
ef63812391 | ||
|
|
0f033b0ac8 | ||
|
|
9fdef3cde9 | ||
|
|
20e8643193 | ||
|
|
8645ed4d9d | ||
|
|
c0b9817ff5 | ||
|
|
b147e57c1c | ||
|
|
ad4a108781 | ||
|
|
df824d77ae | ||
|
|
19888d52dc | ||
|
|
4dc8b3ed3b | ||
|
|
8df54d5cd3 | ||
|
|
aa982b3071 | ||
|
|
8e71dec63a | ||
|
|
31bb1e5dee | ||
|
|
75e1e8dd79 | ||
|
|
d32ccc6eb5 | ||
|
|
7b3e94d568 | ||
|
|
5cfe479044 | ||
|
|
f04ffa5dc6 | ||
|
|
a2a73ce2dd | ||
|
|
66d02eeb6a | ||
|
|
b99c0ca437 | ||
|
|
019b90984d | ||
|
|
5043a49779 | ||
|
|
36aa08a8f5 | ||
|
|
8bc8df32f9 | ||
|
|
bc183ae002 | ||
|
|
b85f9197e3 | ||
|
|
c8fd66fa9b | ||
|
|
6e9f448a0c | ||
|
|
142016778f | ||
|
|
159fb8cd3a | ||
|
|
01c911e178 | ||
|
|
8b3ea8dcef | ||
|
|
fe8b270ab3 | ||
|
|
f02ae5894f | ||
|
|
a6a0b408af | ||
|
|
e9856ac80f | ||
|
|
f553f9dc8d | ||
|
|
5608638e9a | ||
|
|
a53c20767a | ||
|
|
a92bef5b33 | ||
|
|
a9a3b6ec6e | ||
|
|
20d41fff9e | ||
|
|
0b4d7e1346 | ||
|
|
46b9049a24 | ||
|
|
521f4dc365 | ||
|
|
04b507d749 | ||
|
|
5638127813 | ||
|
|
30a7797ba9 | ||
|
|
d09a82b1b8 | ||
|
|
85b5c881ba | ||
|
|
eebce222cf | ||
|
|
ec5ca5d89a | ||
|
|
31a7767ae4 | ||
|
|
fec024334a | ||
|
|
2ad2af4d7c | ||
|
|
2a160d296f | ||
|
|
e43f229e04 | ||
|
|
9158ebc136 | ||
|
|
d758fe3a2b | ||
|
|
4abd0668a3 | ||
|
|
0181700c3b | ||
|
|
6083e9cfcc | ||
|
|
5fec190c70 | ||
|
|
e1743ae5e4 | ||
|
|
ded921c55e | ||
|
|
57e717e898 | ||
|
|
55f21c6caa | ||
|
|
4360775eff | ||
|
|
e2486606f9 | ||
|
|
f8eb368cdb | ||
|
|
9ce51fb082 | ||
|
|
89e50be1e9 | ||
|
|
1259dcea5b | ||
|
|
b4900066b3 | ||
|
|
28acd94cbd | ||
|
|
7aedacb27f | ||
|
|
c9b45ec1a2 | ||
|
|
54cacc30e4 | ||
|
|
9b26fc99d3 | ||
|
|
204846b404 | ||
|
|
3d654791b9 | ||
|
|
63f42f1592 | ||
|
|
0ab0b939da | ||
|
|
522a123f9a | ||
|
|
ec9f8d6e12 | ||
|
|
3c750c75a9 | ||
|
|
5b2b1f499b | ||
|
|
531ffcd55d | ||
|
|
068e4d8bb5 | ||
|
|
5dc33e78ad | ||
|
|
d76a2170a0 | ||
|
|
ec2af3120c | ||
|
|
8de49a3109 | ||
|
|
dbb5a0022e | ||
|
|
cb8c8d6b57 | ||
|
|
93c140ed4e | ||
|
|
457b072f0e | ||
|
|
1869493473 | ||
|
|
f33c66ce15 | ||
|
|
e8d6f86458 | ||
|
|
a000ffdf0d | ||
|
|
202338a160 | ||
|
|
e3eb129a52 | ||
|
|
7654e9f2bb | ||
|
|
a60c03f42f | ||
|
|
60aae228a1 | ||
|
|
b1417f9b56 | ||
|
|
eeeaddbb60 | ||
|
|
b1109022bb | ||
|
|
1807789511 | ||
|
|
49a5b631c2 | ||
|
|
e7aaec81e2 | ||
|
|
05f8e8f3c3 | ||
|
|
e760876470 | ||
|
|
e0ec4d4ebb | ||
|
|
79fa0ade0d | ||
|
|
652b5d6118 | ||
|
|
f4dedf4803 | ||
|
|
06f6a542f5 | ||
|
|
d5b8f886d6 | ||
|
|
97b6dccc30 | ||
|
|
a755487e22 | ||
|
|
5407392f08 | ||
|
|
3359c5ded9 | ||
|
|
caaf8be3b2 | ||
|
|
28ce5d3cb4 | ||
|
|
3dd56c711e | ||
|
|
511fb82ce0 | ||
|
|
1e5524a009 | ||
|
|
d5e6afc7b9 | ||
|
|
91e633b0fb | ||
|
|
397b9880b9 | ||
|
|
54eb26ba67 | ||
|
|
355f7fb4a0 | ||
|
|
325c455e38 | ||
|
|
a7a9792efe | ||
|
|
4bab93e545 | ||
|
|
3c60997b1c | ||
|
|
bf684d9166 | ||
|
|
5c4ee30f37 | ||
|
|
346374b442 | ||
|
|
cc8a387bde | ||
|
|
962685ade6 | ||
|
|
36fdaac406 | ||
|
|
db16db911b | ||
|
|
bb84dfcc27 | ||
|
|
3e71e541e6 | ||
|
|
bcc856d583 | ||
|
|
1eaf480a7d | ||
|
|
86123af7fc | ||
|
|
c3cd2aaf89 | ||
|
|
599afdc7ba | ||
|
|
ffe54af8d9 | ||
|
|
a7b30ef844 | ||
|
|
50b8cb14dc | ||
|
|
0b18f868bc | ||
|
|
c9acb9ae28 | ||
|
|
2e1506a05d | ||
|
|
b35283f970 | ||
|
|
54ac072bfb | ||
|
|
58cefb9cdc | ||
|
|
be4344634d | ||
|
|
2da5d242f7 | ||
|
|
fbd00b2576 | ||
|
|
72548c9575 | ||
|
|
003f3e946d | ||
|
|
dc51d01351 | ||
|
|
c5db525f4a | ||
|
|
c1377e6de7 | ||
|
|
d2c4f425c7 | ||
|
|
803b1a6c77 | ||
|
|
9a35ee9cd1 | ||
|
|
458d22223c | ||
|
|
4ed61136b2 | ||
|
|
1445a29e15 | ||
|
|
55ef040852 | ||
|
|
0c88319248 | ||
|
|
6778bd69de | ||
|
|
f9c9b3a852 | ||
|
|
9ce9e46c57 | ||
|
|
656bde25c8 | ||
|
|
791a360f28 | ||
|
|
376245b749 | ||
|
|
d3e3527c2b | ||
|
|
5b78dfbd5a | ||
|
|
1e461aae3c | ||
|
|
42abc2b9cb | ||
|
|
4f8c320658 | ||
|
|
cbacc89907 | ||
|
|
8e6da5e2d0 | ||
|
|
02980c4d1a | ||
|
|
0129188739 | ||
|
|
98ef642cd1 | ||
|
|
32e886e53b | ||
|
|
315d847f06 | ||
|
|
381d320967 | ||
|
|
2f5b62decb | ||
|
|
2afdb2a0da | ||
|
|
5bfbf92c21 | ||
|
|
a775a0dde9 | ||
|
|
d7f00c0594 | ||
|
|
77c8f874b6 | ||
|
|
fb0a20919b | ||
|
|
0300ba4648 | ||
|
|
d472eee777 | ||
|
|
41bd06e50a | ||
|
|
97334dfbf5 | ||
|
|
e3d8c8e940 | ||
|
|
f2c62db76e | ||
|
|
b1b051c4ce | ||
|
|
a754b2ecc7 | ||
|
|
e0eb625b75 | ||
|
|
937be7678e | ||
|
|
9b88946209 | ||
|
|
74de3d9100 | ||
|
|
42d50014a1 | ||
|
|
a36ae315b0 | ||
|
|
2161ec5fa7 | ||
|
|
32bba007cd | ||
|
|
84d3dc9f40 | ||
|
|
890d032794 | ||
|
|
66f30e1ebf | ||
|
|
ada614d007 | ||
|
|
ea3ab7f13f | ||
|
|
a5e4c24de3 | ||
|
|
bcc7d25b64 | ||
|
|
aae676fdc7 | ||
|
|
0e9aa43476 | ||
|
|
b2ff556aa6 | ||
|
|
69c5b78678 | ||
|
|
8be7f74e9f | ||
|
|
a05150ebe1 | ||
|
|
5e6b607ded | ||
|
|
df2dabfe76 | ||
|
|
5e032fcc6a | ||
|
|
44200a2208 | ||
|
|
e39bb05f01 | ||
|
|
677731dd70 | ||
|
|
fa8e6f2c59 | ||
|
|
509b23ff04 | ||
|
|
cf1765f5a4 | ||
|
|
c541c7e257 | ||
|
|
298b8b71c8 | ||
|
|
5c120a8231 | ||
|
|
88ee8f89fe | ||
|
|
12b8130372 | ||
|
|
58332dad24 | ||
|
|
e97f3e1283 | ||
|
|
e406dca7ae | ||
|
|
e4c1807f76 | ||
|
|
f4412bb086 | ||
|
|
27af8e52ac | ||
|
|
4c9a220300 | ||
|
|
1fe822cd20 | ||
|
|
0ab8d025bf | ||
|
|
a0f3d66607 | ||
|
|
06e7c3363a | ||
|
|
4d200de6b7 | ||
|
|
6200097f7c | ||
|
|
c7af0384fb | ||
|
|
dc87615bd6 | ||
|
|
ff2cfcee97 | ||
|
|
f3c07ed8fc | ||
|
|
7ab44dcb34 | ||
|
|
aa6699d06e | ||
|
|
3cb51a17a6 | ||
|
|
994e8ced3e | ||
|
|
75d26465f1 | ||
|
|
f5052935bd | ||
|
|
84b89de2a6 | ||
|
|
c4f9c4f630 | ||
|
|
c213cd6c3a | ||
|
|
9d22d6e3a0 | ||
|
|
a38419e3cb | ||
|
|
a64779684e | ||
|
|
ecd7012eee | ||
|
|
74a1011fcc | ||
|
|
d4b0a4acca | ||
|
|
ac6e593315 | ||
|
|
b1e77b1658 | ||
|
|
722c3554e9 | ||
|
|
1d08966571 | ||
|
|
fb50ae7544 | ||
|
|
ea695fc9e9 | ||
|
|
5c6d1e6a14 | ||
|
|
b030c40853 | ||
|
|
d6782c35e2 | ||
|
|
120e6db119 | ||
|
|
fa10f8ce19 | ||
|
|
31494b4687 | ||
|
|
857ed0f343 | ||
|
|
8133ff08a7 | ||
|
|
2d315c4d8e | ||
|
|
505f7b6ac9 | ||
|
|
2735eb14bd | ||
|
|
7afbc95eda | ||
|
|
91bb83d8c1 | ||
|
|
55550790e4 | ||
|
|
40221926a9 | ||
|
|
d069374a95 | ||
|
|
1183fe2057 | ||
|
|
f4605d4f74 | ||
|
|
3ce3fb685b | ||
|
|
07c2f7371f | ||
|
|
114aae98a9 | ||
|
|
0bd6548f45 | ||
|
|
06c5b7807b | ||
|
|
e8ef08cae2 | ||
|
|
0d251a9343 | ||
|
|
69a19b0e32 | ||
|
|
1b0b5f3494 | ||
|
|
5abdc8c538 | ||
|
|
9f0ba6d385 | ||
|
|
7863a0f45f | ||
|
|
ef4c2a935c | ||
|
|
40d2e948e4 | ||
|
|
3de4e905d3 | ||
|
|
655f4e199c | ||
|
|
13c3d3a2fb | ||
|
|
db2dca45f6 | ||
|
|
7330a05c78 | ||
|
|
a39c932868 | ||
|
|
a2c24c9197 | ||
|
|
5c3efc681f | ||
|
|
e70d2bd708 | ||
|
|
cf75a961fb | ||
|
|
159f317071 | ||
|
|
713eef592a | ||
|
|
cf03ad8fd9 | ||
|
|
0c0b27901a | ||
|
|
137fe3c8f2 | ||
|
|
d96174076a | ||
|
|
6d5662d96e | ||
|
|
57abd47d99 | ||
|
|
5092b3d791 | ||
|
|
649409d1be | ||
|
|
8f549d896a | ||
|
|
a1359ddbb5 | ||
|
|
304a0dda3e | ||
|
|
fff9c4a4d8 | ||
|
|
2c76102fc4 | ||
|
|
f576cd9417 | ||
|
|
9cfd224b74 | ||
|
|
c12f8de8b4 | ||
|
|
ed9a7c52e2 | ||
|
|
38fcaaa28b | ||
|
|
5317a1c1a9 | ||
|
|
4bc5933ea2 | ||
|
|
6a6bd33fe5 | ||
|
|
8256942a3d | ||
|
|
697632eee8 | ||
|
|
6bbf5b254d | ||
|
|
5831898c4a | ||
|
|
2cc413bec1 | ||
|
|
0af36e89d9 | ||
|
|
b2c0f5d2e5 | ||
|
|
80b74c7da9 | ||
|
|
f14f13b158 | ||
|
|
9dda00b6fa | ||
|
|
a29debb738 | ||
|
|
b990fc43df | ||
|
|
915e9552ee | ||
|
|
c522e0a386 | ||
|
|
c9cc08a9ba | ||
|
|
66e1b1662f | ||
|
|
9372e83bd8 | ||
|
|
b38a240dbb | ||
|
|
76b9506395 | ||
|
|
f1cf636aa2 | ||
|
|
312dcd0e13 | ||
|
|
42c2419613 | ||
|
|
8f7f748e82 | ||
|
|
7ad3bad1be | ||
|
|
5cd682e69f | ||
|
|
5d57780e84 | ||
|
|
f399955204 | ||
|
|
770652fe6b | ||
|
|
9ed5fa8c67 | ||
|
|
5a4ad29727 | ||
|
|
1eda3f2e33 | ||
|
|
95cb95ef96 | ||
|
|
4e7c96634c | ||
|
|
58587b8aea | ||
|
|
3fbf6239db | ||
|
|
faec53d497 | ||
|
|
482dcc534e | ||
|
|
854f61dda6 | ||
|
|
fca38713a1 | ||
|
|
5dd3bade53 | ||
|
|
665360f48d | ||
|
|
65719cb56a | ||
|
|
bdb76d4639 | ||
|
|
15634412ef | ||
|
|
bbcf9649fa | ||
|
|
e845d7314e | ||
|
|
6927b1c94f | ||
|
|
a09c6acd0d | ||
|
|
0963650ccb | ||
|
|
380688b353 | ||
|
|
ad5466bff8 | ||
|
|
a83652bf3f | ||
|
|
c632de314d | ||
|
|
259c9610d5 | ||
|
|
e9936c5524 | ||
|
|
3f60440e72 | ||
|
|
71a15f92fb | ||
|
|
32bc0dd820 | ||
|
|
20d1ac9d01 | ||
|
|
18baf89e0e | ||
|
|
3a1d1f2e59 | ||
|
|
e9a048721d | ||
|
|
68f0c7ff1a | ||
|
|
2875fe94ea | ||
|
|
1870427c0f | ||
|
|
636568fd30 | ||
|
|
bbc2391bf8 | ||
|
|
401684542a | ||
|
|
870edb2513 | ||
|
|
7ad09169ea | ||
|
|
c1a0f8915b | ||
|
|
dcdab8e5a1 | ||
|
|
eb3278fdab | ||
|
|
34db3af48d | ||
|
|
198da960dd | ||
|
|
cb83918fb3 | ||
|
|
f59a48540b | ||
|
|
ccf9c1a5fb | ||
|
|
ba6a85142a | ||
|
|
440baccd2a | ||
|
|
690c073328 | ||
|
|
3f0730ed4f | ||
|
|
01d5663bc8 | ||
|
|
49806cd00e | ||
|
|
935b0848e5 | ||
|
|
5ca20a89a2 | ||
|
|
e89a2266ec | ||
|
|
6607533311 | ||
|
|
4057054220 | ||
|
|
055e43845e | ||
|
|
d67270f2f8 | ||
|
|
d061b6c190 | ||
|
|
945f87d77f | ||
|
|
6c9be52d39 | ||
|
|
98e347f010 | ||
|
|
607e367bb1 | ||
|
|
7a25dc1ef1 | ||
|
|
e22ec4be09 | ||
|
|
51a06622f9 | ||
|
|
22faf5b831 | ||
|
|
e781c662b2 | ||
|
|
5744698d24 | ||
|
|
2c2ab3cd48 | ||
|
|
cfae4f5acd | ||
|
|
de541e3249 | ||
|
|
f5187c5c01 | ||
|
|
9936279443 | ||
|
|
2818773fd4 | ||
|
|
b9293cbcd0 | ||
|
|
5b9e44ddfc | ||
|
|
1791accab7 | ||
|
|
08081360f3 | ||
|
|
e933a95e97 | ||
|
|
4ef457fe6f | ||
|
|
bd9cae8921 | ||
|
|
303a74f8fd | ||
|
|
0b7f126ce1 | ||
|
|
308b5c027f | ||
|
|
ed3abc4b43 | ||
|
|
87ecb3b380 | ||
|
|
7e31763a25 | ||
|
|
c9df57d16a | ||
|
|
3d0f8ee657 | ||
|
|
6421bb4f5c | ||
|
|
3919743885 | ||
|
|
a5a57b9e20 | ||
|
|
e31d2810ad | ||
|
|
140e62fdcd | ||
|
|
014b4deb87 | ||
|
|
956b6cd172 | ||
|
|
bbaca3f044 | ||
|
|
bb8a44b918 | ||
|
|
b5574d5999 | ||
|
|
06dde072da | ||
|
|
8e92a81bb9 | ||
|
|
2c7345ae88 | ||
|
|
33d4696155 | ||
|
|
7d2dcc10e5 | ||
|
|
e82687454c | ||
|
|
84382caebc | ||
|
|
662530e507 | ||
|
|
edf81d0a2e | ||
|
|
7cbae86941 | ||
|
|
8ff7420a5e | ||
|
|
7ae59b1419 | ||
|
|
41036f8ee8 | ||
|
|
380777ca04 | ||
|
|
c658cd1096 | ||
|
|
c7b9946d2f | ||
|
|
0caca473d6 | ||
|
|
3e5d35957d | ||
|
|
6b8b14aba2 | ||
|
|
5db7a90a24 | ||
|
|
88b86611a3 | ||
|
|
886fe2052e | ||
|
|
e4dd194d4a | ||
|
|
a47af60f58 | ||
|
|
35f24eb806 | ||
|
|
36e3119d34 | ||
|
|
8ff3ad824e | ||
|
|
556000c002 | ||
|
|
fda050d3fe | ||
|
|
b1047309c9 | ||
|
|
d766c4945e | ||
|
|
43c98c45b9 | ||
|
|
f7556b5af3 | ||
|
|
cd781c4cf6 | ||
|
|
cd8698b157 | ||
|
|
d921dcddf1 | ||
|
|
9f318ddaef | ||
|
|
5c35ea11c3 | ||
|
|
3b16effff0 | ||
|
|
d3a27ad701 | ||
|
|
2a4589e268 | ||
|
|
80a34c82b9 | ||
|
|
fca7a65ee0 | ||
|
|
30a75bc581 | ||
|
|
7b365367f7 | ||
|
|
3ed5f543e2 | ||
|
|
ceea50b116 | ||
|
|
a5455e27d1 | ||
|
|
6f83d01321 | ||
|
|
c453b82e9f | ||
|
|
b7da316447 | ||
|
|
fb20b2e16c | ||
|
|
9df7c341a9 | ||
|
|
7c113d6e04 | ||
|
|
a6f22167ff | ||
|
|
d49e69735a | ||
|
|
eca73eae18 | ||
|
|
d3a34dfdf9 | ||
|
|
623188d884 | ||
|
|
f093f52792 | ||
|
|
d53607a118 | ||
|
|
5f637e064a | ||
|
|
e4b21e94f5 | ||
|
|
fc37288827 | ||
|
|
dad7245a3a | ||
|
|
4190831081 | ||
|
|
c509a01d7d | ||
|
|
6d259593fd | ||
|
|
bd3e06520f | ||
|
|
41dccd98a9 | ||
|
|
54e6d5c3f2 | ||
|
|
3f6249f39c | ||
|
|
a888714629 | ||
|
|
17ef3231df | ||
|
|
cc30b51d58 | ||
|
|
9d40eacc15 | ||
|
|
a0415c5f4e | ||
|
|
941978b578 | ||
|
|
06538b9122 | ||
|
|
dd895d7c17 | ||
|
|
9257a6cfde | ||
|
|
f8260067ab | ||
|
|
40b06daf1e | ||
|
|
e30915a06b | ||
|
|
2ab3898d28 | ||
|
|
6e38e748b8 | ||
|
|
7ecdd63bef | ||
|
|
8056962203 | ||
|
|
7a42f8c26f | ||
|
|
6c510e42e8 | ||
|
|
45d6ebf084 | ||
|
|
2147c4ffee | ||
|
|
d4ab191f34 | ||
|
|
a5e53a713b | ||
|
|
e3f965a9d6 | ||
|
|
dd00d4c8a5 | ||
|
|
4d292a75fa | ||
|
|
50b6733f57 | ||
|
|
19479b4b3c | ||
|
|
540f58d8ec | ||
|
|
749f1dfcf9 | ||
|
|
176691bb96 | ||
|
|
b9ec8ac9b1 | ||
|
|
28d973b9cb | ||
|
|
a6be54937c | ||
|
|
0664b9af84 | ||
|
|
407d8d1fd2 | ||
|
|
108897f6ad | ||
|
|
3d2decb0ec | ||
|
|
386b884f1b | ||
|
|
ace4da2297 | ||
|
|
a8fb48fb50 | ||
|
|
61f065c0c6 | ||
|
|
d6cf6d120a | ||
|
|
c20c19d8e0 | ||
|
|
bd8bbf76ab | ||
|
|
faccff1834 | ||
|
|
99d3c5a117 | ||
|
|
31eb09edef | ||
|
|
4180c2d754 | ||
|
|
68f5deedff | ||
|
|
9f72196414 | ||
|
|
b32efa9131 | ||
|
|
3cf502fea3 | ||
|
|
863a953ae1 | ||
|
|
a44104d8f7 | ||
|
|
f602bbb0cf | ||
|
|
2807ff5927 | ||
|
|
4fb8e6a4da | ||
|
|
7ec61f089d | ||
|
|
f7a500a8cf | ||
|
|
2b319bd694 | ||
|
|
24cf7c01f8 | ||
|
|
aa50e73909 | ||
|
|
d9b33b5439 | ||
|
|
da58c6bec0 | ||
|
|
c4cbac4331 | ||
|
|
ac26a99143 | ||
|
|
640252d391 | ||
|
|
258a1dda5e | ||
|
|
53a7ce2e46 | ||
|
|
291e2fd8fd | ||
|
|
f180c7698f | ||
|
|
183d6f3011 | ||
|
|
ba71d7ad03 | ||
|
|
26cfaac3bd | ||
|
|
556c8b24c0 | ||
|
|
31f0f527b7 | ||
|
|
b2e0cab702 | ||
|
|
3e3609e0f2 | ||
|
|
83e73d9842 | ||
|
|
673a175ddf | ||
|
|
12eacd3530 | ||
|
|
030f0551fd | ||
|
|
47f5947410 | ||
|
|
aaefa2e83c | ||
|
|
f8e92f7c8d | ||
|
|
b6430e6eb6 | ||
|
|
e60605c7bb | ||
|
|
58d2bd3c81 | ||
|
|
6534d05b76 | ||
|
|
2d7de174c5 | ||
|
|
79aa1dc67f | ||
|
|
7792ad9ea0 | ||
|
|
be6671923b | ||
|
|
0fa1b3f044 | ||
|
|
4ab751696b | ||
|
|
dce4eedf7d | ||
|
|
129b67b751 | ||
|
|
9ab776d53a | ||
|
|
2759a34d96 | ||
|
|
2f9f42750e | ||
|
|
30abd1f904 | ||
|
|
008075466e | ||
|
|
5b4035c320 | ||
|
|
e3feb6a73c | ||
|
|
40fe73317d | ||
|
|
073745030c | ||
|
|
c523437506 | ||
|
|
9eef570d37 | ||
|
|
be37b8cbbd | ||
|
|
c635496677 | ||
|
|
8753ecfd92 | ||
|
|
5eda1f2870 | ||
|
|
d5a60074f7 | ||
|
|
91df57d932 | ||
|
|
e27d4c4302 | ||
|
|
55847f6e10 | ||
|
|
b39d8bae27 | ||
|
|
b0cf23f775 | ||
|
|
c641246056 | ||
|
|
1e5bc9bbea | ||
|
|
99b504b5f6 | ||
|
|
1146454fec | ||
|
|
805e014a75 | ||
|
|
d3acd1efc1 | ||
|
|
9fcd218a5a | ||
|
|
d6a0830cfe | ||
|
|
40a63b9c66 | ||
|
|
eeb19a04cc | ||
|
|
91e457eb03 | ||
|
|
78d1919d7f | ||
|
|
8393acf173 | ||
|
|
bca152a047 | ||
|
|
6a15908a93 | ||
|
|
c626bbab74 | ||
|
|
c5c7dcc6f2 | ||
|
|
03dafe727e | ||
|
|
744921c45e | ||
|
|
abc4a4dcba | ||
|
|
7e0da2f929 | ||
|
|
a3b70d0f1f | ||
|
|
d291724f06 | ||
|
|
122a9ca2cc | ||
|
|
48aaddd32b | ||
|
|
47401af856 | ||
|
|
709adfd812 | ||
|
|
038d0c5412 | ||
|
|
6bb4362ed4 | ||
|
|
e617f9452d | ||
|
|
6d8bb49a37 | ||
|
|
4f6073ee86 | ||
|
|
2e7176304b | ||
|
|
e36cf11004 | ||
|
|
0e49e17f68 | ||
|
|
524de45f6b | ||
|
|
85741a4b60 | ||
|
|
f9ccb8c978 | ||
|
|
ea3d069e49 | ||
|
|
3e6024f183 | ||
|
|
337871693a | ||
|
|
2d921c4577 | ||
|
|
9accff7323 | ||
|
|
88b1ee8c31 | ||
|
|
3ac618bb4e | ||
|
|
0051df3741 | ||
|
|
7eb4e010b0 | ||
|
|
33cc23ada3 | ||
|
|
e5aee372e3 | ||
|
|
6b6ce4a761 | ||
|
|
8c4ea7f8f2 | ||
|
|
c8b268b806 | ||
|
|
faf390bb18 | ||
|
|
cf5e0e0f14 | ||
|
|
7b79f9cc17 | ||
|
|
708d599966 | ||
|
|
1ecd5b78e6 | ||
|
|
fca2e3c51a | ||
|
|
95ea761b2d | ||
|
|
6b3bfa1ee9 | ||
|
|
df3e302a9d | ||
|
|
c88a68c9a8 | ||
|
|
92d01b9cdd | ||
|
|
fe04fa5986 | ||
|
|
c382f541b4 | ||
|
|
f420527207 | ||
|
|
e0c83ebf79 | ||
|
|
c7fb18fc08 | ||
|
|
2db8ab937d | ||
|
|
819f5dd8e5 | ||
|
|
d4a8ed735e | ||
|
|
f07e3bb4d5 | ||
|
|
fa5ef0c221 | ||
|
|
da7499ec0b | ||
|
|
d2f4327e44 | ||
|
|
2eba640180 | ||
|
|
29ae55f340 | ||
|
|
3d2bca3f9f | ||
|
|
7fd8c0c822 | ||
|
|
a9e9c81505 | ||
|
|
e8cc68bdea | ||
|
|
9e51a661a4 | ||
|
|
a167aaf55f | ||
|
|
a54ecbcaa0 | ||
|
|
788462cdfa | ||
|
|
45c5965b99 | ||
|
|
ce7614de46 | ||
|
|
9f78e1ce1e | ||
|
|
2c7b0625e8 | ||
|
|
c3a5da9be1 | ||
|
|
ca796e1920 | ||
|
|
7ce04cf781 | ||
|
|
024a3eb760 | ||
|
|
1702f429b4 | ||
|
|
96d79cf495 | ||
|
|
a6a11a7026 | ||
|
|
970a49e2a5 | ||
|
|
2e013ed4f5 | ||
|
|
f8c396b1fe | ||
|
|
b54870cb60 | ||
|
|
84318acb18 | ||
|
|
a11a042b93 | ||
|
|
8a8aa8f62c | ||
|
|
93f78f4db5 | ||
|
|
404bfdd5e6 | ||
|
|
e4577dc2f1 | ||
|
|
5c932e5a27 | ||
|
|
4bd63c6267 | ||
|
|
aabe24f903 | ||
|
|
69cebd7fbc | ||
|
|
8da371176a | ||
|
|
dd08adf1d1 | ||
|
|
2f67bef139 | ||
|
|
8968c51cdc | ||
|
|
f2fdcc9289 | ||
|
|
aa3a575cbe | ||
|
|
11816d038d | ||
|
|
6a990edb38 | ||
|
|
fa12865924 | ||
|
|
ecdd717742 | ||
|
|
6851334af9 | ||
|
|
9051b29565 | ||
|
|
95c7d3dfbd | ||
|
|
bc1148c00a | ||
|
|
d4556d9299 | ||
|
|
5d389a2359 | ||
|
|
305116874b | ||
|
|
b08a29897f | ||
|
|
b59c1d9122 | ||
|
|
adb9cea701 | ||
|
|
5e148d2e82 | ||
|
|
a0d780558e | ||
|
|
ad56065a4e | ||
|
|
f5dee80b6e | ||
|
|
9cc75881b8 | ||
|
|
593fb13b61 | ||
|
|
fca90592d6 | ||
|
|
d6848e2855 | ||
|
|
7539a4129f | ||
|
|
5402574266 | ||
|
|
853175aa1a | ||
|
|
feb84809ec | ||
|
|
a812c568e4 | ||
|
|
11db25e355 | ||
|
|
ecd2fba629 | ||
|
|
a6763cf5a1 | ||
|
|
c9e91a9b94 | ||
|
|
43fb62c5bd | ||
|
|
941b30847b | ||
|
|
4c5a26698e | ||
|
|
d14a1dd948 | ||
|
|
1c0b434f47 | ||
|
|
573451bade |
@@ -15,10 +15,10 @@ charset = utf-8
|
||||
# 4 space indentation
|
||||
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
indent_size = 2
|
||||
|
||||
[*.bat]
|
||||
charset = latin1
|
||||
|
||||
# Unfortunately, EditorConfig doesn't support space configuration inside import braces directly.
|
||||
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.
|
||||
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_BUILD_TYPE = Production
|
||||
VITE_BUILD_PLATFORM = Framework
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_BUILD_TYPE = Production
|
||||
VITE_BUILD_PLATFORM = Shell
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_BUILD_TYPE = Production
|
||||
VITE_BUILD_PLATFORM = Universal
|
||||
8
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
8
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Bug 反馈
|
||||
description: 报告可能的 NapCat 异常行为
|
||||
title: '[BUG] '
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -10,6 +10,10 @@ body:
|
||||
在提交新的 Bug 反馈前,请确保您:
|
||||
* 已经搜索了现有的 issues,并且没有找到可以解决您问题的方法
|
||||
* 不与现有的某一 issue 重复
|
||||
* **不接受因发送不当内容而导致的问题报告**
|
||||
- 包括但不限于:多媒体发送失败、转发消息失败、消息被拦截等因 18+ 内容、违规内容或触发风控的问题
|
||||
- 提交 issue 前,请确认您发送的多媒体内容、链接、文本等均为正常合规内容,不会触发平台风控机制
|
||||
- 因违规内容导致的问题,一律不予受理
|
||||
- type: input
|
||||
id: system-version
|
||||
attributes:
|
||||
@@ -30,7 +34,7 @@ body:
|
||||
id: napcat-version
|
||||
attributes:
|
||||
label: NapCat 版本
|
||||
description: 可在 LiteLoaderQQNT 的设置页或是 QQNT 的设置页侧栏中找到
|
||||
description: 可在 WebUI 的「系统信息」页中找到
|
||||
placeholder: 1.0.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
60
.github/ISSUE_TEMPLATE/feat_request.yml
vendored
Normal file
60
.github/ISSUE_TEMPLATE/feat_request.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Feat 请求
|
||||
description: 提交新的 NapCat 功能或改进建议
|
||||
title: '[FEAT] '
|
||||
labels: enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
欢迎来到 NapCat 的 Issue Tracker!请填写以下表格来提交功能请求。
|
||||
在提交新的功能请求前,请确保您:
|
||||
* 已经搜索了现有的 issues,并且没有找到类似的建议
|
||||
* 不与现有的某一 issue 重复
|
||||
- type: input
|
||||
id: system-version
|
||||
attributes:
|
||||
label: 系统版本
|
||||
description: 运行 QQNT 的系统版本
|
||||
placeholder: Windows 11 24H2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: qqnt-version
|
||||
attributes:
|
||||
label: QQNT 版本
|
||||
description: 可在 QQNT 的「关于」的设置页中找到
|
||||
placeholder: 9.9.16-29927
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: napcat-version
|
||||
attributes:
|
||||
label: NapCat 版本
|
||||
description: 可在 WebUI 的「系统信息」页中找到
|
||||
placeholder: 1.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: 功能描述
|
||||
description: 请详细描述你希望添加的功能或改进
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-reason
|
||||
attributes:
|
||||
label: 需求背景与理由
|
||||
description: 请说明为什么需要这个功能,解决了什么问题
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-expected
|
||||
attributes:
|
||||
label: 期望的实现方式或效果
|
||||
description: 请描述你期望的功能实现方式或最终效果
|
||||
- type: textarea
|
||||
id: other-info
|
||||
attributes:
|
||||
label: 其他补充信息
|
||||
description: 你还想补充什么?
|
||||
18
.github/prompt/default.md
vendored
Normal file
18
.github/prompt/default.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# {VERSION}
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们提供了轻量化一键部署方案,内置 QQ 和 NapCat,详见使用文档。
|
||||
|
||||
可下载文件:
|
||||
- NapCat.Shell.Windows.Node.zip(无头模式)
|
||||
|
||||
## 注意事项
|
||||
**推荐 QQ 版本:9.9.23+,最低支持 9.9.22**
|
||||
**默认 WebUI 密钥为随机密码,请在控制台查看**
|
||||
|
||||
## 运行库
|
||||
如果 Windows x64 缺少 xxx.dll,请安装 [VC++ 运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||
|
||||
## 更新内容
|
||||
详见 commit 历史。
|
||||
94
.github/prompt/release_note_prompt.txt
vendored
Normal file
94
.github/prompt/release_note_prompt.txt
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
# NapCat Release Note Generator
|
||||
|
||||
你是 NapCat 项目的发布说明生成器。请根据提供的 commit 列表生成标准格式的发布说明。
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **版本号**:第一行必须是 `# {VERSION}`,使用用户提供的版本号(如 v4.10.2),不要添加额外的 V 前缀
|
||||
2. **语言**:全部使用简体中文
|
||||
3. **格式**:严格按照下方模板输出,不要添加额外的 markdown 格式
|
||||
|
||||
## Commit 分析规则
|
||||
|
||||
将 commit 分类为以下类型:
|
||||
- 🐛 **修复**:bug fix、修复、fix 相关
|
||||
- ✨ **新增**:新功能、feat、add 相关
|
||||
- 🔧 **优化**:优化、重构、refactor、improve、perf 相关
|
||||
- 📦 **依赖**:deps、依赖更新(通常可以忽略或合并)
|
||||
- 🔨 **构建**:ci、build、workflow 相关(通常可以忽略)
|
||||
|
||||
## 合并和筛选
|
||||
|
||||
- **合并相似项**:同一功能的多个 commit 合并为一条
|
||||
- **忽略琐碎项**:合并冲突、格式化、typo 等可忽略
|
||||
- **控制数量**:最终保持 5-15 条更新要点
|
||||
- **保留 commit hash**:每条末尾附上短 hash,格式 `(a1b2c3d)`
|
||||
|
||||
## 输出模板
|
||||
|
||||
```
|
||||
# {VERSION}
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们提供了轻量化一键部署方案,内置 QQ 和 NapCat,详见使用文档。
|
||||
|
||||
可下载文件:
|
||||
- NapCat.Shell.Windows.Node.zip(无头模式)
|
||||
|
||||
## 注意事项
|
||||
**推荐 QQ 版本:9.9.23+,最低支持 9.9.22**
|
||||
**默认 WebUI 密钥为随机密码,请在控制台查看**
|
||||
|
||||
## 运行库
|
||||
如果 Windows x64 缺少 xxx.dll,请安装 [VC++ 运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||
|
||||
## 更新内容
|
||||
|
||||
### 🐛 修复
|
||||
1. 修复 xxx 问题 (a1b2c3d)
|
||||
2. 修复 yyy 崩溃 (b2c3d4e)
|
||||
|
||||
### ✨ 新增
|
||||
1. 新增 xxx 功能 (c3d4e5f)
|
||||
2. 支持 yyy 特性 (d4e5f6g)
|
||||
|
||||
### 🔧 优化
|
||||
1. 优化 xxx 性能 (e5f6g7h)
|
||||
2. 重构 yyy 模块 (f6g7h8i)
|
||||
|
||||
---
|
||||
|
||||
**完整更新日志**: [{PREV_VERSION}...{VERSION}](https://github.com/NapNeko/NapCatQQ/compare/{PREV_VERSION}...{VERSION})
|
||||
```
|
||||
|
||||
## 重要约束
|
||||
|
||||
1. 如果某个分类没有内容,则完全省略该分类
|
||||
2. 不要编造不存在的更新
|
||||
3. 保持简洁,每条更新控制在一行内
|
||||
4. 使用用户友好的语言,避免过于技术化的描述
|
||||
5. 重大变更(Breaking Changes)需要在注意事项中加粗提示
|
||||
|
||||
## 文件变化分析
|
||||
|
||||
用户会提供文件变化统计和具体代码diff,帮助你理解变更内容:
|
||||
|
||||
### 目录含义
|
||||
- `packages/napcat-core/` → 核心功能、消息处理、QQ接口
|
||||
- `packages/napcat-onebot/` → OneBot 协议实现、API、事件
|
||||
- `packages/napcat-webui-backend/` → WebUI 后端接口
|
||||
- `packages/napcat-webui-frontend/` → WebUI 前端界面
|
||||
- `packages/napcat-shell/` → Shell 启动器
|
||||
|
||||
### 代码diff阅读指南
|
||||
- `+` 开头的行是新增代码
|
||||
- `-` 开头的行是删除代码
|
||||
- 关注函数名、类名的变化来理解功能变更
|
||||
- 关注 `fix`、`bug`、`error` 等关键词识别修复项
|
||||
- 关注 `add`、`new`、`feature` 等关键词识别新功能
|
||||
- 忽略纯重构(代码移动但功能不变)和格式化变更
|
||||
|
||||
### 截断说明
|
||||
- 如果看到 `[... 已截断 ...]`,表示内容过长被截断
|
||||
- 根据已有信息推断完整变更意图即可
|
||||
231
.github/scripts/lib/comment.ts
vendored
Normal file
231
.github/scripts/lib/comment.ts
vendored
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 构建状态评论模板
|
||||
*/
|
||||
|
||||
export const COMMENT_MARKER = '<!-- napcat-pr-build -->';
|
||||
|
||||
export type BuildStatus = 'success' | 'failure' | 'cancelled' | 'pending' | 'unknown';
|
||||
|
||||
export interface BuildTarget {
|
||||
name: string;
|
||||
status: BuildStatus;
|
||||
error?: string;
|
||||
downloadUrl?: string; // Artifact 直接下载链接
|
||||
}
|
||||
|
||||
// ============== 辅助函数 ==============
|
||||
|
||||
function formatSha (sha: string): string {
|
||||
return sha && sha.length >= 7 ? sha.substring(0, 7) : sha || 'unknown';
|
||||
}
|
||||
|
||||
function escapeCodeBlock (text: string): string {
|
||||
// 替换 ``` 为转义形式,避免破坏 Markdown 代码块
|
||||
return text.replace(/```/g, '\\`\\`\\`');
|
||||
}
|
||||
|
||||
function getTimeString (): string {
|
||||
return new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||
}
|
||||
|
||||
// ============== 状态图标 ==============
|
||||
|
||||
export function getStatusIcon (status: BuildStatus): string {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '✅ 成功';
|
||||
case 'pending':
|
||||
return '⏳ 构建中...';
|
||||
case 'cancelled':
|
||||
return '⚪ 已取消';
|
||||
case 'failure':
|
||||
return '❌ 失败';
|
||||
default:
|
||||
return '❓ 未知';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusEmoji (status: BuildStatus): string {
|
||||
switch (status) {
|
||||
case 'success': return '✅';
|
||||
case 'pending': return '⏳';
|
||||
case 'cancelled': return '⚪';
|
||||
case 'failure': return '❌';
|
||||
default: return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 构建中评论 ==============
|
||||
|
||||
export function generateBuildingComment (prSha: string, targets: string[]): string {
|
||||
const time = getTimeString();
|
||||
const shortSha = formatSha(prSha);
|
||||
|
||||
const lines: string[] = [
|
||||
COMMENT_MARKER,
|
||||
'',
|
||||
'<div align="center">',
|
||||
'',
|
||||
'# 🔨 NapCat 构建中',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'</div>',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 📦 构建目标',
|
||||
'',
|
||||
'| 包名 | 状态 | 说明 |',
|
||||
'| :--- | :---: | :--- |',
|
||||
...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 📋 构建信息',
|
||||
'',
|
||||
`| 项目 | 值 |`,
|
||||
`| :--- | :--- |`,
|
||||
`| 📝 提交 | \`${shortSha}\` |`,
|
||||
`| 🕐 开始时间 | ${time} |`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> ⏳ **构建进行中,请稍候...**',
|
||||
'>',
|
||||
'> 构建完成后将自动更新此评论',
|
||||
'',
|
||||
'</div>',
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ============== 构建结果评论 ==============
|
||||
|
||||
export function generateResultComment (
|
||||
targets: BuildTarget[],
|
||||
prSha: string,
|
||||
runId: string,
|
||||
repository: string,
|
||||
version?: string
|
||||
): string {
|
||||
const runUrl = `https://github.com/${repository}/actions/runs/${runId}`;
|
||||
const shortSha = formatSha(prSha);
|
||||
const time = getTimeString();
|
||||
|
||||
const allSuccess = targets.every(t => t.status === 'success');
|
||||
const anyCancelled = targets.some(t => t.status === 'cancelled');
|
||||
const anyFailure = targets.some(t => t.status === 'failure');
|
||||
|
||||
// 状态徽章
|
||||
let statusBadge: string;
|
||||
let headerTitle: string;
|
||||
if (allSuccess) {
|
||||
statusBadge = '';
|
||||
headerTitle = '# ✅ NapCat 构建成功';
|
||||
} else if (anyCancelled && !anyFailure) {
|
||||
statusBadge = '';
|
||||
headerTitle = '# ⚪ NapCat 构建已取消';
|
||||
} else {
|
||||
statusBadge = '';
|
||||
headerTitle = '# ❌ NapCat 构建失败';
|
||||
}
|
||||
|
||||
const downloadLink = (target: BuildTarget) => {
|
||||
if (target.status !== 'success') return '—';
|
||||
if (target.downloadUrl) {
|
||||
return `[📥 下载](${target.downloadUrl})`;
|
||||
}
|
||||
return `[📥 下载](${runUrl}#artifacts)`;
|
||||
};
|
||||
|
||||
const lines: string[] = [
|
||||
COMMENT_MARKER,
|
||||
'',
|
||||
'<div align="center">',
|
||||
'',
|
||||
headerTitle,
|
||||
'',
|
||||
statusBadge,
|
||||
'',
|
||||
'</div>',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 📦 构建产物',
|
||||
'',
|
||||
'| 包名 | 状态 | 下载 |',
|
||||
'| :--- | :---: | :---: |',
|
||||
...targets.map(t => `| \`${t.name}\` | ${getStatusEmoji(t.status)} ${t.status === 'success' ? '成功' : t.status === 'failure' ? '失败' : t.status === 'cancelled' ? '已取消' : '未知'} | ${downloadLink(t)} |`),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 📋 构建信息',
|
||||
'',
|
||||
`| 项目 | 值 |`,
|
||||
`| :--- | :--- |`,
|
||||
...(version ? [`| 🏷️ 版本号 | \`${version}\` |`] : []),
|
||||
`| 📝 提交 | \`${shortSha}\` |`,
|
||||
`| 🔗 构建日志 | [查看详情](${runUrl}) |`,
|
||||
`| 🕐 完成时间 | ${time} |`,
|
||||
];
|
||||
|
||||
// 添加错误详情
|
||||
const failedTargets = targets.filter(t => t.status === 'failure' && t.error);
|
||||
if (failedTargets.length > 0) {
|
||||
lines.push('', '---', '', '## ⚠️ 错误详情', '');
|
||||
for (const target of failedTargets) {
|
||||
lines.push(
|
||||
`<details>`,
|
||||
`<summary>🔴 <b>${target.name}</b> 构建错误</summary>`,
|
||||
'',
|
||||
'```',
|
||||
escapeCodeBlock(target.error!),
|
||||
'```',
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加底部提示
|
||||
lines.push('---', '');
|
||||
if (allSuccess) {
|
||||
lines.push(
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> 🎉 **所有构建均已成功完成!**',
|
||||
'>',
|
||||
'> 点击上方下载链接获取构建产物进行测试',
|
||||
'',
|
||||
'</div>'
|
||||
);
|
||||
} else if (anyCancelled && !anyFailure) {
|
||||
lines.push(
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> ⚪ **构建已被取消**',
|
||||
'>',
|
||||
'> 可能是由于新的提交触发了新的构建',
|
||||
'',
|
||||
'</div>'
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
'<div align="center">',
|
||||
'',
|
||||
'> ⚠️ **部分构建失败**',
|
||||
'>',
|
||||
'> 请查看上方错误详情或点击构建日志查看完整输出',
|
||||
'',
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
189
.github/scripts/lib/github.ts
vendored
Normal file
189
.github/scripts/lib/github.ts
vendored
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* GitHub API 工具库
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
// ============== 类型定义 ==============
|
||||
|
||||
export interface PullRequest {
|
||||
number: number;
|
||||
state: string;
|
||||
head: {
|
||||
sha: string;
|
||||
ref: string;
|
||||
repo: {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
owner: {
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
id: number;
|
||||
name: string;
|
||||
size_in_bytes: number;
|
||||
archive_download_url: string;
|
||||
}
|
||||
|
||||
// ============== GitHub API Client ==========================
|
||||
|
||||
export class GitHubAPI {
|
||||
private token: string;
|
||||
private baseUrl = 'https://api.github.com';
|
||||
|
||||
constructor (token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private async request<T> (endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async getPullRequest (owner: string, repo: string, pullNumber: number): Promise<PullRequest> {
|
||||
return this.request<PullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
|
||||
}
|
||||
|
||||
async getCollaboratorPermission (owner: string, repo: string, username: string): Promise<string> {
|
||||
const data = await this.request<{ permission: string; }>(
|
||||
`/repos/${owner}/${repo}/collaborators/${username}/permission`
|
||||
);
|
||||
return data.permission;
|
||||
}
|
||||
|
||||
async getRepository (owner: string, repo: string): Promise<Repository> {
|
||||
return this.request(`/repos/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async checkOrgMembership (org: string, username: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/orgs/${org}/members/${username}`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getRunArtifacts (owner: string, repo: string, runId: string): Promise<Artifact[]> {
|
||||
const data = await this.request<{ artifacts: Artifact[]; }>(
|
||||
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`
|
||||
);
|
||||
return data.artifacts;
|
||||
}
|
||||
|
||||
async createComment (owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async findComment (owner: string, repo: string, issueNumber: number, marker: string): Promise<number | null> {
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (page <= 10) { // 最多检查 1000 条评论
|
||||
const comments = await this.request<Array<{ id: number, body: string; }>>(
|
||||
`/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`
|
||||
);
|
||||
|
||||
if (comments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const found = comments.find(c => c.body.includes(marker));
|
||||
if (found) {
|
||||
return found.id;
|
||||
}
|
||||
|
||||
if (comments.length < perPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateComment (owner: string, repo: string, commentId: number, body: string): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/issues/comments/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ body }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async createOrUpdateComment (
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
body: string,
|
||||
marker: string
|
||||
): Promise<void> {
|
||||
const existingId = await this.findComment(owner, repo, issueNumber, marker);
|
||||
if (existingId) {
|
||||
await this.updateComment(owner, repo, existingId, body);
|
||||
console.log(`✓ Updated comment #${existingId}`);
|
||||
} else {
|
||||
await this.createComment(owner, repo, issueNumber, body);
|
||||
console.log('✓ Created new comment');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Output 工具 ==============
|
||||
|
||||
export function setOutput (name: string, value: string): void {
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (outputFile) {
|
||||
appendFileSync(outputFile, `${name}=${value}\n`);
|
||||
}
|
||||
console.log(` ${name}=${value}`);
|
||||
}
|
||||
|
||||
export function setMultilineOutput (name: string, value: string): void {
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (outputFile) {
|
||||
const delimiter = `EOF_${Date.now()}`;
|
||||
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 环境变量工具 ==============
|
||||
|
||||
export function getEnv (name: string, required: true): string;
|
||||
export function getEnv (name: string, required?: false): string | undefined;
|
||||
export function getEnv (name: string, required = false): string | undefined {
|
||||
const value = process.env[name];
|
||||
if (required && !value) {
|
||||
throw new Error(`Environment variable ${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getRepository (): { owner: string, repo: string; } {
|
||||
const repository = getEnv('GITHUB_REPOSITORY', true);
|
||||
const [owner, repo] = repository.split('/');
|
||||
return { owner, repo };
|
||||
}
|
||||
36
.github/scripts/pr-build-building.ts
vendored
Normal file
36
.github/scripts/pr-build-building.ts
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* PR Build - 更新构建中状态评论
|
||||
*
|
||||
* 环境变量:
|
||||
* - GITHUB_TOKEN: GitHub API Token
|
||||
* - PR_NUMBER: PR 编号
|
||||
* - PR_SHA: PR 提交 SHA
|
||||
*/
|
||||
|
||||
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
|
||||
import { generateBuildingComment, COMMENT_MARKER } from './lib/comment.ts';
|
||||
|
||||
const BUILD_TARGETS = ['NapCat.Framework', 'NapCat.Shell'];
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('🔨 Updating building status comment\n');
|
||||
|
||||
const token = getEnv('GITHUB_TOKEN', true);
|
||||
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
|
||||
const prSha = getEnv('PR_SHA', true);
|
||||
const { owner, repo } = getRepository();
|
||||
|
||||
console.log(`PR: #${prNumber}`);
|
||||
console.log(`SHA: ${prSha}`);
|
||||
console.log(`Repo: ${owner}/${repo}\n`);
|
||||
|
||||
const github = new GitHubAPI(token);
|
||||
const comment = generateBuildingComment(prSha, BUILD_TARGETS);
|
||||
|
||||
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
206
.github/scripts/pr-build-check.ts
vendored
Normal file
206
.github/scripts/pr-build-check.ts
vendored
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* PR Build Check Script
|
||||
* 检查 PR 构建触发条件和用户权限
|
||||
*
|
||||
* 环境变量:
|
||||
* - GITHUB_TOKEN: GitHub API Token
|
||||
* - GITHUB_EVENT_NAME: 事件名称
|
||||
* - GITHUB_EVENT_PATH: 事件 payload 文件路径
|
||||
* - GITHUB_REPOSITORY: 仓库名称 (owner/repo)
|
||||
* - GITHUB_OUTPUT: 输出文件路径
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { GitHubAPI, getEnv, getRepository, setOutput } from './lib/github.ts';
|
||||
import type { PullRequest } from './lib/github.ts';
|
||||
|
||||
// ============== 类型定义 ==============
|
||||
|
||||
interface GitHubPayload {
|
||||
pull_request?: PullRequest;
|
||||
issue?: {
|
||||
number: number;
|
||||
pull_request?: object;
|
||||
};
|
||||
comment?: {
|
||||
body: string;
|
||||
user: { login: string; };
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
should_build: boolean;
|
||||
pr_number?: number;
|
||||
pr_sha?: string;
|
||||
pr_head_repo?: string;
|
||||
pr_head_ref?: string;
|
||||
}
|
||||
|
||||
// ============== 权限检查 ==============
|
||||
|
||||
async function checkUserPermission (
|
||||
github: GitHubAPI,
|
||||
owner: string,
|
||||
repo: string,
|
||||
username: string
|
||||
): Promise<boolean> {
|
||||
// 方法1:检查仓库协作者权限
|
||||
try {
|
||||
const permission = await github.getCollaboratorPermission(owner, repo, username);
|
||||
if (['admin', 'write', 'maintain'].includes(permission)) {
|
||||
console.log(`✓ User ${username} has ${permission} permission`);
|
||||
return true;
|
||||
}
|
||||
console.log(`✗ User ${username} has ${permission} permission (insufficient)`);
|
||||
} catch (e) {
|
||||
console.log(`✗ Failed to get collaborator permission: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
// 方法2:检查组织成员身份
|
||||
try {
|
||||
const repoInfo = await github.getRepository(owner, repo);
|
||||
if (repoInfo.owner.type === 'Organization') {
|
||||
const isMember = await github.checkOrgMembership(owner, username);
|
||||
if (isMember) {
|
||||
console.log(`✓ User ${username} is organization member`);
|
||||
return true;
|
||||
}
|
||||
console.log(`✗ User ${username} is not organization member`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`✗ Failed to check org membership: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============== 事件处理 ==============
|
||||
|
||||
function handlePullRequestTarget (payload: GitHubPayload): CheckResult {
|
||||
const pr = payload.pull_request;
|
||||
|
||||
if (!pr) {
|
||||
console.log('✗ No pull_request in payload');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
if (pr.state !== 'open') {
|
||||
console.log(`✗ PR is not open (state: ${pr.state})`);
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
console.log(`✓ PR #${pr.number} is open, triggering build`);
|
||||
return {
|
||||
should_build: true,
|
||||
pr_number: pr.number,
|
||||
pr_sha: pr.head.sha,
|
||||
pr_head_repo: pr.head.repo.full_name,
|
||||
pr_head_ref: pr.head.ref,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleIssueComment (
|
||||
payload: GitHubPayload,
|
||||
github: GitHubAPI,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<CheckResult> {
|
||||
const { issue, comment } = payload;
|
||||
|
||||
if (!issue || !comment) {
|
||||
console.log('✗ No issue or comment in payload');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
// 检查是否是 PR 的评论
|
||||
if (!issue.pull_request) {
|
||||
console.log('✗ Comment is not on a PR');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
// 检查是否是 /build 命令
|
||||
if (!comment.body.trim().startsWith('/build')) {
|
||||
console.log('✗ Comment is not a /build command');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
console.log(`→ /build command from @${comment.user.login}`);
|
||||
|
||||
// 获取 PR 详情
|
||||
const pr = await github.getPullRequest(owner, repo, issue.number);
|
||||
|
||||
// 检查 PR 状态
|
||||
if (pr.state !== 'open') {
|
||||
console.log(`✗ PR is not open (state: ${pr.state})`);
|
||||
await github.createComment(owner, repo, issue.number, '⚠️ 此 PR 已关闭,无法触发构建。');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
// 检查用户权限
|
||||
const username = comment.user.login;
|
||||
const hasPermission = await checkUserPermission(github, owner, repo, username);
|
||||
|
||||
if (!hasPermission) {
|
||||
console.log(`✗ User ${username} has no permission`);
|
||||
await github.createComment(
|
||||
owner,
|
||||
repo,
|
||||
issue.number,
|
||||
`⚠️ @${username} 您没有权限使用 \`/build\` 命令,仅仓库协作者或组织成员可使用。`
|
||||
);
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
console.log(`✓ Build triggered by @${username}`);
|
||||
return {
|
||||
should_build: true,
|
||||
pr_number: issue.number,
|
||||
pr_sha: pr.head.sha,
|
||||
pr_head_repo: pr.head.repo.full_name,
|
||||
pr_head_ref: pr.head.ref,
|
||||
};
|
||||
}
|
||||
|
||||
// ============== 主函数 ==============
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('🔍 PR Build Check\n');
|
||||
|
||||
const token = getEnv('GITHUB_TOKEN', true);
|
||||
const eventName = getEnv('GITHUB_EVENT_NAME', true);
|
||||
const eventPath = getEnv('GITHUB_EVENT_PATH', true);
|
||||
const { owner, repo } = getRepository();
|
||||
|
||||
console.log(`Event: ${eventName}`);
|
||||
console.log(`Repository: ${owner}/${repo}\n`);
|
||||
|
||||
const payload = JSON.parse(readFileSync(eventPath, 'utf-8')) as GitHubPayload;
|
||||
const github = new GitHubAPI(token);
|
||||
|
||||
let result: CheckResult;
|
||||
|
||||
switch (eventName) {
|
||||
case 'pull_request_target':
|
||||
result = handlePullRequestTarget(payload);
|
||||
break;
|
||||
case 'issue_comment':
|
||||
result = await handleIssueComment(payload, github, owner, repo);
|
||||
break;
|
||||
default:
|
||||
console.log(`✗ Unsupported event: ${eventName}`);
|
||||
result = { should_build: false };
|
||||
}
|
||||
|
||||
// 输出结果
|
||||
console.log('\n=== Outputs ===');
|
||||
setOutput('should_build', String(result.should_build));
|
||||
setOutput('pr_number', String(result.pr_number ?? ''));
|
||||
setOutput('pr_sha', result.pr_sha ?? '');
|
||||
setOutput('pr_head_repo', result.pr_head_repo ?? '');
|
||||
setOutput('pr_head_ref', result.pr_head_ref ?? '');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
90
.github/scripts/pr-build-result.ts
vendored
Normal file
90
.github/scripts/pr-build-result.ts
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* PR Build - 更新构建结果评论
|
||||
*
|
||||
* 环境变量:
|
||||
* - GITHUB_TOKEN: GitHub API Token
|
||||
* - PR_NUMBER: PR 编号
|
||||
* - PR_SHA: PR 提交 SHA
|
||||
* - RUN_ID: GitHub Actions Run ID
|
||||
* - NAPCAT_VERSION: 构建版本号
|
||||
* - FRAMEWORK_STATUS: Framework 构建状态
|
||||
* - FRAMEWORK_ERROR: Framework 构建错误信息
|
||||
* - SHELL_STATUS: Shell 构建状态
|
||||
* - SHELL_ERROR: Shell 构建错误信息
|
||||
*/
|
||||
|
||||
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
|
||||
import { generateResultComment, COMMENT_MARKER } from './lib/comment.ts';
|
||||
import type { BuildTarget, BuildStatus } from './lib/comment.ts';
|
||||
|
||||
function parseStatus (value: string | undefined): BuildStatus {
|
||||
if (value === 'success' || value === 'failure' || value === 'cancelled') {
|
||||
return value;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('📝 Updating build result comment\n');
|
||||
|
||||
const token = getEnv('GITHUB_TOKEN', true);
|
||||
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
|
||||
const prSha = getEnv('PR_SHA') || 'unknown';
|
||||
const runId = getEnv('RUN_ID', true);
|
||||
const version = getEnv('NAPCAT_VERSION') || '';
|
||||
const { owner, repo } = getRepository();
|
||||
|
||||
const frameworkStatus = parseStatus(getEnv('FRAMEWORK_STATUS'));
|
||||
const frameworkError = getEnv('FRAMEWORK_ERROR');
|
||||
const shellStatus = parseStatus(getEnv('SHELL_STATUS'));
|
||||
const shellError = getEnv('SHELL_ERROR');
|
||||
|
||||
console.log(`PR: #${prNumber}`);
|
||||
console.log(`SHA: ${prSha}`);
|
||||
console.log(`Version: ${version}`);
|
||||
console.log(`Run: ${runId}`);
|
||||
console.log(`Framework: ${frameworkStatus}${frameworkError ? ` (${frameworkError})` : ''}`);
|
||||
console.log(`Shell: ${shellStatus}${shellError ? ` (${shellError})` : ''}\n`);
|
||||
|
||||
const github = new GitHubAPI(token);
|
||||
const repository = `${owner}/${repo}`;
|
||||
|
||||
// 获取 artifacts 列表,生成直接下载链接
|
||||
const artifactMap: Record<string, string> = {};
|
||||
try {
|
||||
const artifacts = await github.getRunArtifacts(owner, repo, runId);
|
||||
console.log(`Found ${artifacts.length} artifacts`);
|
||||
for (const artifact of artifacts) {
|
||||
// 生成直接下载链接:https://github.com/{owner}/{repo}/actions/runs/{run_id}/artifacts/{artifact_id}
|
||||
const downloadUrl = `https://github.com/${repository}/actions/runs/${runId}/artifacts/${artifact.id}`;
|
||||
artifactMap[artifact.name] = downloadUrl;
|
||||
console.log(` - ${artifact.name}: ${downloadUrl}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Warning: Failed to get artifacts: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
const targets: BuildTarget[] = [
|
||||
{
|
||||
name: 'NapCat.Framework',
|
||||
status: frameworkStatus,
|
||||
error: frameworkError,
|
||||
downloadUrl: artifactMap['NapCat.Framework'],
|
||||
},
|
||||
{
|
||||
name: 'NapCat.Shell',
|
||||
status: shellStatus,
|
||||
error: shellError,
|
||||
downloadUrl: artifactMap['NapCat.Shell'],
|
||||
},
|
||||
];
|
||||
|
||||
const comment = generateResultComment(targets, prSha, runId, repository, version);
|
||||
|
||||
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
149
.github/scripts/pr-build-run.ts
vendored
Normal file
149
.github/scripts/pr-build-run.ts
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* PR Build Runner
|
||||
* 执行构建步骤
|
||||
*
|
||||
* 用法: node pr-build-run.ts <target>
|
||||
* target: framework | shell
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, renameSync, unlinkSync } from 'node:fs';
|
||||
import { setOutput } from './lib/github.ts';
|
||||
|
||||
type BuildTarget = 'framework' | 'shell';
|
||||
|
||||
interface BuildStep {
|
||||
name: string;
|
||||
command: string;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
// ============== 构建步骤 ==============
|
||||
|
||||
function getCommonSteps (): BuildStep[] {
|
||||
return [
|
||||
{
|
||||
name: 'Install pnpm',
|
||||
command: 'npm i -g pnpm',
|
||||
errorMessage: 'Failed to install pnpm',
|
||||
},
|
||||
{
|
||||
name: 'Install dependencies',
|
||||
command: 'pnpm i',
|
||||
errorMessage: 'Failed to install dependencies',
|
||||
},
|
||||
{
|
||||
name: 'Type check',
|
||||
command: 'pnpm run typecheck',
|
||||
errorMessage: 'Type check failed',
|
||||
},
|
||||
{
|
||||
name: 'Test',
|
||||
command: 'pnpm test',
|
||||
errorMessage: 'Tests failed',
|
||||
},
|
||||
{
|
||||
name: 'Build WebUI',
|
||||
command: 'pnpm --filter napcat-webui-frontend run build',
|
||||
errorMessage: 'WebUI build failed',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getTargetSteps (target: BuildTarget): BuildStep[] {
|
||||
if (target === 'framework') {
|
||||
return [
|
||||
{
|
||||
name: 'Build Framework',
|
||||
command: 'pnpm run build:framework',
|
||||
errorMessage: 'Framework build failed',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: 'Build Shell',
|
||||
command: 'pnpm run build:shell',
|
||||
errorMessage: 'Shell build failed',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ============== 执行器 ==============
|
||||
|
||||
function runStep (step: BuildStep): boolean {
|
||||
console.log(`\n::group::${step.name}`);
|
||||
console.log(`> ${step.command}\n`);
|
||||
|
||||
try {
|
||||
execSync(step.command, {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
|
||||
});
|
||||
console.log('::endgroup::');
|
||||
console.log(`✓ ${step.name}`);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
console.log('::endgroup::');
|
||||
console.log(`✗ ${step.name}`);
|
||||
setOutput('error', step.errorMessage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function postBuild (target: BuildTarget): void {
|
||||
const srcDir = target === 'framework'
|
||||
? 'packages/napcat-framework/dist'
|
||||
: 'packages/napcat-shell/dist';
|
||||
const destDir = target === 'framework' ? 'framework-dist' : 'shell-dist';
|
||||
|
||||
console.log(`\n→ Moving ${srcDir} to ${destDir}`);
|
||||
|
||||
if (!existsSync(srcDir)) {
|
||||
throw new Error(`Build output not found: ${srcDir}`);
|
||||
}
|
||||
|
||||
renameSync(srcDir, destDir);
|
||||
|
||||
// Install production dependencies
|
||||
console.log('→ Installing production dependencies');
|
||||
execSync('npm install --omit=dev', {
|
||||
cwd: destDir,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
|
||||
});
|
||||
|
||||
// Remove package-lock.json
|
||||
const lockFile = `${destDir}/package-lock.json`;
|
||||
if (existsSync(lockFile)) {
|
||||
unlinkSync(lockFile);
|
||||
}
|
||||
|
||||
console.log(`✓ Build output ready at ${destDir}`);
|
||||
}
|
||||
|
||||
// ============== 主函数 ==============
|
||||
|
||||
function main (): void {
|
||||
const target = process.argv[2] as BuildTarget;
|
||||
|
||||
if (!target || !['framework', 'shell'].includes(target)) {
|
||||
console.error('Usage: node pr-build-run.ts <framework|shell>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🔨 Building NapCat.${target === 'framework' ? 'Framework' : 'Shell'}\n`);
|
||||
|
||||
const steps = [...getCommonSteps(), ...getTargetSteps(target)];
|
||||
|
||||
for (const step of steps) {
|
||||
if (!runStep(step)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
postBuild(target);
|
||||
console.log('\n✅ Build completed successfully!');
|
||||
}
|
||||
|
||||
main();
|
||||
83
.github/workflows/auto-release.yml
vendored
Normal file
83
.github/workflows/auto-release.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Auto Release Docker
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
shell-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger NapCat-Docker docker-publish workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCat-Docker/actions/workflows/docker-publish.yml/dispatches \
|
||||
-d '{"ref":"main"}'
|
||||
framework-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger NapCat-Framework-Docker docker-publish workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCat.Docker.Framework/actions/workflows/docker-image.yml/dispatches \
|
||||
-d '{"ref":"main"}'
|
||||
appimage-shell-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Get Latest NapCat Version
|
||||
id: get_version
|
||||
run: |
|
||||
# 获取当前仓库的最新 tag
|
||||
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||
# 输出调试信息
|
||||
echo "Debug: Latest NapCat Version is ${latest_tag}"
|
||||
echo "latest_tag=${latest_tag}" >> $GITHUB_ENV
|
||||
- name: Trigger Release NapCat AppImage Workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
NAPCAT_VERSION: ${{ env.latest_tag }}
|
||||
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_x86_64.AppImage' # 写死 QQ 版本
|
||||
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_arm64.AppImage' # 写死 QQ 版本
|
||||
run: |
|
||||
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_version_x86_64=${QQ_VERSION_X86_64}, qq_version_arm64=${QQ_VERSION_ARM64}"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCatAppImageBuild/actions/workflows/release.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_version_x86_64\":\"${QQ_VERSION_X86_64}\",\"qq_version_arm64\":\"${QQ_VERSION_ARM64}\"}}"
|
||||
node-shell-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Get Latest NapCat Version
|
||||
id: get_version
|
||||
run: |
|
||||
# 获取当前仓库的最新 tag
|
||||
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||
# 输出调试信息
|
||||
echo "Debug: Latest NapCat Version is ${latest_tag}"
|
||||
echo "latest_tag=${latest_tag}" >> $GITHUB_ENV
|
||||
- name: Trigger Release NapCat AppImage Workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||
NAPCAT_VERSION: ${{ env.latest_tag }}
|
||||
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_x86_64.AppImage' # 写死 QQ 版本
|
||||
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_arm64.AppImage' # 写死 QQ 版本
|
||||
run: |
|
||||
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_url_amd64=${QQ_VERSION_X86_64}, qq_url_arm64=${QQ_VERSION_ARM64}"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/release.yml/dispatches \
|
||||
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${QQ_VERSION_X86_64}\",\"qq_url_arm64\":\"${QQ_VERSION_ARM64}\"}}"
|
||||
75
.github/workflows/build.yml
vendored
75
.github/workflows/build.yml
vendored
@@ -1,47 +1,94 @@
|
||||
name: "Build Action"
|
||||
name: Build NapCat Artifacts
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
Build-LiteLoader:
|
||||
Build-Framework:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 需要完整历史来获取 tags
|
||||
- name: Use Node.js 20.X
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.Framework
|
||||
- name: Generate Version
|
||||
run: |
|
||||
npm i && cd napcat.webui && npm i && cd .. || exit 1
|
||||
npm run build:framework && npm run depend || exit 1
|
||||
rm package-lock.json
|
||||
# 获取最近的 release tag (格式: vX.X.X)
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
|
||||
# 去掉 v 前缀
|
||||
BASE_VERSION="${LATEST_TAG#v}"
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
|
||||
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "Latest tag: ${LATEST_TAG}"
|
||||
echo "Build version: ${VERSION}"
|
||||
- name: Build NapCat.Framework
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
|
||||
run: |
|
||||
npm i -g pnpm
|
||||
pnpm i
|
||||
pnpm run typecheck || exit 1
|
||||
pnpm test || exit 1
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:framework
|
||||
mv packages/napcat-framework/dist framework-dist
|
||||
cd framework-dist
|
||||
npm install --omit=dev
|
||||
rm ./package-lock.json || exit 0
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Framework
|
||||
path: dist
|
||||
path: framework-dist
|
||||
Build-Shell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 需要完整历史来获取 tags
|
||||
- name: Use Node.js 20.X
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.Shell
|
||||
- name: Generate Version
|
||||
run: |
|
||||
npm i && cd napcat.webui && npm i && cd .. || exit 1
|
||||
npm run build:shell && npm run depend || exit 1
|
||||
rm package-lock.json
|
||||
# 获取最近的 release tag (格式: vX.X.X)
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
|
||||
# 去掉 v 前缀
|
||||
BASE_VERSION="${LATEST_TAG#v}"
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
|
||||
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "Latest tag: ${LATEST_TAG}"
|
||||
echo "Build version: ${VERSION}"
|
||||
- name: Build NapCat.Shell
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
|
||||
run: |
|
||||
npm i -g pnpm
|
||||
pnpm i
|
||||
pnpm run typecheck || exit 1
|
||||
pnpm test || exit 1
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:shell
|
||||
mv packages/napcat-shell/dist shell-dist
|
||||
cd shell-dist
|
||||
npm install --omit=dev
|
||||
rm ./package-lock.json || exit 0
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Shell
|
||||
path: dist
|
||||
path: shell-dist
|
||||
|
||||
303
.github/workflows/pr-build.yml
vendored
Normal file
303
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,303 @@
|
||||
# =============================================================================
|
||||
# PR 构建工作流
|
||||
# =============================================================================
|
||||
# 功能:
|
||||
# 1. 在 PR 提交时自动构建 Framework 和 Shell 包
|
||||
# 2. 支持通过 /build 命令手动触发构建(仅协作者/组织成员)
|
||||
# 3. 在 PR 中发布构建状态评论,并持续更新(不会重复创建)
|
||||
# 4. 支持 Fork PR 的构建(使用 pull_request_target 获取写权限)
|
||||
#
|
||||
# 安全说明:
|
||||
# - 使用 pull_request_target 事件,在 base 分支上下文运行
|
||||
# - 构建脚本始终从 base 分支 checkout,避免恶意 PR 篡改脚本
|
||||
# - PR 代码单独 checkout 到 workspace 目录
|
||||
# =============================================================================
|
||||
|
||||
name: PR Build
|
||||
|
||||
# =============================================================================
|
||||
# 触发条件
|
||||
# =============================================================================
|
||||
on:
|
||||
# PR 事件:打开、同步(新推送)、重新打开时触发
|
||||
# 注意:使用 pull_request_target 而非 pull_request,以便对 Fork PR 有写权限
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Issue 评论事件:用于响应 /build 命令
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# =============================================================================
|
||||
# 权限配置
|
||||
# =============================================================================
|
||||
permissions:
|
||||
contents: read # 读取仓库内容
|
||||
pull-requests: write # 写入 PR 评论
|
||||
issues: write # 写入 Issue 评论(/build 命令响应)
|
||||
actions: read # 读取 Actions 信息(获取构建日志链接)
|
||||
|
||||
# =============================================================================
|
||||
# 并发控制
|
||||
# =============================================================================
|
||||
# 同一 PR 的多次构建会取消之前未完成的构建,避免资源浪费
|
||||
# 注意:只有在 should_build=true 时才会进入实际构建流程,
|
||||
# issue_comment 事件如果不是 /build 命令,会在 check-build 阶段快速退出,
|
||||
# 不会取消正在进行的构建(因为 cancel-in-progress 只影响同 group 的后续任务)
|
||||
concurrency:
|
||||
# 使用不同的 group 策略:
|
||||
# - pull_request_target: 使用 PR 号
|
||||
# - issue_comment: 只有确认是 /build 命令时才使用 PR 号,否则使用 run_id(不冲突)
|
||||
group: pr-build-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/build') && github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# =============================================================================
|
||||
# 任务定义
|
||||
# =============================================================================
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 1: 检查构建条件
|
||||
# ---------------------------------------------------------------------------
|
||||
# 判断是否应该触发构建:
|
||||
# - pull_request_target 事件:总是触发
|
||||
# - issue_comment 事件:检查是否为 /build 命令,且用户有权限
|
||||
# ---------------------------------------------------------------------------
|
||||
check-build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }} # 是否应该构建
|
||||
pr_number: ${{ steps.check.outputs.pr_number }} # PR 编号
|
||||
pr_sha: ${{ steps.check.outputs.pr_sha }} # PR 最新提交 SHA
|
||||
pr_head_repo: ${{ steps.check.outputs.pr_head_repo }} # PR 源仓库(用于 Fork)
|
||||
pr_head_ref: ${{ steps.check.outputs.pr_head_ref }} # PR 源分支
|
||||
steps:
|
||||
# 仅 checkout 脚本目录,加快速度
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
# 使用 Node.js 24 以支持原生 TypeScript 执行
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# 执行检查脚本,判断是否触发构建
|
||||
- name: Check trigger condition
|
||||
id: check
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node --experimental-strip-types .github/scripts/pr-build-check.ts
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 2: 更新评论为"构建中"状态
|
||||
# ---------------------------------------------------------------------------
|
||||
# 在 PR 中创建或更新评论,显示构建正在进行中
|
||||
# ---------------------------------------------------------------------------
|
||||
update-comment-building:
|
||||
needs: check-build
|
||||
if: needs.check-build.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# 更新 PR 评论,显示构建中状态
|
||||
- name: Update building comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
|
||||
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
|
||||
run: node --experimental-strip-types .github/scripts/pr-build-building.ts
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 3: 构建 Framework 包
|
||||
# ---------------------------------------------------------------------------
|
||||
# 执行 napcat-framework 的构建流程
|
||||
# ---------------------------------------------------------------------------
|
||||
build-framework:
|
||||
needs: [check-build, update-comment-building]
|
||||
if: needs.check-build.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
status: ${{ steps.build.outcome }} # 构建结果:success/failure
|
||||
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
|
||||
version: ${{ steps.version.outputs.version }} # 构建版本号
|
||||
steps:
|
||||
# 【安全】先从 base 分支 checkout 构建脚本
|
||||
# 这样即使 PR 中修改了脚本,也不会被执行
|
||||
- name: Checkout scripts from base
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
path: _scripts
|
||||
|
||||
# 将 PR 代码 checkout 到单独的 workspace 目录
|
||||
- name: Checkout PR code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ needs.check-build.outputs.pr_head_repo }}
|
||||
ref: ${{ needs.check-build.outputs.pr_sha }}
|
||||
path: workspace
|
||||
fetch-depth: 0 # 需要完整历史来获取 tags
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# 获取最新 release tag 并生成版本号
|
||||
- name: Generate Version
|
||||
id: version
|
||||
working-directory: workspace
|
||||
run: |
|
||||
# 获取最近的 release tag (格式: vX.X.X)
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
|
||||
# 去掉 v 前缀
|
||||
BASE_VERSION="${LATEST_TAG#v}"
|
||||
SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}"
|
||||
SHORT_SHA="${SHORT_SHA::7}"
|
||||
VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
|
||||
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "Latest tag: ${LATEST_TAG}"
|
||||
echo "Build version: ${VERSION}"
|
||||
|
||||
# 执行构建,使用 base 分支的脚本处理 workspace 中的代码
|
||||
- name: Build
|
||||
id: build
|
||||
working-directory: workspace
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
|
||||
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts framework
|
||||
continue-on-error: true # 允许失败,后续更新评论时处理
|
||||
|
||||
# 构建成功时上传产物
|
||||
- name: Upload Artifact
|
||||
if: steps.build.outcome == 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Framework
|
||||
path: workspace/framework-dist
|
||||
retention-days: 7 # 保留 7 天
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 4: 构建 Shell 包
|
||||
# ---------------------------------------------------------------------------
|
||||
# 执行 napcat-shell 的构建流程(与 Framework 并行执行)
|
||||
# ---------------------------------------------------------------------------
|
||||
build-shell:
|
||||
needs: [check-build, update-comment-building]
|
||||
if: needs.check-build.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
status: ${{ steps.build.outcome }} # 构建结果:success/failure
|
||||
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
|
||||
version: ${{ steps.version.outputs.version }} # 构建版本号
|
||||
steps:
|
||||
# 【安全】先从 base 分支 checkout 构建脚本
|
||||
- name: Checkout scripts from base
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
path: _scripts
|
||||
|
||||
# 将 PR 代码 checkout 到单独的 workspace 目录
|
||||
- name: Checkout PR code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ needs.check-build.outputs.pr_head_repo }}
|
||||
ref: ${{ needs.check-build.outputs.pr_sha }}
|
||||
path: workspace
|
||||
fetch-depth: 0 # 需要完整历史来获取 tags
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# 获取最新 release tag 并生成版本号
|
||||
- name: Generate Version
|
||||
id: version
|
||||
working-directory: workspace
|
||||
run: |
|
||||
# 获取最近的 release tag (格式: vX.X.X)
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
|
||||
# 去掉 v 前缀
|
||||
BASE_VERSION="${LATEST_TAG#v}"
|
||||
SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}"
|
||||
SHORT_SHA="${SHORT_SHA::7}"
|
||||
VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
|
||||
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Latest tag: ${LATEST_TAG}"
|
||||
echo "Build version: ${VERSION}"
|
||||
|
||||
# 执行构建
|
||||
- name: Build
|
||||
id: build
|
||||
working-directory: workspace
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
|
||||
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts shell
|
||||
continue-on-error: true
|
||||
|
||||
# 构建成功时上传产物
|
||||
- name: Upload Artifact
|
||||
if: steps.build.outcome == 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Shell
|
||||
path: workspace/shell-dist
|
||||
retention-days: 7 # 保留 7 天
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 5: 更新评论为构建结果
|
||||
# ---------------------------------------------------------------------------
|
||||
# 汇总所有构建结果,更新 PR 评论显示最终状态
|
||||
# 使用 always() 确保即使构建失败/取消也会执行
|
||||
# ---------------------------------------------------------------------------
|
||||
update-comment-result:
|
||||
needs: [check-build, update-comment-building, build-framework, build-shell]
|
||||
if: always() && needs.check-build.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# 更新评论,显示构建结果和下载链接
|
||||
- name: Update result comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
|
||||
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
# 构建版本号
|
||||
NAPCAT_VERSION: ${{ needs.build-framework.outputs.version || needs.build-shell.outputs.version || '' }}
|
||||
# 获取构建状态,如果 job 被跳过则标记为 cancelled
|
||||
FRAMEWORK_STATUS: ${{ needs.build-framework.outputs.status || 'cancelled' }}
|
||||
FRAMEWORK_ERROR: ${{ needs.build-framework.outputs.error }}
|
||||
SHELL_STATUS: ${{ needs.build-shell.outputs.status || 'cancelled' }}
|
||||
SHELL_ERROR: ${{ needs.build-shell.outputs.error }}
|
||||
run: node --experimental-strip-types .github/scripts/pr-build-result.ts
|
||||
523
.github/workflows/release.yml
vendored
523
.github/workflows/release.yml
vendored
@@ -1,152 +1,427 @@
|
||||
name: "Build Release"
|
||||
name: Release NapCat
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- 'v*'
|
||||
|
||||
permissions: write-all
|
||||
|
||||
env:
|
||||
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
|
||||
OPENROUTER_MODEL: "Antigravity/gemini-3-flash-preview"
|
||||
RELEASE_NAME: "NapCat"
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
# 验证版本号格式
|
||||
validate-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
valid: ${{ steps.check.outputs.valid }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- name: Validate semantic version
|
||||
id: check
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
echo "Checking tag: $TAG"
|
||||
|
||||
# 语义化版本正则表达式
|
||||
# 支持: v1.0.0, v1.0.0-beta, v1.0.0-rc.1, v1.0.0-alpha.1+build.123
|
||||
SEMVER_REGEX="^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?$"
|
||||
|
||||
if [[ "$TAG" =~ $SEMVER_REGEX ]]; then
|
||||
echo "✅ Valid semantic version: $TAG"
|
||||
echo "valid=true" >> $GITHUB_OUTPUT
|
||||
echo "version=$TAG" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "❌ Invalid version format: $TAG"
|
||||
echo "Expected format: vX.Y.Z or vX.Y.Z-prerelease"
|
||||
echo "Examples: v1.0.0, v1.2.3-beta, v2.0.0-rc.1"
|
||||
echo "valid=false" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Build-Framework:
|
||||
needs: validate-version
|
||||
if: needs.validate-version.outputs.valid == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone Repository
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
|
||||
- name: Use Node.js 20.X
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.Framework
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm i -g pnpm
|
||||
pnpm i
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:framework
|
||||
mv packages/napcat-framework/dist framework-dist
|
||||
cd framework-dist
|
||||
npm install --omit=dev
|
||||
rm ./package-lock.json || exit 0
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Framework
|
||||
path: framework-dist
|
||||
|
||||
- name: Check Version
|
||||
run: |
|
||||
ls
|
||||
node ./script/checkVersion.cjs
|
||||
sh ./checkVersion.sh
|
||||
Build-LiteLoader:
|
||||
needs: [check-version]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'NapNeko/NapCatQQ'
|
||||
submodules: true
|
||||
ref: main
|
||||
token: ${{ secrets.NAPCAT_BUILD }}
|
||||
- name: Use Node.js 20.X
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Build NuCat Framework
|
||||
run: |
|
||||
npm i
|
||||
cd napcat.webui
|
||||
npm i
|
||||
cd ..
|
||||
npm run build:framework
|
||||
cd dist
|
||||
npm i --omit=dev
|
||||
cd ..
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Framework
|
||||
path: dist
|
||||
Build-Shell:
|
||||
needs: validate-version
|
||||
if: needs.validate-version.outputs.valid == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-version]
|
||||
steps:
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'NapNeko/NapCatQQ'
|
||||
submodules: true
|
||||
ref: main
|
||||
token: ${{ secrets.NAPCAT_BUILD }}
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Use Node.js 20.X
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.Shell
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm i -g pnpm
|
||||
pnpm i
|
||||
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||
pnpm run build:shell
|
||||
mv packages/napcat-shell/dist shell-dist
|
||||
cd shell-dist
|
||||
npm install --omit=dev
|
||||
rm ./package-lock.json || exit 0
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Shell
|
||||
path: shell-dist
|
||||
Download-QNX64:
|
||||
needs: Build-Shell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 20.X
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Build NuCat Shell
|
||||
run: |
|
||||
npm i
|
||||
cd napcat.webui
|
||||
npm i
|
||||
cd ..
|
||||
npm run build:shell
|
||||
cd dist
|
||||
npm i --omit=dev
|
||||
cd ..
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./artifacts
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Shell
|
||||
path: dist
|
||||
- name: Setup tools
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y aria2 unzip zip p7zip-full curl jq
|
||||
|
||||
- name: Download QQ x64, Node.js and Assemble NapCat.Shell.Windows.Node.zip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TMPDIR=$(mktemp -d)
|
||||
cd "$TMPDIR"
|
||||
|
||||
# -----------------------------
|
||||
# 1) 下载 QQ x64
|
||||
# -----------------------------
|
||||
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
|
||||
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/eb263b35/QQ9.9.23.42086_x64.exe"
|
||||
QQ_ZIP="$(basename "$NT_URL")"
|
||||
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
||||
|
||||
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
||||
mkdir -p "$QQ_EXTRACT"
|
||||
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
||||
|
||||
# -----------------------------
|
||||
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
||||
# -----------------------------
|
||||
NODE_VER="22.11.0"
|
||||
NODE_URL="https://nodejs.org/dist/v$NODE_VER/node-v$NODE_VER-win-x64.zip"
|
||||
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
||||
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
||||
|
||||
NODE_EXTRACT="$TMPDIR/node_extracted"
|
||||
mkdir -p "$NODE_EXTRACT"
|
||||
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
||||
|
||||
# -----------------------------
|
||||
# 3) 创建输出目录
|
||||
# -----------------------------
|
||||
OUT_DIR="$GITHUB_WORKSPACE/NapCat.Shell.Windows.Node"
|
||||
mkdir -p "$OUT_DIR/NapCat.Shell.Windows.Node"
|
||||
|
||||
# -----------------------------
|
||||
# 4) 解压 NapCat.Shell.zip 到 napcat
|
||||
# -----------------------------
|
||||
cp -a "$GITHUB_WORKSPACE/artifacts/NapCat.Shell/." "$OUT_DIR/napcat/"
|
||||
|
||||
# -----------------------------
|
||||
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
|
||||
# -----------------------------
|
||||
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node")
|
||||
for name in "${QQ_TARGETS[@]}"; do
|
||||
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
||||
done
|
||||
|
||||
# -----------------------------
|
||||
# 6) 拷贝仓库文件 napcat.bat 和 index.js
|
||||
# -----------------------------
|
||||
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/napcat.bat" "$OUT_DIR/" || true
|
||||
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/index.js" "$OUT_DIR/" || true
|
||||
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/QQNT.dll" "$OUT_DIR/" || true
|
||||
# -----------------------------
|
||||
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
|
||||
# -----------------------------
|
||||
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Shell.Windows.Node
|
||||
path: NapCat.Shell.Windows.Node
|
||||
|
||||
release-napcat:
|
||||
needs: [Build-LiteLoader,Build-Shell]
|
||||
needs: [Build-Framework, Build-Shell, Download-QNX64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'NapNeko/NapCatQQ'
|
||||
submodules: true
|
||||
ref: main
|
||||
token: ${{ secrets.NAPCAT_BUILD }}
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./artifacts
|
||||
|
||||
- name: Download All Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Compress subdirectories
|
||||
run: |
|
||||
cd ./NapCat.Shell/
|
||||
zip -q -r NapCat.Shell.zip *
|
||||
cd ..
|
||||
cd ./NapCat.Framework/
|
||||
zip -q -r NapCat.Framework.zip *
|
||||
cd ..
|
||||
rm ./NapCat.Shell.zip -rf
|
||||
rm ./NapCat.Framework.zip -rf
|
||||
mv ./NapCat.Shell/NapCat.Shell.zip ./
|
||||
mv ./NapCat.Framework/NapCat.Framework.zip ./
|
||||
- name: Zip Artifacts
|
||||
run: |
|
||||
cd artifacts
|
||||
[ -d NapCat.Framework ] && (cd NapCat.Framework && zip -qr ../../NapCat.Framework.zip .)
|
||||
[ -d NapCat.Shell ] && (cd NapCat.Shell && zip -qr ../../NapCat.Shell.zip .)
|
||||
[ -d NapCat.Shell.Windows.Node ] && (cd NapCat.Shell.Windows.Node && zip -qr ../../NapCat.Shell.Windows.Node.zip .)
|
||||
cd ..
|
||||
|
||||
mkdir ./NapCat.Framework.Windows.Once
|
||||
unzip -q ./external/LiteLoaderWrapper.zip -d ./NapCat.Framework.Windows.Once
|
||||
cd ./NapCat.Framework.Windows.Once
|
||||
ls
|
||||
mkdir -p ./LL/plugins/NapCatQQ
|
||||
unzip -q ../NapCat.Framework.zip -d ./LL/plugins/NapCatQQ
|
||||
zip -q -r NapCat.Framework.Windows.Once.zip *
|
||||
cd ..
|
||||
mv ./NapCat.Framework.Windows.Once/NapCat.Framework.Windows.Once.zip ./
|
||||
- name: Extract version from tag
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
|
||||
- name: Clone Changes Log
|
||||
run: curl -o CHANGELOG.md https://fastly.jsdelivr.net/gh/NapNeko/NapCatQQ@main/docs/changelogs/CHANGELOG.v${{ env.VERSION }}.md
|
||||
|
||||
- name: Create Release Draft and Upload Artifacts
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
name: NapCat V${{ env.VERSION }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
body_path: CHANGELOG.md
|
||||
files: |
|
||||
NapCat.Framework.zip
|
||||
NapCat.Shell.zip
|
||||
NapCat.Framework.Windows.Once.zip
|
||||
draft: true
|
||||
- name: Generate release note via OpenRouter
|
||||
env:
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
|
||||
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
|
||||
GITHUB_OWNER: "NapNeko" # 替换成你的 repo owner
|
||||
GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 当前 tag
|
||||
CURRENT_TAG="${GITHUB_REF#refs/tags/}"
|
||||
echo "Current tag: $CURRENT_TAG"
|
||||
|
||||
# 从 GitHub API 获取 tag 列表
|
||||
TAGS_JSON=$(curl -s "https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/tags?per_page=100")
|
||||
TAGS=( $(echo "$TAGS_JSON" | jq -r '.[].name' | sort -V) )
|
||||
|
||||
# 找到上一个 tag
|
||||
PREV_TAG=""
|
||||
for i in "${!TAGS[@]}"; do
|
||||
if [ "${TAGS[$i]}" = "$CURRENT_TAG" ]; then
|
||||
if [ $i -gt 0 ]; then
|
||||
PREV_TAG="${TAGS[$((i-1))]}"
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "⚠️ Could not find previous tag for $CURRENT_TAG, using first commit"
|
||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD | head -1)
|
||||
fi
|
||||
|
||||
echo "Previous tag: $PREV_TAG"
|
||||
|
||||
# 强制拉取上一个 tag 和当前 tag
|
||||
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force || true
|
||||
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force || true
|
||||
|
||||
# 获取 commit,使用更清晰的格式
|
||||
# 格式: <type>: <subject> (<hash>)
|
||||
COMMITS=$(git log --pretty=format:'- %s (%h)' "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || git log --pretty=format:'- %s (%h)' -20)
|
||||
|
||||
echo "Commit list from $PREV_TAG to $CURRENT_TAG:"
|
||||
echo "$COMMITS"
|
||||
|
||||
# 获取文件变化统计
|
||||
echo "Getting file change statistics..."
|
||||
FILE_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || echo "")
|
||||
|
||||
# 获取总体统计(最后一行)
|
||||
SUMMARY_LINE=$(echo "$FILE_STATS" | tail -1)
|
||||
echo "Summary: $SUMMARY_LINE"
|
||||
|
||||
# 获取每个文件的变化(去掉最后一行汇总)
|
||||
# 截断过长的输出(最多50个文件,每行最多80字符)
|
||||
FILE_CHANGES=$(echo "$FILE_STATS" | head -n -1 | head -50 | cut -c1-80)
|
||||
|
||||
# 如果文件变化太多,进一步精简:只保留主要目录的变化
|
||||
FILE_COUNT=$(echo "$FILE_STATS" | head -n -1 | wc -l)
|
||||
if [ "$FILE_COUNT" -gt 50 ]; then
|
||||
echo "Too many files ($FILE_COUNT), grouping by directory..."
|
||||
# 按目录分组统计
|
||||
DIR_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | head -n -1 | \
|
||||
sed 's/|.*//g' | \
|
||||
awk -F'/' '{if(NF>1) print $1"/"$2; else print $1}' | \
|
||||
sort | uniq -c | sort -rn | head -20)
|
||||
FILE_CHANGES="[按目录分组统计 - 共 $FILE_COUNT 个文件变更]
|
||||
$DIR_STATS"
|
||||
fi
|
||||
|
||||
echo "File changes:"
|
||||
echo "$FILE_CHANGES"
|
||||
|
||||
# 获取具体代码变化(关键文件的diff)
|
||||
echo "Getting code diff for key files..."
|
||||
|
||||
# 定义关键目录(优先展示这些目录的变化)
|
||||
KEY_DIRS="packages/napcat-core packages/napcat-onebot packages/napcat-webui-backend"
|
||||
|
||||
# 获取变更的关键文件列表(排除测试、配置等)
|
||||
KEY_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
|
||||
grep -E "^packages/napcat-(core|onebot|webui-backend|shell)/" | \
|
||||
grep -E "\.(ts|js)$" | \
|
||||
grep -v -E "(test|spec|\.d\.ts|config)" | \
|
||||
head -15)
|
||||
|
||||
CODE_DIFF=""
|
||||
DIFF_CHAR_LIMIT=6000 # 总diff字符限制
|
||||
CURRENT_CHARS=0
|
||||
|
||||
for file in $KEY_FILES; do
|
||||
if [ "$CURRENT_CHARS" -ge "$DIFF_CHAR_LIMIT" ]; then
|
||||
CODE_DIFF="$CODE_DIFF
|
||||
[... 更多文件变化已截断 ...]"
|
||||
break
|
||||
fi
|
||||
|
||||
# 获取单个文件的diff,限制每个文件最多50行
|
||||
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -50)
|
||||
FILE_DIFF_LEN=${#FILE_DIFF}
|
||||
|
||||
# 如果单个文件diff超过1500字符,截断
|
||||
if [ "$FILE_DIFF_LEN" -gt 1500 ]; then
|
||||
FILE_DIFF=$(echo "$FILE_DIFF" | head -c 1500)
|
||||
FILE_DIFF="$FILE_DIFF
|
||||
[... 文件 $file 变化已截断 ...]"
|
||||
fi
|
||||
|
||||
if [ -n "$FILE_DIFF" ]; then
|
||||
CODE_DIFF="$CODE_DIFF
|
||||
|
||||
### $file
|
||||
\`\`\`diff
|
||||
$FILE_DIFF
|
||||
\`\`\`"
|
||||
CURRENT_CHARS=$((CURRENT_CHARS + FILE_DIFF_LEN))
|
||||
fi
|
||||
done
|
||||
|
||||
# 如果没有关键文件变化,获取前5个变更文件的diff
|
||||
if [ -z "$CODE_DIFF" ]; then
|
||||
echo "No key files changed, getting top changed files..."
|
||||
TOP_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
|
||||
grep -E "\.(ts|js)$" | head -5)
|
||||
|
||||
for file in $TOP_FILES; do
|
||||
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -30)
|
||||
if [ -n "$FILE_DIFF" ] && [ ${#FILE_DIFF} -lt 1000 ]; then
|
||||
CODE_DIFF="$CODE_DIFF
|
||||
|
||||
### $file
|
||||
\`\`\`diff
|
||||
$FILE_DIFF
|
||||
\`\`\`"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Code diff preview:"
|
||||
echo "$CODE_DIFF" | head -50
|
||||
|
||||
# 读取 prompt
|
||||
PROMPT_FILE=".github/prompt/release_note_prompt.txt"
|
||||
SYSTEM_PROMPT=$(<"$PROMPT_FILE")
|
||||
|
||||
# 构建用户内容,传递更多上下文(包含文件变化和代码diff)
|
||||
USER_CONTENT="当前版本: $CURRENT_TAG
|
||||
上一版本: $PREV_TAG
|
||||
|
||||
## 提交列表
|
||||
$COMMITS
|
||||
|
||||
## 文件变化统计
|
||||
$SUMMARY_LINE
|
||||
|
||||
## 变更文件列表
|
||||
$FILE_CHANGES
|
||||
|
||||
## 关键代码变化
|
||||
$CODE_DIFF"
|
||||
|
||||
# 构建请求 JSON,增加 max_tokens 以获取更完整的输出
|
||||
BODY=$(jq -n \
|
||||
--arg system "$SYSTEM_PROMPT" \
|
||||
--arg user "$USER_CONTENT" \
|
||||
--arg model "$OPENROUTER_MODEL" \
|
||||
'{model: $model, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.2, max_tokens:1500}')
|
||||
|
||||
echo "=== OpenRouter request body ==="
|
||||
echo "$BODY" | jq .
|
||||
|
||||
# 调用 OpenRouter
|
||||
if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
|
||||
-H "Authorization: Bearer $OPENAI_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY"); then
|
||||
echo "=== raw response ==="
|
||||
echo "$RESPONSE"
|
||||
echo "=== OpenRouter raw response ==="
|
||||
if echo "$RESPONSE" | jq . >/dev/null 2>&1; then
|
||||
echo "$RESPONSE" | jq .
|
||||
else
|
||||
echo "jq failed to parse response"
|
||||
fi
|
||||
|
||||
# 提取生成内容
|
||||
RELEASE_BODY=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // .choices[0].text // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$RELEASE_BODY" ]; then
|
||||
echo "❌ OpenRouter failed to generate release note, using default.md"
|
||||
# 替换默认模板中的版本占位符
|
||||
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
|
||||
else
|
||||
# 后处理:确保版本号正确,并添加比较链接
|
||||
echo -e "$RELEASE_BODY" > CHANGELOG.md
|
||||
# 替换可能的占位符
|
||||
sed -i "s/{VERSION}/$CURRENT_TAG/g" CHANGELOG.md
|
||||
sed -i "s/{PREV_VERSION}/$PREV_TAG/g" CHANGELOG.md
|
||||
fi
|
||||
else
|
||||
echo "❌ Curl failed, using default.md"
|
||||
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
|
||||
fi
|
||||
echo "=== generated release note ==="
|
||||
cat CHANGELOG.md
|
||||
|
||||
- name: Create Release Draft and Upload Artifacts
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
name: NapCat ${{ github.ref_name }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
body_path: CHANGELOG.md
|
||||
files: |
|
||||
NapCat.Shell.Windows.Node.zip
|
||||
NapCat.Framework.zip
|
||||
NapCat.Shell.zip
|
||||
draft: true
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
# Develop
|
||||
node_modules/
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
out/
|
||||
dist/
|
||||
@@ -15,3 +14,6 @@ devconfig/*
|
||||
*.db
|
||||
checkVersion.sh
|
||||
bun.lockb
|
||||
tests/run/
|
||||
guild1.db-wal
|
||||
guild1.db-shm
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 120,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
12
.vscode/launch.json
vendored
Normal file
12
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "调试程序",
|
||||
"command": "pnpm run dev:shell",
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
43
.vscode/settings.json
vendored
43
.vscode/settings.json
vendored
@@ -1,10 +1,37 @@
|
||||
{
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.expand": false,
|
||||
"explorer.fileNesting.patterns": {
|
||||
".env.universal": ".env.*",
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
|
||||
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
|
||||
},
|
||||
"css.customData": [".vscode/tailwindcss.json"],
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.expand": false,
|
||||
"explorer.fileNesting.patterns": {
|
||||
".env.universal": ".env.*",
|
||||
"vite.config.ts": "vite*.ts",
|
||||
"README.md": "CODE_OF_CONDUCT.md, RELEASES.md, CONTRIBUTING.md, CHANGELOG.md, SECURITY.md",
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts",
|
||||
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
|
||||
},
|
||||
"css.customData": [
|
||||
".vscode/tailwindcss.json"
|
||||
],
|
||||
"editor.detectIndentation": false,
|
||||
"editor.tabSize": 2,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": false,
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "always"
|
||||
},
|
||||
"files.autoSave": "onFocusChange",
|
||||
"javascript.preferences.quoteStyle": "single",
|
||||
"typescript.preferences.quoteStyle": "single",
|
||||
"javascript.format.semicolons": "insert",
|
||||
"typescript.format.semicolons": "insert",
|
||||
"javascript.format.insertSpaceBeforeFunctionParenthesis": true,
|
||||
"typescript.format.insertSpaceBeforeFunctionParenthesis": true,
|
||||
"typescript.format.insertSpaceAfterConstructor": true,
|
||||
"javascript.format.insertSpaceAfterConstructor": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.preferences.importModuleSpecifierEnding": "minimal",
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
"javascript.preferences.importModuleSpecifierEnding": "minimal",
|
||||
"typescript.disableAutomaticTypeAcquisition": true
|
||||
}
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
nanaeonn@outlook.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
98
README.md
98
README.md
@@ -1,67 +1,89 @@
|
||||
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=right />
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
|
||||
# NapCat
|
||||
|
||||
_Modern protocol-side framework implemented based on NTQQ._
|
||||
|
||||
> 云起兮风生,心向远方兮路未曾至.
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
## 欢迎回家
|
||||
NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
||||
|
||||
## 特性介绍
|
||||
- [x] **安装简单**:就算是笨蛋也能使用
|
||||
- [x] **性能友好**:就算是低内存也能使用
|
||||
- [x] **接口丰富**:就算是没有也能使用
|
||||
- [x] **稳定好用**:就算是被捉也能使用
|
||||
## New Feature
|
||||
|
||||
## 使用框架
|
||||
在 v4.8.115+ 版本开始
|
||||
|
||||
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
|
||||
2. NapCatQQ 推荐 message_id/user_id/group_id 均使用字符串类型
|
||||
|
||||
- [1] 解决 Docker/跨设备/大文件 的多媒体上下传问题
|
||||
- [2] 采用字符串可以解决扩展到int64的问题,同时也可以解决部分语言(如JavaScript)对大整数支持不佳的问题,增加极少成本。
|
||||
|
||||
## Welcome
|
||||
|
||||
- NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
|
||||
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
||||
|
||||
## Feature
|
||||
|
||||
- **Easy to Use**
|
||||
- 作为初学者能够轻松使用.
|
||||
- **Quick and Efficient**
|
||||
- 在低内存操作系统长时运行.
|
||||
- **Rich API Interface**
|
||||
- 完整实现了大部分标准接口.
|
||||
- **Stable and Reliable**
|
||||
- 持续稳定的开发与维护.
|
||||
|
||||
## Quick Start
|
||||
|
||||
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
|
||||
|
||||
**首次使用**请务必查看如下文档看使用教程
|
||||
|
||||
### 文档地址
|
||||
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||
|
||||
[Cloudflare.Worker](https://doc.napneko.icu/)
|
||||
## Link
|
||||
|
||||
[Cloudflare.HKServer](https://napcat.napneko.icu/)
|
||||
| Docs | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
[Github.IO](https://napneko.github.io/)
|
||||
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.cyou/) | [](https://www.napcat.wiki) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
[Cloudflare.Pages](https://napneko.pages.dev/)
|
||||
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||
|:-:|:-:|:-:|:-:|:-:|
|
||||
|
||||
[Server.Other](https://docs.napcat.cyou/)
|
||||
| Telegram | [](https://t.me/napcatqq) |
|
||||
|:-:|:-:|
|
||||
|
||||
[NapCat.Wiki](https://www.napcat.wiki)
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
## 回家旅途
|
||||
[QQ Group#1](https://qm.qq.com/q/I6LU87a0Yq)
|
||||
> 请不要在其余社区提及本项目(包括其余协议端/相关应用端项目)引发争论,如有建议到达官方交流群讨论或PR。
|
||||
|
||||
[QQ Group#2](https://qm.qq.com/q/HaRcfrHpUk)
|
||||
## Thanks
|
||||
|
||||
[Telegram](https://t.me/MelodicMoonlight)
|
||||
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
||||
|
||||
> QQ Group#2 准许Bot / Telegram与QQ Group#2 为新建Group
|
||||
- [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
|
||||
|
||||
## 性能设计/协议标准
|
||||
NapCat 已实现90%+的 OneBot / GoCQ 标准接口,并提供兼容性保留接口,其设计理念遵守 无数据库/异步优先/后台刷新 的性能思想。
|
||||
- [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
|
||||
|
||||
由此设计带来一系列好处,在开发中,获取群员列表通常小于50Ms,单条文本消息发送在320Ms以内,在1k+的群聊流畅运行,同时带来一些副作用,消息Id无法持久,无法上报撤回消息原始内容。
|
||||
- [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
|
||||
|
||||
NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进,任何合理的标准化 Issue 与 Pr 将被接收。
|
||||
|
||||
## 感谢他们
|
||||
感谢 [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
||||
|
||||
感谢 React 强力驱动 NapCat.WebUi
|
||||
|
||||
不过最最重要的 还是需要感谢屏幕前的你哦~
|
||||
- 不过最最重要的 还是需要感谢屏幕前的你哦~
|
||||
|
||||
---
|
||||
|
||||
## 特殊感谢
|
||||
[LLOneBot](https://github.com/LLOneBot/LLOneBot) 相关的开发曾参与本项目
|
||||
## License
|
||||
|
||||
## 开源附加
|
||||
本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点:
|
||||
|
||||
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**本仓库仅用于提高IM易用性,实现类似Hook推送,此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
|
||||
1. 第三方库代码或修改部分遵循其原始开源许可.
|
||||
2. 本项目获取部分项目授权而不受部分约束
|
||||
2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).
|
||||
|
||||
**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
|
||||
|
||||
11
SECURITY.md
Normal file
11
SECURITY.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| > 4.0 | :white_check_mark: |
|
||||
| < 4.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
you should open an issue
|
||||
52
eslint.config.js
Normal file
52
eslint.config.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import neostandard from 'neostandard';
|
||||
|
||||
/** 尾随逗号 */
|
||||
const commaDangle = val => {
|
||||
if (val?.rules?.['@stylistic/comma-dangle']?.[0] === 'warn') {
|
||||
const rule = val?.rules?.['@stylistic/comma-dangle']?.[1];
|
||||
Object.keys(rule).forEach(key => {
|
||||
rule[key] = 'always-multiline';
|
||||
});
|
||||
val.rules['@stylistic/comma-dangle'][1] = rule;
|
||||
}
|
||||
|
||||
/** 三元表达式 */
|
||||
if (val?.rules?.['@stylistic/indent']) {
|
||||
val.rules['@stylistic/indent'][2] = {
|
||||
...val.rules?.['@stylistic/indent']?.[2],
|
||||
flatTernaryExpressions: true,
|
||||
offsetTernaryExpressions: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** 支持下划线 - 禁用 camelcase 规则 */
|
||||
if (val?.rules?.camelcase) {
|
||||
val.rules.camelcase = 'off';
|
||||
}
|
||||
|
||||
/** 未使用的变量强制报错 */
|
||||
if (val?.rules?.['@typescript-eslint/no-unused-vars']) {
|
||||
val.rules['@typescript-eslint/no-unused-vars'] = ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}];
|
||||
}
|
||||
|
||||
return val;
|
||||
};
|
||||
|
||||
/** 忽略的文件 */
|
||||
const ignores = [
|
||||
'node_modules',
|
||||
'**/dist/**',
|
||||
'launcher',
|
||||
];
|
||||
|
||||
const options = neostandard({
|
||||
ts: true,
|
||||
ignores,
|
||||
semi: true, // 强制使用分号
|
||||
}).map(commaDangle);
|
||||
|
||||
export default options;
|
||||
@@ -1,32 +0,0 @@
|
||||
import eslint from '@eslint/js';
|
||||
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import tsEslintParser from '@typescript-eslint/parser';
|
||||
import globals from "globals";
|
||||
|
||||
const customTsFlatConfig = [
|
||||
{
|
||||
name: 'typescript-eslint/base',
|
||||
languageOptions: {
|
||||
parser: tsEslintParser,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
NodeJS: 'readonly', // 添加 NodeJS 全局变量
|
||||
},
|
||||
},
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
...tsEslintPlugin.configs.recommended.rules,
|
||||
'quotes': ['error', 'single'], // 使用单引号
|
||||
'semi': ['error', 'always'], // 强制使用分号
|
||||
'indent': ['error', 4], // 使用 4 空格缩进
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsEslintPlugin,
|
||||
},
|
||||
ignores: ['src/webui/**'], // 忽略 src/webui/ 目录所有文件
|
||||
},
|
||||
];
|
||||
|
||||
export default [eslint.configs.recommended, ...customTsFlatConfig];
|
||||
BIN
external/LiteLoaderWrapper.zip
vendored
BIN
external/LiteLoaderWrapper.zip
vendored
Binary file not shown.
BIN
external/logo.png
vendored
BIN
external/logo.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 204 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
REM ./launcher.bat 123456
|
||||
REM ./launcher-win10.bat 123456
|
||||
REM 带有REM的为注释 删掉你需要的系统的那行REM这三个单词 修改QQ本脚本启动即可
|
||||
BIN
logo.png
BIN
logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 684 KiB After Width: | Height: | Size: 250 KiB |
@@ -1 +0,0 @@
|
||||
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"
|
||||
@@ -1,7 +0,0 @@
|
||||
dist
|
||||
*.md
|
||||
*.html
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"importOrder": [
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/const/(.*)$",
|
||||
"^@/store/(.*)$",
|
||||
"^@/components/(.*)$",
|
||||
"^@/contexts/(.*)$",
|
||||
"^@/hooks/(.*)$",
|
||||
"^@/utils/(.*)$",
|
||||
"^@/(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["@trivago/prettier-plugin-sort-imports"]
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import eslint_js from '@eslint/js'
|
||||
import tsEslintPlugin from '@typescript-eslint/eslint-plugin'
|
||||
import tsEslintParser from '@typescript-eslint/parser'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import globals from 'globals'
|
||||
|
||||
const customTsFlatConfig = [
|
||||
{
|
||||
name: 'typescript-eslint/base',
|
||||
languageOptions: {
|
||||
parser: tsEslintParser,
|
||||
sourceType: 'module'
|
||||
},
|
||||
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
||||
rules: {
|
||||
...tsEslintPlugin.configs.recommended.rules
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsEslintPlugin
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export default [
|
||||
eslint_js.configs.recommended,
|
||||
|
||||
eslintPluginPrettierRecommended,
|
||||
|
||||
...customTsFlatConfig,
|
||||
{
|
||||
name: 'global config',
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.es2022,
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
},
|
||||
parserOptions: {
|
||||
warnOnUnsupportedTypeScriptVersion: false
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'no-undef': 'off',
|
||||
//关闭不能再promise中使用ansyc
|
||||
'no-async-promise-executor': 'off',
|
||||
//关闭不能再常量中使用??
|
||||
'no-constant-binary-expression': 'off',
|
||||
'@typescript-eslint/ban-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
//禁止失去精度的字面数字
|
||||
'@typescript-eslint/no-loss-of-precision': 'off',
|
||||
//禁止使用any
|
||||
'@typescript-eslint/no-explicit-any': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['**/node_modules', '**/dist', '**/output']
|
||||
},
|
||||
{
|
||||
name: 'react-eslint',
|
||||
files: ['src/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooksPlugin
|
||||
},
|
||||
languageOptions: {
|
||||
...reactPlugin.configs.recommended.languageOptions
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
|
||||
'react/react-in-jsx-scope': 'off'
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
// 需要显示安装 react
|
||||
version: 'detect'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } }
|
||||
},
|
||||
eslintConfigPrettier
|
||||
]
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Suspense, lazy, useEffect } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom'
|
||||
|
||||
import PageBackground from '@/components/page_background'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
import Toaster from '@/components/toaster'
|
||||
|
||||
import DialogProvider from '@/contexts/dialog'
|
||||
import AudioProvider from '@/contexts/songs'
|
||||
|
||||
import useAuth from '@/hooks/auth'
|
||||
|
||||
import store from '@/store'
|
||||
|
||||
const WebLoginPage = lazy(() => import('@/pages/web_login'))
|
||||
const IndexPage = lazy(() => import('@/pages/index'))
|
||||
const QQLoginPage = lazy(() => import('@/pages/qq_login'))
|
||||
const DashboardIndexPage = lazy(() => import('@/pages/dashboard'))
|
||||
const AboutPage = lazy(() => import('@/pages/dashboard/about'))
|
||||
const ConfigPage = lazy(() => import('@/pages/dashboard/config'))
|
||||
const DebugPage = lazy(() => import('@/pages/dashboard/debug'))
|
||||
const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http'))
|
||||
const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'))
|
||||
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'))
|
||||
const LogsPage = lazy(() => import('@/pages/dashboard/logs'))
|
||||
const NetworkPage = lazy(() => import('@/pages/dashboard/network'))
|
||||
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'))
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<Provider store={store}>
|
||||
<PageBackground />
|
||||
<Toaster />
|
||||
<AudioProvider>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<AuthChecker>
|
||||
<AppRoutes />
|
||||
</AuthChecker>
|
||||
</Suspense>
|
||||
</AudioProvider>
|
||||
</Provider>
|
||||
</DialogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthChecker({ children }: { children: React.ReactNode }) {
|
||||
const { isAuth } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuth) {
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const token = search.get('token')
|
||||
let url = '/web_login'
|
||||
|
||||
if (token) {
|
||||
url += `?token=${token}`
|
||||
}
|
||||
navigate(url, { replace: true })
|
||||
}
|
||||
}, [isAuth, navigate])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<IndexPage />}>
|
||||
<Route index element={<DashboardIndexPage />} />
|
||||
<Route path="network" element={<NetworkPage />} />
|
||||
<Route path="config" element={<ConfigPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
<Route path="debug" element={<DebugPage />}>
|
||||
<Route path="ws" element={<WSDebug />} />
|
||||
<Route path="http" element={<HttpDebug />} />
|
||||
</Route>
|
||||
<Route path="file_manager" element={<FileManagerPage />} />
|
||||
<Route path="terminal" element={<TerminalPage />} />
|
||||
<Route path="about" element={<AboutPage />} />
|
||||
</Route>
|
||||
<Route path="/qq_login" element={<QQLoginPage />} />
|
||||
<Route path="/web_login" element={<WebLoginPage />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,425 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Slider } from '@heroui/slider'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
BiSolidSkipNextCircle,
|
||||
BiSolidSkipPreviousCircle
|
||||
} from 'react-icons/bi'
|
||||
import {
|
||||
FaPause,
|
||||
FaPlay,
|
||||
FaRegHandPointRight,
|
||||
FaRepeat,
|
||||
FaShuffle
|
||||
} from 'react-icons/fa6'
|
||||
import { TbRepeatOnce } from 'react-icons/tb'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
import { PlayMode } from '@/const/enum'
|
||||
import key from '@/const/key'
|
||||
|
||||
import { VolumeHighIcon, VolumeLowIcon } from './icons'
|
||||
|
||||
export interface AudioPlayerProps
|
||||
extends React.AudioHTMLAttributes<HTMLAudioElement> {
|
||||
src: string
|
||||
title?: string
|
||||
artist?: string
|
||||
cover?: string
|
||||
pressNext?: () => void
|
||||
pressPrevious?: () => void
|
||||
onPlayEnd?: () => void
|
||||
onChangeMode?: (mode: PlayMode) => void
|
||||
mode?: PlayMode
|
||||
}
|
||||
|
||||
export default function AudioPlayer(props: AudioPlayerProps) {
|
||||
const {
|
||||
src,
|
||||
pressNext,
|
||||
pressPrevious,
|
||||
cover = 'https://nextui.org/images/album-cover.png',
|
||||
title = '未知',
|
||||
artist = '未知',
|
||||
onTimeUpdate,
|
||||
onLoadedData,
|
||||
onPlay,
|
||||
onPause,
|
||||
onPlayEnd,
|
||||
onChangeMode,
|
||||
autoPlay,
|
||||
mode = PlayMode.Loop,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [volume, setVolume] = useState(100)
|
||||
const [isCollapsed, setIsCollapsed] = useLocalStorage(
|
||||
key.isCollapsedMusicPlayer,
|
||||
false
|
||||
)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const startY = useRef(0)
|
||||
const startX = useRef(0)
|
||||
const [translateY, setTranslateY] = useState(0)
|
||||
const [translateX, setTranslateX] = useState(0)
|
||||
const isSmallScreen = useMediaQuery({ maxWidth: 767 })
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 })
|
||||
const shouldAdd = useRef(false)
|
||||
const currentProgress = (currentTime / duration) * 100
|
||||
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
|
||||
key.autoPlay,
|
||||
true
|
||||
)
|
||||
|
||||
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
const audio = event.target as HTMLAudioElement
|
||||
setCurrentTime(audio.currentTime)
|
||||
onTimeUpdate?.(event)
|
||||
}
|
||||
|
||||
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
const audio = event.target as HTMLAudioElement
|
||||
setDuration(audio.duration)
|
||||
onLoadedData?.(event)
|
||||
}
|
||||
|
||||
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
setIsPlaying(true)
|
||||
setStorageAutoPlay(true)
|
||||
onPlay?.(e)
|
||||
}
|
||||
|
||||
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
setIsPlaying(false)
|
||||
onPause?.(e)
|
||||
}
|
||||
|
||||
const changeMode = () => {
|
||||
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single]
|
||||
const currentIndex = modes.findIndex((_mode) => _mode === mode)
|
||||
const nextIndex = currentIndex + 1
|
||||
const nextMode = modes[nextIndex] || modes[0]
|
||||
onChangeMode?.(nextMode)
|
||||
}
|
||||
|
||||
const volumeChange = (value: number) => {
|
||||
setVolume(value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio) {
|
||||
audio.volume = volume / 100
|
||||
}
|
||||
}, [volume])
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
startY.current = e.touches[0].clientY
|
||||
startX.current = e.touches[0].clientX
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
const deltaY = e.touches[0].clientY - startY.current
|
||||
const deltaX = e.touches[0].clientX - startX.current
|
||||
const container = cardRef.current
|
||||
const header = cardRef.current?.querySelector('[data-header]')
|
||||
const headerHeight = header?.clientHeight || 20
|
||||
const addHeight = (container?.clientHeight || headerHeight) - headerHeight
|
||||
const _shouldAdd = isCollapsed && deltaY < 0
|
||||
if (isSmallScreen) {
|
||||
shouldAdd.current = _shouldAdd
|
||||
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY)
|
||||
} else {
|
||||
setTranslateX(deltaX)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (isSmallScreen) {
|
||||
const container = cardRef.current
|
||||
const header = cardRef.current?.querySelector('[data-header]')
|
||||
const headerHeight = header?.clientHeight || 20
|
||||
const addHeight = (container?.clientHeight || headerHeight) - headerHeight
|
||||
const _translateY = translateY - (shouldAdd.current ? addHeight : 0)
|
||||
if (_translateY > 100) {
|
||||
setIsCollapsed(true)
|
||||
} else if (_translateY < -100) {
|
||||
setIsCollapsed(false)
|
||||
}
|
||||
setTranslateY(0)
|
||||
} else {
|
||||
if (translateX > 100) {
|
||||
setIsCollapsed(true)
|
||||
} else if (translateX < -100) {
|
||||
setIsCollapsed(false)
|
||||
}
|
||||
setTranslateX(0)
|
||||
}
|
||||
}
|
||||
|
||||
const dragTranslate = isSmallScreen
|
||||
? translateY
|
||||
? `translateY(${translateY}px)`
|
||||
: ''
|
||||
: translateX
|
||||
? `translateX(${translateX}px)`
|
||||
: ''
|
||||
const collapsedTranslate = isCollapsed
|
||||
? isSmallScreen
|
||||
? 'translateY(90%)'
|
||||
: 'translateX(96%)'
|
||||
: ''
|
||||
|
||||
const translateStyle = dragTranslate || collapsedTranslate
|
||||
|
||||
if (!src) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
|
||||
!translateX && !translateY && 'transition-transform',
|
||||
isCollapsed && 'md:hover:!translate-x-80'
|
||||
)}
|
||||
style={{
|
||||
transform: translateStyle
|
||||
}}
|
||||
>
|
||||
<audio
|
||||
src={src}
|
||||
onLoadedData={handleLoadedData}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={onPlayEnd}
|
||||
autoPlay={autoPlay ?? storageAutoPlay}
|
||||
{...rest}
|
||||
controls={false}
|
||||
hidden
|
||||
ref={audioRef}
|
||||
/>
|
||||
|
||||
<Card
|
||||
ref={cardRef}
|
||||
className={clsx(
|
||||
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
|
||||
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
|
||||
)}
|
||||
classNames={{
|
||||
body: 'p-0'
|
||||
}}
|
||||
shadow="sm"
|
||||
radius="none"
|
||||
>
|
||||
{isMediumUp && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className={clsx(
|
||||
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
|
||||
isCollapsed
|
||||
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
|
||||
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
|
||||
)}
|
||||
variant="solid"
|
||||
color="danger"
|
||||
size="sm"
|
||||
onPress={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<FaRegHandPointRight />
|
||||
</Button>
|
||||
)}
|
||||
{isSmallScreen && (
|
||||
<CardHeader
|
||||
data-header
|
||||
className="flex-row justify-center pt-4"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<div className="w-24 h-2 rounded-full bg-content2-foreground shadow-sm"></div>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0">
|
||||
<div className="relative col-span-6 md:col-span-4 flex justify-center">
|
||||
<Image
|
||||
alt="Album cover"
|
||||
className="object-cover"
|
||||
classNames={{
|
||||
wrapper: 'w-36 aspect-square md:w-24 flex',
|
||||
img: 'block w-full h-full'
|
||||
}}
|
||||
shadow="md"
|
||||
src={cover}
|
||||
width="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col col-span-6 md:col-span-8">
|
||||
<div className="flex flex-col gap-0">
|
||||
<h1 className="font-medium truncate">{title}</h1>
|
||||
<p className="text-xs text-foreground/80 truncate">{artist}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Slider
|
||||
aria-label="Music progress"
|
||||
classNames={{
|
||||
track: 'bg-default-500/30 border-none',
|
||||
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
|
||||
filler: 'rounded-full'
|
||||
}}
|
||||
color="foreground"
|
||||
value={currentProgress || 0}
|
||||
defaultValue={0}
|
||||
size="sm"
|
||||
onChange={(value) => {
|
||||
value = Array.isArray(value) ? value[0] : value
|
||||
const audio = audioRef.current
|
||||
if (audio) {
|
||||
audio.currentTime = (value / 100) * duration
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between h-3">
|
||||
<p className="text-xs">
|
||||
{Math.floor(currentTime / 60)}:
|
||||
{Math.floor(currentTime % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</p>
|
||||
<p className="text-xs text-foreground/50">
|
||||
{Math.floor(duration / 60)}:
|
||||
{Math.floor(duration % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Tooltip
|
||||
content={
|
||||
mode === PlayMode.Loop
|
||||
? '列表循环'
|
||||
: mode === PlayMode.Random
|
||||
? '随机播放'
|
||||
: '单曲循环'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="data-[hover]:bg-foreground/10 text-lg md:text-medium"
|
||||
radius="full"
|
||||
variant="light"
|
||||
size="md"
|
||||
onPress={changeMode}
|
||||
>
|
||||
{mode === PlayMode.Loop && (
|
||||
<FaRepeat className="text-foreground/80" />
|
||||
)}
|
||||
{mode === PlayMode.Random && (
|
||||
<FaShuffle className="text-foreground/80" />
|
||||
)}
|
||||
{mode === PlayMode.Single && (
|
||||
<TbRepeatOnce className="text-foreground/80 text-xl" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="上一首">
|
||||
<Button
|
||||
isIconOnly
|
||||
className="data-[hover]:bg-foreground/10 text-2xl md:text-xl"
|
||||
radius="full"
|
||||
variant="light"
|
||||
size="md"
|
||||
onPress={pressPrevious}
|
||||
>
|
||||
<BiSolidSkipPreviousCircle />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={isPlaying ? '暂停' : '播放'}>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="data-[hover]:bg-foreground/10 text-3xl md:text-3xl"
|
||||
radius="full"
|
||||
variant="light"
|
||||
size="lg"
|
||||
onPress={() => {
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause()
|
||||
setStorageAutoPlay(false)
|
||||
} else {
|
||||
audioRef.current?.play()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <FaPause /> : <FaPlay className="ml-1" />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="下一首">
|
||||
<Button
|
||||
isIconOnly
|
||||
className="data-[hover]:bg-foreground/10 text-2xl md:text-xl"
|
||||
radius="full"
|
||||
variant="light"
|
||||
size="md"
|
||||
onPress={pressNext}
|
||||
>
|
||||
<BiSolidSkipNextCircle />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
placement="top"
|
||||
classNames={{
|
||||
content: 'bg-opacity-30 backdrop-blur-md'
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="data-[hover]:bg-foreground/10 text-xl md:text-xl"
|
||||
radius="full"
|
||||
variant="light"
|
||||
size="md"
|
||||
>
|
||||
<VolumeHighIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Slider
|
||||
orientation="vertical"
|
||||
showTooltip
|
||||
aria-label="Volume"
|
||||
className="h-40"
|
||||
color="primary"
|
||||
defaultValue={volume}
|
||||
onChange={(value) => {
|
||||
value = Array.isArray(value) ? value[0] : value
|
||||
volumeChange(value)
|
||||
}}
|
||||
startContent={<VolumeHighIcon className="text-2xl" />}
|
||||
size="sm"
|
||||
endContent={<VolumeLowIcon className="text-2xl" />}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoMdRefresh } from 'react-icons/io'
|
||||
|
||||
export interface SaveButtonsProps {
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
refresh?: () => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||
onSubmit,
|
||||
reset,
|
||||
isSubmitting,
|
||||
refresh
|
||||
}) => (
|
||||
<div className="max-w-full mx-3 w-96 flex flex-col justify-center gap-3">
|
||||
<div className="flex items-center justify-center gap-2 mt-5">
|
||||
<Button
|
||||
color="default"
|
||||
onPress={() => {
|
||||
reset()
|
||||
toast.success('重置成功')
|
||||
}}
|
||||
>
|
||||
取消更改
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
{refresh && (
|
||||
<Button
|
||||
isIconOnly
|
||||
color="secondary"
|
||||
radius="full"
|
||||
variant="flat"
|
||||
onPress={() => refresh()}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default SaveButtons
|
||||
@@ -1,254 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { FaMicrophone } from 'react-icons/fa6'
|
||||
import { IoMic } from 'react-icons/io5'
|
||||
import { MdEdit, MdUpload } from 'react-icons/md'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
const AudioInsert = () => {
|
||||
const [audioUrl, setAudioUrl] = useState<string>('')
|
||||
const audioInputRef = useRef<HTMLInputElement>(null)
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const showAudioSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'record',
|
||||
data: {
|
||||
file: file
|
||||
}
|
||||
}
|
||||
]
|
||||
showStructuredMessage(messages)
|
||||
}
|
||||
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const [audioPreview, setAudioPreview] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const [recordingTime, setRecordingTime] = useState(0)
|
||||
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
|
||||
streamRef.current = stream
|
||||
const recorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = recorder
|
||||
recorder.start()
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
recorder.onstop = () => {
|
||||
if (audioChunksRef.current.length > 0) {
|
||||
const audioBlob = new Blob(audioChunksRef.current, {
|
||||
type: 'audio/wav'
|
||||
})
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(audioBlob)
|
||||
reader.onloadend = () => {
|
||||
const base64Audio = reader.result as string
|
||||
setAudioPreview(base64Audio)
|
||||
setShowPreview(true)
|
||||
}
|
||||
audioChunksRef.current = []
|
||||
}
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
})
|
||||
recordingIntervalRef.current = setInterval(() => {
|
||||
setRecordingTime((prevTime) => prevTime + 1)
|
||||
}, 1000)
|
||||
} else {
|
||||
mediaRecorderRef.current?.stop()
|
||||
if (recordingIntervalRef.current) {
|
||||
clearInterval(recordingIntervalRef.current)
|
||||
recordingIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isRecording])
|
||||
|
||||
const startRecording = () => {
|
||||
setAudioPreview(null)
|
||||
setShowPreview(false)
|
||||
setRecordingTime(0)
|
||||
setIsRecording(true)
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
setIsRecording(false)
|
||||
}
|
||||
|
||||
const handleShowPreview = () => {
|
||||
if (audioPreview) {
|
||||
showAudioSegment(audioPreview)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = time % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content="发送音频">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<IoMic className="text-xl" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Tooltip content="上传音频">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
audioInputRef?.current?.click()
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content="输入音频地址">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger tooltip="输入音频地址">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-1 p-2">
|
||||
<Input
|
||||
value={audioUrl}
|
||||
onChange={(e) => setAudioUrl(e.target.value)}
|
||||
placeholder="请输入音频地址"
|
||||
/>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
if (!isURI(audioUrl)) {
|
||||
toast.error('请输入正确的音频地址')
|
||||
return
|
||||
}
|
||||
showAudioSegment(audioUrl)
|
||||
setAudioUrl('')
|
||||
}}
|
||||
>
|
||||
<FaMicrophone />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover>
|
||||
<Tooltip content="录制音频">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<IoMic />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-col gap-2 p-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
color={isRecording ? 'danger' : 'danger'}
|
||||
variant="flat"
|
||||
onPress={isRecording ? stopRecording : startRecording}
|
||||
>
|
||||
{isRecording ? '停止录制' : '开始录制'}
|
||||
</Button>
|
||||
{showPreview && audioPreview && (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={handleShowPreview}
|
||||
>
|
||||
查看消息
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(isRecording || audioPreview) && (
|
||||
<div className="flex gap-1 items-center">
|
||||
<span
|
||||
className={clsx(
|
||||
'w-4 h-4 rounded-full',
|
||||
isRecording
|
||||
? 'animate-pulse bg-danger-400'
|
||||
: 'bg-success-400'
|
||||
)}
|
||||
></span>
|
||||
<span>录制时长: {formatTime(recordingTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
{showPreview && audioPreview && (
|
||||
<audio controls src={audioPreview} />
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={audioInputRef}
|
||||
hidden
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result
|
||||
showAudioSegment(dataURL as string)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioInsert
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { BsDice3Fill } from 'react-icons/bs'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
const DiceInsert = () => {
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
|
||||
return (
|
||||
<Tooltip content="发送骰子">
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
showStructuredMessage([
|
||||
{
|
||||
type: 'dice'
|
||||
}
|
||||
])
|
||||
}}
|
||||
>
|
||||
<BsDice3Fill className="text-lg" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiceInsert
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { FaFolder } from 'react-icons/fa6'
|
||||
import { LuFilePlus2 } from 'react-icons/lu'
|
||||
import { MdEdit, MdUpload } from 'react-icons/md'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
const FileInsert = () => {
|
||||
const [fileUrl, setFileUrl] = useState<string>('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const showFileSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'file',
|
||||
data: {
|
||||
file: file
|
||||
}
|
||||
}
|
||||
]
|
||||
showStructuredMessage(messages)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content="发送文件">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<FaFolder className="text-lg" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Tooltip content="上传文件">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
fileInputRef?.current?.click()
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content="输入文件地址">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger tooltip="输入文件地址">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-1 p-2">
|
||||
<Input
|
||||
value={fileUrl}
|
||||
onChange={(e) => setFileUrl(e.target.value)}
|
||||
placeholder="请输入文件地址"
|
||||
/>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
if (!isURI(fileUrl)) {
|
||||
toast.error('请输入正确的文件地址')
|
||||
return
|
||||
}
|
||||
showFileSegment(fileUrl)
|
||||
setFileUrl('')
|
||||
}}
|
||||
>
|
||||
<LuFilePlus2 />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result
|
||||
showFileSegment(dataURL as string)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileInsert
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { MdAddPhotoAlternate, MdEdit, MdImage, MdUpload } from 'react-icons/md'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
export interface ImageInsertProps {
|
||||
insertImage: (url: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
|
||||
const [imgUrl, setImgUrl] = useState<string>('')
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={onOpenChange}>
|
||||
<Tooltip content="插入图片">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<MdImage className="text-xl" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Tooltip content="上传图片">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
imageInputRef?.current?.click()
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content="输入图片地址">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger tooltip="输入图片地址">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-1 p-2">
|
||||
<Input
|
||||
value={imgUrl}
|
||||
onChange={(e) => setImgUrl(e.target.value)}
|
||||
placeholder="请输入图片地址"
|
||||
/>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
if (!isURI(imgUrl)) {
|
||||
toast.error('请输入正确的图片地址')
|
||||
return
|
||||
}
|
||||
insertImage(imgUrl)
|
||||
setImgUrl('')
|
||||
}}
|
||||
>
|
||||
<MdAddPhotoAlternate />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={imageInputRef}
|
||||
hidden
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result
|
||||
insertImage(dataURL as string)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageInsert
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useState } from 'react'
|
||||
import { BsChatQuoteFill } from 'react-icons/bs'
|
||||
import { MdAdd } from 'react-icons/md'
|
||||
|
||||
export interface ReplyInsertProps {
|
||||
insertReply: (messageId: string) => void
|
||||
}
|
||||
|
||||
const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
|
||||
const [replyId, setReplyId] = useState<string>('')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content="回复消息">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<BsChatQuoteFill className="text-lg" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Input
|
||||
placeholder="输入消息 ID"
|
||||
value={replyId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/
|
||||
if (isNumberReg.test(value)) {
|
||||
setReplyId(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
onPress={() => {
|
||||
insertReply(replyId)
|
||||
setReplyId('')
|
||||
}}
|
||||
>
|
||||
<MdAdd />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReplyInsert
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { LiaHandScissors } from 'react-icons/lia'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
const RPSInsert = () => {
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
|
||||
return (
|
||||
<Tooltip content="发送猜拳">
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
showStructuredMessage([
|
||||
{
|
||||
type: 'rps'
|
||||
}
|
||||
])
|
||||
}}
|
||||
>
|
||||
<LiaHandScissors className="text-2xl" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default RPSInsert
|
||||
@@ -1,126 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoVideocam } from 'react-icons/io5'
|
||||
import { MdEdit, MdUpload } from 'react-icons/md'
|
||||
import { TbVideoPlus } from 'react-icons/tb'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
const VideoInsert = () => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>('')
|
||||
const videoInputRef = useRef<HTMLInputElement>(null)
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const showVideoSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'video',
|
||||
data: {
|
||||
file: file
|
||||
}
|
||||
}
|
||||
]
|
||||
showStructuredMessage(messages)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content="发送视频">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="danger" variant="flat" isIconOnly radius="full">
|
||||
<IoVideocam className="text-xl" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Tooltip content="上传视频">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
videoInputRef?.current?.click()
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content="输入视频地址">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger tooltip="输入视频地址">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="danger"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-1 p-2">
|
||||
<Input
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
placeholder="请输入视频地址"
|
||||
/>
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
if (!isURI(videoUrl)) {
|
||||
toast.error('请输入正确的视频地址')
|
||||
return
|
||||
}
|
||||
showVideoSegment(videoUrl)
|
||||
setVideoUrl('')
|
||||
}}
|
||||
>
|
||||
<TbVideoPlus />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={videoInputRef}
|
||||
hidden
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result
|
||||
showVideoSegment(dataURL as string)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoInsert
|
||||
@@ -1,41 +0,0 @@
|
||||
import Quill from 'quill'
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Embed = Quill.import('blots/embed') as any
|
||||
export interface EmojiValue {
|
||||
alt: string
|
||||
src: string
|
||||
id: string
|
||||
}
|
||||
class EmojiBlot extends Embed {
|
||||
static blotName: string = 'emoji'
|
||||
static tagName: string = 'img'
|
||||
static classNames: string[] = ['w-6', 'h-6']
|
||||
|
||||
static create(value: HTMLImageElement) {
|
||||
const node = super.create(value)
|
||||
node.setAttribute('alt', value.alt)
|
||||
node.setAttribute('src', value.src)
|
||||
node.setAttribute('data-id', value.id)
|
||||
node.classList.add(...EmojiBlot.classNames)
|
||||
return node
|
||||
}
|
||||
|
||||
static formats(node: HTMLImageElement): EmojiValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
id: node.getAttribute('data-id') ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
static value(node: HTMLImageElement): EmojiValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
id: node.getAttribute('data-id') ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EmojiBlot
|
||||
@@ -1,30 +0,0 @@
|
||||
import Quill from 'quill'
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Embed = Quill.import('blots/embed') as any
|
||||
export interface ImageValue {
|
||||
alt: string
|
||||
src: string
|
||||
}
|
||||
class ImageBlot extends Embed {
|
||||
static blotName = 'image'
|
||||
static tagName = 'img'
|
||||
static classNames: string[] = ['max-w-48', 'max-h-48', 'align-bottom']
|
||||
|
||||
static create(value: ImageValue) {
|
||||
let node = super.create()
|
||||
node.setAttribute('alt', value.alt)
|
||||
node.setAttribute('src', value.src)
|
||||
node.classList.add(...ImageBlot.classNames)
|
||||
return node
|
||||
}
|
||||
|
||||
static value(node: HTMLImageElement): ImageValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageBlot
|
||||
@@ -1,207 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import type { Range } from 'quill'
|
||||
import 'quill/dist/quill.core.css'
|
||||
import { useRef } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { useCustomQuill } from '@/hooks/use_custom_quill'
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { quillToMessage } from '@/utils/onebot'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
import AudioInsert from './components/audio_insert'
|
||||
import DiceInsert from './components/dice_insert'
|
||||
import EmojiPicker from './components/emoji_picker'
|
||||
import FileInsert from './components/file_insert'
|
||||
import ImageInsert from './components/image_insert'
|
||||
import MusicInsert from './components/music_insert'
|
||||
import ReplyInsert from './components/reply_insert'
|
||||
import RPSInsert from './components/rps_insert'
|
||||
import VideoInsert from './components/video_insert'
|
||||
import EmojiBlot from './formats/emoji_blot'
|
||||
import type { EmojiValue } from './formats/emoji_blot'
|
||||
import ImageBlot from './formats/image_blot'
|
||||
import ReplyBlock from './formats/reply_blot'
|
||||
|
||||
const ChatInput = () => {
|
||||
const memorizedRange = useRef<Range | null>(null)
|
||||
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const formats: string[] = ['image', 'emoji', 'reply']
|
||||
const modules = {
|
||||
toolbar: '#toolbar'
|
||||
}
|
||||
const { quillRef, quill, Quill } = useCustomQuill({
|
||||
modules,
|
||||
formats,
|
||||
placeholder: '请输入消息'
|
||||
})
|
||||
|
||||
if (Quill && !quill) {
|
||||
Quill.register('formats/emoji', EmojiBlot)
|
||||
Quill.register('formats/image', ImageBlot, true)
|
||||
Quill.register('formats/reply', ReplyBlock)
|
||||
}
|
||||
|
||||
if (quill) {
|
||||
quill.on('selection-change', (range) => {
|
||||
if (range) {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
|
||||
if (
|
||||
typeof firstOp?.insert !== 'string' &&
|
||||
firstOp?.insert?.reply &&
|
||||
range.index === 0 &&
|
||||
range.length !== quill.getLength()
|
||||
) {
|
||||
quill.setSelection(1, Quill.sources.SILENT)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
quill.on('text-change', () => {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT)
|
||||
}
|
||||
})
|
||||
|
||||
quill.on('editor-change', (eventName: string) => {
|
||||
if (eventName === 'text-change') {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
quill.root.addEventListener('compositionstart', () => {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
const selection = quill?.getSelection()
|
||||
if (selection) memorizedRange.current = selection
|
||||
}
|
||||
}
|
||||
|
||||
const insertImage = (url: string) => {
|
||||
const selection = memorizedRange.current || quill?.getSelection()
|
||||
quill?.deleteText(selection?.index || 0, selection?.length || 0)
|
||||
quill?.insertEmbed(selection?.index || 0, 'image', {
|
||||
src: url,
|
||||
alt: '图片'
|
||||
})
|
||||
quill?.setSelection((selection?.index || 0) + 1, 0)
|
||||
}
|
||||
function insertReplyBlock(messageId: string) {
|
||||
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/
|
||||
if (!isNumberReg.test(messageId)) {
|
||||
toast.error('请输入正确的消息ID')
|
||||
return
|
||||
}
|
||||
const editorContent = quill?.getContents()
|
||||
const firstOp = editorContent?.ops[0]
|
||||
const currentSelection = quill?.getSelection()
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply
|
||||
) {
|
||||
const delta = quill?.getContents()
|
||||
if (delta) {
|
||||
delta.ops[0] = {
|
||||
insert: { reply: { messageId } }
|
||||
}
|
||||
quill?.setContents(delta, Quill.sources.USER)
|
||||
}
|
||||
} else {
|
||||
quill?.insertEmbed(0, 'reply', { messageId }, Quill.sources.USER)
|
||||
}
|
||||
quill?.setSelection((currentSelection?.index || 0) + 1, 0)
|
||||
quill?.blur()
|
||||
}
|
||||
const onInsertEmoji = (emoji: EmojiValue) => {
|
||||
const selection = memorizedRange.current || quill?.getSelection()
|
||||
quill?.deleteText(selection?.index || 0, selection?.length || 0)
|
||||
quill?.insertEmbed(selection?.index || 0, 'emoji', {
|
||||
alt: emoji.alt,
|
||||
src: emoji.src,
|
||||
id: emoji.id
|
||||
})
|
||||
quill?.setSelection((selection?.index || 0) + 1, 0)
|
||||
}
|
||||
|
||||
const getChatMessage = () => {
|
||||
const delta = quill?.getContents()
|
||||
const ops =
|
||||
delta?.ops?.filter((op) => {
|
||||
return op.insert !== '\n'
|
||||
}) ?? []
|
||||
const messages: OB11Segment[] = ops.map((op) => {
|
||||
return quillToMessage(op)
|
||||
})
|
||||
return messages
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={quillRef}
|
||||
className="border border-default-200 rounded-md !mb-2 !text-base !h-64"
|
||||
/>
|
||||
<div id="toolbar" className="!border-none flex gap-2">
|
||||
<ImageInsert insertImage={insertImage} onOpenChange={onOpenChange} />
|
||||
<EmojiPicker
|
||||
onInsertEmoji={onInsertEmoji}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
<ReplyInsert insertReply={insertReplyBlock} />
|
||||
<FileInsert />
|
||||
<AudioInsert />
|
||||
<VideoInsert />
|
||||
<MusicInsert />
|
||||
<DiceInsert />
|
||||
<RPSInsert />
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={() => {
|
||||
const messages = getChatMessage()
|
||||
showStructuredMessage(messages)
|
||||
}}
|
||||
className="ml-auto"
|
||||
>
|
||||
获取JSON格式
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatInput
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure
|
||||
} from '@heroui/modal'
|
||||
|
||||
import ChatInput from '.'
|
||||
|
||||
export default function ChatInputModal() {
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={onOpen} color="danger" radius="full" variant="flat">
|
||||
构造聊天消息
|
||||
</Button>
|
||||
<Modal
|
||||
size="4xl"
|
||||
scrollBehavior="inside"
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
构造消息
|
||||
</ModalHeader>
|
||||
<ModalBody className="overflow-y-auto">
|
||||
<div className="overflow-y-auto">
|
||||
<ChatInput />
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" onPress={onClose} variant="flat">
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import Editor, { OnMount } from '@monaco-editor/react'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import React from 'react'
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
import monaco from '@/monaco'
|
||||
|
||||
loader.config({
|
||||
monaco,
|
||||
paths: {
|
||||
vs: '/webui/monaco-editor/min/vs'
|
||||
}
|
||||
})
|
||||
|
||||
loader.config({
|
||||
'vs/nls': {
|
||||
availableLanguages: { '*': 'zh-cn' }
|
||||
}
|
||||
})
|
||||
|
||||
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
|
||||
test?: string
|
||||
}
|
||||
|
||||
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor
|
||||
|
||||
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
|
||||
(props, ref) => {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(editor)
|
||||
} else {
|
||||
;(ref as React.RefObject<CodeEditorRef>).current = editor
|
||||
}
|
||||
}
|
||||
if (props.onMount) {
|
||||
props.onMount(editor, monaco)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Editor
|
||||
{...props}
|
||||
onMount={handleEditorDidMount}
|
||||
theme={isDark ? 'vs-dark' : 'light'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default CodeEditor
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Button, ButtonGroup } from '@heroui/button'
|
||||
import { Switch } from '@heroui/switch'
|
||||
import { useState } from 'react'
|
||||
import { CgDebug } from 'react-icons/cg'
|
||||
import { FiEdit3 } from 'react-icons/fi'
|
||||
import { MdDeleteForever } from 'react-icons/md'
|
||||
|
||||
import DisplayCardContainer from './container'
|
||||
|
||||
type NetworkType = OneBotConfig['network']
|
||||
|
||||
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
|
||||
label: string
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||
render?: (
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||
) => React.ReactNode
|
||||
}>
|
||||
|
||||
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
|
||||
data: NetworkType[T][0]
|
||||
showType?: boolean
|
||||
typeLabel: string
|
||||
fields: NetworkDisplayCardFields<T>
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const NetworkDisplayCard = <T extends keyof NetworkType>({
|
||||
data,
|
||||
showType,
|
||||
typeLabel,
|
||||
fields,
|
||||
onEdit,
|
||||
onEnable,
|
||||
onDelete,
|
||||
onEnableDebug
|
||||
}: NetworkDisplayCardProps<T>) => {
|
||||
const { name, enable, debug } = data
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
const handleEnable = () => {
|
||||
setEditing(true)
|
||||
onEnable().finally(() => setEditing(false))
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setEditing(true)
|
||||
onDelete().finally(() => setEditing(false))
|
||||
}
|
||||
|
||||
const handleEnableDebug = () => {
|
||||
setEditing(true)
|
||||
onEnableDebug().finally(() => setEditing(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
action={
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
isDisabled={editing}
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="shadow"
|
||||
>
|
||||
<Button color="warning" startContent={<FiEdit3 />} onPress={onEdit}>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color={debug ? 'success' : 'default'}
|
||||
startContent={<CgDebug />}
|
||||
onPress={handleEnableDebug}
|
||||
>
|
||||
{debug ? '关闭调试' : '开启调试'}
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
startContent={<MdDeleteForever />}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
}
|
||||
enableSwitch={
|
||||
<Switch
|
||||
isDisabled={editing}
|
||||
isSelected={enable}
|
||||
onChange={handleEnable}
|
||||
/>
|
||||
}
|
||||
tag={showType && typeLabel}
|
||||
title={name}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center gap-2 ${
|
||||
field.label === 'URL' ? 'col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-default-400">{field.label}</span>
|
||||
{field.render ? (
|
||||
field.render(field.value)
|
||||
) : (
|
||||
<span>{field.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DisplayCardContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default NetworkDisplayCard
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { title } from '../primitives'
|
||||
|
||||
export interface ContainerProps {
|
||||
title: string
|
||||
tag?: React.ReactNode
|
||||
action: React.ReactNode
|
||||
enableSwitch: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export interface DisplayCardProps {
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const DisplayCardContainer: React.FC<ContainerProps> = ({
|
||||
title: _title,
|
||||
action,
|
||||
tag,
|
||||
enableSwitch,
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<Card className="bg-opacity-50 backdrop-blur-sm">
|
||||
<CardHeader className={'pb-0 flex items-center'}>
|
||||
{tag && (
|
||||
<div className="text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b">
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<h2
|
||||
className={clsx(
|
||||
title({
|
||||
color: 'foreground',
|
||||
size: 'xs',
|
||||
shadow: true
|
||||
}),
|
||||
'truncate'
|
||||
)}
|
||||
>
|
||||
{_title}
|
||||
</h2>
|
||||
<div className="ml-auto">{enableSwitch}</div>
|
||||
</CardHeader>
|
||||
<CardBody className="text-sm">{children}</CardBody>
|
||||
<CardFooter>{action}</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default DisplayCardContainer
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { title } from '@/components/primitives'
|
||||
|
||||
export interface NetworkItemDisplayProps {
|
||||
count: number
|
||||
label: string
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
||||
count,
|
||||
label,
|
||||
size = 'md'
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
className={clsx(
|
||||
'bg-opacity-60 shadow-sm md:rounded-3xl',
|
||||
size === 'md'
|
||||
? 'col-span-8 md:col-span-2 bg-danger-50 shadow-danger-100'
|
||||
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
|
||||
)}
|
||||
shadow="sm"
|
||||
>
|
||||
<CardBody className="items-center md:gap-1 p-1 md:p-2">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-1',
|
||||
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
|
||||
title({
|
||||
color: size === 'md' ? 'pink' : 'yellow',
|
||||
size
|
||||
})
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'whitespace-nowrap text-nowrap flex-shrink-0',
|
||||
size === 'md' ? 'text-sm md:text-base' : 'text-xs md:text-sm',
|
||||
title({
|
||||
color: size === 'md' ? 'pink' : 'yellow',
|
||||
shadow: true,
|
||||
size: 'xxs'
|
||||
})
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default NetworkItemDisplay
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Code } from '@heroui/code'
|
||||
import { MdError } from 'react-icons/md'
|
||||
|
||||
export interface ErrorFallbackProps {
|
||||
error: Error
|
||||
resetErrorBoundary: () => void
|
||||
}
|
||||
function errorFallbackRender({
|
||||
error,
|
||||
resetErrorBoundary
|
||||
}: ErrorFallbackProps) {
|
||||
return (
|
||||
<div className="pt-32 flex flex-col justify-center items-center">
|
||||
<div className="flex items-center">
|
||||
<MdError className="mr-2" color="red" size={30} />
|
||||
<h1 className="text-2xl">出错了</h1>
|
||||
</div>
|
||||
<div className="my-6 flex flex-col justify-center items-center">
|
||||
<p className="mb-2">错误信息</p>
|
||||
<Code>{error.message}</Code>
|
||||
</div>
|
||||
<Button color="primary" size="md" onPress={resetErrorBoundary}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default errorFallbackRender
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Code } from '@heroui/code'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader
|
||||
} from '@heroui/modal'
|
||||
|
||||
import CodeEditor from '@/components/code_editor'
|
||||
|
||||
interface FileEditModalProps {
|
||||
isOpen: boolean
|
||||
file: { path: string; content: string } | null
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
onContentChange: (newContent?: string) => void
|
||||
}
|
||||
|
||||
export default function FileEditModal({
|
||||
isOpen,
|
||||
file,
|
||||
onClose,
|
||||
onSave,
|
||||
onContentChange
|
||||
}: FileEditModalProps) {
|
||||
// 根据文件后缀返回对应语言
|
||||
const getLanguage = (filePath: string) => {
|
||||
if (filePath.endsWith('.js')) return 'javascript'
|
||||
if (filePath.endsWith('.ts')) return 'typescript'
|
||||
if (filePath.endsWith('.tsx')) return 'tsx'
|
||||
if (filePath.endsWith('.jsx')) return 'jsx'
|
||||
if (filePath.endsWith('.vue')) return 'vue'
|
||||
if (filePath.endsWith('.svelte')) return 'svelte'
|
||||
if (filePath.endsWith('.json')) return 'json'
|
||||
if (filePath.endsWith('.html')) return 'html'
|
||||
if (filePath.endsWith('.css')) return 'css'
|
||||
if (filePath.endsWith('.scss')) return 'scss'
|
||||
if (filePath.endsWith('.less')) return 'less'
|
||||
if (filePath.endsWith('.md')) return 'markdown'
|
||||
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml'
|
||||
if (filePath.endsWith('.xml')) return 'xml'
|
||||
if (filePath.endsWith('.sql')) return 'sql'
|
||||
if (filePath.endsWith('.sh')) return 'shell'
|
||||
if (filePath.endsWith('.bat')) return 'bat'
|
||||
if (filePath.endsWith('.php')) return 'php'
|
||||
if (filePath.endsWith('.java')) return 'java'
|
||||
if (filePath.endsWith('.c')) return 'c'
|
||||
if (filePath.endsWith('.cpp')) return 'cpp'
|
||||
if (filePath.endsWith('.h')) return 'h'
|
||||
if (filePath.endsWith('.hpp')) return 'hpp'
|
||||
if (filePath.endsWith('.go')) return 'go'
|
||||
if (filePath.endsWith('.py')) return 'python'
|
||||
if (filePath.endsWith('.rb')) return 'ruby'
|
||||
if (filePath.endsWith('.cs')) return 'csharp'
|
||||
if (filePath.endsWith('.swift')) return 'swift'
|
||||
if (filePath.endsWith('.vb')) return 'vb'
|
||||
if (filePath.endsWith('.lua')) return 'lua'
|
||||
if (filePath.endsWith('.pl')) return 'perl'
|
||||
if (filePath.endsWith('.r')) return 'r'
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal size="full" isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex items-center gap-2 bg-content2 bg-opacity-50">
|
||||
<span>编辑文件</span>
|
||||
<Code className="text-xs">{file?.path}</Code>
|
||||
</ModalHeader>
|
||||
<ModalBody className="p-0">
|
||||
<div className="h-full">
|
||||
<CodeEditor
|
||||
height="100%"
|
||||
value={file?.content || ''}
|
||||
onChange={onContentChange}
|
||||
options={{ wordWrap: 'on' }}
|
||||
language={file?.path ? getLanguage(file.path) : 'plaintext'}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="flat" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="danger" onPress={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader
|
||||
} from '@heroui/modal'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { useRequest } from 'ahooks'
|
||||
import path from 'path-browserify'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import FileManager from '@/controllers/file_manager'
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
isOpen: boolean
|
||||
filePath: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const videoExts = ['.mp4', '.webm']
|
||||
export const audioExts = ['.mp3', '.wav']
|
||||
|
||||
export const supportedPreviewExts = [...videoExts, ...audioExts]
|
||||
|
||||
export default function FilePreviewModal({
|
||||
isOpen,
|
||||
filePath,
|
||||
onClose
|
||||
}: FilePreviewModalProps) {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
const { data, loading, error, run } = useRequest(
|
||||
async () => FileManager.downloadToURL(filePath),
|
||||
{
|
||||
refreshDeps: [filePath],
|
||||
manual: true,
|
||||
refreshDepsAction: () => {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
||||
return
|
||||
}
|
||||
run()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let contentElement = null
|
||||
if (!supportedPreviewExts.includes(ext)) {
|
||||
contentElement = <div>暂不支持预览此文件类型</div>
|
||||
} else if (error) {
|
||||
contentElement = <div>读取文件失败</div>
|
||||
} else if (loading || !data) {
|
||||
contentElement = (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
} else if (videoExts.includes(ext)) {
|
||||
contentElement = <video src={data} controls className="max-w-full" />
|
||||
} else if (audioExts.includes(ext)) {
|
||||
contentElement = <audio src={data} controls className="w-full" />
|
||||
} else {
|
||||
contentElement = (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (filePath) {
|
||||
run()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
|
||||
<ModalContent>
|
||||
<ModalHeader>文件预览</ModalHeader>
|
||||
<ModalBody className="flex justify-center items-center">
|
||||
{contentElement}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="flat" onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { Button, ButtonGroup } from '@heroui/button'
|
||||
import { Pagination } from '@heroui/pagination'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import {
|
||||
type Selection,
|
||||
type SortDescriptor,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@heroui/table'
|
||||
import path from 'path-browserify'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { BiRename } from 'react-icons/bi'
|
||||
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
|
||||
import { PhotoSlider } from 'react-photo-view'
|
||||
|
||||
import FileIcon from '@/components/file_icon'
|
||||
|
||||
import type { FileInfo } from '@/controllers/file_manager'
|
||||
|
||||
import { supportedPreviewExts } from './file_preview_modal'
|
||||
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button'
|
||||
|
||||
export interface FileTableProps {
|
||||
files: FileInfo[]
|
||||
currentPath: string
|
||||
loading: boolean
|
||||
sortDescriptor: SortDescriptor
|
||||
onSortChange: (descriptor: SortDescriptor) => void
|
||||
selectedFiles: Selection
|
||||
onSelectionChange: (selected: Selection) => void
|
||||
onDirectoryClick: (dirPath: string) => void
|
||||
onEdit: (filePath: string) => void
|
||||
onPreview: (filePath: string) => void
|
||||
onRenameRequest: (name: string) => void
|
||||
onMoveRequest: (name: string) => void
|
||||
onCopyPath: (fileName: string) => void
|
||||
onDelete: (filePath: string) => void
|
||||
onDownload: (filePath: string) => void
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default function FileTable({
|
||||
files,
|
||||
currentPath,
|
||||
loading,
|
||||
sortDescriptor,
|
||||
onSortChange,
|
||||
selectedFiles,
|
||||
onSelectionChange,
|
||||
onDirectoryClick,
|
||||
onEdit,
|
||||
onPreview,
|
||||
onRenameRequest,
|
||||
onMoveRequest,
|
||||
onCopyPath,
|
||||
onDelete,
|
||||
onDownload
|
||||
}: FileTableProps) {
|
||||
const [page, setPage] = useState(1)
|
||||
const pages = Math.ceil(files.length / PAGE_SIZE) || 1
|
||||
const start = (page - 1) * PAGE_SIZE
|
||||
const end = start + PAGE_SIZE
|
||||
const displayFiles = files.slice(start, end)
|
||||
const [showImage, setShowImage] = useState(false)
|
||||
const [previewIndex, setPreviewIndex] = useState(0)
|
||||
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([])
|
||||
|
||||
const addPreviewImage = useCallback((image: PreviewImage) => {
|
||||
setPreviewImages((prev) => {
|
||||
const exists = prev.some((p) => p.key === image.key)
|
||||
if (exists) return prev
|
||||
return [...prev, image]
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewImages([])
|
||||
setPreviewIndex(0)
|
||||
setShowImage(false)
|
||||
}, [files])
|
||||
|
||||
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
||||
const index = images.findIndex((image) => image.key === name)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
setPreviewIndex(index)
|
||||
setShowImage(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhotoSlider
|
||||
images={previewImages}
|
||||
visible={showImage}
|
||||
onClose={() => setShowImage(false)}
|
||||
index={previewIndex}
|
||||
onIndexChange={setPreviewIndex}
|
||||
/>
|
||||
<Table
|
||||
aria-label="文件列表"
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={onSortChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
defaultSelectedKeys={[]}
|
||||
selectedKeys={selectedFiles}
|
||||
selectionMode="multiple"
|
||||
bottomContent={
|
||||
<div className="flex w-full justify-center">
|
||||
<Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="danger"
|
||||
page={page}
|
||||
total={pages}
|
||||
onChange={(page) => setPage(page)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn key="name" allowsSorting>
|
||||
名称
|
||||
</TableColumn>
|
||||
<TableColumn key="type" allowsSorting>
|
||||
类型
|
||||
</TableColumn>
|
||||
<TableColumn key="size" allowsSorting>
|
||||
大小
|
||||
</TableColumn>
|
||||
<TableColumn key="mtime" allowsSorting>
|
||||
修改时间
|
||||
</TableColumn>
|
||||
<TableColumn key="actions">操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
isLoading={loading}
|
||||
loadingContent={
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{displayFiles.map((file: FileInfo) => {
|
||||
const filePath = path.join(currentPath, file.name)
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
const previewable = supportedPreviewExts.includes(ext)
|
||||
const images = previewImages
|
||||
return (
|
||||
<TableRow key={file.name}>
|
||||
<TableCell>
|
||||
{imageExts.includes(ext) ? (
|
||||
<ImageNameButton
|
||||
name={file.name}
|
||||
filePath={filePath}
|
||||
onPreview={() => onPreviewImage(file.name, images)}
|
||||
onAddPreview={addPreviewImage}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
onPress={() =>
|
||||
file.isDirectory
|
||||
? onDirectoryClick(file.name)
|
||||
: previewable
|
||||
? onPreview(filePath)
|
||||
: onEdit(filePath)
|
||||
}
|
||||
className="text-left justify-start"
|
||||
startContent={
|
||||
<FileIcon
|
||||
name={file.name}
|
||||
isDirectory={file.isDirectory}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{file.name}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||
<TableCell>
|
||||
{isNaN(file.size) || file.isDirectory
|
||||
? '-'
|
||||
: `${file.size} 字节`}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup size="sm">
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onRenameRequest(file.name)}
|
||||
>
|
||||
<BiRename />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onMoveRequest(file.name)}
|
||||
>
|
||||
<FiMove />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onCopyPath(file.name)}
|
||||
>
|
||||
<FiCopy />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onDownload(filePath)}
|
||||
>
|
||||
<FiDownload />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => onDelete(filePath)}
|
||||
>
|
||||
<FiTrash2 />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { useRequest } from 'ahooks'
|
||||
import path from 'path-browserify'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import FileManager from '@/controllers/file_manager'
|
||||
|
||||
import FileIcon from '../file_icon'
|
||||
|
||||
export interface PreviewImage {
|
||||
key: string
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
||||
|
||||
export interface ImageNameButtonProps {
|
||||
name: string
|
||||
filePath: string
|
||||
onPreview: () => void
|
||||
onAddPreview: (image: PreviewImage) => void
|
||||
}
|
||||
|
||||
export default function ImageNameButton({
|
||||
name,
|
||||
filePath,
|
||||
onPreview,
|
||||
onAddPreview
|
||||
}: ImageNameButtonProps) {
|
||||
const { data, loading, error, run } = useRequest(
|
||||
async () => FileManager.downloadToURL(filePath),
|
||||
{
|
||||
refreshDeps: [filePath],
|
||||
manual: true,
|
||||
refreshDepsAction: () => {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (!filePath || !imageExts.includes(ext)) {
|
||||
return
|
||||
}
|
||||
run()
|
||||
}
|
||||
}
|
||||
)
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
onAddPreview({
|
||||
key: name,
|
||||
src: data,
|
||||
alt: name
|
||||
})
|
||||
}
|
||||
}, [data, name, onAddPreview])
|
||||
|
||||
useEffect(() => {
|
||||
if (filePath) {
|
||||
run()
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<Button
|
||||
variant="light"
|
||||
className="text-left justify-start"
|
||||
onPress={onPreview}
|
||||
startContent={
|
||||
error ? (
|
||||
<FileIcon name={name} isDirectory={false} />
|
||||
) : loading || !data ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<Image src={data} alt={name} className="w-8 h-8" radius="sm" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader
|
||||
} from '@heroui/modal'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import clsx from 'clsx'
|
||||
import path from 'path-browserify'
|
||||
import { useState } from 'react'
|
||||
import { IoAdd, IoRemove } from 'react-icons/io5'
|
||||
|
||||
import FileManager from '@/controllers/file_manager'
|
||||
|
||||
interface MoveModalProps {
|
||||
isOpen: boolean
|
||||
moveTargetPath: string
|
||||
selectionInfo: string
|
||||
onClose: () => void
|
||||
onMove: () => void
|
||||
onSelect: (dir: string) => void // 新增回调
|
||||
}
|
||||
|
||||
// 将 DirectoryTree 改为递归组件
|
||||
// 新增 selectedPath 属性,用于标识当前选中的目录
|
||||
function DirectoryTree({
|
||||
basePath,
|
||||
onSelect,
|
||||
selectedPath
|
||||
}: {
|
||||
basePath: string
|
||||
onSelect: (dir: string) => void
|
||||
selectedPath?: string
|
||||
}) {
|
||||
const [dirs, setDirs] = useState<string[]>([])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
// 新增loading状态
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchDirectories = async () => {
|
||||
try {
|
||||
// 直接使用 basePath 调用接口,移除 process.platform 判断
|
||||
const list = await FileManager.listDirectories(basePath)
|
||||
setDirs(list.map((item) => item.name))
|
||||
} catch (error) {
|
||||
// ...error handling...
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (!expanded) {
|
||||
setExpanded(true)
|
||||
setLoading(true)
|
||||
await fetchDirectories()
|
||||
setLoading(false)
|
||||
} else {
|
||||
setExpanded(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
onSelect(basePath)
|
||||
handleToggle()
|
||||
}
|
||||
|
||||
// 计算显示的名称
|
||||
const getDisplayName = () => {
|
||||
if (basePath === '/') return '/'
|
||||
if (/^[A-Z]:$/i.test(basePath)) return basePath
|
||||
return path.basename(basePath)
|
||||
}
|
||||
|
||||
// 更新 Button 的 variant 逻辑
|
||||
const isSeleted = selectedPath === basePath
|
||||
const variant = isSeleted
|
||||
? 'solid'
|
||||
: selectedPath && path.dirname(selectedPath) === basePath
|
||||
? 'flat'
|
||||
: 'light'
|
||||
|
||||
return (
|
||||
<div className="ml-4">
|
||||
<Button
|
||||
onPress={handleClick}
|
||||
className="py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md"
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant={variant}
|
||||
startContent={
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-md',
|
||||
isSeleted ? 'bg-danger-600' : 'bg-danger-50'
|
||||
)}
|
||||
>
|
||||
{expanded ? <IoRemove /> : <IoAdd />}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{getDisplayName()}
|
||||
</Button>
|
||||
{expanded && (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="flex py-1 px-8">
|
||||
<Spinner size="sm" color="danger" />
|
||||
</div>
|
||||
) : (
|
||||
dirs.map((dirName) => {
|
||||
const childPath =
|
||||
basePath === '/' && /^[A-Z]:$/i.test(dirName)
|
||||
? dirName
|
||||
: path.join(basePath, dirName)
|
||||
return (
|
||||
<DirectoryTree
|
||||
key={childPath}
|
||||
basePath={childPath}
|
||||
onSelect={onSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MoveModal({
|
||||
isOpen,
|
||||
moveTargetPath,
|
||||
selectionInfo,
|
||||
onClose,
|
||||
onMove,
|
||||
onSelect
|
||||
}: MoveModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>选择目标目录</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="rounded-md p-2 border border-default-300 overflow-auto max-h-60">
|
||||
<DirectoryTree
|
||||
basePath="/"
|
||||
onSelect={onSelect}
|
||||
selectedPath={moveTargetPath}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-default-500 mt-2">
|
||||
当前选择:{moveTargetPath || '未选择'}
|
||||
</p>
|
||||
<p className="text-sm text-default-500">移动项:{selectionInfo}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="flat" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="danger" onPress={onMove}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader
|
||||
} from '@heroui/modal'
|
||||
|
||||
interface RenameModalProps {
|
||||
isOpen: boolean
|
||||
newFileName: string
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onClose: () => void
|
||||
onRename: () => void
|
||||
}
|
||||
|
||||
export default function RenameModal({
|
||||
isOpen,
|
||||
newFileName,
|
||||
onNameChange,
|
||||
onClose,
|
||||
onRename
|
||||
}: RenameModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>重命名</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input label="新名称" value={newFileName} onChange={onNameChange} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="flat" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="danger" onPress={onRename}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ChevronRightIcon } from '../icons'
|
||||
|
||||
const ItemCounter = ({ number }: { number: number }) => (
|
||||
<div className="flex items-center gap-1 text-default-400">
|
||||
<span className="text-small">{number}</span>
|
||||
<ChevronRightIcon className="text-xl" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ItemCounter
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { getReleaseTime } from '@/utils/time'
|
||||
|
||||
import type { GithubRelease as GithubReleaseType } from '@/types/github'
|
||||
|
||||
export interface GithubReleaseProps {
|
||||
releaseData: GithubReleaseType
|
||||
}
|
||||
const GithubRelease: React.FC<GithubReleaseProps> = (props) => {
|
||||
const { releaseData } = props
|
||||
const [releaseTime, setReleaseTime] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (releaseData) {
|
||||
const timer = setInterval(() => {
|
||||
const time = getReleaseTime(releaseData.published_at)
|
||||
|
||||
setReleaseTime(time)
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
}, [releaseData])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>Releases</span>
|
||||
<div className="px-2 py-1 rounded-small bg-default-100 bg-opacity-50 backdrop-blur-sm group-data-[hover=true]:bg-default-200">
|
||||
<span className="text-tiny text-default-600">{releaseData.name}</span>
|
||||
<div className="flex gap-2 text-tiny">
|
||||
<span className="text-default-500">{releaseTime}</span>
|
||||
<span className="text-success">Latest</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GithubRelease
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRequest } from 'ahooks'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoCopy, IoRefresh } from 'react-icons/io5'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
import PageLoading from './page_loading'
|
||||
|
||||
export default function Hitokoto() {
|
||||
const {
|
||||
data: dataOri,
|
||||
error,
|
||||
loading,
|
||||
run
|
||||
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
|
||||
pollingInterval: 10000,
|
||||
throttleWait: 1000
|
||||
})
|
||||
const data = dataOri?.data
|
||||
const onCopy = () => {
|
||||
try {
|
||||
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success('复制成功')
|
||||
} catch (error) {
|
||||
toast.error('复制失败, 请手动复制')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
{loading && <PageLoading />}
|
||||
{error ? (
|
||||
<div className="text-danger-400">一言加载失败:{error.message}</div>
|
||||
) : (
|
||||
<>
|
||||
<div>{data?.hitokoto}</div>
|
||||
<div className="text-right">
|
||||
—— <span className="text-default-400">{data?.from}</span>{' '}
|
||||
{data?.from_who}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Tooltip content="刷新" placement="top">
|
||||
<Button
|
||||
onPress={run}
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
>
|
||||
<IoRefresh />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="复制" placement="top">
|
||||
<Button
|
||||
onPress={onCopy}
|
||||
size="sm"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="success"
|
||||
variant="flat"
|
||||
>
|
||||
<IoCopy />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Input } from '@heroui/input'
|
||||
import { useRef } from 'react'
|
||||
|
||||
export interface ImageInputProps {
|
||||
onChange: (base64: string) => void
|
||||
value: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="w-5 h-5 flex-shrink-0">
|
||||
<Image
|
||||
src={value}
|
||||
alt={label}
|
||||
className="w-5 h-5 flex-shrink-0 rounded-none"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
label={label}
|
||||
type="file"
|
||||
placeholder="选择图片"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const base64 = reader.result as string
|
||||
onChange(base64)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onPress={() => {
|
||||
onChange('')
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
}}
|
||||
color="danger"
|
||||
variant="flat"
|
||||
size="sm"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageInput
|
||||
@@ -1,136 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Select, SelectItem } from '@heroui/select'
|
||||
import type { Selection } from '@react-types/shared'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { colorizeLogLevel } from '@/utils/terminal'
|
||||
|
||||
import PageLoading from '../page_loading'
|
||||
import XTerm from '../xterm'
|
||||
import type { XTermRef } from '../xterm'
|
||||
import LogLevelSelect from './log_level_select'
|
||||
|
||||
export interface HistoryLogsProps {
|
||||
list: string[]
|
||||
onSelect: (name: string) => void
|
||||
selectedLog?: string
|
||||
refreshList: () => void
|
||||
refreshLog: () => void
|
||||
listLoading?: boolean
|
||||
logLoading?: boolean
|
||||
listError?: Error
|
||||
logContent?: string
|
||||
}
|
||||
const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
|
||||
const {
|
||||
list,
|
||||
onSelect,
|
||||
selectedLog,
|
||||
refreshList,
|
||||
refreshLog,
|
||||
listLoading,
|
||||
logContent,
|
||||
listError,
|
||||
logLoading
|
||||
} = props
|
||||
const Xterm = useRef<XTermRef>(null)
|
||||
|
||||
const [logLevel, setLogLevel] = useState<Selection>(
|
||||
new Set(['info', 'warn', 'error'])
|
||||
)
|
||||
|
||||
const logToColored = (log: string) => {
|
||||
const logs = log
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const colored = colorizeLogLevel(line)
|
||||
return colored
|
||||
})
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true
|
||||
}
|
||||
return logLevel.has(log.level)
|
||||
})
|
||||
.map((log) => log.content)
|
||||
.join('\r\n')
|
||||
return logs
|
||||
}
|
||||
|
||||
const onDownloadLog = () => {
|
||||
if (!logContent) {
|
||||
return
|
||||
}
|
||||
const blob = new Blob([logContent], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selectedLog}.log`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Xterm.current || !logContent) {
|
||||
return
|
||||
}
|
||||
Xterm.current.clear()
|
||||
const _logContent = logToColored(logContent)
|
||||
Xterm.current.write(_logContent + '\r\nnapcat@webui:~$ ')
|
||||
}, [logContent, logLevel])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>历史日志 - NapCat WebUI</title>
|
||||
<Card className="max-w-full h-full bg-opacity-50 backdrop-blur-sm">
|
||||
<CardHeader className="flex-row justify-start gap-3">
|
||||
<Select
|
||||
label="选择日志"
|
||||
size="sm"
|
||||
isLoading={listLoading}
|
||||
errorMessage={listError?.message}
|
||||
classNames={{
|
||||
trigger:
|
||||
'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60'
|
||||
}}
|
||||
placeholder="选择日志"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
onSelect(value)
|
||||
}}
|
||||
selectedKeys={[selectedLog || '']}
|
||||
items={list.map((name) => ({
|
||||
value: name,
|
||||
label: name
|
||||
}))}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<LogLevelSelect
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
/>
|
||||
<Button className="flex-shrink-0" onPress={onDownloadLog}>
|
||||
下载日志
|
||||
</Button>
|
||||
<Button onPress={refreshList}>刷新列表</Button>
|
||||
<Button onPress={refreshLog}>刷新日志</Button>
|
||||
</CardHeader>
|
||||
<CardBody className="relative">
|
||||
<PageLoading loading={logLoading} />
|
||||
<XTerm className="w-full h-full" ref={Xterm} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryLogs
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import type { Selection } from '@react-types/shared'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoDownloadOutline } from 'react-icons/io5'
|
||||
|
||||
import { colorizeLogLevelWithTag } from '@/utils/terminal'
|
||||
|
||||
import WebUIManager, { Log } from '@/controllers/webui_manager'
|
||||
|
||||
import type { XTermRef } from '../xterm'
|
||||
import XTerm from '../xterm'
|
||||
import LogLevelSelect from './log_level_select'
|
||||
|
||||
const RealTimeLogs = () => {
|
||||
const Xterm = useRef<XTermRef>(null)
|
||||
const [logLevel, setLogLevel] = useState<Selection>(
|
||||
new Set(['info', 'warn', 'error'])
|
||||
)
|
||||
const [dataArr, setDataArr] = useState<Log[]>([])
|
||||
|
||||
const onDownloadLog = () => {
|
||||
const logContent = dataArr
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true
|
||||
}
|
||||
return logLevel.has(log.level)
|
||||
})
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n')
|
||||
const blob = new Blob([logContent], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'napcat.log'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const writeStream = () => {
|
||||
try {
|
||||
const _data = dataArr
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true
|
||||
}
|
||||
return logLevel.has(log.level)
|
||||
})
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n')
|
||||
Xterm.current?.clear()
|
||||
Xterm.current?.write(_data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error('获取实时日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
writeStream()
|
||||
}, [logLevel, dataArr])
|
||||
|
||||
useEffect(() => {
|
||||
const subscribeLogs = () => {
|
||||
try {
|
||||
const source = WebUIManager.getRealTimeLogs((data) => {
|
||||
setDataArr((prev) => {
|
||||
const newData = [...prev, ...data]
|
||||
if (newData.length > 1000) {
|
||||
newData.splice(0, newData.length - 1000)
|
||||
}
|
||||
return newData
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
source.close()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('获取实时日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const close = subscribeLogs()
|
||||
return () => {
|
||||
console.log('close')
|
||||
close?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>实时日志 - NapCat WebUI</title>
|
||||
<div className="flex items-center gap-2">
|
||||
<LogLevelSelect
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
/>
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
onPress={onDownloadLog}
|
||||
startContent={<IoDownloadOutline className="text-lg" />}
|
||||
>
|
||||
下载日志
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 h-full overflow-hidden">
|
||||
<XTerm ref={Xterm} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RealTimeLogs
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Modal as NextUIModal,
|
||||
useDisclosure
|
||||
} from '@heroui/modal'
|
||||
import React from 'react'
|
||||
|
||||
export interface ModalProps {
|
||||
content: React.ReactNode
|
||||
title?: React.ReactNode
|
||||
size?: React.ComponentProps<typeof NextUIModal>['size']
|
||||
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']
|
||||
onClose?: () => void
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
backdrop?: 'opaque' | 'blur' | 'transparent'
|
||||
showCancel?: boolean
|
||||
dismissible?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
const {
|
||||
backdrop = 'blur',
|
||||
title,
|
||||
content,
|
||||
showCancel = true,
|
||||
dismissible,
|
||||
confirmText = '确定',
|
||||
cancelText = '取消',
|
||||
onClose,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
...rest
|
||||
} = props
|
||||
const { onClose: onNativeClose } = useDisclosure()
|
||||
|
||||
return (
|
||||
<NextUIModal
|
||||
defaultOpen
|
||||
backdrop={backdrop}
|
||||
isDismissable={dismissible}
|
||||
onClose={() => {
|
||||
onClose?.()
|
||||
onNativeClose()
|
||||
}}
|
||||
classNames={{
|
||||
backdrop: 'z-[99]',
|
||||
wrapper: 'z-[99]'
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<ModalContent>
|
||||
{(nativeClose) => (
|
||||
<>
|
||||
{title && (
|
||||
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
|
||||
)}
|
||||
<ModalBody className="break-all">{content}</ModalBody>
|
||||
<ModalFooter>
|
||||
{showCancel && (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
onCancel?.()
|
||||
nativeClose()
|
||||
}}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={() => {
|
||||
onConfirm?.()
|
||||
nativeClose()
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</NextUIModal>
|
||||
)
|
||||
})
|
||||
|
||||
Modal.displayName = 'Modal'
|
||||
|
||||
export default Modal
|
||||
@@ -1,242 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Snippet } from '@heroui/snippet'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoLink, IoSend } from 'react-icons/io5'
|
||||
import { PiCatDuotone } from 'react-icons/pi'
|
||||
|
||||
import key from '@/const/key'
|
||||
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'
|
||||
|
||||
import ChatInputModal from '@/components/chat_input/modal'
|
||||
import CodeEditor from '@/components/code_editor'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { parseAxiosResponse } from '@/utils/url'
|
||||
import { generateDefaultJson, parse } from '@/utils/zod'
|
||||
|
||||
import DisplayStruct from './display_struct'
|
||||
|
||||
export interface OneBotApiDebugProps {
|
||||
path: OneBotHttpApiPath
|
||||
data: OneBotHttpApiContent
|
||||
}
|
||||
|
||||
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const { path, data } = props
|
||||
const currentURL = new URL(window.location.origin)
|
||||
currentURL.port = '3000'
|
||||
const defaultHttpUrl = currentURL.href
|
||||
const [httpConfig, setHttpConfig] = useLocalStorage(key.httpDebugConfig, {
|
||||
url: defaultHttpUrl,
|
||||
token: ''
|
||||
})
|
||||
const [requestBody, setRequestBody] = useState('{}')
|
||||
const [responseContent, setResponseContent] = useState('')
|
||||
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false)
|
||||
const [isResponseOpen, setIsResponseOpen] = useState(false)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const responseRef = useRef<HTMLDivElement>(null)
|
||||
const parsedRequest = parse(data.request)
|
||||
const parsedResponse = parse(data.response)
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (isFetching) return
|
||||
setIsFetching(true)
|
||||
const r = toast.loading('正在发送请求...')
|
||||
try {
|
||||
const parsedRequestBody = JSON.parse(requestBody)
|
||||
const requestURL = new URL(httpConfig.url)
|
||||
requestURL.pathname = path
|
||||
request
|
||||
.post(requestURL.href, parsedRequestBody, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${httpConfig.token}`
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
.then((res) => {
|
||||
setResponseContent(parseAxiosResponse(res))
|
||||
toast.success('请求发送完成,请查看响应')
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('请求发送失败:' + err.message)
|
||||
setResponseContent(parseAxiosResponse(err.response))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
setIsResponseOpen(true)
|
||||
responseRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
})
|
||||
toast.dismiss(r)
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('请求体 JSON 格式错误')
|
||||
setIsFetching(false)
|
||||
toast.dismiss(r)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setRequestBody(generateDefaultJson(data.request))
|
||||
setResponseContent('')
|
||||
}, [path])
|
||||
|
||||
return (
|
||||
<section className="p-4 pt-14 rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-danger-400">
|
||||
<PiCatDuotone />
|
||||
{data.description}
|
||||
</h1>
|
||||
<h1 className="text-lg font-bold mb-4">
|
||||
<Snippet
|
||||
className="bg-default-50 bg-opacity-50 backdrop-blur-md"
|
||||
symbol={<IoLink size={18} className="inline-block mr-1" />}
|
||||
tooltipProps={{
|
||||
content: '点击复制地址'
|
||||
}}
|
||||
>
|
||||
{path}
|
||||
</Snippet>
|
||||
</h1>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
label="HTTP URL"
|
||||
placeholder="输入 HTTP URL"
|
||||
value={httpConfig.url}
|
||||
onChange={(e) =>
|
||||
setHttpConfig({ ...httpConfig, url: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Token"
|
||||
placeholder="输入 Token"
|
||||
value={httpConfig.token}
|
||||
onChange={(e) =>
|
||||
setHttpConfig({ ...httpConfig, token: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onPress={sendRequest}
|
||||
color="danger"
|
||||
size="lg"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
isDisabled={isFetching}
|
||||
>
|
||||
<IoSend />
|
||||
</Button>
|
||||
</div>
|
||||
<Card
|
||||
shadow="sm"
|
||||
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
|
||||
>
|
||||
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">请求体</span>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={() => setIsCodeEditorOpen(!isCodeEditorOpen)}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{isCodeEditorOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
ref={responseRef}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isCodeEditorOpen ? 1 : 0,
|
||||
height: isCodeEditorOpen ? 'auto' : 0
|
||||
}}
|
||||
>
|
||||
<CodeEditor
|
||||
value={requestBody}
|
||||
onChange={(value) => setRequestBody(value ?? '')}
|
||||
language="json"
|
||||
height="400px"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-1">
|
||||
<ChatInputModal />
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onPress={() =>
|
||||
setRequestBody(generateDefaultJson(data.request))
|
||||
}
|
||||
>
|
||||
填充示例请求体
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card
|
||||
shadow="sm"
|
||||
className="my-4 relative bg-opacity-50 backdrop-blur-md"
|
||||
>
|
||||
<PageLoading loading={isFetching} />
|
||||
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">响应</span>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={() => setIsResponseOpen(!isResponseOpen)}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{isResponseOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
<Button
|
||||
color="success"
|
||||
variant="flat"
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(responseContent)
|
||||
toast.success('响应内容已复制到剪贴板')
|
||||
}}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
className="overflow-y-auto text-sm"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isResponseOpen ? 1 : 0,
|
||||
height: isResponseOpen ? 300 : 0
|
||||
}}
|
||||
>
|
||||
<pre>
|
||||
<code>
|
||||
{responseContent || (
|
||||
<div className="text-gray-400">暂无响应</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</motion.div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className="p-2 md:p-4 border border-default-50 dark:border-default-200 rounded-lg backdrop-blur-sm">
|
||||
<h2 className="text-xl font-semibold mb-2">请求体结构</h2>
|
||||
<DisplayStruct schema={parsedRequest} />
|
||||
<h2 className="text-xl font-semibold mt-4 mb-2">响应体结构</h2>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotApiDebug
|
||||
@@ -1,202 +0,0 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { motion } from 'motion/react'
|
||||
import React, { useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { TbSquareRoundedChevronRightFilled } from 'react-icons/tb'
|
||||
|
||||
import type { LiteralValue, ParsedSchema } from '@/utils/zod'
|
||||
|
||||
interface DisplayStructProps {
|
||||
schema: ParsedSchema | ParsedSchema[]
|
||||
}
|
||||
|
||||
const SchemaType = ({
|
||||
type,
|
||||
value
|
||||
}: {
|
||||
type: string
|
||||
value?: LiteralValue
|
||||
}) => {
|
||||
let name = type
|
||||
switch (type) {
|
||||
case 'union':
|
||||
name = '联合类型'
|
||||
break
|
||||
case 'value':
|
||||
name = '固定值'
|
||||
break
|
||||
}
|
||||
let chipColor: 'primary' | 'success' | 'danger' | 'warning' | 'secondary' =
|
||||
'primary'
|
||||
switch (type) {
|
||||
case 'enum':
|
||||
chipColor = 'warning'
|
||||
break
|
||||
case 'union':
|
||||
chipColor = 'secondary'
|
||||
break
|
||||
case 'array':
|
||||
chipColor = 'danger'
|
||||
break
|
||||
case 'object':
|
||||
chipColor = 'success'
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip size="sm" color={chipColor} variant="flat">
|
||||
{name}
|
||||
{type === 'value' && (
|
||||
<span className="px-1 rounded-full bg-primary-400 text-white ml-1">
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
const SchemaLabel: React.FC<{
|
||||
schema: ParsedSchema
|
||||
}> = ({ schema }) => (
|
||||
<>
|
||||
{Array.isArray(schema.type) ? (
|
||||
schema.type.map((type) => (
|
||||
<SchemaType key={type} type={type} value={schema?.value} />
|
||||
))
|
||||
) : (
|
||||
<SchemaType type={schema.type} value={schema?.value} />
|
||||
)}
|
||||
{schema.optional && (
|
||||
<Chip size="sm" color="default" variant="flat">
|
||||
可选
|
||||
</Chip>
|
||||
)}
|
||||
{schema.description && (
|
||||
<span className="text-xs text-default-400">{schema.description}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const SchemaContainer: React.FC<{
|
||||
schema: ParsedSchema
|
||||
children: React.ReactNode
|
||||
}> = ({ schema, children }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const toggleExpand = () => setExpanded(!expanded)
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div
|
||||
onClick={toggleExpand}
|
||||
className="md:cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ rotate: 0 }}
|
||||
animate={{ rotate: expanded ? 90 : 0 }}
|
||||
>
|
||||
<TbSquareRoundedChevronRightFilled />
|
||||
</motion.div>
|
||||
<Tooltip content="点击复制" placement="top" showArrow>
|
||||
<span
|
||||
className="border-b border-transparent border-dashed hover:border-primary-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(schema.name || '')
|
||||
toast.success('已复制')
|
||||
}}
|
||||
>
|
||||
{schema.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<SchemaLabel schema={schema} />
|
||||
</div>
|
||||
<motion.div
|
||||
className="ml-5 overflow-hidden"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: expanded ? 1 : 0, height: expanded ? 'auto' : 0 }}
|
||||
>
|
||||
<div className="h-2"></div>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => {
|
||||
if (schema.type === 'object') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
{schema.children && schema.children.length > 0 ? (
|
||||
schema.children.map((child, i) => (
|
||||
<RenderSchema key={child.name || i} schema={child} />
|
||||
))
|
||||
) : (
|
||||
<div>{`{}`}</div>
|
||||
)}
|
||||
</SchemaContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (schema.type === 'array' || schema.type === 'union') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
{schema.children?.map((child, i) => (
|
||||
<RenderSchema key={child.name || i} schema={child} />
|
||||
))}
|
||||
</SchemaContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (schema.type === 'enum' && Array.isArray(schema.enum)) {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
<div className="flex gap-1 items-center">
|
||||
{schema.enum?.map((value, i) => (
|
||||
<Chip
|
||||
key={value?.toString() || i}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="success"
|
||||
>
|
||||
{value?.toString()}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</SchemaContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-1 pl-5">
|
||||
<Tooltip content="点击复制" placement="top" showArrow>
|
||||
<span
|
||||
className="border-b border-transparent border-dashed hover:border-primary-400 md:cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(schema.name || '')
|
||||
toast.success('已复制')
|
||||
}}
|
||||
>
|
||||
{schema.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<SchemaLabel schema={schema} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DisplayStruct: React.FC<DisplayStructProps> = ({ schema }) => {
|
||||
return (
|
||||
<div className="p-4 bg-content2 rounded-lg bg-opacity-50">
|
||||
{Array.isArray(schema) ? (
|
||||
schema.map((s, i) => <RenderSchema key={s.name || i} schema={s} />)
|
||||
) : (
|
||||
<RenderSchema schema={schema} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DisplayStruct
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import clsx from 'clsx'
|
||||
import { motion } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api'
|
||||
|
||||
export interface OneBotApiNavListProps {
|
||||
data: OneBotHttpApi
|
||||
selectedApi: OneBotHttpApiPath
|
||||
onSelect: (apiName: OneBotHttpApiPath) => void
|
||||
openSideBar: boolean
|
||||
}
|
||||
|
||||
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
const { data, selectedApi, onSelect, openSideBar } = props
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
return (
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'h-[calc(100vh-3.5rem)] left-0 !overflow-hidden md:w-auto z-20 top-[3.3rem] md:top-[3rem] absolute md:sticky md:float-start',
|
||||
openSideBar && 'bg-background bg-opacity-20 backdrop-blur-md'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
transition={{
|
||||
type: openSideBar ? 'spring' : 'tween',
|
||||
stiffness: 150,
|
||||
damping: 15
|
||||
}}
|
||||
animate={{ width: openSideBar ? '16rem' : '0rem' }}
|
||||
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
|
||||
>
|
||||
<div className="w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0">
|
||||
<Input
|
||||
className="sticky top-0 z-10 text-danger-600"
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-opacity-30 bg-danger-50 backdrop-blur-sm border border-danger-300 mb-2',
|
||||
input: 'bg-transparent !text-danger-400 !placeholder-danger-400'
|
||||
}}
|
||||
radius="full"
|
||||
placeholder="搜索 API"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
isClearable
|
||||
onClear={() => setSearchValue('')}
|
||||
/>
|
||||
{Object.entries(data).map(([apiName, api]) => (
|
||||
<Card
|
||||
key={apiName}
|
||||
shadow="none"
|
||||
className={clsx(
|
||||
'w-full border border-danger-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-danger-400',
|
||||
{
|
||||
hidden: !(
|
||||
apiName.includes(searchValue) ||
|
||||
api.description?.includes(searchValue)
|
||||
)
|
||||
},
|
||||
{
|
||||
'!bg-opacity-40 border border-danger-400 bg-danger-50 text-danger-600':
|
||||
apiName === selectedApi
|
||||
}
|
||||
)}
|
||||
isPressable
|
||||
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
||||
>
|
||||
<CardBody>
|
||||
<h2 className="font-bold">{api.description}</h2>
|
||||
<div
|
||||
className={clsx('text-sm text-danger-200', {
|
||||
'!text-danger-400': apiName === selectedApi
|
||||
})}
|
||||
>
|
||||
{apiName}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotApiNavList
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Snippet } from '@heroui/snippet'
|
||||
import { motion } from 'motion/react'
|
||||
import { IoCode } from 'react-icons/io5'
|
||||
|
||||
import OneBotDisplayMeta from '@/components/onebot/display_card/meta'
|
||||
|
||||
import { getEventName, isOB11Event } from '@/utils/onebot'
|
||||
import { timestampToDateString } from '@/utils/time'
|
||||
|
||||
import type {
|
||||
AllOB11WsResponse,
|
||||
OB11AllEvent,
|
||||
OB11Request
|
||||
} from '@/types/onebot'
|
||||
|
||||
import OneBotMessage from './message'
|
||||
import OneBotNotice from './notice'
|
||||
import OneBotDisplayResponse from './response'
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, scale: 0.8, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring', stiffness: 300, damping: 20 }
|
||||
}
|
||||
}
|
||||
|
||||
function RequestComponent({ data: _ }: { data: OB11Request }) {
|
||||
return <div>Request消息,暂未适配</div>
|
||||
}
|
||||
|
||||
export interface OneBotItemRenderProps {
|
||||
data: AllOB11WsResponse[]
|
||||
index: number
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
export const getItemSize = (event: OB11AllEvent['post_type']) => {
|
||||
if (event === 'meta_event') {
|
||||
return 100
|
||||
}
|
||||
if (event === 'message') {
|
||||
return 180
|
||||
}
|
||||
if (event === 'request') {
|
||||
return 100
|
||||
}
|
||||
if (event === 'notice') {
|
||||
return 100
|
||||
}
|
||||
if (event === 'message_sent') {
|
||||
return 250
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
const renderDetail = (data: AllOB11WsResponse) => {
|
||||
if (isOB11Event(data)) {
|
||||
switch (data.post_type) {
|
||||
case 'meta_event':
|
||||
return <OneBotDisplayMeta data={data} />
|
||||
case 'message':
|
||||
return <OneBotMessage data={data} />
|
||||
case 'request':
|
||||
return <RequestComponent data={data} />
|
||||
case 'notice':
|
||||
return <OneBotNotice data={data} />
|
||||
case 'message_sent':
|
||||
return <OneBotMessage data={data} />
|
||||
default:
|
||||
return <div>未知类型的消息</div>
|
||||
}
|
||||
}
|
||||
return <OneBotDisplayResponse data={data} />
|
||||
}
|
||||
|
||||
const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
|
||||
const msg = data[index]
|
||||
const isEvent = isOB11Event(msg)
|
||||
return (
|
||||
<div style={style} className="p-1 overflow-visible w-full h-full">
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="h-full px-2"
|
||||
>
|
||||
<Card className="w-full h-full py-2 bg-opacity-50 backdrop-blur-sm">
|
||||
<CardHeader className="py-0 text-default-500 flex-row gap-2">
|
||||
<div className="font-bold">
|
||||
{isEvent ? getEventName(msg.post_type) : '请求响应'}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{isEvent && timestampToDateString(msg.time)}
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Popover
|
||||
placement="left"
|
||||
showArrow
|
||||
classNames={{
|
||||
content: 'max-h-96 max-w-96 overflow-hidden p-0'
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
className="text-medium"
|
||||
>
|
||||
<IoCode />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Snippet
|
||||
hideSymbol
|
||||
tooltipProps={{
|
||||
content: '点击复制'
|
||||
}}
|
||||
classNames={{
|
||||
copyButton: 'self-start sticky top-0 right-0'
|
||||
}}
|
||||
className="bg-content1 h-full overflow-y-scroll items-start"
|
||||
>
|
||||
{JSON.stringify(msg, null, 2)
|
||||
.split('\n')
|
||||
.map((line, i) => (
|
||||
<span key={i} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</Snippet>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="py-0">{renderDetail(msg)}</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotItemRender
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { VariableSizeList } from 'react-window'
|
||||
|
||||
import OneBotItemRender, {
|
||||
getItemSize
|
||||
} from '@/components/onebot/display_card/render'
|
||||
|
||||
import { isOB11Event } from '@/utils/onebot'
|
||||
|
||||
import type { AllOB11WsResponse } from '@/types/onebot'
|
||||
|
||||
export interface OneBotMessageListProps {
|
||||
messages: AllOB11WsResponse[]
|
||||
}
|
||||
|
||||
const OneBotMessageList: React.FC<OneBotMessageListProps> = (props) => {
|
||||
const { messages } = props
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<VariableSizeList>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400)
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (containerRef.current) {
|
||||
setContainerHeight(containerRef.current.offsetHeight)
|
||||
}
|
||||
})
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.resetAfterIndex(0, true)
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden" ref={containerRef}>
|
||||
<VariableSizeList
|
||||
ref={listRef}
|
||||
itemCount={messages.length}
|
||||
width="100%"
|
||||
style={{
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
itemSize={(idx) => {
|
||||
const msg = messages[idx]
|
||||
if (isOB11Event(msg)) {
|
||||
const size = getItemSize(msg.post_type)
|
||||
return size
|
||||
} else {
|
||||
return 100
|
||||
}
|
||||
}}
|
||||
height={containerHeight}
|
||||
itemData={messages}
|
||||
itemKey={(index) => messages.length - index - 1}
|
||||
>
|
||||
{OneBotItemRender}
|
||||
</VariableSizeList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotMessageList
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure
|
||||
} from '@heroui/modal'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import ChatInputModal from '@/components/chat_input/modal'
|
||||
import CodeEditor from '@/components/code_editor'
|
||||
import type { CodeEditorRef } from '@/components/code_editor'
|
||||
|
||||
export interface OneBotSendModalProps {
|
||||
sendMessage: (msg: string) => void
|
||||
}
|
||||
|
||||
const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
||||
const { sendMessage } = props
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure()
|
||||
const editorRef = useRef<CodeEditorRef | null>(null)
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(onClose: () => void) => {
|
||||
const msg = editorRef.current?.getValue()
|
||||
if (!msg) {
|
||||
toast.error('消息不能为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
sendMessage(msg)
|
||||
toast.success('消息发送成功')
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.error('消息发送失败')
|
||||
}
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={onOpen} color="danger" radius="full" variant="flat">
|
||||
构造请求
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
placement="top-center"
|
||||
size="5xl"
|
||||
scrollBehavior="outside"
|
||||
isDismissable={false}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
构造请求
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="h-96 dark:bg-[rgb(30,30,30)] p-2 rounded-md border border-default-100">
|
||||
<CodeEditor
|
||||
height="100%"
|
||||
defaultLanguage="json"
|
||||
defaultValue={`{
|
||||
"action": "get_group_list"
|
||||
}`}
|
||||
ref={editorRef}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<ChatInputModal />
|
||||
|
||||
<Button color="danger" variant="flat" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={() => handleSendMessage(onClose)}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default OneBotSendModal
|
||||
@@ -1,39 +0,0 @@
|
||||
import clsx from 'clsx'
|
||||
import { ReadyState } from 'react-use-websocket'
|
||||
|
||||
export interface WSStatusProps {
|
||||
state: ReadyState
|
||||
}
|
||||
|
||||
function StatusTag({
|
||||
title,
|
||||
color
|
||||
}: {
|
||||
title: string
|
||||
color: 'success' | 'danger' | 'warning'
|
||||
}) {
|
||||
const textClassName = `text-${color} text-sm`
|
||||
const bgClassName = `bg-${color}`
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center gap-1 rounded-md px-2 col-span-2 md:col-span-1">
|
||||
<div className={clsx('w-4 h-4 rounded-full', bgClassName)}></div>
|
||||
<div className={textClassName}>{title}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WSStatus({ state }: WSStatusProps) {
|
||||
if (state === ReadyState.OPEN) {
|
||||
return <StatusTag title="已连接" color="success" />
|
||||
}
|
||||
if (state === ReadyState.CLOSED) {
|
||||
return <StatusTag title="已关闭" color="danger" />
|
||||
}
|
||||
if (state === ReadyState.CONNECTING) {
|
||||
return <StatusTag title="连接中" color="warning" />
|
||||
}
|
||||
if (state === ReadyState.CLOSING) {
|
||||
return <StatusTag title="关闭中" color="warning" />
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Image } from '@heroui/image'
|
||||
import React from 'react'
|
||||
|
||||
import bkg_color from '@/assets/images/bkg-color.png'
|
||||
|
||||
const PageBackground = () => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="fixed w-full h-full -z-[0] flex justify-end opacity-80">
|
||||
<Image
|
||||
className="overflow-hidden object-contain -top-42 h-[160%] -right-[30%] -rotate-45 pointer-events-none select-none -z-10 relative"
|
||||
src={bkg_color}
|
||||
/>
|
||||
</div>
|
||||
<div className="fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80">
|
||||
<Image
|
||||
className="relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44"
|
||||
src={bkg_color}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageBackground
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Image } from '@heroui/image'
|
||||
import clsx from 'clsx'
|
||||
import { BsTencentQq } from 'react-icons/bs'
|
||||
|
||||
import { SelfInfo } from '@/types/user'
|
||||
|
||||
import PageLoading from './page_loading'
|
||||
|
||||
export interface QQInfoCardProps {
|
||||
data?: SelfInfo
|
||||
error?: Error
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
return (
|
||||
<Card
|
||||
className="relative bg-danger-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-danger-300 dark:shadow-danger-50"
|
||||
shadow="none"
|
||||
radius="lg"
|
||||
>
|
||||
<PageLoading loading={loading} />
|
||||
{error ? (
|
||||
<CardBody className="items-center gap-1 justify-center">
|
||||
<div className="flex-1 text-content1-foreground">Error</div>
|
||||
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
|
||||
{error.message}
|
||||
</div>
|
||||
</CardBody>
|
||||
) : (
|
||||
<CardBody className="flex-row items-center gap-2 overflow-hidden relative">
|
||||
<div className="absolute right-0 bottom-0 text-5xl text-danger-400">
|
||||
<BsTencentQq />
|
||||
</div>
|
||||
<div className="relative flex-shrink-0 z-10">
|
||||
<Image
|
||||
src={
|
||||
data?.avatarUrl ??
|
||||
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=1`
|
||||
}
|
||||
className="shadow-md rounded-full w-12 aspect-square"
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-danger-100 z-10',
|
||||
data?.online ? 'bg-green-500' : 'bg-gray-500'
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex-col justify-center">
|
||||
<div className="text-lg truncate">{data?.nick}</div>
|
||||
<div className="text-danger-500 text-sm">{data?.uin}</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default QQInfoCard
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
|
||||
interface QrCodeLoginProps {
|
||||
qrcode: string
|
||||
}
|
||||
|
||||
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden">
|
||||
{!qrcode && (
|
||||
<div className="absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center">
|
||||
<Spinner color="danger" />
|
||||
</div>
|
||||
)}
|
||||
<QRCodeSVG size={180} value={qrcode} />
|
||||
</div>
|
||||
<div className="mt-5 text-center">请使用QQ或者TIM扫描上方二维码</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QrCodeLogin
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Image } from '@heroui/image'
|
||||
import clsx from 'clsx'
|
||||
import { motion } from 'motion/react'
|
||||
import React from 'react'
|
||||
import { IoMdLogOut } from 'react-icons/io'
|
||||
import { MdDarkMode, MdLightMode } from 'react-icons/md'
|
||||
|
||||
import useAuth from '@/hooks/auth'
|
||||
import useDialog from '@/hooks/use-dialog'
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
import logo from '@/assets/images/logo.png'
|
||||
import type { MenuItem } from '@/config/site'
|
||||
|
||||
import Menus from './menus'
|
||||
|
||||
interface SideBarProps {
|
||||
open: boolean
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
const { open, items } = props
|
||||
const { toggleTheme, isDark } = useTheme()
|
||||
const { revokeAuth } = useAuth()
|
||||
const dialog = useDialog()
|
||||
const onRevokeAuth = () => {
|
||||
dialog.confirm({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
onConfirm: revokeAuth
|
||||
})
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'overflow-hidden fixed top-0 left-0 h-full z-50 bg-background md:bg-transparent md:static shadow-md md:shadow-none rounded-r-md md:rounded-none'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: open ? '16rem' : 0 }}
|
||||
transition={{
|
||||
type: open ? 'spring' : 'tween',
|
||||
stiffness: 150,
|
||||
damping: open ? 15 : 10
|
||||
}}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
|
||||
<div className="flex justify-center items-center my-2 gap-2">
|
||||
<Image radius="none" height={40} src={logo} className="mb-2" />
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center font-bold',
|
||||
'!text-2xl shiny-text'
|
||||
)}
|
||||
>
|
||||
NapCat
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex flex-col flex-1 px-4">
|
||||
<Menus items={items} />
|
||||
<div className="mt-auto mb-10 md:mb-0">
|
||||
<Button
|
||||
className="w-full"
|
||||
color="danger"
|
||||
radius="full"
|
||||
variant="light"
|
||||
onPress={toggleTheme}
|
||||
startContent={
|
||||
!isDark ? <MdLightMode size={16} /> : <MdDarkMode size={16} />
|
||||
}
|
||||
>
|
||||
切换主题
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full mb-2"
|
||||
color="danger"
|
||||
radius="full"
|
||||
variant="light"
|
||||
onPress={onRevokeAuth}
|
||||
startContent={<IoMdLogOut size={16} />}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SideBar
|
||||
@@ -1,157 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Image } from '@heroui/image'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
import { matchPath, useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
import type { MenuItem } from '@/config/site'
|
||||
|
||||
const renderItems = (items: MenuItem[], children = false) => {
|
||||
return items?.map((item) => {
|
||||
const navigate = useNavigate()
|
||||
const locate = useLocation()
|
||||
const [open, setOpen] = React.useState(!!item.autoOpen)
|
||||
const canOpen = React.useMemo(
|
||||
() => item.items && item.items.length > 0,
|
||||
[item.items]
|
||||
)
|
||||
const [b64img] = useLocalStorage(key.backgroundImage, '')
|
||||
const [customIcons] = useLocalStorage<Record<string, string>>(
|
||||
key.customIcons,
|
||||
{}
|
||||
)
|
||||
const isActive = React.useMemo(() => {
|
||||
if (item.href) {
|
||||
return !!matchPath(item.href, locate.pathname)
|
||||
}
|
||||
|
||||
return false
|
||||
}, [item.href, locate.pathname])
|
||||
|
||||
const goTo = (href: string) => {
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (item.items) {
|
||||
const shouldOpen = item.items.some(
|
||||
(item) => item?.href && !!matchPath(item.href, locate.pathname)
|
||||
)
|
||||
|
||||
if (shouldOpen) setOpen(true)
|
||||
}
|
||||
}, [item.items, locate.pathname])
|
||||
const panelRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div key={item.href + item.label}>
|
||||
<Button
|
||||
className={clsx(
|
||||
'flex items-center w-full text-left justify-start dark:text-white',
|
||||
// children && 'rounded-l-lg',
|
||||
isActive && 'bg-opacity-60',
|
||||
b64img && 'backdrop-blur-md text-white'
|
||||
)}
|
||||
color="danger"
|
||||
endContent={
|
||||
canOpen ? (
|
||||
// div实现箭头V效果
|
||||
<div
|
||||
className={clsx(
|
||||
'ml-auto relative w-3 h-3 transition-transform',
|
||||
open && 'transform rotate-180',
|
||||
isActive ? 'text-danger-500' : 'text-red-300 dark:text-white',
|
||||
'before:rounded-full',
|
||||
'before:content-[""]',
|
||||
'before:block',
|
||||
'before:absolute',
|
||||
'before:w-3',
|
||||
'before:h-[4.5px]',
|
||||
'before:bg-current',
|
||||
'before:top-1/2',
|
||||
'before:-left-[3px]',
|
||||
'before:transform',
|
||||
'before:-translate-y-1/2',
|
||||
'before:rotate-45',
|
||||
'after:rounded-full',
|
||||
'after:content-[""]',
|
||||
'after:block',
|
||||
'after:absolute',
|
||||
'after:w-3',
|
||||
'after:h-[4.5px]',
|
||||
'after:bg-current',
|
||||
'after:top-1/2',
|
||||
'after:left-[3px]',
|
||||
'after:transform',
|
||||
'after:-translate-y-1/2',
|
||||
'after:-rotate-45'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
|
||||
isActive
|
||||
? 'bg-danger-500 animate-spinner-ease-spin'
|
||||
: 'bg-red-300 dark:bg-white'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
radius="full"
|
||||
startContent={
|
||||
customIcons[item.label] ? (
|
||||
<Image
|
||||
radius="none"
|
||||
src={customIcons[item.label]}
|
||||
alt={item.label}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
) : (
|
||||
item.icon
|
||||
)
|
||||
}
|
||||
variant={isActive ? (children ? 'solid' : 'shadow') : 'light'}
|
||||
onPress={() => {
|
||||
if (item.href) {
|
||||
if (!isActive) {
|
||||
goTo(item.href)
|
||||
}
|
||||
} else if (canOpen) {
|
||||
setOpen(!open)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="ml-4 overflow-hidden transition-all duration-300"
|
||||
style={{
|
||||
height: open ? panelRef.current?.scrollHeight : 0
|
||||
}}
|
||||
>
|
||||
{item.items && renderItems(item.items, true)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
interface MenusProps {
|
||||
items: MenuItem[]
|
||||
}
|
||||
const Menus: React.FC<MenusProps> = (props) => {
|
||||
const { items } = props
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-content-center flex-1 gap-2">
|
||||
{renderItems(items)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Menus
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Switch } from '@heroui/switch'
|
||||
import clsx from 'clsx'
|
||||
import React, { forwardRef } from 'react'
|
||||
|
||||
export interface SwitchCardProps {
|
||||
label?: string
|
||||
description?: string
|
||||
value?: boolean
|
||||
onValueChange?: (value: boolean) => void
|
||||
name?: string
|
||||
onBlur?: React.FocusEventHandler
|
||||
disabled?: boolean
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>
|
||||
}
|
||||
|
||||
const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
|
||||
(props, ref) => {
|
||||
const { label, description, value, onValueChange, disabled } = props
|
||||
const selectString = value ? 'true' : 'false'
|
||||
|
||||
return (
|
||||
<Switch
|
||||
classNames={{
|
||||
base: clsx(
|
||||
'inline-flex flex-row-reverse w-full max-w-md bg-content1 hover:bg-content2 items-center',
|
||||
'justify-between cursor-pointer rounded-lg gap-2 p-3 border-2 border-transparent',
|
||||
'data-[selected=true]:border-primary bg-opacity-50 backdrop-blur-sm'
|
||||
)
|
||||
}}
|
||||
{...props}
|
||||
ref={ref}
|
||||
isDisabled={disabled}
|
||||
isSelected={value}
|
||||
value={selectString}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-medium">{label}</p>
|
||||
<p className="text-tiny text-default-400">{description}</p>
|
||||
</div>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SwitchCard.displayName = 'SwitchCard'
|
||||
|
||||
export default SwitchCard
|
||||
@@ -1,229 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'
|
||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
|
||||
import { RiMacFill } from 'react-icons/ri'
|
||||
|
||||
import useDialog from '@/hooks/use-dialog'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { compareVersion } from '@/utils/version'
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
import { GithubRelease } from '@/types/github'
|
||||
|
||||
import TailwindMarkdown from './tailwind_markdown'
|
||||
|
||||
export interface SystemInfoItemProps {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
value?: React.ReactNode
|
||||
endContent?: React.ReactNode
|
||||
}
|
||||
|
||||
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
title,
|
||||
value = '--',
|
||||
icon,
|
||||
endContent
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-danger-50 dark:shadow-danger-100 rounded text-danger-400">
|
||||
{icon}
|
||||
<div className="w-24">{title}</div>
|
||||
<div className="text-danger-200">{value}</div>
|
||||
<div className="ml-auto">{endContent}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface NewVersionTipProps {
|
||||
currentVersion?: string
|
||||
}
|
||||
|
||||
const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
const { currentVersion } = props
|
||||
const dialog = useDialog()
|
||||
const { data: releaseData, error } = useRequest(() =>
|
||||
request.get<GithubRelease[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
||||
)
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Tooltip content="检查新版本失败">
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="danger"
|
||||
variant="shadow"
|
||||
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
||||
onPress={() => {
|
||||
dialog.alert({
|
||||
title: '检查新版本失败',
|
||||
content: error.message
|
||||
})
|
||||
}}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const latestVersion = releaseData?.data?.[0]?.tag_name
|
||||
|
||||
if (!latestVersion || !currentVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (compareVersion(latestVersion, currentVersion) <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const middleVersions: GithubRelease[] = []
|
||||
|
||||
for (let i = 0; i < releaseData.data.length; i++) {
|
||||
const versionInfo = releaseData.data[i]
|
||||
if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
|
||||
middleVersions.push(versionInfo)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content="有新版本可用">
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="danger"
|
||||
variant="shadow"
|
||||
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
||||
onPress={() => {
|
||||
dialog.confirm({
|
||||
title: '有新版本可用',
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm space-x-2">
|
||||
<span>当前版本</span>
|
||||
<Chip color="primary" variant="flat">
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="text-sm space-x-2">
|
||||
<span>最新版本</span>
|
||||
<Chip color="primary">{latestVersion}</Chip>
|
||||
</div>
|
||||
<div className="text-sm space-y-2 !mt-4">
|
||||
{middleVersions.map((versionInfo) => (
|
||||
<div
|
||||
key={versionInfo.tag_name}
|
||||
className="p-4 bg-content1 rounded-md shadow-small"
|
||||
>
|
||||
<TailwindMarkdown content={versionInfo.body} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
scrollBehavior: 'inside',
|
||||
size: '3xl',
|
||||
confirmText: '前往下载',
|
||||
onConfirm() {
|
||||
window.open(
|
||||
'https://github.com/NapNeko/NapCatQQ/releases',
|
||||
'_blank',
|
||||
'noopener'
|
||||
)
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const NapCatVersion = () => {
|
||||
const {
|
||||
data: packageData,
|
||||
loading: packageLoading,
|
||||
error: packageError
|
||||
} = useRequest(WebUIManager.getPackageInfo)
|
||||
|
||||
const currentVersion = packageData?.version
|
||||
|
||||
return (
|
||||
<SystemInfoItem
|
||||
title="NapCat 版本"
|
||||
icon={<IoLogoOctocat className="text-xl" />}
|
||||
value={
|
||||
packageError ? (
|
||||
`错误:${packageError.message}`
|
||||
) : packageLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
currentVersion
|
||||
)
|
||||
}
|
||||
endContent={<NewVersionTip currentVersion={currentVersion} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SystemInfoProps {
|
||||
archInfo?: string
|
||||
}
|
||||
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
const { archInfo } = props
|
||||
const {
|
||||
data: qqVersionData,
|
||||
loading: qqVersionLoading,
|
||||
error: qqVersionError
|
||||
} = useRequest(WebUIManager.getQQVersion)
|
||||
return (
|
||||
<Card className="bg-opacity-60 shadow-sm shadow-danger-50 dark:shadow-danger-100 overflow-visible flex-1">
|
||||
<CardHeader className="pb-0 items-center gap-1 text-danger-500 font-extrabold">
|
||||
<FaCircleInfo className="text-lg" />
|
||||
<span>系统信息</span>
|
||||
</CardHeader>
|
||||
<CardBody className="flex-1">
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<NapCatVersion />
|
||||
<SystemInfoItem
|
||||
title="QQ 版本"
|
||||
icon={<FaQq className="text-lg" />}
|
||||
value={
|
||||
qqVersionError ? (
|
||||
`错误:${qqVersionError.message}`
|
||||
) : qqVersionLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
qqVersionData
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SystemInfoItem
|
||||
title="WebUI 版本"
|
||||
icon={<IoLogoChrome className="text-xl" />}
|
||||
value="Next"
|
||||
/>
|
||||
<SystemInfoItem
|
||||
title="系统版本"
|
||||
icon={<RiMacFill className="text-xl" />}
|
||||
value={archInfo}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemInfo
|
||||
@@ -1,130 +0,0 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Image } from '@heroui/image'
|
||||
import clsx from 'clsx'
|
||||
import { BiSolidMemoryCard } from 'react-icons/bi'
|
||||
import { GiCpu } from 'react-icons/gi'
|
||||
|
||||
import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png'
|
||||
|
||||
import UsagePie from './usage_pie'
|
||||
|
||||
export interface SystemStatusItemProps {
|
||||
title: string
|
||||
value?: string | number
|
||||
size?: 'md' | 'lg'
|
||||
unit?: string
|
||||
}
|
||||
|
||||
const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
|
||||
title,
|
||||
value = '-',
|
||||
size = 'md',
|
||||
unit
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'shadow-sm p-2 rounded-md text-sm bg-content1 bg-opacity-30',
|
||||
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between'
|
||||
)}
|
||||
>
|
||||
<div className="w-24">{title}</div>
|
||||
<div className="text-default-400">
|
||||
{value}
|
||||
{unit}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SystemStatusDisplayProps {
|
||||
data?: SystemStatus
|
||||
}
|
||||
|
||||
const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
||||
const memoryUsage = {
|
||||
system: 0,
|
||||
qq: 0
|
||||
}
|
||||
if (data) {
|
||||
const system = Number(data.memory.total) || 1
|
||||
const systemUsage = Number(data.memory.usage.system)
|
||||
const qqUsage = Number(data.memory.usage.qq)
|
||||
memoryUsage.system = (systemUsage / system) * 100
|
||||
memoryUsage.qq = (qqUsage / system) * 100
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-opacity-60 shadow-sm shadow-danger-50 dark:shadow-danger-100 col-span-1 lg:col-span-2 relative overflow-hidden">
|
||||
<div className="absolute h-full right-0 top-0">
|
||||
<Image
|
||||
src={bkg}
|
||||
alt="background"
|
||||
className="select-none pointer-events-none !opacity-30 w-full h-full"
|
||||
classNames={{
|
||||
wrapper: 'w-full h-full',
|
||||
img: 'object-contain w-full h-full'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CardBody className="overflow-visible md:flex-row gap-4 items-center justify-stretch z-10">
|
||||
<div className="flex-1 w-full md:max-w-96">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-1 text-danger-400">
|
||||
<GiCpu className="text-xl" />
|
||||
<span>CPU</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SystemStatusItem title="型号" value={data?.cpu.model} size="lg" />
|
||||
<SystemStatusItem title="内核数" value={data?.cpu.core} />
|
||||
<SystemStatusItem title="主频" value={data?.cpu.speed} unit="GHz" />
|
||||
<SystemStatusItem
|
||||
title="使用率"
|
||||
value={data?.cpu.usage.system}
|
||||
unit="%"
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title="QQ主线程"
|
||||
value={data?.cpu.usage.qq}
|
||||
unit="%"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-1 text-danger-400 mt-2">
|
||||
<BiSolidMemoryCard className="text-xl" />
|
||||
<span>内存</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SystemStatusItem
|
||||
title="总量"
|
||||
value={data?.memory.total}
|
||||
size="lg"
|
||||
unit="MB"
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title="使用量"
|
||||
value={data?.memory.usage.system}
|
||||
unit="MB"
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title="QQ主线程"
|
||||
value={data?.memory.usage.qq}
|
||||
unit="MB"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row md:flex-col gap-2 flex-shrink-0 w-full justify-center md:w-40 min-h-40 mt-4 md:mt-0 md:mx-auto">
|
||||
<UsagePie
|
||||
systemUsage={Number(data?.cpu.usage.system) || 0}
|
||||
processUsage={Number(data?.cpu.usage.qq) || 0}
|
||||
title="CPU占用"
|
||||
/>
|
||||
<UsagePie
|
||||
systemUsage={memoryUsage.system}
|
||||
processUsage={memoryUsage.qq}
|
||||
title="内存占用"
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
export default SystemStatusDisplay
|
||||
@@ -1,49 +0,0 @@
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
||||
return (
|
||||
<Markdown
|
||||
className="prose prose-sm sm:prose lg:prose-lg xl:prose-xl"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="text-2xl font-bold" {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2 className="text-xl font-bold" {...props} />
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3 className="text-lg font-bold" {...props} />
|
||||
),
|
||||
p: ({ node, ...props }) => <p className="m-0" {...props} />,
|
||||
a: ({ node, ...props }) => (
|
||||
<a
|
||||
className="text-primary-500 inline-block hover:underline"
|
||||
target="_blank"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul className="list-disc list-inside" {...props} />
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol className="list-decimal list-inside" {...props} />
|
||||
),
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-default-300 pl-4 italic"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ node, ...props }) => (
|
||||
<code className="bg-default-100 p-1 rounded text-xs" {...props} />
|
||||
)
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default TailwindMarkdown
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import TerminalManager from '@/controllers/terminal_manager'
|
||||
|
||||
import XTerm, { XTermRef } from '../xterm'
|
||||
|
||||
interface TerminalInstanceProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export function TerminalInstance({ id }: TerminalInstanceProps) {
|
||||
const termRef = useRef<XTermRef>(null)
|
||||
const connected = useRef(false)
|
||||
|
||||
const handleData = (data: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.data) {
|
||||
termRef.current?.write(parsed.data)
|
||||
}
|
||||
} catch (e) {
|
||||
termRef.current?.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (connected.current) {
|
||||
TerminalManager.disconnectTerminal(id, handleData)
|
||||
}
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleInput = (data: string) => {
|
||||
TerminalManager.sendInput(id, data)
|
||||
}
|
||||
|
||||
const handleResize = (cols: number, rows: number) => {
|
||||
if (!connected.current) {
|
||||
connected.current = true
|
||||
console.log('instance', rows, cols)
|
||||
TerminalManager.connectTerminal(id, handleData, { rows, cols })
|
||||
} else {
|
||||
TerminalManager.sendResize(id, cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<XTerm
|
||||
ref={termRef}
|
||||
onInput={handleInput}
|
||||
onResize={handleResize} // 使用 fitAddon 改变后触发的 resize 回调
|
||||
className="w-full h-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Toaster as HotToaster } from 'react-hot-toast'
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
export const Toaster = () => {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<HotToaster
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: '20px',
|
||||
background: isDark ? '#333' : '#fff',
|
||||
color: isDark ? '#fff' : '#333'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toaster
|
||||
@@ -1,12 +0,0 @@
|
||||
export default function UnderConstruction() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full pt-4">
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<div className="text-6xl font-bold text-gray-500">🚧</div>
|
||||
<div className="text-2xl font-bold text-gray-500">
|
||||
Under Construction
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import * as echarts from 'echarts'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
interface UsagePieProps {
|
||||
systemUsage: number
|
||||
processUsage: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
const defaultOption: echarts.EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '<center>{b}<br/><b>{d}%</b></center>',
|
||||
borderRadius: 10,
|
||||
extraCssText: 'backdrop-filter: blur(10px);'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '系统占用',
|
||||
type: 'pie',
|
||||
radius: ['70%', '90%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'center',
|
||||
formatter: '系统占用',
|
||||
fontSize: 14
|
||||
},
|
||||
itemStyle: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: 100,
|
||||
name: '系统总量'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const UsagePie: React.FC<UsagePieProps> = ({
|
||||
systemUsage,
|
||||
processUsage,
|
||||
title
|
||||
}) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null)
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
if (chartRef.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current)
|
||||
const option = defaultOption
|
||||
chartInstance.current.setOption(option)
|
||||
const observer = new ResizeObserver(() => {
|
||||
chartInstance.current?.resize()
|
||||
})
|
||||
observer.observe(chartRef.current)
|
||||
return () => {
|
||||
chartInstance.current?.dispose()
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
series: [
|
||||
{
|
||||
label: {
|
||||
formatter: title
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}, [title])
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
darkMode: theme === 'dark',
|
||||
tooltip: {
|
||||
backgroundColor:
|
||||
theme === 'dark'
|
||||
? 'rgba(0, 0, 0, 0.8)'
|
||||
: 'rgba(255, 255, 255, 0.8)',
|
||||
textStyle: {
|
||||
color: theme === 'dark' ? '#fff' : '#333'
|
||||
}
|
||||
},
|
||||
color:
|
||||
theme === 'dark'
|
||||
? ['#D33FF0', '#EF8664', '#E25180']
|
||||
: ['#D33FF0', '#EA7D9B', '#FFC107'],
|
||||
series: [
|
||||
{
|
||||
itemStyle: {
|
||||
borderColor: theme === 'dark' ? '#333' : '#F0A9A7'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
series: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: processUsage,
|
||||
name: 'QQ占用'
|
||||
},
|
||||
{
|
||||
value: systemUsage - processUsage,
|
||||
name: '其他进程占用'
|
||||
},
|
||||
{
|
||||
value: 100 - systemUsage,
|
||||
name: '剩余系统总量'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}, [systemUsage, processUsage])
|
||||
|
||||
return <div ref={chartRef} className="w-36 h-36 flex-shrink-0" />
|
||||
}
|
||||
|
||||
export default UsagePie
|
||||
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
BugIcon2,
|
||||
FileIcon,
|
||||
InfoIcon,
|
||||
LogIcon,
|
||||
RouteIcon,
|
||||
SettingsIcon,
|
||||
SignalTowerIcon,
|
||||
TerminalIcon
|
||||
} from '@/components/icons'
|
||||
|
||||
export type SiteConfig = typeof siteConfig
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
autoOpen?: boolean
|
||||
href?: string
|
||||
items?: MenuItem[]
|
||||
customIcon?: string
|
||||
}
|
||||
|
||||
export const siteConfig = {
|
||||
name: 'NapCat WebUI',
|
||||
description: 'NapCat WebUI.',
|
||||
navItems: [
|
||||
{
|
||||
label: '基础信息',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<RouteIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/'
|
||||
},
|
||||
{
|
||||
label: '网络配置',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<SignalTowerIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/network'
|
||||
},
|
||||
{
|
||||
label: '其他配置',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<SettingsIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/config'
|
||||
},
|
||||
{
|
||||
label: '猫猫日志',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<LogIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/logs'
|
||||
},
|
||||
{
|
||||
label: '接口调试',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<BugIcon2 />
|
||||
</div>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
label: 'HTTP',
|
||||
href: '/debug/http'
|
||||
},
|
||||
{
|
||||
label: 'Websocket',
|
||||
href: '/debug/ws'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '文件管理',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<FileIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/file_manager'
|
||||
},
|
||||
{
|
||||
label: '系统终端',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<TerminalIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/terminal'
|
||||
},
|
||||
{
|
||||
label: '关于我们',
|
||||
icon: (
|
||||
<div className="w-5 h-5">
|
||||
<InfoIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/about'
|
||||
}
|
||||
] as MenuItem[],
|
||||
links: {
|
||||
github: 'https://github.com/NapNeko/NapCatQQ',
|
||||
docs: 'https://napcat.napneko.icu/'
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// Songs Context
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { createContext, useEffect, useState } from 'react'
|
||||
|
||||
import { PlayMode } from '@/const/enum'
|
||||
import key from '@/const/key'
|
||||
|
||||
import AudioPlayer from '@/components/audio_player'
|
||||
|
||||
import { get163MusicListSongs, getNextMusic } from '@/utils/music'
|
||||
|
||||
import type { FinalMusic } from '@/types/music'
|
||||
|
||||
export interface MusicContextProps {
|
||||
setListId: (id: string) => void
|
||||
listId: string
|
||||
onNext: () => void
|
||||
onPrevious: () => void
|
||||
}
|
||||
|
||||
export interface MusicProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const AudioContext = createContext<MusicContextProps>({
|
||||
setListId: () => {},
|
||||
listId: '5438670983',
|
||||
onNext: () => {},
|
||||
onPrevious: () => {}
|
||||
})
|
||||
|
||||
const AudioProvider: React.FC<MusicProviderProps> = ({ children }) => {
|
||||
const [listId, setListId] = useLocalStorage(key.musicID, '5438670983')
|
||||
const [musicList, setMusicList] = useState<FinalMusic[]>([])
|
||||
const [musicId, setMusicId] = useState<number>(0)
|
||||
const [playMode, setPlayMode] = useState<PlayMode>(PlayMode.Loop)
|
||||
const music = musicList.find((music) => music.id === musicId)
|
||||
const onNext = () => {
|
||||
const nextID = getNextMusic(musicList, musicId, playMode)
|
||||
setMusicId(nextID)
|
||||
}
|
||||
const onPrevious = () => {
|
||||
const index = musicList.findIndex((music) => music.id === musicId)
|
||||
if (index === 0) {
|
||||
setMusicId(musicList[musicList.length - 1].id)
|
||||
} else {
|
||||
setMusicId(musicList[index - 1].id)
|
||||
}
|
||||
}
|
||||
const onPlayEnd = () => {
|
||||
const nextID = getNextMusic(musicList, musicId, playMode)
|
||||
setMusicId(nextID)
|
||||
}
|
||||
const changeMode = (mode: PlayMode) => {
|
||||
setPlayMode(mode)
|
||||
}
|
||||
const fetchMusicList = async (id: string) => {
|
||||
const res = await get163MusicListSongs(id)
|
||||
setMusicList(res)
|
||||
setMusicId(res[0].id)
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchMusicList(listId)
|
||||
}, [listId])
|
||||
return (
|
||||
<AudioContext.Provider
|
||||
value={{
|
||||
setListId,
|
||||
listId,
|
||||
onNext,
|
||||
onPrevious
|
||||
}}
|
||||
>
|
||||
<AudioPlayer
|
||||
title={music?.title}
|
||||
src={music?.url || ''}
|
||||
artist={music?.artist}
|
||||
cover={music?.cover}
|
||||
mode={playMode}
|
||||
pressNext={onNext}
|
||||
pressPrevious={onPrevious}
|
||||
onPlayEnd={onPlayEnd}
|
||||
onChangeMode={changeMode}
|
||||
/>
|
||||
{children}
|
||||
</AudioContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioProvider
|
||||
@@ -1,199 +0,0 @@
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { serverRequest } from '@/utils/request'
|
||||
|
||||
export interface FileInfo {
|
||||
name: string
|
||||
isDirectory: boolean
|
||||
size: number
|
||||
mtime: Date
|
||||
}
|
||||
|
||||
export default class FileManager {
|
||||
public static async listFiles(path: string = '/') {
|
||||
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
|
||||
`/File/list?path=${encodeURIComponent(path)}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
// 新增:按目录获取
|
||||
public static async listDirectories(path: string = '/') {
|
||||
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
|
||||
`/File/list?path=${encodeURIComponent(path)}&onlyDirectory=true`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async createDirectory(path: string): Promise<boolean> {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/mkdir',
|
||||
{ path }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async delete(path: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/delete',
|
||||
{ path }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async readFile(path: string) {
|
||||
const { data } = await serverRequest.get<ServerResponse<string>>(
|
||||
`/File/read?path=${encodeURIComponent(path)}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async writeFile(path: string, content: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/write',
|
||||
{ path, content }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async createFile(path: string): Promise<boolean> {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/create',
|
||||
{ path }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async batchDelete(paths: string[]) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/batchDelete',
|
||||
{ paths }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async rename(oldPath: string, newPath: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/rename',
|
||||
{ oldPath, newPath }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async move(sourcePath: string, targetPath: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/move',
|
||||
{ sourcePath, targetPath }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async batchMove(
|
||||
items: { sourcePath: string; targetPath: string }[]
|
||||
) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/batchMove',
|
||||
{ items }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static download(path: string) {
|
||||
const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
|
||||
toast
|
||||
.promise(
|
||||
serverRequest
|
||||
.post(downloadUrl, void 0, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
throw new Error('下载失败')
|
||||
}),
|
||||
{
|
||||
loading: '正在下载文件...',
|
||||
success: '下载成功',
|
||||
error: '下载失败'
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
let fileName = path.split('/').pop() || ''
|
||||
if (path.split('.').length === 1) {
|
||||
fileName += '.zip'
|
||||
}
|
||||
link.setAttribute('download', fileName)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
public static async batchDownload(paths: string[]) {
|
||||
const downloadUrl = `/File/batchDownload`
|
||||
toast
|
||||
.promise(
|
||||
serverRequest
|
||||
.post(
|
||||
downloadUrl,
|
||||
{ paths },
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
throw new Error('下载失败')
|
||||
}),
|
||||
{
|
||||
loading: '正在下载文件...',
|
||||
success: '下载成功',
|
||||
error: '下载失败'
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
const fileName = 'files.zip'
|
||||
link.setAttribute('download', fileName)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
public static async downloadToURL(path: string) {
|
||||
const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
|
||||
const response = await serverRequest.post(downloadUrl, void 0, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
return window.URL.createObjectURL(new Blob([response.data]))
|
||||
}
|
||||
|
||||
public static async upload(path: string, files: File[]) {
|
||||
const formData = new FormData()
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
`/File/upload?path=${encodeURIComponent(path)}`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { serverRequest } from '@/utils/request'
|
||||
|
||||
import { SelfInfo } from '@/types/user'
|
||||
|
||||
export default class QQManager {
|
||||
public static async getOB11Config() {
|
||||
const data = await serverRequest.post<ServerResponse<OneBotConfig>>(
|
||||
'/OB11Config/GetConfig'
|
||||
)
|
||||
|
||||
return data.data.data
|
||||
}
|
||||
|
||||
public static async setOB11Config(config: OneBotConfig) {
|
||||
await serverRequest.post<ServerResponse<null>>('/OB11Config/SetConfig', {
|
||||
config: JSON.stringify(config)
|
||||
})
|
||||
}
|
||||
|
||||
public static async checkQQLoginStatus() {
|
||||
const data = await serverRequest.post<
|
||||
ServerResponse<{
|
||||
isLogin: string
|
||||
qrcodeurl: string
|
||||
}>
|
||||
>('/QQLogin/CheckLoginStatus')
|
||||
|
||||
return data.data.data
|
||||
}
|
||||
|
||||
public static async checkQQLoginStatusWithQrcode() {
|
||||
const data = await serverRequest.post<
|
||||
ServerResponse<{ qrcodeurl: string; isLogin: string }>
|
||||
>('/QQLogin/CheckLoginStatus')
|
||||
|
||||
return data.data.data
|
||||
}
|
||||
|
||||
public static async getQQLoginQrcode() {
|
||||
const data = await serverRequest.post<
|
||||
ServerResponse<{
|
||||
qrcode: string
|
||||
}>
|
||||
>('/QQLogin/GetQQLoginQrcode')
|
||||
|
||||
return data.data.data.qrcode
|
||||
}
|
||||
|
||||
public static async getQQQuickLoginList() {
|
||||
const data = await serverRequest.post<ServerResponse<string[]>>(
|
||||
'/QQLogin/GetQuickLoginList'
|
||||
)
|
||||
|
||||
return data.data.data
|
||||
}
|
||||
|
||||
public static async getQQQuickLoginListNew() {
|
||||
const data = await serverRequest.post<ServerResponse<LoginListItem[]>>(
|
||||
'/QQLogin/GetQuickLoginListNew'
|
||||
)
|
||||
return data.data.data
|
||||
}
|
||||
|
||||
public static async setQuickLogin(uin: string) {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/SetQuickLogin', {
|
||||
uin
|
||||
})
|
||||
}
|
||||
|
||||
public static async getQQLoginInfo() {
|
||||
const data = await serverRequest.post<ServerResponse<SelfInfo>>(
|
||||
'/QQLogin/GetQQLoginInfo'
|
||||
)
|
||||
return data.data.data
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { serverRequest } from '@/utils/request'
|
||||
|
||||
type TerminalCallback = (data: string) => void
|
||||
|
||||
interface TerminalConnection {
|
||||
ws: WebSocket
|
||||
callbacks: Set<TerminalCallback>
|
||||
isConnected: boolean
|
||||
buffer: string[] // 添加缓存数组
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface TerminalInfo {
|
||||
id: string
|
||||
}
|
||||
|
||||
class TerminalManager {
|
||||
private connections: Map<string, TerminalConnection> = new Map()
|
||||
private readonly MAX_BUFFER_SIZE = 1000 // 限制缓存大小
|
||||
|
||||
async createTerminal(cols: number, rows: number): Promise<TerminalSession> {
|
||||
const { data } = await serverRequest.post<ServerResponse<TerminalSession>>(
|
||||
'/Log/terminal/create',
|
||||
{ cols, rows }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
async closeTerminal(id: string): Promise<void> {
|
||||
await serverRequest.post(`/Log/terminal/${id}/close`)
|
||||
}
|
||||
|
||||
async getTerminalList(): Promise<TerminalInfo[]> {
|
||||
const { data } =
|
||||
await serverRequest.get<ServerResponse<TerminalInfo[]>>(
|
||||
'/Log/terminal/list'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
connectTerminal(
|
||||
id: string,
|
||||
callback: TerminalCallback,
|
||||
config?: {
|
||||
cols?: number
|
||||
rows?: number
|
||||
}
|
||||
): WebSocket {
|
||||
let conn = this.connections.get(id)
|
||||
const { cols = 80, rows = 24 } = config || {}
|
||||
if (!conn) {
|
||||
const url = new URL(window.location.href)
|
||||
url.protocol = url.protocol.replace('http', 'ws')
|
||||
url.pathname = `/api/ws/terminal`
|
||||
url.searchParams.set('id', id)
|
||||
const token = JSON.parse(localStorage.getItem('token') || '')
|
||||
if (!token) {
|
||||
throw new Error('No token found')
|
||||
}
|
||||
url.searchParams.set('token', token)
|
||||
const ws = new WebSocket(url.toString())
|
||||
conn = {
|
||||
ws,
|
||||
callbacks: new Set([callback]),
|
||||
isConnected: false,
|
||||
buffer: [] // 初始化缓存
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = event.data
|
||||
// 保存到缓存
|
||||
conn?.buffer.push(data)
|
||||
if ((conn?.buffer.length ?? 0) > this.MAX_BUFFER_SIZE) {
|
||||
conn?.buffer.shift()
|
||||
}
|
||||
conn?.callbacks.forEach((cb) => cb(data))
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
if (conn) conn.isConnected = true
|
||||
this.sendResize(id, cols, rows)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (conn) conn.isConnected = false
|
||||
}
|
||||
|
||||
this.connections.set(id, conn)
|
||||
} else {
|
||||
conn.callbacks.add(callback)
|
||||
// 恢复历史内容
|
||||
conn.buffer.forEach((data) => callback(data))
|
||||
}
|
||||
|
||||
return conn.ws
|
||||
}
|
||||
|
||||
disconnectTerminal(id: string, callback: TerminalCallback) {
|
||||
const conn = this.connections.get(id)
|
||||
if (!conn) return
|
||||
|
||||
conn.callbacks.delete(callback)
|
||||
}
|
||||
|
||||
removeTerminal(id: string) {
|
||||
const conn = this.connections.get(id)
|
||||
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.close()
|
||||
}
|
||||
this.connections.delete(id)
|
||||
}
|
||||
|
||||
sendInput(id: string, data: string) {
|
||||
const conn = this.connections.get(id)
|
||||
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
}
|
||||
|
||||
sendResize(id: string, cols: number, rows: number) {
|
||||
const conn = this.connections.get(id)
|
||||
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const terminalManager = new TerminalManager()
|
||||
|
||||
export default terminalManager
|
||||
@@ -1,141 +0,0 @@
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill'
|
||||
|
||||
import { LogLevel } from '@/const/enum'
|
||||
|
||||
import { serverRequest } from '@/utils/request'
|
||||
|
||||
export interface Log {
|
||||
level: LogLevel
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface TerminalInfo {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default class WebUIManager {
|
||||
public static async checkWebUiLogined() {
|
||||
const { data } =
|
||||
await serverRequest.post<ServerResponse<boolean>>('/auth/check')
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async loginWithToken(token: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<AuthResponse>>(
|
||||
'/auth/login',
|
||||
{ token }
|
||||
)
|
||||
return data.data.Credential
|
||||
}
|
||||
|
||||
public static async changePassword(oldToken: string, newToken: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/auth/update_token',
|
||||
{ oldToken, newToken }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async proxy<T>(url = '') {
|
||||
const data = await serverRequest.get<ServerResponse<string>>(
|
||||
'/base/proxy?url=' + encodeURIComponent(url)
|
||||
)
|
||||
data.data.data = JSON.parse(data.data.data)
|
||||
return data.data as ServerResponse<T>
|
||||
}
|
||||
|
||||
public static async getPackageInfo() {
|
||||
const { data } =
|
||||
await serverRequest.get<ServerResponse<PackageInfo>>('/base/PackageInfo')
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async getQQVersion() {
|
||||
const { data } =
|
||||
await serverRequest.get<ServerResponse<string>>('/base/QQVersion')
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async getLogList() {
|
||||
const { data } =
|
||||
await serverRequest.get<ServerResponse<string[]>>('/Log/GetLogList')
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async getLogContent(logName: string) {
|
||||
const { data } = await serverRequest.get<ServerResponse<string>>(
|
||||
`/Log/GetLog?id=${logName}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static getRealTimeLogs(writer: (data: Log[]) => void) {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
throw new Error('未登录')
|
||||
}
|
||||
const _token = JSON.parse(token)
|
||||
const eventSource = new EventSourcePolyfill('/api/Log/GetLogRealTime', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${_token}`,
|
||||
Accept: 'text/event-stream'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
data.message = data.message.replace(/\n/g, '\r\n')
|
||||
writer([data])
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE连接出错:', error)
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
return eventSource
|
||||
}
|
||||
|
||||
public static getSystemStatus(writer: (data: SystemStatus) => void) {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
throw new Error('未登录')
|
||||
}
|
||||
const _token = JSON.parse(token)
|
||||
const eventSource = new EventSourcePolyfill(
|
||||
'/api/base/GetSysStatusRealTime',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${_token}`,
|
||||
Accept: 'text/event-stream'
|
||||
},
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as SystemStatus
|
||||
writer(data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE连接出错:', error)
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
return eventSource
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
const useAuth = () => {
|
||||
const [token, setToken] = useLocalStorage<string>(key.token, '')
|
||||
|
||||
return {
|
||||
token,
|
||||
isAuth: !!token,
|
||||
revokeAuth: () => setToken('')
|
||||
}
|
||||
}
|
||||
|
||||
export default useAuth
|
||||
@@ -1,189 +0,0 @@
|
||||
import { updateConfig as storeUpdateConfig } from '@/store/modules/config'
|
||||
|
||||
import { deepClone } from '@/utils/object'
|
||||
|
||||
import QQManager from '@/controllers/qq_manager'
|
||||
|
||||
import { useAppDispatch, useAppSelector } from './use-store'
|
||||
|
||||
const useConfig = () => {
|
||||
const config = useAppSelector((state) => state.config.value)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const createNetworkConfig = async <T extends keyof OneBotConfig['network']>(
|
||||
key: T,
|
||||
value: OneBotConfig['network'][T][0]
|
||||
) => {
|
||||
const allNetworkNames = Object.keys(config.network).reduce((acc, key) => {
|
||||
const _key = key as keyof OneBotConfig['network']
|
||||
return acc.concat(config.network[_key].map((item) => item.name))
|
||||
}, [] as string[])
|
||||
|
||||
if (value.name && allNetworkNames.includes(value.name)) {
|
||||
throw new Error('已经存在相同的配置项名')
|
||||
}
|
||||
|
||||
const newConfig = deepClone(config)
|
||||
|
||||
;(newConfig.network[key] as (typeof value)[]).push(value)
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const updateNetworkConfig = async <T extends keyof OneBotConfig['network']>(
|
||||
key: T,
|
||||
value: OneBotConfig['network'][T][0]
|
||||
) => {
|
||||
const newConfig = deepClone(config)
|
||||
const name = value.name
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项')
|
||||
}
|
||||
|
||||
newConfig.network[key][index] = value
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const deleteNetworkConfig = async <T extends keyof OneBotConfig['network']>(
|
||||
key: T,
|
||||
name: string
|
||||
) => {
|
||||
const newConfig = deepClone(config)
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项')
|
||||
}
|
||||
|
||||
newConfig.network[key].splice(index, 1)
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const enableNetworkConfig = async <T extends keyof OneBotConfig['network']>(
|
||||
key: T,
|
||||
name: string
|
||||
) => {
|
||||
const newConfig = deepClone(config)
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项')
|
||||
}
|
||||
|
||||
newConfig.network[key][index].enable = !newConfig.network[key][index].enable
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const enableDebugNetworkConfig = async <
|
||||
T extends keyof OneBotConfig['network']
|
||||
>(
|
||||
key: T,
|
||||
name: string
|
||||
) => {
|
||||
const newConfig = deepClone(config)
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项')
|
||||
}
|
||||
|
||||
newConfig.network[key][index].debug = !newConfig.network[key][index].debug
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const updateSingleConfig = async <T extends keyof OneBotConfig>(
|
||||
key: T,
|
||||
value: OneBotConfig[T]
|
||||
) => {
|
||||
const newConfig = deepClone(config)
|
||||
|
||||
newConfig[key] = value
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const updateConfig = async (newConfig: OneBotConfig) => {
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const refreshConfig = async () => {
|
||||
const newConfig = await QQManager.getOB11Config()
|
||||
|
||||
if (JSON.stringify(newConfig) === JSON.stringify(config)) {
|
||||
return config
|
||||
}
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const mergeConfig = async (newConfig: OneBotConfig) => {
|
||||
const mergedConfig = deepClone(config)
|
||||
|
||||
Object.assign(mergedConfig, newConfig)
|
||||
|
||||
await QQManager.setOB11Config(mergedConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(mergedConfig))
|
||||
|
||||
return mergedConfig
|
||||
}
|
||||
|
||||
const saveConfigWithoutNetwork = async (newConfig: OneBotConfig) => {
|
||||
newConfig.network = config.network
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
return newConfig
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
createNetworkConfig,
|
||||
refreshConfig,
|
||||
updateConfig,
|
||||
updateSingleConfig,
|
||||
updateNetworkConfig,
|
||||
deleteNetworkConfig,
|
||||
enableNetworkConfig,
|
||||
enableDebugNetworkConfig,
|
||||
mergeConfig,
|
||||
saveConfigWithoutNetwork
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { DialogContext } from '@/contexts/dialog'
|
||||
|
||||
const useDialog = () => {
|
||||
const dialog = React.useContext(DialogContext)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
export default useDialog
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { AudioContext } from '@/contexts/songs'
|
||||
|
||||
const useMusic = () => {
|
||||
const music = React.useContext(AudioContext)
|
||||
|
||||
return music
|
||||
}
|
||||
|
||||
export default useMusic
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// 全局图片缓存
|
||||
const imageCache = new Map<string, HTMLImageElement>()
|
||||
|
||||
export function usePreloadImages(urls: string[]) {
|
||||
const [loadedUrls, setLoadedUrls] = useState<Record<string, boolean>>({})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const isMounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true
|
||||
|
||||
// 检查是否所有图片都已缓存
|
||||
const allCached = urls.every((url) => imageCache.has(url))
|
||||
if (allCached) {
|
||||
setLoadedUrls(urls.reduce((acc, url) => ({ ...acc, [url]: true }), {}))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
const loadedImages: Record<string, boolean> = {}
|
||||
let pendingCount = urls.length
|
||||
|
||||
urls.forEach((url) => {
|
||||
// 如果已经缓存,直接标记为已加载
|
||||
if (imageCache.has(url)) {
|
||||
loadedImages[url] = true
|
||||
pendingCount--
|
||||
if (pendingCount === 0) {
|
||||
setLoadedUrls(loadedImages)
|
||||
setIsLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
if (!isMounted.current) return
|
||||
loadedImages[url] = true
|
||||
imageCache.set(url, img)
|
||||
pendingCount--
|
||||
|
||||
if (pendingCount === 0) {
|
||||
setLoadedUrls(loadedImages)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
img.onerror = () => {
|
||||
if (!isMounted.current) return
|
||||
loadedImages[url] = false
|
||||
pendingCount--
|
||||
|
||||
if (pendingCount === 0) {
|
||||
setLoadedUrls(loadedImages)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [urls])
|
||||
|
||||
return { loadedUrls, isLoading }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user