Compare commits
818 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9403c9c34 | ||
|
|
dc4914e3ca | ||
|
|
f3dbc4afad | ||
|
|
e3e22aebd9 | ||
|
|
0ca2f20b24 | ||
|
|
14279c773d | ||
|
|
8372f63eb6 | ||
|
|
b7b62d7922 | ||
|
|
162cce1f50 | ||
|
|
aa49c6ccbc | ||
|
|
a40e52079f | ||
|
|
c29e329548 | ||
|
|
e2d26f6a25 | ||
|
|
1752256868 | ||
|
|
23d7f0dcc1 | ||
|
|
288aeed178 | ||
|
|
9a9a618136 | ||
|
|
723eb319e1 | ||
|
|
96684a8d13 | ||
|
|
fc9fe5e21e | ||
|
|
24b763d808 | ||
|
|
f761cdff00 | ||
|
|
b785769138 | ||
|
|
6d1febd70a | ||
|
|
bdbaf503ca | ||
|
|
f9e74cf436 | ||
|
|
e043669a10 | ||
|
|
78d8fdba9d | ||
|
|
5c0f0386a6 | ||
|
|
30b39283b6 | ||
|
|
de84c39d2f | ||
|
|
65152e7e37 | ||
|
|
ba343ce5fa | ||
|
|
60495668a6 | ||
|
|
f2ac624dbb | ||
|
|
6238849d3f | ||
|
|
82cb903c1f | ||
|
|
5e5eb95b55 | ||
|
|
74e6f8b03e | ||
|
|
a2bf0d2b16 | ||
|
|
7532d39978 | ||
|
|
5cc9bf7418 | ||
|
|
20bdb940cd | ||
|
|
e9b214cff8 | ||
|
|
54f5fb2877 | ||
|
|
e86cb9e1cc | ||
|
|
3f258b9016 | ||
|
|
b54e144d0e | ||
|
|
7b20a7b775 | ||
|
|
df66b3e917 | ||
|
|
a919622d08 | ||
|
|
2a9ce950b7 | ||
|
|
48c12b765d | ||
|
|
1120055eed | ||
|
|
c66b6649e2 | ||
|
|
8479099926 | ||
|
|
cab65be1c9 | ||
|
|
6689e976c2 | ||
|
|
712dfa3fe1 | ||
|
|
346121f3c2 | ||
|
|
61c073ad6c | ||
|
|
4b3733bc19 | ||
|
|
b29c6bd83f | ||
|
|
b40fc4bd30 | ||
|
|
a225ba6075 | ||
|
|
303fe39c01 | ||
|
|
d343cbcf71 | ||
|
|
0eef8c5174 | ||
|
|
46fe257585 | ||
|
|
f69a57863e | ||
|
|
8876aadcfa | ||
|
|
485e9691a0 | ||
|
|
a0e7283ae6 | ||
|
|
b44c0647f1 | ||
|
|
7e60ab9064 | ||
|
|
f05c1f42b5 | ||
|
|
672bbb4265 | ||
|
|
10c1041b06 | ||
|
|
59c73facfe | ||
|
|
ba7d4cd392 | ||
|
|
d76a50c216 | ||
|
|
617223777b | ||
|
|
6ef047050d | ||
|
|
942ecc4c04 | ||
|
|
e72f9a8374 | ||
|
|
9cf782eb5b | ||
|
|
660338688a | ||
|
|
2d50bd7536 | ||
|
|
b02a4f1347 | ||
|
|
1748fdea34 | ||
|
|
6bbaf43671 | ||
|
|
4a66aaadad | ||
|
|
e2e239f6d9 | ||
|
|
fe22403e66 | ||
|
|
3313c71805 | ||
|
|
1e60e83514 | ||
|
|
9c893abcdf | ||
|
|
ead891ca2f | ||
|
|
8713e3cc86 | ||
|
|
3cc83d10d3 | ||
|
|
192ded374a | ||
|
|
13997c7e74 | ||
|
|
71b0dd4cc2 | ||
|
|
a58a0cdffe | ||
|
|
6aeb040db4 | ||
|
|
fef20e361e | ||
|
|
a63a07701d | ||
|
|
5dd56f2db3 | ||
|
|
275b095574 | ||
|
|
05eae71fba | ||
|
|
777b3c9445 | ||
|
|
a214168b1e | ||
|
|
9d55d02557 | ||
|
|
16c084ba80 | ||
|
|
b0f4ccc186 | ||
|
|
96d0606b4d | ||
|
|
450b9ec28a | ||
|
|
2ccf03fc1b | ||
|
|
38dfb3af07 | ||
|
|
ae4c59bfdb | ||
|
|
c9f4fdbee8 | ||
|
|
d21f461dda | ||
|
|
28a5a83315 | ||
|
|
11d11b88bf | ||
|
|
ff7658b5ba | ||
|
|
351faf2891 | ||
|
|
7d66229bad | ||
|
|
2b08be1e7d | ||
|
|
8255cfd479 | ||
|
|
f356bb4407 | ||
|
|
07e60291a2 | ||
|
|
2dbe8e6685 | ||
|
|
40f36b2afd | ||
|
|
d4260d5103 | ||
|
|
45f68bc936 | ||
|
|
9469074837 | ||
|
|
193807bb6f | ||
|
|
d4548db5b9 | ||
|
|
29aaea6fe6 | ||
|
|
369cc6438f | ||
|
|
d80b39c77b | ||
|
|
626725a8ca | ||
|
|
8be96358ae | ||
|
|
f2bfbfa3c5 | ||
|
|
7c9ffd6abc | ||
|
|
b370354287 | ||
|
|
145d71e283 | ||
|
|
eeea82d815 | ||
|
|
babd267bc4 | ||
|
|
e136c931ac | ||
|
|
ae00602345 | ||
|
|
5382108ee7 | ||
|
|
514063d3fb | ||
|
|
b08f396fec | ||
|
|
d37a7f06f1 | ||
|
|
ad7bca3aae | ||
|
|
4fb70ba80e | ||
|
|
1225b2eb9e | ||
|
|
24b2f103b9 | ||
|
|
0d304b58ca | ||
|
|
f419dbd794 | ||
|
|
7854cc81a8 | ||
|
|
9ad1bd29bd | ||
|
|
b88d4f0ecb | ||
|
|
44168b62d2 | ||
|
|
1dab013436 | ||
|
|
64a4a7aff5 | ||
|
|
e43b545c89 | ||
|
|
69fcde250e | ||
|
|
63d6290166 | ||
|
|
c1d759f3f3 | ||
|
|
3a782bc69c | ||
|
|
bea752879c | ||
|
|
a48fcb3819 | ||
|
|
68a07bc952 | ||
|
|
828dba09b0 | ||
|
|
0d2189e9e8 | ||
|
|
f0f0ab81e4 | ||
|
|
64b5fa7038 | ||
|
|
1d04c9b9c9 | ||
|
|
dee719ac25 | ||
|
|
ea676876f1 | ||
|
|
c1a4d5d81e | ||
|
|
95d88804e4 | ||
|
|
1fa072790f | ||
|
|
fe19c1183c | ||
|
|
be40f55bd9 | ||
|
|
30a10eaf6d | ||
|
|
3bc0c86df4 | ||
|
|
03c8726e6e | ||
|
|
de47491ded | ||
|
|
c691cdaa0e | ||
|
|
53efdc2802 | ||
|
|
9644076463 | ||
|
|
cb4e88f8aa | ||
|
|
adc16fc58d | ||
|
|
d6860a3e24 | ||
|
|
7e6116de45 | ||
|
|
1688a2ca25 | ||
|
|
fe57acfce0 | ||
|
|
1ae49b28b1 | ||
|
|
ef4e9c8b40 | ||
|
|
5da0758e89 | ||
|
|
816cab252d | ||
|
|
843f638835 | ||
|
|
e4684b2e12 | ||
|
|
c17365b6c9 | ||
|
|
01835c0ac5 | ||
|
|
e5749bd6ef | ||
|
|
689e58737b | ||
|
|
38da061cf1 | ||
|
|
e79940e52e | ||
|
|
88dd6068b6 | ||
|
|
7dd10f9c96 | ||
|
|
94aaf83107 | ||
|
|
e84fc5f424 | ||
|
|
f342b08179 | ||
|
|
0fcad02f3b | ||
|
|
43d2406ee9 | ||
|
|
78e2d05730 | ||
|
|
425bf808ed | ||
|
|
6d2916dc9f | ||
|
|
2281e4224b | ||
|
|
95282f9883 | ||
|
|
b470f182c9 | ||
|
|
0bba1068af | ||
|
|
947a7d8296 | ||
|
|
bd36cbf888 | ||
|
|
d8fa47bff7 | ||
|
|
1132beea5e | ||
|
|
2e3314e6c3 | ||
|
|
daa8f857f8 | ||
|
|
6d14271fe8 | ||
|
|
9284d48f67 | ||
|
|
c5d1c5a468 | ||
|
|
b98512789f | ||
|
|
6b8ed8d527 | ||
|
|
ec4500dcef | ||
|
|
288e63ce68 | ||
|
|
b3885584bb | ||
|
|
968b24be1e | ||
|
|
5a23c1783a | ||
|
|
ddeeb5a7c3 | ||
|
|
0b9bbcc7b8 | ||
|
|
022c8b4515 | ||
|
|
be04991928 | ||
|
|
34770567a5 | ||
|
|
6154fc2157 | ||
|
|
e77dcdd3d4 | ||
|
|
58a3532c1b | ||
|
|
116a5eeb43 | ||
|
|
decd50cb40 | ||
|
|
355563244c | ||
|
|
51aad628b5 | ||
|
|
7dd7a2cf34 | ||
|
|
4c0ff7c7f2 | ||
|
|
8aba3cbe00 | ||
|
|
e21c3ec507 | ||
|
|
fdbb0b2ca8 | ||
|
|
180195ab7d | ||
|
|
8add4e6b46 | ||
|
|
3d622d2efe | ||
|
|
bb7ed7b963 | ||
|
|
d541ea41ad | ||
|
|
7c7ebc9eb7 | ||
|
|
22275c3b12 | ||
|
|
8744a34e8e | ||
|
|
e98836fd0e | ||
|
|
feb62196a2 | ||
|
|
9fd29a2958 | ||
|
|
546c82ca40 | ||
|
|
f132dc38f4 | ||
|
|
58c70b8ca6 | ||
|
|
147f55eefe | ||
|
|
229b7b0c12 | ||
|
|
4b7b5ff8a4 | ||
|
|
4906bde746 | ||
|
|
a87a1a8988 | ||
|
|
e05f45e681 | ||
|
|
b4acacea81 | ||
|
|
fa9645b05b | ||
|
|
1ed4052814 | ||
|
|
7dc814461f | ||
|
|
9154ec0e8c | ||
|
|
3a2ea60583 | ||
|
|
b36bff3a1e | ||
|
|
b3d8cbf280 | ||
|
|
38fb02d112 | ||
|
|
2597f893cd | ||
|
|
ebdd036654 | ||
|
|
5032f0e6a9 | ||
|
|
ad963d718d | ||
|
|
69d314bce3 | ||
|
|
4a7425a947 | ||
|
|
c172ac0d5c | ||
|
|
01a66493a8 | ||
|
|
188f8b3faa | ||
|
|
ebcf5fad71 | ||
|
|
d1a656db82 | ||
|
|
4f6a11fd7c | ||
|
|
1d09a946bb | ||
|
|
6c4eb7edbd | ||
|
|
4f9f669ac6 | ||
|
|
f9e0e78473 | ||
|
|
b004facfca | ||
|
|
fb6ee2910f | ||
|
|
3fedc9b730 | ||
|
|
b260427312 | ||
|
|
dd1447e93c | ||
|
|
dbcc213562 | ||
|
|
1c019cd5c8 | ||
|
|
e37bde77a1 | ||
|
|
57bf0d2021 | ||
|
|
88b00f7069 | ||
|
|
7b08cbb2f7 | ||
|
|
97c0ec184d | ||
|
|
d18c845088 | ||
|
|
a64d97774d | ||
|
|
2ddc51aa4f | ||
|
|
28afe2a922 | ||
|
|
c2e97bf191 | ||
|
|
c922752a1f | ||
|
|
08f36a74ca | ||
|
|
d7809dd00c | ||
|
|
27582004da | ||
|
|
3d6a176cde | ||
|
|
4a2073a038 | ||
|
|
c8a65ecbe4 | ||
|
|
3750d5cba0 | ||
|
|
55b383780e | ||
|
|
6aec0ddf88 | ||
|
|
7c8e94d1df | ||
|
|
5ecbf626c8 | ||
|
|
584f580e3b | ||
|
|
280de47dac | ||
|
|
c7c05f5897 | ||
|
|
bb86180582 | ||
|
|
aff228edd3 | ||
|
|
f65ae6d703 | ||
|
|
0fccc06883 | ||
|
|
8652966645 | ||
|
|
6d84eb9f09 | ||
|
|
1a3dccac29 | ||
|
|
fa8de34fc5 | ||
|
|
10cfd6be80 | ||
|
|
a390b36e7c | ||
|
|
d6b5994e22 | ||
|
|
08611a97e7 | ||
|
|
35bbb44ce3 | ||
|
|
8ff879661a | ||
|
|
a8f01f099d | ||
|
|
040ab1096b | ||
|
|
0cbdf24315 | ||
|
|
164ea79bd1 | ||
|
|
97f3435bb3 | ||
|
|
63b108ff6b | ||
|
|
b0880cb369 | ||
|
|
5f70ee8e18 | ||
|
|
4c64f7a2c3 | ||
|
|
262927e459 | ||
|
|
b16c566004 | ||
|
|
1af82dbee6 | ||
|
|
2e9a5a4e13 | ||
|
|
b455f603dc | ||
|
|
37c0c3e339 | ||
|
|
b6cb341082 | ||
|
|
1af1a06700 | ||
|
|
79e4ecfdbe | ||
|
|
1585271e37 | ||
|
|
c240b171e4 | ||
|
|
9c405e90ac | ||
|
|
3ec3212ca5 | ||
|
|
b1289f6177 | ||
|
|
64b7ba48c8 | ||
|
|
f093053ea4 | ||
|
|
9faa0ded59 | ||
|
|
0f7dafeb23 | ||
|
|
472d1960d9 | ||
|
|
6e50acf106 | ||
|
|
a3fb4b1534 | ||
|
|
382cae32a2 | ||
|
|
0aa4851f8e | ||
|
|
65271e6d13 | ||
|
|
671cf8d588 | ||
|
|
afc7c81028 | ||
|
|
c330aee560 | ||
|
|
eafe63c886 | ||
|
|
53206d05b8 | ||
|
|
af085d457e | ||
|
|
fb36033939 | ||
|
|
584e7672df | ||
|
|
d4f7a5a1c0 | ||
|
|
2a9ea81ad4 | ||
|
|
276948dd68 | ||
|
|
990c5583f2 | ||
|
|
644f1b5640 | ||
|
|
5261fbe870 | ||
|
|
e4f2d85e2b | ||
|
|
8e3ccdc24a | ||
|
|
cd6d93affd | ||
|
|
6096ab0c9b | ||
|
|
0a87bb1db1 | ||
|
|
a19042c655 | ||
|
|
a889687a6a | ||
|
|
e1cdc715aa | ||
|
|
a82b3a0a29 | ||
|
|
d93a71f0be | ||
|
|
899dc765bc | ||
|
|
449490e52d | ||
|
|
5541d7974e | ||
|
|
ae3eb36183 | ||
|
|
d57e9a397c | ||
|
|
9d4fd16d81 | ||
|
|
3b16e7a123 | ||
|
|
1c4a2176e9 | ||
|
|
62f9243714 | ||
|
|
03bd23d314 | ||
|
|
27497d1812 | ||
|
|
f36c1bd2b5 | ||
|
|
cf72b2cdb9 | ||
|
|
44f6950fea | ||
|
|
308ddfedea | ||
|
|
ac7c330e2f | ||
|
|
1bde3492da | ||
|
|
f884518df3 | ||
|
|
1f7f9ce9db | ||
|
|
58acde2292 | ||
|
|
4e0fe2f449 | ||
|
|
536793ab25 | ||
|
|
23a48e07a2 | ||
|
|
1e55557154 | ||
|
|
752231086d | ||
|
|
6f315a408a | ||
|
|
6fa4caa85e | ||
|
|
1b36c1752f | ||
|
|
cd58498971 | ||
|
|
1586137a5d | ||
|
|
6cb8bf74df | ||
|
|
787802d0db | ||
|
|
b4ad39db12 | ||
|
|
c13edbe017 | ||
|
|
7546da4f90 | ||
|
|
76b9a8d9e7 | ||
|
|
d6d52338e9 | ||
|
|
caa67a0f49 | ||
|
|
6ddc3ea996 | ||
|
|
7edbf7c724 | ||
|
|
4f233ca886 | ||
|
|
457831536a | ||
|
|
ccef0d87db | ||
|
|
584d290283 | ||
|
|
2ab14fa33b | ||
|
|
f0317e1d74 | ||
|
|
17a206e0f4 | ||
|
|
8ea352cc2f | ||
|
|
0f10920898 | ||
|
|
eb098ca775 | ||
|
|
e25caddfef | ||
|
|
c74cf6cf6e | ||
|
|
ce2d04fa64 | ||
|
|
40a4e29c7e | ||
|
|
60385715e6 | ||
|
|
3cce92e83d | ||
|
|
602b0067d2 | ||
|
|
51d07db99b | ||
|
|
33d121fd64 | ||
|
|
e409dbd5b8 | ||
|
|
79d203470a | ||
|
|
0f1341615b | ||
|
|
97f5410b1c | ||
|
|
195f6b7e50 | ||
|
|
6691f40c49 | ||
|
|
bc1849f0a0 | ||
|
|
0f64ea1403 | ||
|
|
320fc1604c | ||
|
|
a8eaf3b995 | ||
|
|
308a951f78 | ||
|
|
9f98b549e9 | ||
|
|
0e2a259999 | ||
|
|
b3d3561111 | ||
|
|
ad857b0810 | ||
|
|
0918fa1685 | ||
|
|
273d1f8ef2 | ||
|
|
af1e0a2a60 | ||
|
|
79ae772367 | ||
|
|
d57c8aa305 | ||
|
|
bbd8c1b6d4 | ||
|
|
ced9288ed7 | ||
|
|
cf87e2d5ac | ||
|
|
153d4c1d01 | ||
|
|
1c50fa228e | ||
|
|
0067dc6be3 | ||
|
|
36389a5b8c | ||
|
|
c7443d993e | ||
|
|
9f8dbf3c75 | ||
|
|
35332544e4 | ||
|
|
f2bc832aca | ||
|
|
a6847f7f53 | ||
|
|
396ab64874 | ||
|
|
59ee3d8ceb | ||
|
|
3e152bd389 | ||
|
|
56e8f61bbf | ||
|
|
83c00b0544 | ||
|
|
5f82cc715e | ||
|
|
3ce7fc34f0 | ||
|
|
9fc5291fec | ||
|
|
27c7a842db | ||
|
|
ffe1992df1 | ||
|
|
a80877bab7 | ||
|
|
c787a3c786 | ||
|
|
abda382b96 | ||
|
|
c5ab0a2cc6 | ||
|
|
15340dd550 | ||
|
|
deaf444864 | ||
|
|
a5413d1116 | ||
|
|
6cb6a5822b | ||
|
|
2ffd6f7430 | ||
|
|
cd9eaf4fd7 | ||
|
|
3cfe27b7b3 | ||
|
|
44d78fd2ea | ||
|
|
0cf3342449 | ||
|
|
7e4c6516c5 | ||
|
|
73d7eb65b8 | ||
|
|
fca4afb606 | ||
|
|
b15672d593 | ||
|
|
7a37a18f23 | ||
|
|
a14806e840 | ||
|
|
bbd2851f36 | ||
|
|
48418771d4 | ||
|
|
a81071a50a | ||
|
|
304b990994 | ||
|
|
8824869cd1 | ||
|
|
325cce5f82 | ||
|
|
85db26a704 | ||
|
|
65b0acdcb4 | ||
|
|
9a27af8c5a | ||
|
|
93ad0859e8 | ||
|
|
5e62bac245 | ||
|
|
bea6c1e326 | ||
|
|
df76b01826 | ||
|
|
5d22cb84bf | ||
|
|
f01c61e09f | ||
|
|
d50e67f3bc | ||
|
|
3726c472fc | ||
|
|
dc174e81cf | ||
|
|
c9867bc453 | ||
|
|
8e282fb216 | ||
|
|
e9c0792cb3 | ||
|
|
e7e1b4c43f | ||
|
|
dc56c177b7 | ||
|
|
c0ee998874 | ||
|
|
e1ff50e1e3 | ||
|
|
0e440955c8 | ||
|
|
a16dd497c4 | ||
|
|
5aa4e9339d | ||
|
|
723fa96519 | ||
|
|
75252fded6 | ||
|
|
51fbcdfa56 | ||
|
|
61c9b97d70 | ||
|
|
23b09d09ce | ||
|
|
a00f6ab8ff | ||
|
|
bb59095bad | ||
|
|
da57124d5e | ||
|
|
a00800a128 | ||
|
|
a98db1699d | ||
|
|
e3d9e736ad | ||
|
|
28f38d8b80 | ||
|
|
3b7c34258f | ||
|
|
9dde646695 | ||
|
|
4bdee63f28 | ||
|
|
20dced021d | ||
|
|
17cf640e23 | ||
|
|
24369daea0 | ||
|
|
873bf905ab | ||
|
|
da0756adf0 | ||
|
|
09942ec946 | ||
|
|
2650bc6068 | ||
|
|
6bd7274c9c | ||
|
|
129ccf9e39 | ||
|
|
e2b789cfbc | ||
|
|
bb70e91277 | ||
|
|
f6c07a29ce | ||
|
|
4347983fc7 | ||
|
|
12b463d9e8 | ||
|
|
edc0949bed | ||
|
|
85780917c2 | ||
|
|
e45919cac1 | ||
|
|
c61821ef4e | ||
|
|
011902598b | ||
|
|
3186c6ca0e | ||
|
|
3a680a132f | ||
|
|
455dda54e8 | ||
|
|
5ea5ab07d9 | ||
|
|
35c8025b00 | ||
|
|
615c162663 | ||
|
|
c4bd15e5a0 | ||
|
|
edc92905f7 | ||
|
|
bf5bbd3689 | ||
|
|
eb70ca233b | ||
|
|
8718816fce | ||
|
|
7d36330b4b | ||
|
|
1fa0474fef | ||
|
|
4070b27148 | ||
|
|
3892b0ed05 | ||
|
|
a06cf69d7a | ||
|
|
61dc2568e8 | ||
|
|
ac6362e698 | ||
|
|
94afdf5495 | ||
|
|
d96f8acdbc | ||
|
|
d39c795f92 | ||
|
|
8e12e0562b | ||
|
|
7a1babb418 | ||
|
|
8d65f0c2a8 | ||
|
|
b8dff560f0 | ||
|
|
b48c26ee73 | ||
|
|
8328e51ae0 | ||
|
|
7070eb8a7d | ||
|
|
d0aa26441c | ||
|
|
1bba7103c8 | ||
|
|
7f8dd744f2 | ||
|
|
2f4a707498 | ||
|
|
569bc3c8ec | ||
|
|
b01421aa94 | ||
|
|
30d933bd85 | ||
|
|
377998335b | ||
|
|
21d21aa438 | ||
|
|
18cf1ea3d7 | ||
|
|
60ea884fe2 | ||
|
|
999fa9d9a6 | ||
|
|
e80034e7f8 | ||
|
|
b16f99941a | ||
|
|
3503e7d5b1 | ||
|
|
d1d80acef8 | ||
|
|
16fe916b07 | ||
|
|
d754c3dae3 | ||
|
|
1b32a3e8cd | ||
|
|
15a6f215b4 | ||
|
|
38014ba342 | ||
|
|
7dcc293a09 | ||
|
|
35ce244490 | ||
|
|
3bade2060a | ||
|
|
f8307f25c9 | ||
|
|
5c9ebb9aae | ||
|
|
ebc2a764c2 | ||
|
|
bed21856ab | ||
|
|
61805d13ab | ||
|
|
e47d8d5d2b | ||
|
|
0bd81499f6 | ||
|
|
201ae2c237 | ||
|
|
df4c3c7676 | ||
|
|
667693902f | ||
|
|
9e261d30f8 | ||
|
|
5f6bade809 | ||
|
|
273168ae5c | ||
|
|
a55269e9e6 | ||
|
|
9c386f8533 | ||
|
|
17ee5f456a | ||
|
|
6cefdb5d37 | ||
|
|
74fc8bd131 | ||
|
|
aa9dab5d96 | ||
|
|
5b461f8e1f | ||
|
|
bde06be3df | ||
|
|
fe17986b2a | ||
|
|
e9160ecefd | ||
|
|
05ebd48f09 | ||
|
|
6dbc3f4bab | ||
|
|
bc7166789b | ||
|
|
750b91db66 | ||
|
|
b69a338e13 | ||
|
|
036fe65b12 | ||
|
|
732017ac77 | ||
|
|
5bd71b4688 | ||
|
|
44ba2dff78 | ||
|
|
0954e4bde2 | ||
|
|
5b183d31e2 | ||
|
|
b2017764eb | ||
|
|
f27cd796b6 | ||
|
|
3c051b8698 | ||
|
|
052d6edd13 | ||
|
|
e7dc61e3d9 | ||
|
|
f0aefdfdf8 | ||
|
|
0beec368b8 | ||
|
|
3f1d03a127 | ||
|
|
eb143c28e3 | ||
|
|
1631951a24 | ||
|
|
31bdd89373 | ||
|
|
ad5ae12d44 | ||
|
|
c838db262c | ||
|
|
623b807a11 | ||
|
|
ce9335a842 | ||
|
|
1c62465c3e | ||
|
|
a2c176bdee | ||
|
|
bff8c0f86b | ||
|
|
1065973e07 | ||
|
|
8e042d5691 | ||
|
|
d9a6b32e5f | ||
|
|
eed3f97fbf | ||
|
|
6b9a8ed108 | ||
|
|
adc718b751 | ||
|
|
df9981d0c9 | ||
|
|
f58b661b1b | ||
|
|
ec1926ba60 | ||
|
|
e853851933 | ||
|
|
3705ce3b90 | ||
|
|
7ad73ff251 | ||
|
|
6c23e8892a | ||
|
|
58efafac71 | ||
|
|
abf2364bf6 | ||
|
|
0650f35dbb | ||
|
|
cc593634d2 | ||
|
|
79a3b9de8a | ||
|
|
ceb46ec974 | ||
|
|
a7e2893a57 | ||
|
|
2efe8efde0 | ||
|
|
31047b0d44 | ||
|
|
7c2b724d10 | ||
|
|
ca5670f06b | ||
|
|
427e05871d | ||
|
|
bef56bdb56 | ||
|
|
d450d02e18 | ||
|
|
85a766cc7b | ||
|
|
a473f356c9 | ||
|
|
52b5fdf383 | ||
|
|
b886f02043 | ||
|
|
61963ea497 | ||
|
|
2f9b27ad9e | ||
|
|
9334109767 | ||
|
|
2bc52576d9 | ||
|
|
700d2c4a51 | ||
|
|
103bdb32c8 | ||
|
|
92b745e180 | ||
|
|
a2007083b8 | ||
|
|
36a5f7ff29 | ||
|
|
f727aea51d | ||
|
|
936ca24328 | ||
|
|
62f49b6087 | ||
|
|
e9ddbf9962 | ||
|
|
196cf522e6 | ||
|
|
3fce3bf4a7 | ||
|
|
1cfee25695 | ||
|
|
5711285a77 | ||
|
|
e6f537ca3a | ||
|
|
3b5220af57 | ||
|
|
fa6b4b1d2d | ||
|
|
7968e5374b | ||
|
|
64997ebe45 | ||
|
|
f8592b01e2 | ||
|
|
087474f514 | ||
|
|
1725088f05 | ||
|
|
ec1b756a3d | ||
|
|
76a06e0817 | ||
|
|
02fb608d7b | ||
|
|
e17fc2fc12 | ||
|
|
4f6c317652 | ||
|
|
46c198be26 | ||
|
|
8552203d43 | ||
|
|
139eaa7016 | ||
|
|
d81120ab8f | ||
|
|
6353d56beb | ||
|
|
aa05496b42 | ||
|
|
dc15e537d8 | ||
|
|
6fbd41f40a | ||
|
|
0181f614e1 | ||
|
|
fded7b0b28 | ||
|
|
7e637f835a | ||
|
|
deaaf1834d | ||
|
|
139c870f99 | ||
|
|
4cc2350bc6 | ||
|
|
8b31a118da | ||
|
|
cca26acb78 | ||
|
|
245edbd2f6 | ||
|
|
903d22c622 | ||
|
|
8b1805628e | ||
|
|
11c8c488da | ||
|
|
4dd4e0e148 | ||
|
|
21f352aa64 | ||
|
|
6c4beffdb7 | ||
|
|
43d3efa838 | ||
|
|
1c99839ab4 | ||
|
|
c9e05ce5b1 | ||
|
|
3fe7ed0e1d | ||
|
|
b3bff5c6f5 | ||
|
|
e357bac70f | ||
|
|
ad51d4e4f3 | ||
|
|
912d8ced93 | ||
|
|
8334999e98 | ||
|
|
5e23ea7809 | ||
|
|
b62d291aab | ||
|
|
a34dd8148f | ||
|
|
ba13e6ac35 | ||
|
|
8efa5f7a28 | ||
|
|
f0ef9565e2 | ||
|
|
78688ab63c | ||
|
|
e90b30bf63 | ||
|
|
5312b82ba7 | ||
|
|
bc705f2560 | ||
|
|
6477f43de1 | ||
|
|
bdc0fdd076 | ||
|
|
1f09e1ff93 | ||
|
|
4bcc89d9da | ||
|
|
8f93b49dde | ||
|
|
74eeae900e | ||
|
|
63424bb134 | ||
|
|
1c5e410881 | ||
|
|
f79cc41f3c | ||
|
|
49cccbe69e | ||
|
|
c4a02f7497 | ||
|
|
59e12c5e96 | ||
|
|
a347bdc412 | ||
|
|
3f3c1ecd02 | ||
|
|
d5d9c78c91 | ||
|
|
5b0d8d902b | ||
|
|
2978e46d02 | ||
|
|
54e0633d77 | ||
|
|
ab3db66195 | ||
|
|
17e19da3d8 | ||
|
|
f22aca0c5d | ||
|
|
c257e11ee3 |
2
.github/workflows/build.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
name: ${{ env.frontend_version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
make_latest: true
|
||||
files: |
|
||||
dist.zip
|
||||
env:
|
||||
|
||||
1
.gitignore
vendored
@@ -13,6 +13,7 @@ dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
package-lock.json
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
2
.vscode/settings.json
vendored
@@ -110,4 +110,4 @@
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/locales"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1
components.d.ts
vendored
@@ -8,6 +8,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ConfirmDialog: typeof import('./src/@core/components/ConfirmDialog.vue')['default']
|
||||
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
|
||||
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
|
||||
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
|
||||
|
||||
@@ -16,13 +16,17 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
|
||||
|
||||
## 3. 核心概念
|
||||
|
||||
每个插件需要提供三个标准组件:
|
||||
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
|
||||
|
||||
| 组件名称 | 文件名 | 用途 |
|
||||
|---------|-------|------|
|
||||
| Page | Page.vue | 插件详情页面 |
|
||||
| Config | Config.vue | 插件配置页面 |
|
||||
| Dashboard | Dashboard.vue | 仪表板组件 |
|
||||
| 组件名称 | 暴露名 | 文件名 | 用途 |
|
||||
|---------|--------|--------|------|
|
||||
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
|
||||
| Config | `./Config` | Config.vue | 插件配置页面 |
|
||||
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
|
||||
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
|
||||
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
|
||||
|
||||
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage` → `Page`;`nav_key` 为 `main` 时仅尝试 `AppPage` → `Page`。
|
||||
|
||||
## 4. 快速开始
|
||||
|
||||
@@ -56,6 +60,8 @@ export default defineConfig({
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
@@ -245,17 +251,110 @@ const props = defineProps({
|
||||
|
||||
<template>
|
||||
<div class="dashboard-widget">
|
||||
<!-- 仪表板内容 -->
|
||||
<v-card>
|
||||
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- 组件内容 -->
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-hover>
|
||||
<!-- 仪表板内容 -->
|
||||
<template #default="{ isHovering, props: hoverProps }">
|
||||
<v-card v-bind="hoverProps">
|
||||
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- 组件内容 -->
|
||||
</v-card-text>
|
||||
<!-- 只在悬停时显示拖拽图标 -->
|
||||
<div v-show="isHovering" class="absolute right-5 top-5">
|
||||
<v-icon class="cursor-move">mdi-drag</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-hover>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.4 AppPage 组件(侧栏全页)
|
||||
|
||||
用于主应用左侧导航中的独立页面(路由 `#/plugin-app/:pluginId/:navKey?`),占据默认布局下的主内容区;与 `Page` 不同,不嵌在插件管理弹窗中。
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
navKey: { type: String, default: 'main' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="text-h6 mb-2">侧栏全页示例({{ pluginId }} / {{ navKey }})</div>
|
||||
<v-btn size="small" @click="emit('action')">通知主应用</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 后端:注册侧栏入口
|
||||
|
||||
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
|
||||
| `title` | 侧栏显示标题 |
|
||||
| `icon` | MDI 图标名,如 `mdi-rss` |
|
||||
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
|
||||
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
|
||||
| `order` | 可选:同组内排序,数值越小越靠前 |
|
||||
|
||||
```python
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "示例订阅页",
|
||||
"icon": "mdi-rss",
|
||||
"section": "subscribe",
|
||||
"permission": "subscribe",
|
||||
"order": 10,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### 同一插件多个全页界面(多 `nav_key`)
|
||||
|
||||
在 `get_sidebar_nav` 中**返回多条**记录,每条使用不同的 `nav_key` / `title` / `section` 等,侧栏与「更多」中会出现多个入口,路由形如 `#/plugin-app/<插件ID>/<nav_key>`。
|
||||
|
||||
前端加载远程组件的顺序为:
|
||||
|
||||
| `nav_key` | 依次尝试的联邦暴露名 |
|
||||
|-----------|----------------------|
|
||||
| `main` 或省略 | `./AppPage` → `./Page` |
|
||||
| 其它(如 `settings`、`my_tool`) | `./AppPage{PascalCase}` → `./AppPage` → `./Page` |
|
||||
|
||||
`PascalCase` 规则:按 `-`、`_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings`;`my_tool` → `./AppPageMyTool`。
|
||||
|
||||
**两种实现方式(二选一或混用):**
|
||||
|
||||
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
|
||||
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`。
|
||||
|
||||
`vite.config` 多暴露示例:
|
||||
|
||||
```typescript
|
||||
exposes: {
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 构建和部署
|
||||
|
||||
### 构建项目
|
||||
@@ -264,7 +363,10 @@ const props = defineProps({
|
||||
yarn build
|
||||
```
|
||||
|
||||
将生成的dist文件夹上传到插件后端目录下(默认为`dist/assets`)
|
||||
- 将生成的dist文件夹上传到插件后端目录下(默认为`dist/assets`)
|
||||
|
||||
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*`、`_plugin-vue_export-helper-*`、`remoteEntry.js`
|
||||
|
||||
|
||||
- 在插件的后端python代码中,实现以下方法来集成远程组件:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MoviePilot 插件远程组件示例
|
||||
|
||||
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例实现了三个标准组件:Page(详情页面)、Config(配置页面)和Dashboard(仪表板组件)。
|
||||
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例包含 Page、Config、Dashboard、AppPage,以及可选的 `AppPageSettings`(`nav_key=settings` 时由主应用优先加载,用于演示「一插件多全页界面」)。
|
||||
|
||||
## 1. 开发环境准备
|
||||
|
||||
@@ -28,7 +28,9 @@ plugin-component/
|
||||
│ ├── components/
|
||||
│ │ ├── Page.vue # 插件详情页面组件
|
||||
│ │ ├── Config.vue # 插件配置页面组件
|
||||
│ │ └── Dashboard.vue # 插件仪表板组件
|
||||
│ │ ├── Dashboard.vue # 插件仪表板组件
|
||||
│ │ ├── AppPage.vue # 侧栏全页(主内容区,nav_key=main)
|
||||
│ │ └── AppPageSettings.vue # 可选第二全页(nav_key=settings)
|
||||
│ ├── App.vue # 本地开发入口组件
|
||||
│ └── main.js # 本地开发入口文件
|
||||
├── vite.config.js # Vite和模块联邦配置
|
||||
|
||||
32
examples/plugin-component/src/components/AppPage.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 侧栏全页:在主应用 #/plugin-app/:pluginId/:navKey 中渲染,占据主内容区。
|
||||
* 需在插件后端实现 get_sidebar_nav 才会出现在侧栏。
|
||||
*/
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
navKey: {
|
||||
type: String,
|
||||
default: 'main',
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-app-page pa-4">
|
||||
<div class="text-h6 mb-2">AppPage(侧栏全页)</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
pluginId: {{ pluginId }} · navKey: {{ navKey }}
|
||||
</div>
|
||||
<v-btn size="small" variant="tonal" @click="emit('action')">action</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
17
examples/plugin-component/src/components/AppPageSettings.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 示例:nav_key=settings 时主应用会优先加载 AppPageSettings,再回退 AppPage。
|
||||
*/
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
navKey: { type: String, default: 'settings' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="text-subtitle-1">Settings 子界面(AppPageSettings)</div>
|
||||
<div class="text-caption text-medium-emphasis">navKey={{ navKey }} · pluginId={{ pluginId }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,6 +12,8 @@ export default defineConfig({
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
|
||||
506
index.html
@@ -1,211 +1,359 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" style="
|
||||
<html lang="zh-CN" style="
|
||||
overflow: hidden auto;
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="pragma" content="no-cache" />
|
||||
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="expires" content="0" />
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
|
||||
<title>MoviePilot</title>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 核心viewport设置 - 针对PWA优化 -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" />
|
||||
|
||||
<!-- 防止缩放和选择,提供原生应用体验 -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||
|
||||
<!-- 基础信息 -->
|
||||
<meta name="description" content="MoviePilot - 智能影视媒体库管理工具" />
|
||||
<meta name="author" content="MoviePilot" />
|
||||
<meta name="keywords" content="MoviePilot,影视,媒体库,管理" />
|
||||
|
||||
<!-- 安全和隐私 -->
|
||||
<meta name="Robots" content="noindex,nofollow,noarchive" />
|
||||
<meta name="referrer" content="origin" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
||||
<!-- PWA - 基础图标 -->
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
|
||||
<!-- iOS Safari PWA 优化 -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
|
||||
|
||||
<!-- iOS Safari 全屏模式 -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
|
||||
<meta name="description" content="MoviePilot" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="referrer" content="never" />
|
||||
<meta name="msapplication-TileColor" content="#7D34FD" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
|
||||
<!-- iOS Safari 防止自动识别 -->
|
||||
<meta name="apple-mobile-web-app-orientations" content="portrait" />
|
||||
|
||||
<!-- Android Chrome PWA 优化 -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="mobile-web-app-title" content="MoviePilot" />
|
||||
|
||||
<!-- Microsoft Windows PWA -->
|
||||
<meta name="msapplication-TileColor" content="#0E1116" />
|
||||
<meta name="msapplication-TileImage" content="/android-chrome-192x192.png" />
|
||||
<meta name="msapplication-config" content="none" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
<meta name="msapplication-navbutton-color" content="#0E1116" />
|
||||
|
||||
<!-- 主题色彩 - 适配深色和浅色模式 -->
|
||||
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
|
||||
<!-- 屏幕方向锁定 -->
|
||||
<meta name="screen-orientation" content="portrait" />
|
||||
<meta name="x5-orientation" content="portrait" />
|
||||
<meta name="x5-fullscreen" content="true" />
|
||||
<meta name="x5-page-mode" content="app" />
|
||||
|
||||
<!-- UC浏览器优化 -->
|
||||
<meta name="browsermode" content="application" />
|
||||
<meta name="wap-font-scale" content="no" />
|
||||
|
||||
<!-- 360浏览器优化 -->
|
||||
<meta name="renderer" content="webkit" />
|
||||
|
||||
<!-- 触摸优化 -->
|
||||
<meta name="HandheldFriendly" content="True" />
|
||||
<meta name="MobileOptimized" content="320" />
|
||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||
|
||||
<!-- 缓存控制 -->
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
|
||||
<!-- DNS预解析和预连接 -->
|
||||
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
|
||||
<link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
|
||||
<link rel="dns-prefetch" href="//image.tmdb.org" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
|
||||
<style>
|
||||
#app {
|
||||
min-block-size: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
z-index: 99999;
|
||||
display: block;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
block-size: 100vh;
|
||||
inline-size: 100vw;
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
position: absolute;
|
||||
inset-block-start: 35%;
|
||||
inset-inline-start: calc(50% - 5rem);
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
}
|
||||
|
||||
.loading-complete .loading-logo {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.loading-complete {
|
||||
filter: blur(15px);
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
block-size: 55px;
|
||||
inline-size: 55px;
|
||||
inset-block-start: 80%;
|
||||
inset-inline-start: calc(50% - 27.5px);
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
.loading-complete .loading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.loading .effect-1,
|
||||
.loading .effect-2,
|
||||
.loading .effect-3 {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
block-size: 100%;
|
||||
border-inline-start: 3px solid var(--initial-loader-color, #eee);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.loading .effect-1 {
|
||||
animation: rotate 1s ease infinite;
|
||||
}
|
||||
|
||||
.loading .effect-2 {
|
||||
animation: rotate-opacity 1s ease infinite 0.1s;
|
||||
}
|
||||
|
||||
.loading .effect-3 {
|
||||
animation: rotate-opacity 1s ease infinite 0.2s;
|
||||
}
|
||||
|
||||
.loading .effects {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-opacity {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
/* 超时通知样式 */
|
||||
#loading-timeout {
|
||||
position: absolute;
|
||||
z-index: 2500;
|
||||
display: none;
|
||||
inset-block-end: 20px;
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#timeout-btn {
|
||||
color: var(--initial-loader-color, #9155FD);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
margin-inline-start: 8px;
|
||||
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
|
||||
if (loaderColor) document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
|
||||
if (primaryColor) document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
// 检测系统主题是否为深色模式
|
||||
function checkPrefersColorSchemeIsDark() {
|
||||
try {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 主题色彩初始化
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
|
||||
// 检查主题设置
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const isAutoTheme = savedTheme === 'auto'
|
||||
|
||||
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
|
||||
if (isAutoTheme || !loaderColor) {
|
||||
loaderColor = checkPrefersColorSchemeIsDark() ? '#0E1116' : '#FFFFFF'
|
||||
}
|
||||
if (!primaryColor) {
|
||||
primaryColor = '#9155FD'
|
||||
}
|
||||
|
||||
// 应用主题色彩
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
|
||||
// 状态栏适配
|
||||
if (window.navigator.standalone) {
|
||||
document.documentElement.style.setProperty('--status-bar-height', '20px')
|
||||
}
|
||||
|
||||
// 安全区域适配
|
||||
function updateSafeArea() {
|
||||
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')
|
||||
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)',
|
||||
)
|
||||
|
||||
if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)
|
||||
if (safeAreaBottom) document.documentElement.style.setProperty('--safe-area-bottom', safeAreaBottom)
|
||||
}
|
||||
|
||||
updateSafeArea()
|
||||
window.addEventListener('resize', updateSafeArea)
|
||||
window.addEventListener('orientationchange', updateSafeArea)
|
||||
|
||||
// 清除缓存处理逻辑
|
||||
window.clearAndReload = async function() {
|
||||
try {
|
||||
// 1. 清除所有缓存
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)))
|
||||
console.log('[VersionChecker] 已清除所有缓存')
|
||||
}
|
||||
// 2. 注销 Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map(registration => registration.unregister()))
|
||||
console.log('[VersionChecker] 已注销所有 Service Worker')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VersionChecker] 清除缓存时出错:', e)
|
||||
} finally {
|
||||
// 3. 重载页面
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
window.location.replace(url.pathname + url.search + url.hash)
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(function() {
|
||||
const timeoutEl = document.getElementById('loading-timeout');
|
||||
if (timeoutEl) {
|
||||
// 适配多语言
|
||||
const lang = navigator.language || 'zh-CN';
|
||||
const messages = {
|
||||
'zh-CN': {
|
||||
text: '页面加载似乎遇到了阻碍,请尝试',
|
||||
btn: '清除缓存'
|
||||
},
|
||||
'zh-TW': {
|
||||
text: '頁面載入似乎遇到了阻礙,請嘗試',
|
||||
btn: '清除快取'
|
||||
},
|
||||
'en-US': {
|
||||
text: 'Page loading seems to be blocked, please try',
|
||||
btn: 'Clear Cache'
|
||||
}
|
||||
};
|
||||
|
||||
// 默认匹配前缀,如 en-GB 匹配 en-US 的逻辑
|
||||
let msg = messages['zh-CN'];
|
||||
if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) {
|
||||
msg = messages['zh-TW'];
|
||||
} else if (lang.startsWith('en')) {
|
||||
msg = messages['en-US'];
|
||||
}
|
||||
|
||||
const textNode = document.createTextNode(msg.text + ' ');
|
||||
const btnLink = document.createElement('a');
|
||||
btnLink.href = 'javascript:void(0)';
|
||||
btnLink.id = 'timeout-btn';
|
||||
btnLink.onclick = window.clearAndReload;
|
||||
btnLink.textContent = msg.btn;
|
||||
|
||||
timeoutEl.innerHTML = '';
|
||||
timeoutEl.appendChild(textNode);
|
||||
timeoutEl.appendChild(btnLink);
|
||||
timeoutEl.style.display = 'block';
|
||||
}
|
||||
}, 15000); // 15秒后显示超时提示
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body style="margin: 0">
|
||||
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<svg width="160px" height="160px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
|
||||
<style>
|
||||
/* 添加SVG内部的动画样式 */
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
filter: drop-shadow(0 0 3px rgba(141, 81, 249, 0.3));
|
||||
}
|
||||
|
||||
50% {
|
||||
filter: drop-shadow(0 0 6px rgba(141, 81, 249, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
/* 为各个元素添加动画 */
|
||||
#a2-c {
|
||||
filter: drop-shadow(0 0 5px rgba(141, 81, 249, 0.3));
|
||||
animation: glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
path {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 错开不同元素的动画开始时间 */
|
||||
g:nth-child(2) path {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
g:nth-child(3) path {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
g:nth-child(4) path {
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
|
||||
g:nth-child(5) path {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
</style>
|
||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||
<rect x="0" y="0" width="192" height="192" style="fill: none" />
|
||||
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
|
||||
<path
|
||||
d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
|
||||
style="fill: url(#_Linear1)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
|
||||
style="fill: url(#_Linear2)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
|
||||
style="fill: url(#_Linear3)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
|
||||
style="fill: rgb(165, 118, 255)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
|
||||
style="fill: url(#_Linear4)" />
|
||||
</g>
|
||||
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
|
||||
<path
|
||||
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
|
||||
style="fill: rgb(104, 0, 197)" />
|
||||
<clipPath id="_clip5">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z" />
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip5)">
|
||||
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
|
||||
<path
|
||||
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
|
||||
style="fill: url(#_Linear6)" />
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
|
||||
style="fill: url(#_Radial7)" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)">
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)">
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)">
|
||||
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="_Radial7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160px" height="160px" />
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
<!-- 超时提示 - 默认隐藏 -->
|
||||
<div id="loading-timeout"></div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
22
package.json
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.4.7",
|
||||
"version": "2.10.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"prebuild": "npm run build:icons",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5050",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
@@ -27,6 +28,7 @@
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@fullcalendar/vue3": "^6.1.15",
|
||||
"@iconify/utils": "^2.2.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.2",
|
||||
@@ -40,42 +42,53 @@
|
||||
"ace-builds": "^1.37.4",
|
||||
"apexcharts": "^4.0.0",
|
||||
"axios": "^1.7.9",
|
||||
"body-scroll-lock": "^3.1.5",
|
||||
"colorthief": "^2.6.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-http-proxy": "^2.1.1",
|
||||
"http-proxy-middleware": "^3.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.83.4",
|
||||
"tailwindcss": "^ 3.4.17",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-toast-notification": "^3.1.3",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
"vue3-apexcharts": "^1.8.0",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "3.7.3",
|
||||
"vuetify-use-dialog": "^0.6.11",
|
||||
"webfontloader": "^1.6.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/line-md": "^1.2.13",
|
||||
"@iconify-json/lucide": "^1.2.85",
|
||||
"@iconify-json/material-symbols": "^1.2.51",
|
||||
"@iconify-json/mdi": "^1.1.52",
|
||||
"@iconify/tools": "^4.0.4",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
"@originjs/vite-plugin-federation": "^1.4.1",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/markdown-it-link-attributes": "^3.0.5",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^20.1.4",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
@@ -105,6 +118,7 @@
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-pages": "^0.32.1",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vite-plugin-top-level-await": "^1.5.0",
|
||||
"vite-plugin-vue-layouts": "^0.11.0",
|
||||
"vite-plugin-vuetify": "2.0.4",
|
||||
"vue-shepherd": "^4.1.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 102 KiB |
@@ -1,97 +0,0 @@
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
display: block;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
block-size: 100vh;
|
||||
inline-size: 100vw;
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
position: absolute;
|
||||
inset-block-start: 35%;
|
||||
inset-inline-start: calc(50% - 5rem);
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
}
|
||||
|
||||
/* 添加logo完成动画 - 放大虚化效果 */
|
||||
.loading-complete .loading-logo {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
/* 添加加载背景消失动画 - 放大虚化效果 */
|
||||
.loading-complete {
|
||||
filter: blur(15px);
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
block-size: 55px;
|
||||
inline-size: 55px;
|
||||
inset-block-start: 80%;
|
||||
inset-inline-start: calc(50% - 27.5px);
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
/* 完成时隐藏加载动画 */
|
||||
.loading-complete .loading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.loading .effect-1,
|
||||
.loading .effect-2,
|
||||
.loading .effect-3 {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
block-size: 100%;
|
||||
border-inline-start: 3px solid var(--initial-loader-color, #eee);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.loading .effect-1 {
|
||||
animation: rotate 1s ease infinite;
|
||||
}
|
||||
|
||||
.loading .effect-2 {
|
||||
animation: rotate-opacity 1s ease infinite 0.1s;
|
||||
}
|
||||
|
||||
.loading .effect-3 {
|
||||
animation: rotate-opacity 1s ease infinite 0.2s;
|
||||
}
|
||||
|
||||
.loading .effects {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-opacity {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
53
public/logo.svg
Normal file
@@ -0,0 +1,53 @@
|
||||
<svg width="3em" height="3em" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||
<rect x="0" y="0" width="192" height="192" style="fill:none;"/>
|
||||
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
|
||||
<path d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z" style="fill:url(#_Linear1);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z" style="fill:url(#_Linear2);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z" style="fill:rgb(141,81,249);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z" style="fill:url(#_Linear3);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z" style="fill:rgb(165,118,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z" style="fill:url(#_Linear4);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
|
||||
<path d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z" style="fill:rgb(141,81,249);"/>
|
||||
</g>
|
||||
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
|
||||
<path d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z" style="fill:rgb(104,0,197);"/>
|
||||
<clipPath id="_clip5">
|
||||
<path d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip5)">
|
||||
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
|
||||
<path d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z" style="fill:url(#_Linear6);"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z" style="fill:rgb(141,81,249);"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z" style="fill:url(#_Radial7);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)"><stop offset="0" style="stop-color:rgb(141,81,249);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(116,50,223);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)"><stop offset="0" style="stop-color:rgb(141,81,249);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(116,50,223);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)"><stop offset="0" style="stop-color:rgb(211,187,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(211,187,255);stop-opacity:0"/></linearGradient>
|
||||
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)"><stop offset="0" style="stop-color:rgb(211,187,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(211,187,255);stop-opacity:0"/></linearGradient>
|
||||
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)"><stop offset="0" style="stop-color:rgb(116,50,223);stop-opacity:1"/><stop offset="0.51" style="stop-color:rgb(110,38,217);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(91,0,197);stop-opacity:1"/></linearGradient>
|
||||
<radialGradient id="_Radial7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)"><stop offset="0" style="stop-color:rgb(211,187,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(211,187,255);stop-opacity:0"/></radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
160
public/offline.html
Normal file
@@ -0,0 +1,160 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>MoviePilot - 离线</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #9155FD;
|
||||
--surface-color: #FFFFFF;
|
||||
--text-color: #333333;
|
||||
--border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--surface-color: #0E1116;
|
||||
--text-color: #FFFFFF;
|
||||
--border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--surface-color);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.offline-container {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
background: var(--surface-color);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1), 0 0 0 1px var(--border-color);
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 32px;
|
||||
background: rgba(145, 85, 253, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
fill: var(--primary-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 32px;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(145, 85, 253, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #EF5350;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="offline-container">
|
||||
<div class="icon-wrapper">
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<path d="M12,2.03C17.73,2.5 22,7.08 22,12.75C22,13.84 21.79,14.89 21.4,15.86L19.53,14C19.5,13.83 19.5,13.67 19.5,13.5A2.5,2.5 0 0,0 17,11A2.5,2.5 0 0,0 14.5,13.5A2.5,2.5 0 0,0 17,16A2.5,2.5 0 0,0 19.5,13.5C19.5,13.67 19.5,13.83 19.53,14L21.4,15.86C20.04,19.09 16.9,21.47 13.19,21.97L11.75,20.53C11.83,20.5 11.92,20.5 12,20.5A2.5,2.5 0 0,0 14.5,18A2.5,2.5 0 0,0 12,15.5A2.5,2.5 0 0,0 9.5,18C9.5,18.08 9.5,18.17 9.53,18.25L7.66,16.38C7.25,15.96 6.86,15.5 6.5,15H8.17C8.06,14.7 8,14.35 8,14A3,3 0 0,1 11,11A3,3 0 0,1 14,14C14,14.35 13.94,14.7 13.83,15H15.5C15.14,15.5 14.75,15.96 14.34,16.38L12.47,14.5C12.5,14.42 12.5,14.33 12.47,14.25L10.6,12.38C10.18,11.97 9.72,11.59 9.23,11.25L7.36,9.38C6.94,8.96 6.5,8.61 6,8.31V6.64L4.14,4.78C3.6,5.55 3.17,6.4 2.86,7.31L1,5.45V4.46L2.05,3.41C2.5,2.86 3.05,2.41 3.66,2.06L20,18.4L18.73,19.67L12.47,13.41L11.75,20.53C11.83,20.5 11.92,20.5 12,20.5A2.5,2.5 0 0,0 14.5,18A2.5,2.5 0 0,0 12,15.5A2.5,2.5 0 0,0 9.5,18C9.5,18.08 9.5,18.17 9.53,18.25L7.66,16.38C7.25,15.96 6.86,15.5 6.5,15H8.17C8.06,14.7 8,14.35 8,14A3,3 0 0,1 11,11A3,3 0 0,1 14,14C14,14.35 13.94,14.7 13.83,15H15.5C15.14,15.5 14.75,15.96 14.34,16.38L2.46,4.5C3.5,3.17 4.9,2.15 6.5,1.58V3.25C5.43,3.7 4.47,4.33 3.66,5.11L2.61,6.16V8.03C3.16,7.33 3.82,6.73 4.57,6.25V8.31C3.57,9.14 2.75,10.19 2.21,11.39L1,10.18V8.65C1.5,6.16 3.03,4.03 5.11,2.71L6.39,4C8.97,2.73 12.03,2.24 14.97,3.03L16.84,4.9C18.17,5.86 19.25,7.16 19.94,8.68L18.07,6.81C17.07,5.5 15.66,4.5 14,4.04V5.71C15.93,6.17 17.5,7.53 18.33,9.3L16.46,7.43C15.46,6.61 14.2,6.08 12.82,6V7.67C13.69,7.79 14.47,8.11 15.14,8.58L13.27,6.71C12.94,6.66 12.6,6.63 12.25,6.63L10.38,4.76C10.87,4.66 11.37,4.59 11.88,4.56L10,2.68C10.66,2.56 11.33,2.5 12,2.5V2.03Z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1>您当前处于离线状态</h1>
|
||||
<p>无法连接到 MoviePilot 服务器。请检查您的网络连接后重试。</p>
|
||||
|
||||
<button class="retry-button" onclick="window.location.reload()">
|
||||
重新加载
|
||||
</button>
|
||||
|
||||
<div class="status-badge">
|
||||
<span class="status-dot"></span>
|
||||
<span>离线状态</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 监听网络状态变化
|
||||
window.addEventListener('online', function() {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// Service Worker 消息处理
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', function(event) {
|
||||
if (event.data && event.data.type === 'OFFLINE_STATUS' && !event.data.offline) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
shims.d.ts
vendored
@@ -12,3 +12,4 @@ declare module 'vue-prism-component' {
|
||||
export default component
|
||||
}
|
||||
declare module 'vue-shepherd';
|
||||
declare module 'colorthief';
|
||||
|
||||
86
src/@core/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
type?: 'info' | 'warn' | 'error'
|
||||
title?: string
|
||||
content?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
width?: string | number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'info',
|
||||
title: '',
|
||||
content: '',
|
||||
confirmText: '',
|
||||
cancelText: '',
|
||||
width: '28rem',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
// 对话框类型对应的图标和颜色
|
||||
const typeConfig = {
|
||||
info: {
|
||||
icon: 'mdi-information',
|
||||
color: 'info',
|
||||
},
|
||||
warn: {
|
||||
icon: 'mdi-alert',
|
||||
color: 'warning',
|
||||
},
|
||||
error: {
|
||||
icon: 'mdi-alert-circle',
|
||||
color: 'error',
|
||||
},
|
||||
}
|
||||
|
||||
// 获取当前类型的配置
|
||||
const currentType = computed(() => typeConfig[props.type])
|
||||
|
||||
// 确认按钮点击
|
||||
function handleConfirm() {
|
||||
emit('confirm')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
// 取消按钮点击
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" :max-width="width">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<div class="d-flex align-center justify-start mt-3">
|
||||
<VAvatar :color="currentType.color" variant="text" size="x-large">
|
||||
<VIcon size="x-large" :icon="currentType.icon" />
|
||||
</VAvatar>
|
||||
<div class="mx-3">
|
||||
<p class="font-weight-bold text-xl text-high-emphasis">{{ title }}</p>
|
||||
<p>{{ content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
<VCardActions class="mx-auto">
|
||||
<VBtn variant="tonal" color="secondary" class="px-5" @click="handleCancel">
|
||||
{{ cancelText }}
|
||||
</VBtn>
|
||||
<VBtn variant="elevated" :color="currentType.color" @click="handleConfirm" class="px-5">
|
||||
{{ confirmText }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
<VDialogCloseBtn @click="handleCancel" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -15,7 +15,7 @@ function onClick() {
|
||||
|
||||
<template>
|
||||
<IconBtn
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'"
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
<VIcon icon="mdi-close" />
|
||||
|
||||
@@ -1,15 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
progress: Number,
|
||||
text: String,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center mb-5">
|
||||
<VProgressCircular v-if="!props.text || !props.progress" class="mb-3" size="64" indeterminate color="primary" />
|
||||
<VProgressCircular v-if="props.progress" class="mb-3" color="primary" :model-value="props.progress" size="64" />
|
||||
<span>{{ props.text }}</span>
|
||||
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center my-5">
|
||||
<div class="initial-loading-container">
|
||||
<div class="initial-loading-content">
|
||||
<div class="wave-loader">
|
||||
<div class="wave-dot"></div>
|
||||
<div class="wave-dot"></div>
|
||||
<div class="wave-dot"></div>
|
||||
<div class="wave-dot"></div>
|
||||
</div>
|
||||
<div class="initial-loading-text" v-if="props.text">{{ props.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 初始的加载状态 */
|
||||
.initial-loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 20vh;
|
||||
}
|
||||
|
||||
.initial-loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.wave-loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
block-size: 40px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wave-dot {
|
||||
border-radius: 50%;
|
||||
animation: wave 1.5s ease-in-out infinite;
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
block-size: 8px;
|
||||
inline-size: 8px;
|
||||
}
|
||||
|
||||
.wave-dot:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.wave-dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.wave-dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.wave-dot:nth-child(4) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
}
|
||||
|
||||
.initial-loading-text {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ defineProps({
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="title" class="my-3 md:flex md:items-center md:justify-between">
|
||||
<div v-if="title" class="my-3 mx-3 md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1 mx-0">
|
||||
<h2
|
||||
class="ms-1 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-3xl sm:leading-9 md:mb-0"
|
||||
|
||||
@@ -51,8 +51,8 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
inset-block-end: 30px;
|
||||
inset-inline-end: 30px;
|
||||
inset-block-end: 2rem;
|
||||
inset-inline-end: 2rem;
|
||||
}
|
||||
|
||||
.global-action-button {
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
@use "@layouts/styles/_placeholders";
|
||||
@use "@configured-variables" as variables;
|
||||
|
||||
// 👉 Alert
|
||||
.v-alert {
|
||||
.v-alert__close {
|
||||
.v-icon {
|
||||
block-size: 20px !important;
|
||||
font-size: 20px !important;
|
||||
inline-size: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.v-alert--prominent) .v-alert__prepend {
|
||||
.v-icon {
|
||||
block-size: 1.375rem !important;
|
||||
font-size: 1.375rem !important;
|
||||
inline-size: 1.375rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-alert-title {
|
||||
line-height: 1.5rem;
|
||||
margin-block-end: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Avatar font-size
|
||||
.v-avatar {
|
||||
@include mixins.avatar-font-sizes($map: variables.$avatar-font-sizes);
|
||||
@@ -33,6 +57,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Button
|
||||
.v-btn {
|
||||
/* stylelint-disable-next-line no-descending-specificity */
|
||||
&:not(.v-btn--icon) .v-icon {
|
||||
--v-icon-size-multiplier: 0.9525 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Chip
|
||||
.v-chip.v-chip--size-default .v-avatar {
|
||||
--v-avatar-height: 24px;
|
||||
}
|
||||
|
||||
.v-chip.v-chip--density-comfortable {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// Dialog responsive width
|
||||
.v-dialog {
|
||||
.v-card {
|
||||
@@ -40,7 +81,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
@media (width >= 576px) {
|
||||
.v-dialog {
|
||||
&.v-dialog-sm,
|
||||
&.v-dialog-lg,
|
||||
@@ -50,7 +91,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
@media (width >= 992px) {
|
||||
.v-dialog {
|
||||
&.v-dialog-lg,
|
||||
&.v-dialog-xl {
|
||||
@@ -59,18 +100,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
@media (width >= 1200px) {
|
||||
.v-dialog.v-dialog-xl,
|
||||
.v-dialog.v-dialog-xl .v-overlay__content > .v-card {
|
||||
inline-size: 1165px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// v-tab with pill support
|
||||
// 👉 Expansion Panel
|
||||
.v-expansion-panel {
|
||||
.v-expansion-panel-text {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Tooltip
|
||||
.v-tooltip > .v-overlay__content {
|
||||
font-weight: 500;
|
||||
line-height: 0.875rem;
|
||||
}
|
||||
|
||||
// 👉 List
|
||||
|
||||
// 👉 Tab with pill support
|
||||
.v-tabs.v-tabs-pill {
|
||||
.v-tab.v-btn {
|
||||
border-radius: 0.375rem !important;
|
||||
border-radius: 6px !important;
|
||||
min-inline-size: 8.125rem;
|
||||
transition: none;
|
||||
|
||||
@@ -94,7 +149,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 added box shadow
|
||||
// 👉 Timeline added box shadow
|
||||
.v-timeline-item {
|
||||
.v-timeline-divider__dot {
|
||||
.v-timeline-divider__inner-dot {
|
||||
@@ -160,7 +215,6 @@
|
||||
}
|
||||
|
||||
// 👉 Slider
|
||||
|
||||
.v-slider.v-input--horizontal .v-slider-track__fill {
|
||||
block-size: var(--v-slider-track-size);
|
||||
}
|
||||
@@ -171,7 +225,19 @@
|
||||
|
||||
.v-slider-thumb {
|
||||
.v-slider-thumb__label {
|
||||
background: rgb(117, 117, 117);
|
||||
color: rgb(var(--v-theme-on-primary));
|
||||
|
||||
&::before {
|
||||
color: rgb(117, 117, 117);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Switch
|
||||
.v-switch {
|
||||
.v-selection-control:not(.v-selection-control--dirty) .v-switch__thumb {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,5 +245,45 @@
|
||||
.v-table--density-default > .v-table__wrapper > table > tbody > tr > td,
|
||||
.v-table--density-default > .v-table__wrapper > table > thead > tr > td,
|
||||
.v-table--density-default > .v-table__wrapper > table > tfoot > tr > td {
|
||||
block-size: 50px;
|
||||
block-size: 50px !important;
|
||||
}
|
||||
|
||||
.v-table {
|
||||
--v-table-header-height: 54px !important;
|
||||
|
||||
th {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
|
||||
font-size: 0.75rem;
|
||||
|
||||
.v-data-table-header__content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.v-selection-control {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.v-data-table {
|
||||
th {
|
||||
background: rgb(var(--v-table-header-background)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Pagination
|
||||
.v-pagination {
|
||||
.v-btn {
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 SnackBar
|
||||
.v-snackbar--variant-elevated {
|
||||
@include mixins.elevation(6);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@use "@configured-variables" as variables;
|
||||
@use "placeholders" as *;
|
||||
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
|
||||
@use "../misc";
|
||||
@use "misc";
|
||||
@use "mixins";
|
||||
|
||||
$header: ".layout-navbar";
|
||||
@@ -16,25 +16,43 @@ $header: ".layout-navbar";
|
||||
@if variables.$vertical-nav-navbar-style == "elevated" {
|
||||
// Add transition
|
||||
#{$header} {
|
||||
transition: padding 0.2s ease, background-color 0.18s ease;
|
||||
transition: padding 0.2s ease;
|
||||
}
|
||||
|
||||
// If navbar is contained => Add border radius to header
|
||||
@if variables.$layout-vertical-nav-navbar-is-contained {
|
||||
#{$header} {
|
||||
// #{$header} {
|
||||
// border-radius: 0 0 variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
// Scrolled styles for sticky navbar
|
||||
@at-root {
|
||||
/* ℹ️ This html selector with not selector is required when:
|
||||
dialog is opened and window don't have any scroll. This removes window-scrolled class from layout and out style broke
|
||||
/* ℹ️ Only apply scrolled styles when window is actually scrolled,
|
||||
not when dialog is opened without scroll
|
||||
*/
|
||||
html.v-overlay-scroll-blocked:not([style*="--v-body-scroll-y: 0px;"]) .layout-navbar-fixed,
|
||||
&.window-scrolled.layout-navbar-fixed {
|
||||
|
||||
#{$header} {
|
||||
padding-inline: 1rem;
|
||||
|
||||
@extend %default-layout-vertical-nav-scrolled-sticky-elevated-nav;
|
||||
@extend %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled;
|
||||
}
|
||||
|
||||
.navbar-blur#{$header} {
|
||||
@extend %blurry-bg;
|
||||
}
|
||||
}
|
||||
|
||||
/* ℹ️ Ensure header styles are preserved when dialog is opened,
|
||||
but only if window was scrolled before dialog opened
|
||||
*/
|
||||
html.v-overlay-scroll-blocked &.window-scrolled.layout-navbar-fixed {
|
||||
|
||||
#{$header} {
|
||||
padding-inline: 1rem;
|
||||
|
||||
@extend %default-layout-vertical-nav-scrolled-sticky-elevated-nav;
|
||||
@extend %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled;
|
||||
}
|
||||
@@ -63,7 +81,7 @@ $header: ".layout-navbar";
|
||||
|
||||
#{$header} {
|
||||
@if variables.$layout-vertical-nav-navbar-is-contained {
|
||||
// border-radius: variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
|
||||
border-radius: variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
|
||||
}
|
||||
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
@@ -1,5 +1,5 @@
|
||||
@use "@core/scss/placeholders";
|
||||
@use "@core/scss/variables" as core-vars;
|
||||
@use "placeholders";
|
||||
@use "variables" as core-vars;
|
||||
|
||||
.layout-navbar {
|
||||
@if core-vars.$navbar-high-emphasis-text {
|
||||
|
||||
@@ -11,11 +11,58 @@
|
||||
|
||||
// ℹ️ adding styling for code tag
|
||||
code {
|
||||
background: rgba(var(--v-code-background-color), var(--v-focus-opacity));
|
||||
border-radius: 3px;
|
||||
background: rgba(var(--v-code-background-color), var(--v-focus-opacity));
|
||||
color: currentcolor;
|
||||
font-size: 85%;
|
||||
font-weight: 400;
|
||||
padding-block: 0.2em;
|
||||
padding-inline: 0.4em;
|
||||
}
|
||||
|
||||
%blurry-bg {
|
||||
position: relative;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
|
||||
|
||||
@media (width >= 1280px) and (hover: hover) {
|
||||
background: rgba(var(--v-theme-background), 1);
|
||||
|
||||
.v-theme--transparent & {
|
||||
backdrop-filter: blur(var(--transparent-blur-light, 5px));
|
||||
background: rgba(var(--v-theme-background), var(--transparent-opacity-light, 0.1)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 1280px), (hover: none) {
|
||||
background: transparent;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
backdrop-filter: blur(24px);
|
||||
block-size: calc(env(safe-area-inset-top, 0px) + var(--navbar-tab-height) + 4rem);
|
||||
content: "";
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
pointer-events: none;
|
||||
transition: padding 0.3s ease-in-out;
|
||||
|
||||
.v-theme--light & {
|
||||
background: rgba(var(--v-theme-surface), 0.6);
|
||||
}
|
||||
|
||||
.v-theme--dark & {
|
||||
background: rgba(var(--v-theme-background), 0.5);
|
||||
}
|
||||
|
||||
.v-theme--purple & {
|
||||
background: rgba(var(--v-theme-background), 0.5);
|
||||
}
|
||||
|
||||
.v-theme--transparent & {
|
||||
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
|
||||
background: rgba(var(--v-theme-background), var(--transparent-opacity-heavy, 0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@use "vuetify/lib/styles/settings" as vuetify_settings;
|
||||
@use "sass:map";
|
||||
@use "vuetify/lib/styles/settings/_index.sass" as vuetify_settings;
|
||||
@use "@styles/variables/_vuetify.scss" as vuetify;
|
||||
|
||||
@mixin themed($property, $light-value, $dark-value) {
|
||||
@at-root {
|
||||
@@ -17,11 +19,12 @@
|
||||
// ℹ️ This mixin is inspired from vuetify for adding hover styles via before pseudo element
|
||||
@mixin before-pseudo() {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
border-radius: inherit;
|
||||
background: currentcolor;
|
||||
block-size: 100%;
|
||||
border-radius: inherit;
|
||||
content: "";
|
||||
inline-size: 100%;
|
||||
inset: 0;
|
||||
@@ -43,8 +46,8 @@
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
background-color: currentcolor;
|
||||
border-radius: inherit;
|
||||
background-color: currentcolor;
|
||||
content: "";
|
||||
inset: 0;
|
||||
opacity: $opacity;
|
||||
@@ -56,10 +59,81 @@
|
||||
@mixin avatar-font-sizes($map: $avatar-sizes) {
|
||||
@each $sizeName, $multiplier in vuetify_settings.$size-scales {
|
||||
/* stylelint-disable-next-line scss/no-global-function-names */
|
||||
$size: map-get($map, $sizeName);
|
||||
$size: map.get($map, $sizeName);
|
||||
|
||||
&.v-avatar--size-#{$sizeName} {
|
||||
font-size: #{$size}px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin elevation($z, $important: false) {
|
||||
box-shadow: map.get(vuetify.$shadow-key-umbra, $z), map.get(vuetify.$shadow-key-penumbra, $z), map.get(vuetify.$shadow-key-ambient, $z) if($important, !important, null);
|
||||
}
|
||||
|
||||
@mixin bordered-skin($component, $border-property: "border", $important: false) {
|
||||
#{$component} {
|
||||
box-shadow: none !important;
|
||||
#{$border-property}: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) if($important, !important, null);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin selected-states($selector) {
|
||||
#{$selector} {
|
||||
opacity: calc(var(--v-selected-opacity) * var(--v-theme-overlay-multiplier));
|
||||
}
|
||||
|
||||
&:hover
|
||||
#{$selector} {
|
||||
opacity: calc(var(--v-selected-opacity) + var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));
|
||||
}
|
||||
|
||||
&:focus-visible
|
||||
#{$selector} {
|
||||
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
&:focus {
|
||||
#{$selector} {
|
||||
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin push-anchors() {
|
||||
:target {
|
||||
scroll-margin-block-start: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin xs {
|
||||
@media (width >= 0) and (width <= 599.98px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin sm {
|
||||
@media (width >= 600px) and (width <= 959.98px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin md {
|
||||
@media (width >= 960px) and (width <= 1279.98px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin lg {
|
||||
@media (width >= 1280px) and (width <= 1919.98px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin xl {
|
||||
@media (width >= 1920px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,25 @@
|
||||
@use "@configured-variables" as variables;
|
||||
|
||||
// 👉 Demo spacers
|
||||
// TODO: Use vuetify SCSS variable here
|
||||
$card-spacer-content: 16px;
|
||||
|
||||
.demo-space-x {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-block-start: -$card-spacer-content;
|
||||
|
||||
& > * {
|
||||
margin-block-start: $card-spacer-content;
|
||||
margin-inline-end: $card-spacer-content;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-space-y {
|
||||
& > * {
|
||||
margin-block-end: $card-spacer-content;
|
||||
|
||||
&:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Card match height
|
||||
.match-height.v-row {
|
||||
.v-card {
|
||||
block-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Whitespace
|
||||
.whitespace-no-wrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 👉 Colors
|
||||
|
||||
/*
|
||||
ℹ️ Vuetify is applying `.text-white` class to badge icon but don't provide its styles
|
||||
Moreover, we also use this class in some places
|
||||
|
||||
ℹ️ In vuetify 2 with `$color-pack: false` SCSS var config this class was getting generated but this is not the case in v3
|
||||
|
||||
ℹ️ We also need !important to get correct color in badge icon
|
||||
*/
|
||||
.text-white {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.bg-var-theme-background {
|
||||
background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity)) !important;
|
||||
}
|
||||
|
||||
// [/^bg-light-(\w+)$/, ([, w]) => ({ backgroundColor: `rgba(var(--v-theme-${w}), var(--v-activated-opacity))` })],
|
||||
@each $color-name in variables.$theme-colors-name {
|
||||
.bg-light-#{$color-name} {
|
||||
background-color: rgba(var(--v-theme-#{$color-name}), var(--v-activated-opacity)) !important;
|
||||
// 👉 Pagination small-select dropdown for table
|
||||
// TODO: remove this class after vuetify datatable implememtation
|
||||
|
||||
.per-page-select {
|
||||
margin-block: auto;
|
||||
|
||||
.v-field__input {
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.v-field__append-inner {
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
|
||||
.v-icon {
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Typography
|
||||
.font-weight-semibold {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.leading-normal {
|
||||
line-height: normal !important;
|
||||
}
|
||||
|
||||
@@ -9,20 +9,53 @@
|
||||
- You can also use variable for consistency (e.g. mx 1 rem should be applied to both vertical nav items and vertical nav header)
|
||||
*/
|
||||
@use "sass:map";
|
||||
|
||||
// 使用模板中的变量,不再进行配置
|
||||
@use "@layouts/styles/variables" as layouts-vars;
|
||||
@use "utils";
|
||||
@use "vuetify/lib/styles/tools/functions" as *;
|
||||
|
||||
// 👉 Default layout
|
||||
// 合并两个文件中的@forward配置
|
||||
@forward "@layouts/styles/variables" with (
|
||||
// 来自_variables.scss的配置
|
||||
$layout-vertical-nav-collapsed-width: 68px !default,
|
||||
|
||||
// 来自template/_variables.scss的配置
|
||||
$layout-vertical-nav-z-index: 1004,
|
||||
$layout-overlay-z-index: 1003
|
||||
);
|
||||
|
||||
$navbar-high-emphasis-text: true !default;
|
||||
// 使用命名空间来避免变量冲突
|
||||
@use "@layouts/styles/variables" as layouts-vars;
|
||||
|
||||
// 移除@forward配置,已合并到template/_variables.scss
|
||||
// @forward "@layouts/styles/variables" with (
|
||||
// $layout-vertical-nav-collapsed-width: 68px !default,
|
||||
// );
|
||||
// @use "@layouts/styles/variables" as *;
|
||||
$vertical-nav-horizontal-padding-custom: 1.375rem 1rem;
|
||||
|
||||
// ℹ️ We created this SCSS var to extract the start padding
|
||||
// Docs: https://sass-lang.com/documentation/modules/string
|
||||
// $vertical-nav-horizontal-padding => 0 8px;
|
||||
// string.index(#{$vertical-nav-horizontal-padding}, " ") + 1 => 2
|
||||
// string.index(#{$vertical-nav-horizontal-padding}, " ") => 1
|
||||
// string.slice(0 8px, 2, -1) => 8px => $card-actions-padding-x
|
||||
|
||||
$vertical-nav-horizontal-padding-start: utils.get-first-value($vertical-nav-horizontal-padding-custom) !default;
|
||||
$vertical-nav-items-icon-margin-inline-end: 0.625rem !default;
|
||||
|
||||
// Vertical Nav Configuration
|
||||
$vertical-nav-collapsed-width: 68px !default;
|
||||
|
||||
// ℹ️ This is used to keep consistency between nav items and nav header left & right margin
|
||||
// This is used by nav items & nav header
|
||||
$vertical-nav-horizontal-spacing: 0 1.125rem !default;
|
||||
$vertical-nav-horizontal-padding: $vertical-nav-horizontal-padding-custom !default;
|
||||
|
||||
// Vertical nav header padding
|
||||
$vertical-nav-header-padding: 1rem 0.25rem 1rem $vertical-nav-horizontal-padding-start !default;
|
||||
|
||||
// 👉 Custom Variables
|
||||
$avatar-font-sizes: (
|
||||
"x-small":12,
|
||||
"small":14,
|
||||
"default":18,
|
||||
"large":20,
|
||||
"x-large":24
|
||||
) !default;
|
||||
|
||||
$theme-colors-name: (
|
||||
"primary",
|
||||
@@ -41,31 +74,16 @@ $default-layout-with-vertical-nav-navbar-footer-roundness: 10px !default;
|
||||
$vertical-nav-background-color-rgb: var(--v-theme-background) !default;
|
||||
$vertical-nav-background-color: rgb(#{$vertical-nav-background-color-rgb}) !default;
|
||||
|
||||
// ℹ️ This is used to keep consistency between nav items and nav header left & right margin
|
||||
// This is used by nav items & nav header
|
||||
$vertical-nav-horizontal-spacing: 0 1.125rem !default;
|
||||
$vertical-nav-horizontal-padding: 1.375rem 1rem !default;
|
||||
|
||||
/*
|
||||
ℹ️ We created this SCSS var to extract the start padding
|
||||
Docs: https://sass-lang.com/documentation/modules/string
|
||||
$vertical-nav-horizontal-padding => 0 8px;
|
||||
string.index(#{$vertical-nav-horizontal-padding}, " ") + 1 => 2
|
||||
string.index(#{$vertical-nav-horizontal-padding}, " ") => 1
|
||||
string.slice(0 8px, 2, -1) => 8px => $card-actions-padding-x
|
||||
*/
|
||||
$vertical-nav-horizontal-padding-start: utils.get-first-value($vertical-nav-horizontal-padding);
|
||||
|
||||
// Vertical nav header height. Mostly we will align it with navbar height;
|
||||
$vertical-nav-header-height: layouts-vars.$layout-vertical-nav-navbar-height !default;
|
||||
$vertical-nav-navbar-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
|
||||
$vertical-nav-navbar-elevation: 3 !default;
|
||||
$vertical-nav-navbar-style: "elevated" !default; // options: elevated, floating
|
||||
$vertical-nav-floating-navbar-top: 1rem !default;
|
||||
|
||||
// Vertical nav header padding
|
||||
$vertical-nav-header-padding: 1rem 0.25rem 1rem $vertical-nav-horizontal-padding-start !default;
|
||||
$vertical-nav-header-inline-spacing: $vertical-nav-horizontal-spacing !default;
|
||||
|
||||
// Move logo when vertical nav is mini (collapsed but not hovered)
|
||||
$vertical-nav-header-logo-translate-x-when-vertical-nav-mini: -4px;
|
||||
$vertical-nav-header-logo-translate-x-when-vertical-nav-mini: -4px !default;
|
||||
|
||||
// Space between logo and title
|
||||
$vertical-nav-header-logo-title-spacing: 0.9rem !default;
|
||||
@@ -77,22 +95,130 @@ $vertical-nav-section-title-mt: 1.5rem !default;
|
||||
$vertical-nav-section-title-mb: 0.5rem !default;
|
||||
|
||||
// Vertical nav icons
|
||||
$vertical-nav-items-icon-size: 1.5rem;
|
||||
$vertical-nav-items-icon-margin-inline-end: 0.625rem;
|
||||
$vertical-nav-items-icon-size: 1.5rem !default;
|
||||
$vertical-nav-items-nested-icon-size: 0.9rem !default;
|
||||
|
||||
// Transition duration for nav group arrow
|
||||
$vertical-nav-nav-group-arrow-transition-duration: 0.15s !default;
|
||||
|
||||
// Timing function for nav group arrow
|
||||
$vertical-nav-nav-group-arrow-transition-timing-function: ease-in-out !default;
|
||||
|
||||
// 👉 Horizontal nav
|
||||
|
||||
/*
|
||||
❗ Heads up
|
||||
==================
|
||||
Here we assume we will always use shorthand property which will apply same padding on four side
|
||||
This is because this have been used as value of top property by `.popper-content`
|
||||
*/
|
||||
$horizontal-nav-padding: 0.6875rem !default;
|
||||
|
||||
// Gap between top level horizontal nav items
|
||||
$horizontal-nav-top-level-items-gap: 4px !default;
|
||||
|
||||
// Horizontal nav icons
|
||||
$horizontal-nav-items-icon-size: 1.5rem !default;
|
||||
$horizontal-nav-third-level-icon-size: 0.9rem !default;
|
||||
$horizontal-nav-items-icon-margin-inline-end: 0.625rem !default;
|
||||
|
||||
// ℹ️ We used SCSS variable because we want to allow users to update max height of popper content
|
||||
// 120px is combined height of navbar & horizontal nav
|
||||
$horizontal-nav-popper-content-max-height: calc((var(--vh, 1vh) * 100) - 120px - 4rem) !default;
|
||||
|
||||
// ℹ️ This variable is used for horizontal nav popper content's `margin-top` and "The bridge"'s height. We need to sync both values.
|
||||
$horizontal-nav-popper-content-top: calc($horizontal-nav-padding + 0.375rem) !default;
|
||||
|
||||
// 👉 Plugins
|
||||
|
||||
$plugin-ps-thumb-y-dark: rgba(var(--v-theme-surface-variant), 0.35);
|
||||
$plugin-ps-thumb-y-dark: rgba(var(--v-theme-surface-variant), 0.35) !default;
|
||||
|
||||
// 👉 Custom Variables
|
||||
$avatar-font-sizes: () !default;
|
||||
$avatar-font-sizes: map.deep-merge(
|
||||
// 👉 Vuetify
|
||||
|
||||
// Used in src/@core/scss/base/libs/vuetify/_overrides.scss
|
||||
$vuetify-reduce-default-compact-button-icon-size: true !default;
|
||||
|
||||
// 👉 Custom variables
|
||||
// for utility classes
|
||||
$font-sizes: () !default;
|
||||
$font-sizes: map-deep-merge(
|
||||
(
|
||||
"x-small":12,
|
||||
"small":14,
|
||||
"default":18,
|
||||
"large":20,
|
||||
"x-large":24
|
||||
"xs": 0.75rem,
|
||||
"sm": 0.875rem,
|
||||
"base": 1rem,
|
||||
"lg": 1.125rem,
|
||||
"xl": 1.25rem,
|
||||
"2xl": 1.5rem,
|
||||
"3xl": 1.875rem,
|
||||
"4xl": 2.25rem,
|
||||
"5xl": 3rem,
|
||||
"6xl": 3.75rem,
|
||||
"7xl": 4.5rem,
|
||||
"8xl": 6rem,
|
||||
"9xl": 8rem
|
||||
),
|
||||
$avatar-font-sizes
|
||||
$font-sizes
|
||||
);
|
||||
|
||||
// line height
|
||||
$font-line-height: () !default;
|
||||
$font-line-height: map-deep-merge(
|
||||
(
|
||||
"xs": 1rem,
|
||||
"sm": 1.25rem,
|
||||
"base": 1.5rem,
|
||||
"lg": 1.75rem,
|
||||
"xl": 1.75rem,
|
||||
"2xl": 2rem,
|
||||
"3xl": 2.25rem,
|
||||
"4xl": 2.5rem,
|
||||
"5xl": 1,
|
||||
"6xl": 1,
|
||||
"7xl": 1,
|
||||
"8xl": 1,
|
||||
"9xl": 1
|
||||
),
|
||||
$font-line-height
|
||||
);
|
||||
|
||||
// gap utility class
|
||||
$gap: () !default;
|
||||
$gap: map-deep-merge(
|
||||
(
|
||||
"0": 0,
|
||||
"1": 0.25rem,
|
||||
"2": 0.5rem,
|
||||
"3": 0.75rem,
|
||||
"4": 1rem,
|
||||
"5": 1.25rem,
|
||||
"6":1.5rem,
|
||||
"7": 1.75rem,
|
||||
"8": 2rem,
|
||||
"9": 2.25rem,
|
||||
"10": 2.5rem,
|
||||
"11": 2.75rem,
|
||||
"12": 3rem,
|
||||
"14": 3.5rem,
|
||||
"16": 4rem,
|
||||
"20": 5rem,
|
||||
"24": 6rem,
|
||||
"28": 7rem,
|
||||
"32": 8rem,
|
||||
"36": 9rem,
|
||||
"40": 10rem,
|
||||
"44": 11rem,
|
||||
"48": 12rem,
|
||||
"52": 13rem,
|
||||
"56": 14rem,
|
||||
"60": 15rem,
|
||||
"64": 16rem,
|
||||
"72": 18rem,
|
||||
"80": 20rem,
|
||||
"96": 24rem
|
||||
),
|
||||
$gap
|
||||
);
|
||||
|
||||
// 👉 Default layout
|
||||
|
||||
$navbar-high-emphasis-text: true !default;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@use "./placeholders";
|
||||
@use "@configured-variables" as variables;
|
||||
@use "@core/scss/mixins" as mixins;
|
||||
@use "./mixins" as mixins;
|
||||
@use "vuetify/lib/styles/tools/states" as vuetifyStates;
|
||||
@use "vuetify/lib/styles/tools/elevation" as elevation;
|
||||
|
||||
@@ -118,11 +118,6 @@
|
||||
opacity: var(--v-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Vertical nav link
|
||||
.nav-link {
|
||||
@extend %nav-link;
|
||||
|
||||
> .router-link-exact-active {
|
||||
@extend %nav-link-active;
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
@use "sass:map";
|
||||
@use "template/index";
|
||||
|
||||
// 保留这个引用以向后兼容,但实际功能已经移至template/index.scss
|
||||
// 基础变量和配置
|
||||
@use "variables";
|
||||
@use "mixins";
|
||||
@use "utils";
|
||||
|
||||
// 布局相关
|
||||
@use "default-layout";
|
||||
@use "vertical-nav";
|
||||
@use "default-layout-w-vertical-nav";
|
||||
|
||||
// 组件样式
|
||||
@use "components";
|
||||
|
||||
// 工具类
|
||||
@use "utilities";
|
||||
|
||||
// 其他样式
|
||||
@use "misc";
|
||||
@use "dark";
|
||||
|
||||
// 第三方库样式
|
||||
@use "libs/perfect-scrollbar";
|
||||
@use "libs/apex-chart";
|
||||
@use "libs/full-calendar";
|
||||
@use "libs/vuetify";
|
||||
|
||||
// 全局样式
|
||||
a {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// Vuetify 3 don't provide margin bottom style like vuetify 2
|
||||
p {
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
// Iconify icon size
|
||||
svg.iconify {
|
||||
block-size: 1em;
|
||||
inline-size: 1em;
|
||||
}
|
||||
|
||||
@@ -1,67 +1,88 @@
|
||||
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
|
||||
@use "@configured-variables" as variables;
|
||||
@use "../mixins";
|
||||
|
||||
// 👉 Apex chart
|
||||
.apexcharts-canvas {
|
||||
&line[stroke="transparent"] {
|
||||
display: "none";
|
||||
// For RTL alignment
|
||||
.apexcharts-yaxis-texts-g {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
.apexcharts-tooltip {
|
||||
@include mixins_elevation.elevation(3);
|
||||
|
||||
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
background: rgb(var(--v-theme-surface));
|
||||
line-height: 1.5;
|
||||
|
||||
.apexcharts-tooltip-title {
|
||||
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
background: rgb(var(--v-theme-surface));
|
||||
font-weight: 500;
|
||||
margin-block-end: 0.25rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
|
||||
font-size: inherit;
|
||||
gap: 0.5rem;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-text-label,
|
||||
.apexcharts-tooltip-text-value {
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-series-group {
|
||||
padding-block: 0 0.5rem;
|
||||
padding-inline: 1rem;
|
||||
|
||||
&:last-child {
|
||||
padding-block-end: 1rem;
|
||||
}
|
||||
|
||||
&.active {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.apexcharts-theme-light {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
&.apexcharts-theme-dark {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-series-group:first-of-type {
|
||||
padding-block-end: 0;
|
||||
border-color: rgb(var(--v-border-color));
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: none;
|
||||
|
||||
.apexcharts-tooltip-text-label,
|
||||
.apexcharts-tooltip-text-value {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.apexcharts-xaxistooltip {
|
||||
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
background: rgb(var(--v-theme-grey-50));
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
|
||||
|
||||
&::after {
|
||||
border-block-end-color: rgb(var(--v-theme-grey-50));
|
||||
}
|
||||
|
||||
&::before {
|
||||
border-block-end-color: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
.apexcharts-marker {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
// 👉 stroke-dasharray
|
||||
.apexcharts-radialbar,
|
||||
.apexcharts-radialbar-slice-current {
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.apexcharts-xaxistooltip,
|
||||
.apexcharts-yaxistooltip {
|
||||
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
background: rgb(var(--v-theme-grey-50));
|
||||
|
||||
&::after {
|
||||
border-inline-start-color: rgb(var(--v-theme-grey-50));
|
||||
}
|
||||
border-color: rgb(var(--v-border-color));
|
||||
background: rgb(var(--v-theme-surface));
|
||||
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
border-inline-start-color: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-block-end-color: rgb(var(--v-border-color));
|
||||
}
|
||||
}
|
||||
|
||||
.apexcharts-xaxistooltip-text,
|
||||
.apexcharts-yaxistooltip-text {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
// 👉 Text color
|
||||
.apexcharts-text,
|
||||
.apexcharts-tooltip-text,
|
||||
.apexcharts-datalabel-label,
|
||||
@@ -69,19 +90,16 @@
|
||||
.apexcharts-xaxistooltip-text,
|
||||
.apexcharts-yaxistooltip-text,
|
||||
.apexcharts-legend-text {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity)) !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.apexcharts-pie-label {
|
||||
fill: white;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.apexcharts-marker {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.apexcharts-legend-marker {
|
||||
margin-inline-end: 0.3875rem;
|
||||
// 👉 Annotation Label
|
||||
.apexcharts-annotation-rect {
|
||||
&.apexcharts-xaxis-annotation-rect,
|
||||
&.apexcharts-yaxis-annotation-rect {
|
||||
fill-opacity: 0.05;
|
||||
stroke-opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use "@core/scss/utils";
|
||||
@use "@configured-variables" as variables;
|
||||
@use "../../utils";
|
||||
|
||||
// 👉 Application
|
||||
// ℹ️ We need accurate vh in mobile devices as well
|
||||
@@ -45,6 +45,17 @@ h6,
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Button
|
||||
@if variables.$vuetify-reduce-default-compact-button-icon-size {
|
||||
.v-btn--density-compact.v-btn--size-default {
|
||||
.v-btn__content > svg {
|
||||
block-size: 22px;
|
||||
font-size: 22px;
|
||||
inline-size: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Card
|
||||
// Removes padding-top for immediately placed v-card-text after itself
|
||||
.v-card-text {
|
||||
@@ -71,7 +82,9 @@ h6,
|
||||
&.v-checkbox-btn,
|
||||
&.v-radio,
|
||||
&.v-radio-btn {
|
||||
margin-inline-start: -0.5625rem;
|
||||
.v-selection-control__wrapper {
|
||||
margin-inline-start: -0.5625rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +92,9 @@ h6,
|
||||
&.v-radio,
|
||||
&.v-radio-btn,
|
||||
&.v-checkbox-btn {
|
||||
margin-inline-start: -0.3125rem;
|
||||
.v-selection-control__wrapper {
|
||||
margin-inline-start: -0.3125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +102,9 @@ h6,
|
||||
&.v-checkbox-btn,
|
||||
&.v-radio,
|
||||
&.v-radio-btn {
|
||||
margin-inline-start: -0.6875rem;
|
||||
.v-selection-control__wrapper {
|
||||
margin-inline-start: -0.6875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,13 +171,141 @@ h6,
|
||||
padding-block: 0 !important;
|
||||
padding-inline: 0 !important;
|
||||
|
||||
> .v-ripple__container {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-block-end: var(--v-card-list-gap) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-list-item:hover,
|
||||
.v-list-item:focus,
|
||||
.v-list-item:active,
|
||||
.v-list-item.active {
|
||||
> .v-list-item__overlay {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Table
|
||||
.v-table {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
|
||||
// 👉 Divider
|
||||
.v-divider {
|
||||
color: rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
// 👉 DataTable
|
||||
.v-data-table {
|
||||
/* stylelint-disable-next-line no-descending-specificity */
|
||||
.v-checkbox-btn .v-selection-control__wrapper {
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
.v-selection-control {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.v-pagination {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 v-field
|
||||
.v-field:hover .v-field__outline {
|
||||
--v-field-border-opacity: var(--v-medium-emphasis-opacity);
|
||||
}
|
||||
|
||||
// 👉 VLabel
|
||||
.v-label {
|
||||
opacity: 1 !important;
|
||||
|
||||
&:not(.v-field-label--floating) {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Overlay
|
||||
.v-overlay__scrim,
|
||||
.v-navigation-drawer__scrim {
|
||||
background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// 透明主题下全屏弹窗的overlay背景透明度调整
|
||||
html[data-theme="transparent"] .v-dialog--fullscreen .v-overlay__scrim {
|
||||
background: rgba(var(--v-overlay-scrim-background), 0.3);
|
||||
}
|
||||
|
||||
// 👉 VMessages
|
||||
.v-messages {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// 👉 Alert close btn
|
||||
.v-alert__close {
|
||||
.v-btn--icon .v-icon {
|
||||
--v-icon-size-multiplier: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Badge icon alignment
|
||||
.v-badge__badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// 👉 Dialog
|
||||
.v-dialog--fullscreen {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
// 透明主题下全屏弹窗背景透明
|
||||
html[data-theme="transparent"] .v-dialog--fullscreen {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
// For dialog card title
|
||||
.v-card-item + .v-card-text {
|
||||
padding-block-start: 0 !important;
|
||||
}
|
||||
|
||||
// 👉 v-slide-group (List of chips)
|
||||
.v-slide-group {
|
||||
.v-slide-group__container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
// Spacing between buttons in v-slide-group
|
||||
.v-slide-group-item:not(:last-child) {
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Expansion Panel
|
||||
.v-expansion-panels {
|
||||
.v-expansion-panel-title {
|
||||
min-block-size: unset !important;
|
||||
padding-block: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 v-textarea
|
||||
.v-textarea {
|
||||
textarea {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Cursor
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
$shadow-key-umbra-opacity-custom: var(--v-shadow-key-umbra-opacity);
|
||||
$shadow-key-penumbra-opacity-custom: var(--v-shadow-key-penumbra-opacity);
|
||||
$shadow-key-ambient-opacity-custom: var(--v-shadow-key-ambient-opacity);
|
||||
/* stylelint-disable-next-line max-line-length */
|
||||
$font-family-custom: 'Inter', 'Noto Sans SC', sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
|
||||
// 👉 Card transition properties
|
||||
$card-transition-property-custom: box-shadow, opacity;
|
||||
|
||||
@forward "vuetify/settings" with (
|
||||
// 👉 General settings
|
||||
// 👉 General settings
|
||||
$color-pack: false !default,
|
||||
$body-font-family: $font-family-custom !default,
|
||||
$border-radius-root: 6px !default,
|
||||
|
||||
// 👉 Shadow opacity
|
||||
// 👉 Shadow opacity
|
||||
$shadow-key-umbra-opacity: $shadow-key-umbra-opacity-custom !default,
|
||||
$shadow-key-penumbra-opacity: $shadow-key-penumbra-opacity-custom !default,
|
||||
$shadow-key-ambient-opacity: $shadow-key-ambient-opacity-custom !default,
|
||||
|
||||
$body-font-family: $font-family-custom !default,
|
||||
$border-radius-root: 6px !default,
|
||||
|
||||
$shadow-key-umbra: (
|
||||
0: (0 0 0 0 var(--v-shadow-key-umbra-opacity)),
|
||||
1: (0 2px 1px -1px var(--v-shadow-key-umbra-opacity)),
|
||||
@@ -119,6 +121,18 @@ $card-transition-property-custom: box-shadow, opacity;
|
||||
24: (0 9px 46px 8px $shadow-key-ambient-opacity-custom)
|
||||
) !default,
|
||||
|
||||
// 👉 Card
|
||||
$card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
|
||||
$card-elevation: 6 !default,
|
||||
$card-title-line-height: 2rem !default,
|
||||
$card-actions-min-height: unset !default,
|
||||
$card-text-padding: 1.25rem !default,
|
||||
$card-item-padding: 1.25rem !default,
|
||||
$card-actions-padding: 0 12px 12px !default,
|
||||
$card-transition-property: $card-transition-property-custom !default,
|
||||
$card-subtitle-opacity: 1 !default,
|
||||
$card-title-letter-spacing: 0.0094rem !default,
|
||||
|
||||
// 👉 Typography
|
||||
$typography: (
|
||||
"h1": (
|
||||
@@ -170,29 +184,14 @@ $card-transition-property-custom: box-shadow, opacity;
|
||||
)
|
||||
) !default,
|
||||
|
||||
// 👉 States
|
||||
$states: ("activated": 0.08) !default,
|
||||
|
||||
// 👉 Card
|
||||
$card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
|
||||
$card-elevation: 6 !default,
|
||||
$card-title-line-height: 1.6 !default,
|
||||
$card-actions-min-height: unset !default,
|
||||
$card-text-padding: 20px !default,
|
||||
$card-item-padding: 15px 20px !default,
|
||||
$card-actions-padding: 0 12px 12px !default,
|
||||
$card-title-letter-spacing: 0.0094rem !default,
|
||||
$card-subtitle-opacity: 1 !default,
|
||||
$card-transition-property: $card-transition-property-custom !default,
|
||||
|
||||
// 👉 Navigation Drawer
|
||||
$navigation-drawer-color: rgba(var(--v-theme-on-surface), var(--v-high-medium-opacity)) !default,
|
||||
|
||||
// 👉 Table
|
||||
$table-color: rgba(var(--v-theme-on-surface), var(--v-high-medium-opacity)) !default,
|
||||
// 👉 List
|
||||
$list-item-icon-margin-end: 16px !default,
|
||||
$list-item-icon-margin-start: 16px !default,
|
||||
$list-item-subtitle-opacity: 1 !default,
|
||||
$list-subheader-text-opacity: 1 !default,
|
||||
|
||||
// 👉 Tooltip
|
||||
$tooltip-background-color:#212121 !default,
|
||||
$tooltip-background-color: #212121 !default,
|
||||
$tooltip-text-color: rgb(var(--v-theme-on-primary)) !default,
|
||||
$tooltip-font-size: 0.75rem !default,
|
||||
$tooltip-border-radius: 4px !default,
|
||||
@@ -205,6 +204,8 @@ $card-transition-property-custom: box-shadow, opacity;
|
||||
|
||||
// 👉 Badge
|
||||
$badge-border-color:rgb(var(--v-theme-surface)) !default,
|
||||
$badge-dot-height: 0.5rem !default,
|
||||
$badge-dot-width: 0.5rem !default,
|
||||
|
||||
// 👉 Button
|
||||
$button-height: 38px !default,
|
||||
@@ -212,6 +213,7 @@ $card-transition-property-custom: box-shadow, opacity;
|
||||
$button-border-radius: 5px !default,
|
||||
$button-padding-ratio: 1.7 !default,
|
||||
$button-text-letter-spacing: 0.025rem !default,
|
||||
$button-icon-density: ("default": 0.5, "comfortable": -2, "compact": -3) !default,
|
||||
|
||||
// 👉 Dialog
|
||||
$dialog-card-header-padding: 20px !default,
|
||||
@@ -220,6 +222,7 @@ $card-transition-property-custom: box-shadow, opacity;
|
||||
|
||||
// 👉 Chip
|
||||
$chip-label-border-radius: 4px !default,
|
||||
$chip-close-size: 20px !default,
|
||||
|
||||
// 👉 Expansion panel
|
||||
$expansion-panel-title-padding: 16px 20px !default,
|
||||
@@ -232,9 +235,6 @@ $card-transition-property-custom: box-shadow, opacity;
|
||||
// 👉 Menu
|
||||
$menu-content-border-radius: 5px !default,
|
||||
|
||||
// 👉 List
|
||||
$list-subheader-text-opacity: 1 !default,
|
||||
|
||||
// 👉 Snackbar
|
||||
$snackbar-background:#212121 !default,
|
||||
$snackbar-border-radius: 4px !default,
|
||||
@@ -243,7 +243,12 @@ $card-transition-property-custom: box-shadow, opacity;
|
||||
// 👉 Tabs
|
||||
$tabs-height: 40px !default,
|
||||
|
||||
// 👉 Timeline
|
||||
// 👉 Slider
|
||||
$slider-track-active-size: 4px !default,
|
||||
$slider-thumb-label-padding: 4px 12px !default,
|
||||
$slider-thumb-label-font-size: 0.875rem !default,
|
||||
|
||||
// 👉 Timeline
|
||||
$timeline-dot-size: 34px !default,
|
||||
$timeline-dot-divider-background: transparent !default,
|
||||
|
||||
@@ -252,4 +257,7 @@ $card-transition-property-custom: box-shadow, opacity;
|
||||
|
||||
// 👉 Navigation Drawer
|
||||
$navigation-drawer-scrim-opacity:0.5 !default,
|
||||
|
||||
// 👉 Table
|
||||
$table-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)),
|
||||
);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
@use "variables";
|
||||
@use "overrides";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.auth-wrapper {
|
||||
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
min-block-size: 100%;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
.auth-footer-mask {
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
%layout-navbar {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
// Vertical nav scrolled sticky elevated nav
|
||||
%default-layout-vertical-nav-scrolled-sticky-elevated-nav {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
|
||||
}
|
||||
|
||||
// Floating navbar and sticky elevated navbar scrolled
|
||||
%default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
|
||||
}
|
||||
|
||||
// Floating navbar overlay
|
||||
%default-layout-vertical-nav-floating-navbar-overlay {
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-surface), 0.9);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@use "@core/scss/mixins";
|
||||
@use "../mixins";
|
||||
@use "@configured-variables" as variables;
|
||||
@use "vuetify/lib/styles/tools/states" as vuetifyStates;
|
||||
@use "@core/scss/utils";
|
||||
@use "../utils";
|
||||
|
||||
// Nav items styles (including section title)
|
||||
%vertical-nav-item {
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
|
||||
@use "@configured-variables" as variables;
|
||||
@use "mixins";
|
||||
|
||||
// 👉 Alert
|
||||
.v-alert {
|
||||
.v-alert__close {
|
||||
.v-icon {
|
||||
block-size: 20px !important;
|
||||
font-size: 20px !important;
|
||||
inline-size: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.v-alert--prominent) .v-alert__prepend {
|
||||
.v-icon {
|
||||
block-size: 1.375rem !important;
|
||||
font-size: 1.375rem !important;
|
||||
inline-size: 1.375rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-alert-title {
|
||||
line-height: 1.5rem;
|
||||
margin-block-end: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Avatar font-size
|
||||
.v-avatar {
|
||||
@include mixins.avatar-font-sizes($map: variables.$avatar-font-sizes);
|
||||
}
|
||||
|
||||
// 👉 Button
|
||||
.v-btn {
|
||||
/* stylelint-disable-next-line no-descending-specificity */
|
||||
&:not(.v-btn--icon) .v-icon {
|
||||
--v-icon-size-multiplier: 0.9525 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Chip
|
||||
.v-chip.v-chip--size-default .v-avatar {
|
||||
--v-avatar-height: 24px;
|
||||
}
|
||||
|
||||
.v-chip.v-chip--density-comfortable {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// 👉 Expansion Panel
|
||||
.v-expansion-panel {
|
||||
.v-expansion-panel-text {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Tooltip
|
||||
.v-tooltip > .v-overlay__content {
|
||||
font-weight: 500;
|
||||
line-height: 0.875rem;
|
||||
}
|
||||
|
||||
// 👉 List
|
||||
|
||||
// 👉 Tab with pill support
|
||||
.v-tabs.v-tabs-pill {
|
||||
.v-tab.v-btn {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Timeline added box shadow
|
||||
.v-timeline-item {
|
||||
.v-timeline-divider__dot {
|
||||
.v-timeline-divider__inner-dot {
|
||||
box-shadow: 0 0 0 0.1875rem rgb(var(--v-theme-on-surface-variant));
|
||||
|
||||
@each $color-name in variables.$theme-colors-name {
|
||||
|
||||
&.bg-#{$color-name} {
|
||||
box-shadow: 0 0 0 0.1875rem rgba(var(--v-theme-#{$color-name}), 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Timeline Outlined style
|
||||
.v-timeline-variant-outlined.v-timeline {
|
||||
.v-timeline-divider__dot {
|
||||
.v-timeline-divider__inner-dot {
|
||||
box-shadow: inset 0 0 0 0.125rem rgb(var(--v-theme-on-surface-variant));
|
||||
|
||||
@each $color-name in variables.$theme-colors-name {
|
||||
background-color: rgb(var(--v-theme-surface)) !important;
|
||||
|
||||
&.bg-#{$color-name} {
|
||||
box-shadow: inset 0 0 0 0.125rem rgb(var(--v-theme-#{$color-name}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Expansion panels
|
||||
.v-expansion-panel-title,
|
||||
.v-expansion-panel-title--active,
|
||||
.v-expansion-panel-title:hover,
|
||||
.v-expansion-panel-title:focus,
|
||||
.v-expansion-panel-title:focus-visible,
|
||||
.v-expansion-panel-title--active:focus,
|
||||
.v-expansion-panel-title--active:hover {
|
||||
.v-expansion-panel-title__overlay {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Set Elevation when panel open
|
||||
|
||||
.v-expansion-panels:not(.v-expansion-panels--variant-accordion) {
|
||||
.v-expansion-panel.v-expansion-panel--active {
|
||||
.v-expansion-panel__shadow {
|
||||
@include mixins_elevation.elevation(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Slider
|
||||
.v-slider-thumb {
|
||||
.v-slider-thumb__label {
|
||||
background: rgb(117, 117, 117);
|
||||
color: rgb(var(--v-theme-on-primary));
|
||||
|
||||
&::before {
|
||||
color: rgb(117, 117, 117);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Switch
|
||||
.v-switch {
|
||||
.v-selection-control:not(.v-selection-control--dirty) .v-switch__thumb {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Table
|
||||
.v-table--density-default > .v-table__wrapper > table > tbody > tr > td,
|
||||
.v-table--density-default > .v-table__wrapper > table > thead > tr > td,
|
||||
.v-table--density-default > .v-table__wrapper > table > tfoot > tr > td {
|
||||
block-size: 50px !important;
|
||||
}
|
||||
|
||||
.v-table {
|
||||
--v-table-header-height: 54px !important;
|
||||
|
||||
th {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
|
||||
font-size: 0.75rem;
|
||||
|
||||
.v-data-table-header__content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.v-selection-control {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.v-data-table {
|
||||
th {
|
||||
background: rgb(var(--v-table-header-background)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Pagination
|
||||
.v-pagination {
|
||||
.v-btn {
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 SnackBar
|
||||
.v-snackbar--variant-elevated {
|
||||
@include mixins.elevation(6);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "vuetify/lib/styles/settings" as vuetify_settings;
|
||||
@use "@styles/variables/_vuetify.scss" as vuetify;
|
||||
|
||||
@mixin avatar-font-sizes($map: $avatar-sizes) {
|
||||
@each $sizeName, $multiplier in vuetify_settings.$size-scales {
|
||||
/* stylelint-disable-next-line scss/no-global-function-names */
|
||||
$size: map.get($map, $sizeName);
|
||||
|
||||
&.v-avatar--size-#{$sizeName} {
|
||||
font-size: #{$size}px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin elevation($z, $important: false) {
|
||||
box-shadow: map.get(vuetify.$shadow-key-umbra, $z), map.get(vuetify.$shadow-key-penumbra, $z), map.get(vuetify.$shadow-key-ambient, $z) if($important, !important, null);
|
||||
}
|
||||
|
||||
@mixin before-pseudo() {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
border-radius: inherit;
|
||||
background: currentcolor;
|
||||
block-size: 100%;
|
||||
content: "";
|
||||
inline-size: 100%;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin bordered-skin($component, $border-property: "border", $important: false) {
|
||||
#{$component} {
|
||||
box-shadow: none !important;
|
||||
#{$border-property}: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) if($important, !important, null);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin selected-states($selector) {
|
||||
#{$selector} {
|
||||
opacity: calc(var(--v-selected-opacity) * var(--v-theme-overlay-multiplier));
|
||||
}
|
||||
|
||||
&:hover
|
||||
#{$selector} {
|
||||
opacity: calc(var(--v-selected-opacity) + var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));
|
||||
}
|
||||
|
||||
&:focus-visible
|
||||
#{$selector} {
|
||||
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
&:focus {
|
||||
#{$selector} {
|
||||
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin push-anchors() {
|
||||
:target {
|
||||
scroll-margin-block-start: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin xs {
|
||||
@media (width >= 0) and (width <= 599.98px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin sm {
|
||||
@media (width >= 600px) and (width <= 959.98px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin md {
|
||||
@media (width >= 960px) and (width <= 1279.98px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin lg {
|
||||
@media (width >= 1280px) and (width <= 1919.98px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin xl {
|
||||
@media (width >= 1920px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
.bg-var-theme-background {
|
||||
background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity)) !important;
|
||||
}
|
||||
|
||||
// 👉 Pagination small-select dropdown for table
|
||||
// TODO: remove this class after vuetify datatable implememtation
|
||||
|
||||
.per-page-select {
|
||||
margin-block: auto;
|
||||
|
||||
.v-field__input {
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.v-field__append-inner {
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
|
||||
.v-icon {
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
@use "sass:string";
|
||||
|
||||
/*
|
||||
ℹ️ This function is helpful when we have multi dimensional value
|
||||
|
||||
Assume we have padding variable `$nav-padding-horizontal: 10px;`
|
||||
With above variable let's say we use it in some style:
|
||||
```scss
|
||||
.selector {
|
||||
margin-left: $nav-padding-horizontal;
|
||||
}
|
||||
```
|
||||
|
||||
Now, problem is we can also have value as `$nav-padding-horizontal: 10px 15px;`
|
||||
In this case above style will be invalid.
|
||||
|
||||
This function will extract the left most value from the variable value.
|
||||
|
||||
$nav-padding-horizontal: 10px; => 10px;
|
||||
$nav-padding-horizontal: 10px 15px; => 10px;
|
||||
|
||||
This is safe:
|
||||
```scss
|
||||
.selector {
|
||||
margin-left: get-first-value($nav-padding-horizontal);
|
||||
}
|
||||
```
|
||||
*/
|
||||
@function get-first-value($var) {
|
||||
$start-at: string.index(#{$var}, " ");
|
||||
|
||||
@if $start-at {
|
||||
@return string.slice(
|
||||
#{$var},
|
||||
0,
|
||||
$start-at
|
||||
);
|
||||
} @else {
|
||||
@return $var;
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "utils";
|
||||
@use "vuetify/lib/styles/tools/functions" as *;
|
||||
|
||||
$vertical-nav-horizontal-padding-custom: 1.375rem 1rem;
|
||||
|
||||
// ℹ️ We created this SCSS var to extract the start padding
|
||||
// Docs: https://sass-lang.com/documentation/modules/string
|
||||
// $vertical-nav-horizontal-padding => 0 8px;
|
||||
// string.index(#{$vertical-nav-horizontal-padding}, " ") + 1 => 2
|
||||
// string.index(#{$vertical-nav-horizontal-padding}, " ") => 1
|
||||
// string.slice(0 8px, 2, -1) => 8px => $card-actions-padding-x
|
||||
|
||||
$vertical-nav-horizontal-padding-start: utils.get-first-value($vertical-nav-horizontal-padding-custom) !default;
|
||||
$vertical-nav-items-icon-margin-inline-end: 0.625rem !default;
|
||||
|
||||
// Vertical Nav Configuration
|
||||
$vertical-nav-collapsed-width: 68px !default;
|
||||
|
||||
// ℹ️ This is used to keep consistency between nav items and nav header left & right margin
|
||||
// This is used by nav items & nav header
|
||||
$vertical-nav-horizontal-spacing: 0 1.125rem !default;
|
||||
$vertical-nav-horizontal-padding: $vertical-nav-horizontal-padding-custom !default;
|
||||
|
||||
// Vertical nav header padding
|
||||
$vertical-nav-header-padding: 1rem 0.25rem 1rem $vertical-nav-horizontal-padding-start !default;
|
||||
|
||||
// 👉 Custom Variables
|
||||
$avatar-font-sizes: (
|
||||
"x-small":12,
|
||||
"small":14,
|
||||
"default":18,
|
||||
"large":20,
|
||||
"x-large":24
|
||||
) !default;
|
||||
|
||||
// 合并两个文件中的@forward配置
|
||||
@forward "@layouts/styles/variables" with (
|
||||
// 来自_variables.scss的配置
|
||||
$layout-vertical-nav-collapsed-width: 68px !default,
|
||||
|
||||
// 来自template/_variables.scss的配置
|
||||
$layout-vertical-nav-z-index: 1004,
|
||||
$layout-overlay-z-index: 1003
|
||||
);
|
||||
|
||||
// 使用命名空间来避免变量冲突
|
||||
@use "@layouts/styles/variables" as layouts-vars;
|
||||
|
||||
$theme-colors-name: (
|
||||
"primary",
|
||||
"secondary",
|
||||
"error",
|
||||
"info",
|
||||
"success",
|
||||
"warning"
|
||||
) !default;
|
||||
|
||||
// 👉 Default layout with vertical nav
|
||||
|
||||
$default-layout-with-vertical-nav-navbar-footer-roundness: 10px !default;
|
||||
|
||||
// 👉 Vertical nav
|
||||
$vertical-nav-background-color-rgb: var(--v-theme-background) !default;
|
||||
$vertical-nav-background-color: rgb(#{$vertical-nav-background-color-rgb}) !default;
|
||||
|
||||
// ℹ️ This is used to keep consistency between nav items and nav header left & right margin
|
||||
// This is used by nav items & nav header
|
||||
$vertical-nav-horizontal-spacing: 1rem !default;
|
||||
$vertical-nav-horizontal-padding: 0.75rem !default;
|
||||
|
||||
// Vertical nav header height. Mostly we will align it with navbar height;
|
||||
$vertical-nav-header-height: layouts-vars.$layout-vertical-nav-navbar-height !default;
|
||||
$vertical-nav-navbar-elevation: 3 !default;
|
||||
$vertical-nav-navbar-style: "elevated" !default; // options: elevated, floating
|
||||
$vertical-nav-floating-navbar-top: 1rem !default;
|
||||
|
||||
// Vertical nav header padding
|
||||
$vertical-nav-header-padding: 1rem $vertical-nav-horizontal-padding !default;
|
||||
$vertical-nav-header-inline-spacing: $vertical-nav-horizontal-spacing !default;
|
||||
|
||||
// Move logo when vertical nav is mini (collapsed but not hovered)
|
||||
$vertical-nav-header-logo-translate-x-when-vertical-nav-mini: -4px !default;
|
||||
|
||||
// Space between logo and title
|
||||
$vertical-nav-header-logo-title-spacing: 0.9rem !default;
|
||||
|
||||
// Section title margin top (when its not first child)
|
||||
$vertical-nav-section-title-mt: 1.5rem !default;
|
||||
|
||||
// Section title margin bottom
|
||||
$vertical-nav-section-title-mb: 0.5rem !default;
|
||||
|
||||
// Vertical nav icons
|
||||
$vertical-nav-items-icon-size: 1.5rem !default;
|
||||
$vertical-nav-items-nested-icon-size: 0.9rem !default;
|
||||
$vertical-nav-items-icon-margin-inline-end: 0.5rem !default;
|
||||
|
||||
// Transition duration for nav group arrow
|
||||
$vertical-nav-nav-group-arrow-transition-duration: 0.15s !default;
|
||||
|
||||
// Timing function for nav group arrow
|
||||
$vertical-nav-nav-group-arrow-transition-timing-function: ease-in-out !default;
|
||||
|
||||
// 👉 Horizontal nav
|
||||
|
||||
/*
|
||||
❗ Heads up
|
||||
==================
|
||||
Here we assume we will always use shorthand property which will apply same padding on four side
|
||||
This is because this have been used as value of top property by `.popper-content`
|
||||
*/
|
||||
$horizontal-nav-padding: 0.6875rem !default;
|
||||
|
||||
// Gap between top level horizontal nav items
|
||||
$horizontal-nav-top-level-items-gap: 4px !default;
|
||||
|
||||
// Horizontal nav icons
|
||||
$horizontal-nav-items-icon-size: 1.5rem !default;
|
||||
$horizontal-nav-third-level-icon-size: 0.9rem !default;
|
||||
$horizontal-nav-items-icon-margin-inline-end: 0.625rem !default;
|
||||
|
||||
// ℹ️ We used SCSS variable because we want to allow users to update max height of popper content
|
||||
// 120px is combined height of navbar & horizontal nav
|
||||
$horizontal-nav-popper-content-max-height: calc((var(--vh, 1vh) * 100) - 120px - 4rem) !default;
|
||||
|
||||
// ℹ️ This variable is used for horizontal nav popper content's `margin-top` and "The bridge"'s height. We need to sync both values.
|
||||
$horizontal-nav-popper-content-top: calc($horizontal-nav-padding + 0.375rem) !default;
|
||||
|
||||
// 👉 Plugins
|
||||
|
||||
$plugin-ps-thumb-y-dark: rgba(var(--v-theme-surface-variant), 0.35) !default;
|
||||
|
||||
// 👉 Vuetify
|
||||
|
||||
// Used in src/@core/scss/base/libs/vuetify/_overrides.scss
|
||||
$vuetify-reduce-default-compact-button-icon-size: true !default;
|
||||
|
||||
// 👉 Custom variables
|
||||
// for utility classes
|
||||
$font-sizes: () !default;
|
||||
$font-sizes: map-deep-merge(
|
||||
(
|
||||
"xs": 0.75rem,
|
||||
"sm": 0.875rem,
|
||||
"base": 1rem,
|
||||
"lg": 1.125rem,
|
||||
"xl": 1.25rem,
|
||||
"2xl": 1.5rem,
|
||||
"3xl": 1.875rem,
|
||||
"4xl": 2.25rem,
|
||||
"5xl": 3rem,
|
||||
"6xl": 3.75rem,
|
||||
"7xl": 4.5rem,
|
||||
"8xl": 6rem,
|
||||
"9xl": 8rem
|
||||
),
|
||||
$font-sizes
|
||||
);
|
||||
|
||||
// line height
|
||||
$font-line-height: () !default;
|
||||
$font-line-height: map-deep-merge(
|
||||
(
|
||||
"xs": 1rem,
|
||||
"sm": 1.25rem,
|
||||
"base": 1.5rem,
|
||||
"lg": 1.75rem,
|
||||
"xl": 1.75rem,
|
||||
"2xl": 2rem,
|
||||
"3xl": 2.25rem,
|
||||
"4xl": 2.5rem,
|
||||
"5xl": 1,
|
||||
"6xl": 1,
|
||||
"7xl": 1,
|
||||
"8xl": 1,
|
||||
"9xl": 1
|
||||
),
|
||||
$font-line-height
|
||||
);
|
||||
|
||||
// gap utility class
|
||||
$gap: () !default;
|
||||
$gap: map-deep-merge(
|
||||
(
|
||||
"0": 0,
|
||||
"1": 0.25rem,
|
||||
"2": 0.5rem,
|
||||
"3": 0.75rem,
|
||||
"4": 1rem,
|
||||
"5": 1.25rem,
|
||||
"6":1.5rem,
|
||||
"7": 1.75rem,
|
||||
"8": 2rem,
|
||||
"9": 2.25rem,
|
||||
"10": 2.5rem,
|
||||
"11": 2.75rem,
|
||||
"12": 3rem,
|
||||
"14": 3.5rem,
|
||||
"16": 4rem,
|
||||
"20": 5rem,
|
||||
"24": 6rem,
|
||||
"28": 7rem,
|
||||
"32": 8rem,
|
||||
"36": 9rem,
|
||||
"40": 10rem,
|
||||
"44": 11rem,
|
||||
"48": 12rem,
|
||||
"52": 13rem,
|
||||
"56": 14rem,
|
||||
"60": 15rem,
|
||||
"64": 16rem,
|
||||
"72": 18rem,
|
||||
"80": 20rem,
|
||||
"96": 24rem
|
||||
),
|
||||
$gap
|
||||
);
|
||||
|
||||
// Avatar sizes map
|
||||
$avatar-font-sizes: (
|
||||
"x-small": 0.625rem,
|
||||
"small": 0.75rem,
|
||||
"default": 0.875rem,
|
||||
"large": 1rem,
|
||||
"x-large": 1.125rem,
|
||||
) !default;
|
||||
@@ -1,42 +0,0 @@
|
||||
@use "sass:map";
|
||||
|
||||
// Layout
|
||||
@use "../vertical-nav";
|
||||
@use "../default-layout";
|
||||
@use "default-layout-w-vertical-nav";
|
||||
|
||||
// Components
|
||||
@use "components";
|
||||
|
||||
// Utilities
|
||||
@use "utilities";
|
||||
@use "../utils";
|
||||
|
||||
// Misc
|
||||
@use "../misc";
|
||||
|
||||
// Dark
|
||||
@use "../dark";
|
||||
|
||||
// Variables
|
||||
@use "variables";
|
||||
|
||||
// libs
|
||||
@use "libs/perfect-scrollbar";
|
||||
@use "libs/vuetify";
|
||||
|
||||
a {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// Vuetify 3 don't provide margin bottom style like vuetify 2
|
||||
p {
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
// Iconify icon size
|
||||
svg.iconify {
|
||||
block-size: 1em;
|
||||
inline-size: 1em;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
@use "@configureTheme" as theme;
|
||||
@use "@configured-variables" as variables;
|
||||
@use "../mixins";
|
||||
|
||||
// 👉 Apex chart
|
||||
.apexcharts-canvas {
|
||||
// For RTL alignment
|
||||
.apexcharts-yaxis-texts-g {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
.apexcharts-tooltip {
|
||||
line-height: 1.5;
|
||||
|
||||
.apexcharts-tooltip-title {
|
||||
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
background: rgb(var(--v-theme-surface));
|
||||
font-weight: 500;
|
||||
margin-block-end: 0.25rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
|
||||
font-size: inherit;
|
||||
gap: 0.5rem;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-text-label,
|
||||
.apexcharts-tooltip-text-value {
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-series-group {
|
||||
padding-block: 0 0.5rem;
|
||||
padding-inline: 1rem;
|
||||
|
||||
&:last-child {
|
||||
padding-block-end: 1rem;
|
||||
}
|
||||
|
||||
&.active {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.apexcharts-theme-light {
|
||||
border-color: rgb(var(--v-border-color));
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: none;
|
||||
|
||||
.apexcharts-tooltip-text-label,
|
||||
.apexcharts-tooltip-text-value {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.apexcharts-marker {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
// 👉 stroke-dasharray
|
||||
.apexcharts-radialbar,
|
||||
.apexcharts-radialbar-slice-current {
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.apexcharts-xaxistooltip,
|
||||
.apexcharts-yaxistooltip {
|
||||
border-color: rgb(var(--v-border-color));
|
||||
background: rgb(var(--v-theme-surface));
|
||||
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
border-block-end-color: rgb(var(--v-border-color));
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Text color
|
||||
.apexcharts-text,
|
||||
.apexcharts-tooltip-text,
|
||||
.apexcharts-datalabel-label,
|
||||
.apexcharts-datalabel,
|
||||
.apexcharts-xaxistooltip-text,
|
||||
.apexcharts-yaxistooltip-text,
|
||||
.apexcharts-legend-text {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity)) !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
// 👉 Annotation Label
|
||||
.apexcharts-annotation-rect {
|
||||
&.apexcharts-xaxis-annotation-rect,
|
||||
&.apexcharts-yaxis-annotation-rect {
|
||||
fill-opacity: 0.05;
|
||||
stroke-opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
@use "@configured-variables" as variables;
|
||||
@use "../../../utils";
|
||||
|
||||
// 👉 Application
|
||||
// ℹ️ We need accurate vh in mobile devices as well
|
||||
.v-application__wrap {
|
||||
/* stylelint-disable-next-line liberty/use-logical-spec */
|
||||
min-height: calc(var(--vh, 1vh) * 100);
|
||||
}
|
||||
|
||||
// 👉 Typography
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
.text-h1,
|
||||
.text-h2,
|
||||
.text-h3,
|
||||
.text-h4,
|
||||
.text-h5,
|
||||
.text-h6,
|
||||
.text-button,
|
||||
.text-overline,
|
||||
.v-card-title {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.text-body-1,
|
||||
.text-body-2,
|
||||
.text-subtitle-1,
|
||||
.text-subtitle-2 {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
|
||||
// 👉 Grid
|
||||
// Remove margin-bottom of v-input_details inside grid (validation error message)
|
||||
.v-row {
|
||||
.v-col,
|
||||
[class^="v-col-*"] {
|
||||
.v-input__details {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Button
|
||||
@if variables.$vuetify-reduce-default-compact-button-icon-size {
|
||||
.v-btn--density-compact.v-btn--size-default {
|
||||
.v-btn__content > svg {
|
||||
block-size: 22px;
|
||||
font-size: 22px;
|
||||
inline-size: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Card
|
||||
// Removes padding-top for immediately placed v-card-text after itself
|
||||
.v-card-text {
|
||||
& + & {
|
||||
padding-block-start: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
👉 Checkbox & Radio Ripple
|
||||
|
||||
TODO Checkbox and switch component. Remove it when vuetify resolve the extra spacing: https://github.com/vuetifyjs/vuetify/issues/15519
|
||||
We need this because form elements likes checkbox and switches are by default set to height of textfield height which is way big than we want
|
||||
Tested with checkbox & switches
|
||||
*/
|
||||
.v-checkbox.v-input,
|
||||
.v-switch.v-input {
|
||||
--v-input-control-height: auto;
|
||||
|
||||
flex: unset;
|
||||
}
|
||||
|
||||
.v-selection-control--density-comfortable {
|
||||
&.v-checkbox-btn,
|
||||
&.v-radio,
|
||||
&.v-radio-btn {
|
||||
.v-selection-control__wrapper {
|
||||
margin-inline-start: -0.5625rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-selection-control--density-compact {
|
||||
&.v-radio,
|
||||
&.v-radio-btn,
|
||||
&.v-checkbox-btn {
|
||||
.v-selection-control__wrapper {
|
||||
margin-inline-start: -0.3125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-selection-control--density-default {
|
||||
&.v-checkbox-btn,
|
||||
&.v-radio,
|
||||
&.v-radio-btn {
|
||||
.v-selection-control__wrapper {
|
||||
margin-inline-start: -0.6875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-radio-group {
|
||||
.v-selection-control-group {
|
||||
.v-radio:not(:last-child) {
|
||||
margin-inline-end: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
👉 Tabs
|
||||
Disable tab transition
|
||||
|
||||
This is for tabs where we don't have card wrapper to tabs and have multiple cards as tab content.
|
||||
|
||||
This class will disable transition and adds `overflow: unset` on `VWindow` to allow spreading shadow
|
||||
*/
|
||||
.disable-tab-transition {
|
||||
overflow: unset !important;
|
||||
|
||||
.v-window__container {
|
||||
block-size: auto !important;
|
||||
}
|
||||
|
||||
.v-window-item:not(.v-window-item--active) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.v-window__container .v-window-item {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 List
|
||||
.v-list {
|
||||
// Set icons opacity to .87
|
||||
.v-list-item__prepend > .v-icon,
|
||||
.v-list-item__append > .v-icon {
|
||||
opacity: var(--v-high-emphasis-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Card list
|
||||
|
||||
/*
|
||||
ℹ️ Custom class
|
||||
|
||||
Remove list spacing inside card
|
||||
|
||||
This is because card title gets padding of 20px and list item have padding of 16px. Moreover, list container have padding-bottom as well.
|
||||
*/
|
||||
.card-list {
|
||||
--v-card-list-gap: 20px;
|
||||
|
||||
&.v-list {
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
min-block-size: unset;
|
||||
min-block-size: auto !important;
|
||||
padding-block: 0 !important;
|
||||
padding-inline: 0 !important;
|
||||
|
||||
> .v-ripple__container {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-block-end: var(--v-card-list-gap) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-list-item:hover,
|
||||
.v-list-item:focus,
|
||||
.v-list-item:active,
|
||||
.v-list-item.active {
|
||||
> .v-list-item__overlay {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Divider
|
||||
.v-divider {
|
||||
color: rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
// 👉 DataTable
|
||||
.v-data-table {
|
||||
/* stylelint-disable-next-line no-descending-specificity */
|
||||
.v-checkbox-btn .v-selection-control__wrapper {
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
.v-selection-control {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.v-pagination {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 v-field
|
||||
.v-field:hover .v-field__outline {
|
||||
--v-field-border-opacity: var(--v-medium-emphasis-opacity);
|
||||
}
|
||||
|
||||
// 👉 VLabel
|
||||
.v-label {
|
||||
opacity: 1 !important;
|
||||
|
||||
&:not(.v-field-label--floating) {
|
||||
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Overlay
|
||||
.v-overlay__scrim,
|
||||
.v-navigation-drawer__scrim {
|
||||
background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity)) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
// 👉 VMessages
|
||||
.v-messages {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// 👉 Alert close btn
|
||||
.v-alert__close {
|
||||
.v-btn--icon .v-icon {
|
||||
--v-icon-size-multiplier: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Badge icon alignment
|
||||
.v-badge__badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// 👉 Dialog
|
||||
.v-dialog--fullscreen {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
// For dialog card title
|
||||
.v-card-item + .v-card-text {
|
||||
padding-block-start: 0 !important;
|
||||
}
|
||||
|
||||
// 👉 v-slide-group (List of chips)
|
||||
.v-slide-group {
|
||||
.v-slide-group__container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
// Spacing between buttons in v-slide-group
|
||||
.v-slide-group-item:not(:last-child) {
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Expansion Panel
|
||||
.v-expansion-panels {
|
||||
.v-expansion-panel-title {
|
||||
min-block-size: unset !important;
|
||||
padding-block: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 v-textarea
|
||||
.v-textarea {
|
||||
textarea {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Cursor
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
$shadow-key-umbra-opacity-custom: var(--v-shadow-key-umbra-opacity);
|
||||
$shadow-key-penumbra-opacity-custom: var(--v-shadow-key-penumbra-opacity);
|
||||
$shadow-key-ambient-opacity-custom: var(--v-shadow-key-ambient-opacity);
|
||||
/* stylelint-disable-next-line max-line-length */
|
||||
$font-family-custom: 'Inter', 'Noto Sans SC', sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
|
||||
// 👉 Card transition properties
|
||||
$card-transition-property-custom: box-shadow, opacity;
|
||||
|
||||
@forward "vuetify/settings" with (
|
||||
// 👉 General settings
|
||||
$color-pack: false !default,
|
||||
|
||||
// 👉 Shadow opacity
|
||||
$shadow-key-umbra-opacity: $shadow-key-umbra-opacity-custom !default,
|
||||
$shadow-key-penumbra-opacity: $shadow-key-penumbra-opacity-custom !default,
|
||||
$shadow-key-ambient-opacity: $shadow-key-ambient-opacity-custom !default,
|
||||
|
||||
$body-font-family: $font-family-custom !default,
|
||||
$border-radius-root: 6px !default,
|
||||
|
||||
$shadow-key-umbra: (
|
||||
0: (0 0 0 0 var(--v-shadow-key-umbra-opacity)),
|
||||
1: (0 2px 1px -1px var(--v-shadow-key-umbra-opacity)),
|
||||
2: (0 3px 1px -2px var(--v-shadow-key-umbra-opacity)),
|
||||
|
||||
// ℹ️ Modified
|
||||
3: (0 4px 14px -4px var(--v-shadow-key-umbra-opacity)),
|
||||
|
||||
4: (0 2px 4px -1px var(--v-shadow-key-umbra-opacity)),
|
||||
5: (0 3px 5px -1px var(--v-shadow-key-umbra-opacity)),
|
||||
|
||||
// ℹ️ Modified
|
||||
6: (0 4px 5px -2px var(--v-shadow-key-umbra-opacity)),
|
||||
|
||||
7: (0 4px 5px -2px var(--v-shadow-key-umbra-opacity)),
|
||||
8: (0 5px 5px -3px var(--v-shadow-key-umbra-opacity)),
|
||||
9: (0 5px 6px -3px var(--v-shadow-key-umbra-opacity)),
|
||||
10: (0 6px 6px -3px var(--v-shadow-key-umbra-opacity)),
|
||||
11: (0 6px 7px -4px var(--v-shadow-key-umbra-opacity)),
|
||||
12: (0 7px 8px -4px var(--v-shadow-key-umbra-opacity)),
|
||||
13: (0 7px 8px -4px var(--v-shadow-key-umbra-opacity)),
|
||||
14: (0 7px 9px -4px var(--v-shadow-key-umbra-opacity)),
|
||||
15: (0 8px 9px -5px var(--v-shadow-key-umbra-opacity)),
|
||||
16: (0 8px 10px -5px var(--v-shadow-key-umbra-opacity)),
|
||||
17: (0 8px 11px -5px var(--v-shadow-key-umbra-opacity)),
|
||||
18: (0 9px 11px -5px var(--v-shadow-key-umbra-opacity)),
|
||||
19: (0 9px 12px -6px var(--v-shadow-key-umbra-opacity)),
|
||||
20: (0 10px 13px -6px var(--v-shadow-key-umbra-opacity)),
|
||||
21: (0 10px 13px -6px var(--v-shadow-key-umbra-opacity)),
|
||||
22: (0 10px 14px -6px var(--v-shadow-key-umbra-opacity)),
|
||||
23: (0 11px 14px -7px var(--v-shadow-key-umbra-opacity)),
|
||||
24: (0 11px 15px -7px var(--v-shadow-key-umbra-opacity))
|
||||
) !default,
|
||||
|
||||
$shadow-key-penumbra: (
|
||||
0: (0 0 0 0 $shadow-key-penumbra-opacity-custom),
|
||||
1: (0 1px 1px 0 $shadow-key-penumbra-opacity-custom),
|
||||
2: (0 2px 2px 0 $shadow-key-penumbra-opacity-custom),
|
||||
|
||||
// ℹ️ Modified
|
||||
3: (0 4px 8px -4px $shadow-key-penumbra-opacity-custom),
|
||||
|
||||
4: (0 4px 5px 0 $shadow-key-penumbra-opacity-custom),
|
||||
5: (0 5px 8px 0 $shadow-key-penumbra-opacity-custom),
|
||||
|
||||
// ℹ️ Modified
|
||||
6: (0 2px 10px 1px $shadow-key-penumbra-opacity-custom),
|
||||
|
||||
7: (0 7px 10px 1px $shadow-key-penumbra-opacity-custom),
|
||||
8: (0 8px 10px 1px $shadow-key-penumbra-opacity-custom),
|
||||
9: (0 9px 12px 1px $shadow-key-penumbra-opacity-custom),
|
||||
10: (0 10px 14px 1px $shadow-key-penumbra-opacity-custom),
|
||||
11: (0 11px 15px 1px $shadow-key-penumbra-opacity-custom),
|
||||
12: (0 12px 17px 2px $shadow-key-penumbra-opacity-custom),
|
||||
13: (0 13px 19px 2px $shadow-key-penumbra-opacity-custom),
|
||||
14: (0 14px 21px 2px $shadow-key-penumbra-opacity-custom),
|
||||
15: (0 15px 22px 2px $shadow-key-penumbra-opacity-custom),
|
||||
16: (0 16px 24px 2px $shadow-key-penumbra-opacity-custom),
|
||||
17: (0 17px 26px 2px $shadow-key-penumbra-opacity-custom),
|
||||
18: (0 18px 28px 2px $shadow-key-penumbra-opacity-custom),
|
||||
19: (0 19px 29px 2px $shadow-key-penumbra-opacity-custom),
|
||||
20: (0 20px 31px 3px $shadow-key-penumbra-opacity-custom),
|
||||
21: (0 21px 33px 3px $shadow-key-penumbra-opacity-custom),
|
||||
22: (0 22px 35px 3px $shadow-key-penumbra-opacity-custom),
|
||||
23: (0 23px 36px 3px $shadow-key-penumbra-opacity-custom),
|
||||
24: (0 24px 38px 3px $shadow-key-penumbra-opacity-custom)
|
||||
) !default,
|
||||
|
||||
$shadow-key-ambient: (
|
||||
0: (0 0 0 0 $shadow-key-ambient-opacity-custom),
|
||||
1: (0 1px 3px 0 $shadow-key-ambient-opacity-custom),
|
||||
2: (0 1px 5px 0 $shadow-key-ambient-opacity-custom),
|
||||
|
||||
// ℹ️ Modified
|
||||
3: (0 4px 8px -4px $shadow-key-ambient-opacity-custom),
|
||||
|
||||
4: (0 1px 10px 0 $shadow-key-ambient-opacity-custom),
|
||||
5: (0 1px 14px 0 $shadow-key-ambient-opacity-custom),
|
||||
|
||||
// ℹ️ Modified
|
||||
6: (0 2px 16px 1px $shadow-key-ambient-opacity-custom),
|
||||
|
||||
7: (0 2px 16px 1px $shadow-key-ambient-opacity-custom),
|
||||
8: (0 3px 14px 2px $shadow-key-ambient-opacity-custom),
|
||||
9: (0 3px 16px 2px $shadow-key-ambient-opacity-custom),
|
||||
10: (0 4px 18px 3px $shadow-key-ambient-opacity-custom),
|
||||
11: (0 4px 20px 3px $shadow-key-ambient-opacity-custom),
|
||||
12: (0 5px 22px 4px $shadow-key-ambient-opacity-custom),
|
||||
13: (0 5px 24px 4px $shadow-key-ambient-opacity-custom),
|
||||
14: (0 5px 26px 4px $shadow-key-ambient-opacity-custom),
|
||||
15: (0 6px 28px 5px $shadow-key-ambient-opacity-custom),
|
||||
16: (0 6px 30px 5px $shadow-key-ambient-opacity-custom),
|
||||
17: (0 6px 32px 5px $shadow-key-ambient-opacity-custom),
|
||||
18: (0 7px 34px 6px $shadow-key-ambient-opacity-custom),
|
||||
19: (0 7px 36px 6px $shadow-key-ambient-opacity-custom),
|
||||
20: (0 8px 38px 7px $shadow-key-ambient-opacity-custom),
|
||||
21: (0 8px 40px 7px $shadow-key-ambient-opacity-custom),
|
||||
22: (0 8px 42px 7px $shadow-key-ambient-opacity-custom),
|
||||
23: (0 9px 44px 8px $shadow-key-ambient-opacity-custom),
|
||||
24: (0 9px 46px 8px $shadow-key-ambient-opacity-custom)
|
||||
) !default,
|
||||
|
||||
// 👉 Card
|
||||
$card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
|
||||
$card-elevation: 6 !default,
|
||||
$card-title-line-height: 2rem !default,
|
||||
$card-actions-min-height: unset !default,
|
||||
$card-text-padding: 1.25rem !default,
|
||||
$card-item-padding: 1.25rem !default,
|
||||
$card-actions-padding: 0 12px 12px !default,
|
||||
$card-transition-property: $card-transition-property-custom !default,
|
||||
$card-subtitle-opacity: 1 !default,
|
||||
$card-title-letter-spacing: 0.0094rem !default,
|
||||
|
||||
// 👉 Typography
|
||||
$typography: (
|
||||
"h1": (
|
||||
"weight": 500,
|
||||
"line-height": 7rem,
|
||||
"letter-spacing": -0.0938rem
|
||||
),
|
||||
"h2": (
|
||||
"weight": 500,
|
||||
"line-height": 4.5rem,
|
||||
"letter-spacing": -0.0313rem
|
||||
),
|
||||
"h3": (
|
||||
"weight": 500,
|
||||
"line-height": 3.5rem
|
||||
),
|
||||
"h4": (
|
||||
"weight": 500,
|
||||
"line-height": 2.625rem,
|
||||
"letter-spacing": 0.0156rem
|
||||
),
|
||||
"h5": (
|
||||
"weight": 500,
|
||||
"line-height": 2rem
|
||||
),
|
||||
"h6": (
|
||||
"letter-spacing": 0.0094rem
|
||||
),
|
||||
"subtitle-1": (
|
||||
"letter-spacing": 0.0094rem
|
||||
),
|
||||
"subtitle-2": (
|
||||
"line-height": 1.375rem,
|
||||
"letter-spacing": 0.0063rem,
|
||||
),
|
||||
"body-1": (
|
||||
"letter-spacing": 0.0094rem,
|
||||
),
|
||||
"body-2": (
|
||||
"letter-spacing": 0.0094rem,
|
||||
),
|
||||
"caption": (
|
||||
"letter-spacing": 0.025rem,
|
||||
),
|
||||
"overline": (
|
||||
"weight": 400,
|
||||
"line-height": 1.125rem,
|
||||
"letter-spacing": 0.0625rem,
|
||||
)
|
||||
) !default,
|
||||
|
||||
// 👉 List
|
||||
$list-item-icon-margin-end: 16px !default,
|
||||
$list-item-icon-margin-start: 16px !default,
|
||||
$list-item-subtitle-opacity: 1 !default,
|
||||
$list-subheader-text-opacity: 1 !default,
|
||||
|
||||
// 👉 Tooltip
|
||||
$tooltip-background-color: #212121 !default,
|
||||
$tooltip-text-color: rgb(var(--v-theme-on-primary)) !default,
|
||||
$tooltip-font-size: 0.75rem !default,
|
||||
$tooltip-border-radius: 4px !default,
|
||||
$tooltip-padding: 4px 8px !default,
|
||||
|
||||
// 👉 Alert
|
||||
$alert-title-font-size: 1rem !default,
|
||||
$alert-border-radius: 5px !default,
|
||||
$alert-title-letter-spacing: 0.15px !default,
|
||||
|
||||
// 👉 Badge
|
||||
$badge-border-color:rgb(var(--v-theme-surface)) !default,
|
||||
$badge-dot-height: 0.5rem !default,
|
||||
$badge-dot-width: 0.5rem !default,
|
||||
|
||||
// 👉 Button
|
||||
$button-height: 38px !default,
|
||||
$button-elevation: ("default": 3, "hover": 4, "active": 8) !default,
|
||||
$button-border-radius: 5px !default,
|
||||
$button-padding-ratio: 1.7 !default,
|
||||
$button-text-letter-spacing: 0.025rem !default,
|
||||
$button-icon-density: ("default": 0.5, "comfortable": -2, "compact": -3) !default,
|
||||
|
||||
// 👉 Dialog
|
||||
$dialog-card-header-padding: 20px !default,
|
||||
$dialog-card-header-text-padding-top: 0 !default,
|
||||
$dialog-card-text-padding: 20px !default,
|
||||
|
||||
// 👉 Chip
|
||||
$chip-label-border-radius: 4px !default,
|
||||
$chip-close-size: 20px !default,
|
||||
|
||||
// 👉 Expansion panel
|
||||
$expansion-panel-title-padding: 16px 20px !default,
|
||||
$expansion-panel-title-font-size: 1rem !default,
|
||||
$expansion-panel-disabled-overlay: 0 !default,
|
||||
$expansion-panel-active-title-min-height: 51px !default,
|
||||
$expansion-panel-title-min-height: 51px !default,
|
||||
$expansion-panel-text-padding: 0 20px 20px !default,
|
||||
|
||||
// 👉 Menu
|
||||
$menu-content-border-radius: 5px !default,
|
||||
|
||||
// 👉 Snackbar
|
||||
$snackbar-background:#212121 !default,
|
||||
$snackbar-border-radius: 4px !default,
|
||||
$snackbar-color: rgb(var(--v-theme-on-primary)) !default,
|
||||
|
||||
// 👉 Tabs
|
||||
$tabs-height: 40px !default,
|
||||
|
||||
// 👉 Slider
|
||||
$slider-track-active-size: 4px !default,
|
||||
$slider-thumb-label-padding: 4px 12px !default,
|
||||
$slider-thumb-label-font-size: 0.875rem !default,
|
||||
|
||||
// 👉 Timeline
|
||||
$timeline-dot-size: 34px !default,
|
||||
$timeline-dot-divider-background: transparent !default,
|
||||
|
||||
// 👉 Overlay
|
||||
$overlay-opacity: 0.5 !default,
|
||||
|
||||
// 👉 Navigation Drawer
|
||||
$navigation-drawer-scrim-opacity:0.5 !default,
|
||||
|
||||
// 👉 Table
|
||||
$table-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)),
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
@use "variables";
|
||||
@use "overrides";
|
||||
@@ -1,25 +0,0 @@
|
||||
.layout-blank {
|
||||
.misc-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
overflow: hidden;
|
||||
|
||||
.misc-footer-img {
|
||||
position: absolute;
|
||||
inline-size: 100%;
|
||||
inset-block-end: 0;
|
||||
}
|
||||
|
||||
.misc-footer-tree {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.misc-avatar {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
.layout-blank {
|
||||
.auth-wrapper {
|
||||
min-block-size: calc(var(--vh, 1vh) * 100);
|
||||
|
||||
.auth-footer-mask {
|
||||
position: absolute;
|
||||
inset-block-end: 0;
|
||||
min-inline-size: 100%;
|
||||
}
|
||||
|
||||
.auth-footer-start-tree,
|
||||
.auth-footer-end-tree {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-footer-start-tree {
|
||||
inset-block-end: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
.auth-footer-end-tree {
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.auth-illustration {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.skin--bordered {
|
||||
.auth-card-v2 {
|
||||
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset-block-start: 2rem;
|
||||
inset-inline-start: 2.3rem;
|
||||
}
|
||||
|
||||
.auth-card-v2 {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
@use "@configured-variables" as variables;
|
||||
@use "misc";
|
||||
@use "../mixins";
|
||||
|
||||
%default-layout-vertical-nav-scrolled-sticky-elevated-nav {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
%default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled {
|
||||
// @include mixins.elevation(variables.$vertical-nav-navbar-elevation);
|
||||
|
||||
// If navbar is contained => Squeeze navbar content on scroll
|
||||
@if variables.$layout-vertical-nav-navbar-is-contained {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
%default-layout-vertical-nav-floating-navbar-overlay {
|
||||
isolation: isolate;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
/* stylelint-disable property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
/* stylelint-enable */
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(var(--v-theme-background), 70%) 44%,
|
||||
rgba(var(--v-theme-background), 43%) 73%,
|
||||
rgba(var(--v-theme-background), 0%)
|
||||
);
|
||||
background-repeat: repeat;
|
||||
block-size: calc(variables.$layout-vertical-nav-navbar-height + variables.$vertical-nav-floating-navbar-top + 0.5rem);
|
||||
content: "";
|
||||
inset-block-start: -(variables.$vertical-nav-floating-navbar-top);
|
||||
inset-inline: 0;
|
||||
/* stylelint-disable property-no-vendor-prefix */
|
||||
-webkit-mask: linear-gradient(black, black 18%, transparent 100%);
|
||||
mask: linear-gradient(black, black 18%, transparent 100%);
|
||||
/* stylelint-enable */
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
%layout-navbar {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@forward "nav";
|
||||
@forward "vertical-nav";
|
||||
@forward "default-layout";
|
||||
@forward "default-layout-vertical-nav";
|
||||
@forward "misc";
|
||||
@@ -1,73 +0,0 @@
|
||||
%blurry-bg {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
|
||||
.v-theme--light & {
|
||||
border-radius: 0 0 16px 16px;
|
||||
backdrop-filter: blur(16px);
|
||||
background: rgba(var(--v-theme-surface), 0.6);
|
||||
box-shadow: 0 0 8px 0 rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
block-size: calc(env(safe-area-inset-top, 0px) + 5rem);
|
||||
content: "";
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
|
||||
.v-theme--dark & {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-background), 1) 0%,
|
||||
rgba(var(--v-theme-background), 0.8) 20%,
|
||||
rgba(var(--v-theme-background), 0.6) 40%,
|
||||
rgba(var(--v-theme-background), 0.4) 60%,
|
||||
rgba(var(--v-theme-background), 0.2) 80%,
|
||||
rgba(var(--v-theme-background), 0.0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.v-theme--purple & {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-background), 1) 0%,
|
||||
rgba(var(--v-theme-background), 0.8) 20%,
|
||||
rgba(var(--v-theme-background), 0.6) 40%,
|
||||
rgba(var(--v-theme-background), 0.4) 60%,
|
||||
rgba(var(--v-theme-background), 0.2) 80%,
|
||||
rgba(var(--v-theme-background), 0.0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.v-theme--transparent & {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-background), 0.5) 0%,
|
||||
rgba(var(--v-theme-background), 0.4) 20%,
|
||||
rgba(var(--v-theme-background), 0.3) 40%,
|
||||
rgba(var(--v-theme-background), 0.2) 60%,
|
||||
rgba(var(--v-theme-background), 0.1) 80%,
|
||||
rgba(var(--v-theme-background), 0.0) 100%
|
||||
);
|
||||
|
||||
@media (width <= 640px) {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-background), 0.9) 0%,
|
||||
rgba(var(--v-theme-background), 0.7) 20%,
|
||||
rgba(var(--v-theme-background), 0.5) 40%,
|
||||
rgba(var(--v-theme-background), 0.3) 60%,
|
||||
rgba(var(--v-theme-background), 0.1) 80%,
|
||||
rgba(var(--v-theme-background), 0.0) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
%nav-link-active {
|
||||
background:
|
||||
linear-gradient(
|
||||
-72.47deg,
|
||||
rgb(var(--v-theme-primary)) 22.16%,
|
||||
rgba(var(--v-theme-primary), 0.7) 76.47%
|
||||
) !important;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
@use "@configured-variables" as variables;
|
||||
|
||||
// ℹ️ Add divider around section title
|
||||
%vertical-nav-section-title {
|
||||
/*
|
||||
ℹ️ We will use this to add gap between divider and text.
|
||||
Moreover, we will use this to adjust the `flex-basis` property of left divider
|
||||
*/
|
||||
$divider-gap: 0.625rem;
|
||||
|
||||
// Thanks: https://stackoverflow.com/a/62359101/10796681
|
||||
.title-text {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
column-gap: $divider-gap;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
content: "";
|
||||
}
|
||||
|
||||
&::after {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&::before {
|
||||
flex: 0 1 calc(variables.$vertical-nav-horizontal-padding-start - $divider-gap);
|
||||
margin-inline-start: -#{variables.$vertical-nav-horizontal-padding-start};
|
||||
}
|
||||
}
|
||||
|
||||
// ℹ️ Update the margin-inline-end when vertical nav is in mini state. We done same for link & group.
|
||||
@at-root {
|
||||
.layout-nav-type-vertical.layout-vertical-nav-collapsed .layout-vertical-nav:not(.hovered) .nav-section-title {
|
||||
margin-inline: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
%vertical-nav-item-interactive {
|
||||
// Add pill shape styles
|
||||
block-size: 2.625rem !important;
|
||||
border-end-end-radius: 3.125rem !important;
|
||||
border-end-start-radius: 0 !important;
|
||||
border-start-end-radius: 3.125rem !important;
|
||||
border-start-start-radius: 0 !important;
|
||||
}
|
||||
|
||||
%vertical-nav-item-interactive {
|
||||
// ℹ️ Wobble effect
|
||||
// transition: margin-inline 0.4s ease-in-out;
|
||||
// will-change: margin-inline;
|
||||
|
||||
transition: margin-inline 0.15s ease-in-out;
|
||||
will-change: margin-inline;
|
||||
|
||||
// Reduce margin inline end when vertical nav is in collapsed mode and not hovered
|
||||
.layout-nav-type-vertical.layout-vertical-nav-collapsed .layout-vertical-nav:not(.hovered) & {
|
||||
margin-inline: 0 5px;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,13 @@ export function kFormatter(num: number) {
|
||||
: Math.abs(num).toFixed(0).replace(regex, ',')
|
||||
}
|
||||
|
||||
// 格式化下载量显示,超过1000显示为x.xk格式
|
||||
export function formatDownloadCount(num: number): string {
|
||||
if (!num || num < 1000) return num?.toLocaleString() || '0'
|
||||
|
||||
return `${(num / 1000).toFixed(1)}k`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format and return date in Humanize format
|
||||
* Intl docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format
|
||||
|
||||
@@ -2,8 +2,7 @@ import ColorThief from 'colorthief'
|
||||
|
||||
// 将 RGB 转换为十六进制
|
||||
function rgbStringToHex(rgbArray: number[]): string {
|
||||
if (rgbArray.length !== 3 || rgbArray.some(isNaN))
|
||||
throw new Error('Invalid RGB string format')
|
||||
if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format')
|
||||
|
||||
const [r, g, b] = rgbArray
|
||||
|
||||
@@ -21,3 +20,27 @@ export async function getDominantColor(image: HTMLImageElement): Promise<string>
|
||||
const dominantColor = colorThief.getColor(image)
|
||||
return rgbStringToHex(dominantColor)
|
||||
}
|
||||
|
||||
// 预加载图片
|
||||
export async function preloadImage(url: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => resolve(true)
|
||||
img.onerror = () => resolve(false)
|
||||
|
||||
// 设置超时,防止图片长时间加载
|
||||
const timeout = setTimeout(() => {
|
||||
img.src = ''
|
||||
resolve(false)
|
||||
}, 5000) // 5秒超时
|
||||
|
||||
img.src = url
|
||||
|
||||
// 如果图片已经缓存,onload可能不会触发
|
||||
if (img.complete) {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,3 +65,6 @@ export function getQueryValue(key: string, url = window.location.href): string {
|
||||
const res = reg.exec(url)
|
||||
return res ? res[1] : ''
|
||||
}
|
||||
|
||||
// 导出 navigator 相关函数
|
||||
export { isMobileDevice, isIOSDevice, isAndroidDevice } from './navigator'
|
||||
|
||||
@@ -35,6 +35,19 @@ export function urlBase64ToUint8Array(base64String: string) {
|
||||
return outputArray
|
||||
}
|
||||
|
||||
// Uint8Array 转 Base64URL
|
||||
export function bufferToBase64Url(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '')
|
||||
}
|
||||
|
||||
// Base64URL 转 Uint8Array
|
||||
export function base64UrlToUint8Array(base64Url: string): Uint8Array {
|
||||
return Uint8Array.from(atob(base64Url.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
}
|
||||
|
||||
// 判断是否为PWA
|
||||
export const isPWA = async (): Promise<boolean> => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
@@ -43,3 +56,56 @@ export const isPWA = async (): Promise<boolean> => {
|
||||
}
|
||||
return (window.navigator as any).standalone === true
|
||||
}
|
||||
|
||||
// 同步检测PWA显示模式
|
||||
export const isPWADisplayMode = (): boolean => {
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone ||
|
||||
document.referrer.includes('android-app://')
|
||||
)
|
||||
}
|
||||
|
||||
// 全面的PWA检测(推荐使用)
|
||||
export const checkPWAStatus = async () => {
|
||||
const hasServiceWorker = await isPWA()
|
||||
const isStandaloneMode = isPWADisplayMode()
|
||||
|
||||
return {
|
||||
// 是否有PWA功能(Service Worker)
|
||||
hasPWAFeatures: hasServiceWorker,
|
||||
// 是否在独立显示模式下运行
|
||||
isStandaloneMode,
|
||||
// 综合判断:更宽松的检测,在移动设备上默认启用PWA功能
|
||||
isPWAEnvironment: hasServiceWorker || isStandaloneMode || isMobileDevice(),
|
||||
// 完整的PWA体验:既有功能又在独立模式下运行
|
||||
isFullPWA: hasServiceWorker && isStandaloneMode,
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否为移动设备
|
||||
export const isMobileDevice = (): boolean => {
|
||||
// 检查用户代理字符串
|
||||
const userAgent = navigator.userAgent || ''
|
||||
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i
|
||||
|
||||
// 检查触摸屏支持
|
||||
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
|
||||
// 检查屏幕尺寸(小于768px认为是移动设备)
|
||||
const isMobileSize = window.innerWidth < 768
|
||||
|
||||
return mobileRegex.test(userAgent) || hasTouchScreen || isMobileSize
|
||||
}
|
||||
|
||||
// 检测是否为iOS设备
|
||||
export const isIOSDevice = (): boolean => {
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
return /iphone|ipad|ipod/.test(userAgent) && !(window as any).MSStream
|
||||
}
|
||||
|
||||
// 检测是否为Android设备
|
||||
export const isAndroidDevice = (): boolean => {
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
return /android/.test(userAgent)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ const sources: BundleScriptConfig = {
|
||||
// 'mdi:logout',
|
||||
// 'octicon:book-24',
|
||||
// 'octicon:code-square-24',
|
||||
'lucide:sparkles',
|
||||
'material-symbols:passkey',
|
||||
'line-md:loading-twotone-loop',
|
||||
],
|
||||
|
||||
json: [
|
||||
@@ -154,7 +157,13 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
// Sort icons by prefix
|
||||
const organizedList = organizeIconsList(sources.icons)
|
||||
for (const prefix in organizedList) {
|
||||
const filename = require.resolve(`@iconify/json/json/${prefix}.json`)
|
||||
let filename
|
||||
try {
|
||||
filename = require.resolve(`@iconify-json/${prefix}/icons.json`)
|
||||
}
|
||||
catch (err) {
|
||||
filename = require.resolve(`@iconify/json/json/${prefix}.json`)
|
||||
}
|
||||
|
||||
sourcesJSON.push({
|
||||
filename,
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Transition } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup(props, { slots }) {
|
||||
const isOverlayNavActive = ref(false)
|
||||
const isLayoutOverlayVisible = ref(false)
|
||||
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)
|
||||
|
||||
const route = useRoute()
|
||||
const { mdAndDown } = useDisplay()
|
||||
|
||||
// ℹ️ This is alternative to below two commented watcher
|
||||
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
|
||||
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
|
||||
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
VerticalNav,
|
||||
{ isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive },
|
||||
{
|
||||
'nav-header': () => slots['vertical-nav-header']?.(),
|
||||
'before-nav-items': () => slots['before-vertical-nav-items']?.(),
|
||||
'default': () => slots['vertical-nav-content']?.(),
|
||||
'after-nav-items': () => slots['after-vertical-nav-items']?.(),
|
||||
},
|
||||
)
|
||||
|
||||
// 👉 Navbar
|
||||
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
|
||||
h(
|
||||
'div',
|
||||
{ class: 'navbar-content-container' },
|
||||
slots.navbar?.({
|
||||
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
const main = h(
|
||||
'main',
|
||||
{ class: 'layout-page-content' },
|
||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
|
||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
),
|
||||
)
|
||||
|
||||
// 👉 根据路由 meta 决定 footer 高度
|
||||
const shouldShowFooter = !route.meta.hideFooter
|
||||
|
||||
// 👉 Footer
|
||||
const footer = h('footer', { class: 'layout-footer' }, [
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: ['footer-content-container', !shouldShowFooter && 'footer-content-container-noheight'],
|
||||
},
|
||||
slots.footer?.(),
|
||||
),
|
||||
])
|
||||
|
||||
// 👉 Overlay
|
||||
const layoutOverlay = h('div', {
|
||||
class: ['layout-overlay', 'touch-none', { visible: isLayoutOverlayVisible.value }],
|
||||
onClick: () => {
|
||||
isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value
|
||||
},
|
||||
})
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: [
|
||||
'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
|
||||
'layout-navbar-fixed',
|
||||
mdAndDown.value && 'layout-overlay-nav',
|
||||
route.meta.layoutWrapperClasses,
|
||||
scrollDistance.value && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@configured-variables' as variables;
|
||||
@use '@layouts/styles/placeholders';
|
||||
@use '@layouts/styles/mixins';
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
block-size: 100%;
|
||||
|
||||
.layout-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
min-block-size: calc(var(--vh, 1vh) * 100);
|
||||
transition: padding-inline-start 0.2s ease-in-out;
|
||||
will-change: padding-inline-start;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
width: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
|
||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||
inset-block-start: 0;
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
.layout-navbar {
|
||||
@if variables.$layout-vertical-nav-navbar-is-contained {
|
||||
@include mixins.boxed-content;
|
||||
} @else {
|
||||
.navbar-content-container {
|
||||
// @include mixins.boxed-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-navbar-fixed .layout-navbar {
|
||||
@extend %layout-navbar-fixed;
|
||||
}
|
||||
|
||||
&.layout-navbar-hidden .layout-navbar {
|
||||
@extend %layout-navbar-hidden;
|
||||
}
|
||||
|
||||
// 👉 Footer
|
||||
.layout-footer {
|
||||
@include mixins.boxed-content;
|
||||
}
|
||||
|
||||
// 👉 Layout overlay
|
||||
.layout-overlay {
|
||||
position: fixed;
|
||||
z-index: variables.$layout-overlay-z-index;
|
||||
background-color: rgb(0 0 0 / 60%);
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease-in-out;
|
||||
will-change: transform;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.layout-overlay-nav) .layout-content-wrapper {
|
||||
padding-inline-start: variables.$layout-vertical-nav-width;
|
||||
}
|
||||
|
||||
// Adjust right column pl when vertical nav is collapsed
|
||||
&.layout-vertical-nav-collapsed .layout-content-wrapper {
|
||||
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
|
||||
}
|
||||
|
||||
// 👉 Content height fixed
|
||||
&.layout-content-height-fixed {
|
||||
.layout-content-wrapper {
|
||||
max-block-size: calc(var(--vh) * 100);
|
||||
}
|
||||
|
||||
.layout-page-content {
|
||||
// display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.page-content-container {
|
||||
inline-size: 100%;
|
||||
|
||||
> :first-child {
|
||||
max-block-size: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
|
||||
.layout-navbar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -17,11 +17,34 @@ export default defineComponent({
|
||||
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
|
||||
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
const isDialogOpen = ref(false)
|
||||
const wasScrolledBeforeDialog = ref(false)
|
||||
|
||||
// 监听弹窗状态变化
|
||||
const checkDialogState = () => {
|
||||
const wasDialogOpen = isDialogOpen.value
|
||||
isDialogOpen.value = document.documentElement.classList.contains('v-overlay-scroll-blocked')
|
||||
|
||||
// 当弹窗刚打开时,记录当前的滚动状态
|
||||
if (!wasDialogOpen && isDialogOpen.value) {
|
||||
wasScrolledBeforeDialog.value = scrollDistance.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
})
|
||||
|
||||
// 初始检查弹窗状态
|
||||
checkDialogState()
|
||||
|
||||
// 监听 DOM 变化以检测弹窗状态
|
||||
const observer = new MutationObserver(checkDialogState)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -38,15 +61,25 @@ export default defineComponent({
|
||||
)
|
||||
|
||||
// 👉 Navbar
|
||||
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
|
||||
h(
|
||||
'div',
|
||||
{ class: 'navbar-content-container' },
|
||||
slots.navbar?.({
|
||||
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
|
||||
}),
|
||||
),
|
||||
])
|
||||
const navbar = h(
|
||||
'header',
|
||||
{ class: ['layout-navbar navbar-blur'] },
|
||||
[
|
||||
h(
|
||||
'div',
|
||||
{ class: 'navbar-content-container' },
|
||||
[
|
||||
slots.navbar?.({
|
||||
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
|
||||
}),
|
||||
// 👉 Dynamic Header Tab in NavBar
|
||||
slots['dynamic-header-tab']?.()
|
||||
? h('div', { class: 'layout-dynamic-header-tab' }, slots['dynamic-header-tab']?.())
|
||||
: null,
|
||||
].filter(Boolean),
|
||||
),
|
||||
].filter(Boolean),
|
||||
)
|
||||
|
||||
const main = h(
|
||||
'main',
|
||||
@@ -86,7 +119,7 @@ export default defineComponent({
|
||||
'layout-navbar-fixed',
|
||||
mdAndDown.value && 'layout-overlay-nav',
|
||||
route.meta.layoutWrapperClasses,
|
||||
scrollDistance.value && 'window-scrolled',
|
||||
(scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
@@ -109,7 +142,7 @@ export default defineComponent({
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
block-size: 100%;
|
||||
min-block-size: 100%;
|
||||
|
||||
.layout-content-wrapper {
|
||||
display: flex;
|
||||
@@ -127,7 +160,9 @@ export default defineComponent({
|
||||
inset-block-start: 0;
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
|
||||
block-size: calc(
|
||||
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
|
||||
);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
@@ -135,10 +170,6 @@ export default defineComponent({
|
||||
.layout-navbar {
|
||||
@if variables.$layout-vertical-nav-navbar-is-contained {
|
||||
@include mixins.boxed-content;
|
||||
} @else {
|
||||
.navbar-content-container {
|
||||
// @include mixins.boxed-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,7 +224,9 @@ export default defineComponent({
|
||||
|
||||
.layout-page-content {
|
||||
// display: flex;
|
||||
overflow: hidden;
|
||||
// 使用 clip 替代 hidden,避免 Chrome 144+ 滚动锁定问题
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
|
||||
.page-content-container {
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -6,13 +6,15 @@
|
||||
|
||||
html {
|
||||
background: rgb(var(--v-theme-background));
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
overflow-y: overlay;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
body {
|
||||
background: rgb(var(--v-theme-background));
|
||||
overscroll-behavior-y: contain;
|
||||
// Chrome 144+ 兼容性:覆盖 Vuetify 的内联 overflow: hidden 样式
|
||||
overflow: visible !important;
|
||||
|
||||
--webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@@ -35,13 +37,15 @@ body,
|
||||
.layout-page-content {
|
||||
@include mixins.boxed-content(true);
|
||||
|
||||
overflow: hidden;
|
||||
// Chrome 144+ 兼容性:使用 clip 替代 hidden,避免滚动锁定问题
|
||||
// overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动
|
||||
overflow: clip;
|
||||
flex-grow: 1;
|
||||
|
||||
// TODO: Use grid gutter variable here;
|
||||
padding-block: 1.5rem;
|
||||
padding-inline: 0.5rem;
|
||||
padding-block-start: calc(env(safe-area-inset-top) + 4.5rem);
|
||||
padding-inline: 0.5rem;
|
||||
|
||||
// display: flex;display
|
||||
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom))
|
||||
min-block-size: 100%;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ValidationRule } from 'vuetify/types/services/validation'
|
||||
type ValidationRule = (value: any) => string | boolean
|
||||
|
||||
// 必输校验
|
||||
export const requiredValidator: ValidationRule = (value: any) => {
|
||||
|
||||
326
src/App.vue
@@ -3,13 +3,19 @@ import { useTheme } from 'vuetify'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
import api from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||
import { SupportedLocale } from '@/types/i18n'
|
||||
import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
||||
import { preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
@@ -17,13 +23,13 @@ globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
const localeValue = getBrowserLocale()
|
||||
setI18nLanguage(localeValue as SupportedLocale)
|
||||
|
||||
// 显示状态
|
||||
const show = ref(false)
|
||||
|
||||
// 检查是否登录
|
||||
const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 生成背景图片key
|
||||
const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'))
|
||||
|
||||
@@ -31,7 +37,9 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
let backgroundRotationTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
@@ -40,169 +48,243 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
if (window.Apex) {
|
||||
// 数据标签
|
||||
window.Apex.dataLabels = {
|
||||
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||
// 如果有小数点,保留两位小数,否则保留整数
|
||||
const data = w.config.series[seriesIndex]
|
||||
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||
},
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
if (heartbeatInterval) {
|
||||
stopHeartbeat()
|
||||
}
|
||||
// 图例
|
||||
window.Apex.legend = {
|
||||
labels: {
|
||||
useSeriesColors: true,
|
||||
},
|
||||
|
||||
// 开始心跳任务
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
try {
|
||||
if (isLogin.value) {
|
||||
await api.get('dashboard/cpu')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatInterval) {
|
||||
window.clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
// 标题
|
||||
window.Apex.title = {
|
||||
style: {
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||
},
|
||||
}
|
||||
|
||||
// 配置 ApexCharts 全局选项
|
||||
function configureApexCharts() {
|
||||
if (typeof window !== 'undefined' && window.Apex) {
|
||||
try {
|
||||
// 获取当前主题
|
||||
const currentTheme = globalTheme.name.value
|
||||
const isDark = currentTheme === 'dark' || currentTheme === 'transparent'
|
||||
|
||||
// 数据标签
|
||||
window.Apex.dataLabels = {
|
||||
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||
// 如果有小数点,保留两位小数,否则保留整数
|
||||
const data = w.config.series[seriesIndex]
|
||||
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||
},
|
||||
}
|
||||
// 图例
|
||||
window.Apex.legend = {
|
||||
labels: {
|
||||
useSeriesColors: true,
|
||||
},
|
||||
}
|
||||
// 标题
|
||||
window.Apex.title = {
|
||||
style: {
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||
},
|
||||
}
|
||||
// 鼠标悬浮提示
|
||||
window.Apex.tooltip = {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ApexCharts 全局配置失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新data-theme属性以便CSS选择器能正确匹配
|
||||
function updateHtmlThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
// 确保body元素也有相同的主题属性,以便更好地选择弹出窗口
|
||||
document.body.setAttribute('data-theme', themeName)
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImages() {
|
||||
try {
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`)
|
||||
const controller = new AbortController()
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
activeImageIndex.value = 0
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// 背景图片轮换函数
|
||||
function rotateBackgroundImage() {
|
||||
if (backgroundImages.value.length > 1) {
|
||||
// 计算下一个图片索引
|
||||
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
// 预加载下一张图片
|
||||
preloadImage(backgroundImages.value[nextIndex]).then(success => {
|
||||
// 只有图片成功加载才切换
|
||||
if (success) {
|
||||
activeImageIndex.value = nextIndex
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 开始背景图片轮换
|
||||
function startBackgroundRotation() {
|
||||
// 清除轮换定时器
|
||||
if (backgroundRotationTimer) clearInterval(backgroundRotationTimer)
|
||||
// 清除现有定时器
|
||||
removeBackgroundTimer('background-rotation')
|
||||
|
||||
if (backgroundImages.value.length > 1) {
|
||||
backgroundRotationTimer = setInterval(() => {
|
||||
// 计算下一个图片索引
|
||||
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
// 预加载下一张图片
|
||||
preloadImage(backgroundImages.value[nextIndex]).then(success => {
|
||||
// 只有图片成功加载才切换
|
||||
if (success) {
|
||||
activeImageIndex.value = nextIndex
|
||||
}
|
||||
})
|
||||
}, 10000) // 每10秒切换一次
|
||||
// 使用优化的定时器管理器,后台时自动暂停
|
||||
addBackgroundTimer(
|
||||
'background-rotation',
|
||||
rotateBackgroundImage,
|
||||
10000, // 每10秒切换一次
|
||||
{
|
||||
runInBackground: false, // 后台时不运行
|
||||
skipInitialRun: true, // 不需要立即执行
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载图片
|
||||
function preloadImage(url: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => resolve(true)
|
||||
img.onerror = () => resolve(false)
|
||||
|
||||
// 设置超时,防止图片长时间加载
|
||||
const timeout = setTimeout(() => {
|
||||
img.src = ''
|
||||
resolve(false)
|
||||
}, 5000) // 5秒超时
|
||||
|
||||
img.src = url
|
||||
|
||||
// 如果图片已经缓存,onload可能不会触发
|
||||
if (img.complete) {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 添加logo动画效果并延迟移除加载界面
|
||||
function animateAndRemoveLoader() {
|
||||
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
||||
if (loadingBg) {
|
||||
// 先添加完成动画类
|
||||
loadingBg.classList.add('loading-complete')
|
||||
removeEl('#loading-bg')
|
||||
document.documentElement.style.removeProperty('background')
|
||||
}
|
||||
}
|
||||
|
||||
// 等待动画完成后再移除元素
|
||||
setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
// 将background属性从html的style中移除
|
||||
document.documentElement.style.removeProperty('background')
|
||||
// 显示页面
|
||||
show.value = true
|
||||
}, 500) // 与CSS动画持续时间匹配
|
||||
// 检查PWA状态并移除加载界面
|
||||
async function removeLoadingWithStateCheck() {
|
||||
try {
|
||||
// 设置各个组件的加载状态
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', true)
|
||||
globalLoadingStateManager.setLoadingState('global-settings', true)
|
||||
globalLoadingStateManager.setLoadingState('background-images', true)
|
||||
|
||||
// 静默检查PWA状态恢复
|
||||
const pwaController = (window as any).pwaStateController
|
||||
if (pwaController) {
|
||||
await pwaController.waitForStateRestore()
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
// 并行加载关键资源
|
||||
await Promise.all([
|
||||
globalSettingsStore.initialize().then(async () => {
|
||||
// 如果已登录,加载用户相关设置
|
||||
if (isLogin.value) {
|
||||
await globalSettingsStore.loadUserSettings()
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
globalLoadingStateManager.setLoadingState('background-images', false)
|
||||
resolve(void 0)
|
||||
}, 50)
|
||||
}),
|
||||
])
|
||||
|
||||
// 等待所有加载完成
|
||||
await globalLoadingStateManager.waitForAllComplete()
|
||||
|
||||
// 移除加载界面
|
||||
animateAndRemoveLoader()
|
||||
|
||||
// 检查未读消息
|
||||
checkAndEmitUnreadMessages()
|
||||
} catch (error) {
|
||||
// 即使出错也要移除加载界面
|
||||
globalLoadingStateManager.reset()
|
||||
animateAndRemoveLoader()
|
||||
}
|
||||
}
|
||||
|
||||
// 加载背景图片
|
||||
async function loadBackgroundImages() {
|
||||
await fetchBackgroundImages()
|
||||
.then(() => {
|
||||
startBackgroundRotation()
|
||||
})
|
||||
.catch(() => {
|
||||
// 3秒后重试
|
||||
async function loadBackgroundImages(retryCount = 0) {
|
||||
const maxRetries = 3
|
||||
try {
|
||||
await fetchBackgroundImages()
|
||||
startBackgroundRotation()
|
||||
} catch (error: any) {
|
||||
const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED'
|
||||
if (retryCount < maxRetries) {
|
||||
const baseDelay = isAbortError ? 1000 : 3000
|
||||
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
|
||||
setTimeout(() => {
|
||||
loadBackgroundImages()
|
||||
}, 3000)
|
||||
})
|
||||
loadBackgroundImages(retryCount + 1)
|
||||
}, retryDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 移除URL中的时间戳参数
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.has('_t')) {
|
||||
url.searchParams.delete('_t')
|
||||
const newUrl = url.pathname + url.search + url.hash
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
}
|
||||
|
||||
// 配置 ApexCharts
|
||||
configureApexCharts()
|
||||
|
||||
// 初始化data-theme属性
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
|
||||
// 默认隐藏页面
|
||||
show.value = false
|
||||
// 初始化主题管理器 - 统一处理主题初始化
|
||||
await themeManager.setTheme(themeValue)
|
||||
|
||||
// 监听主题变化
|
||||
watch(
|
||||
() => globalTheme.name.value,
|
||||
newTheme => {
|
||||
// 更新HTML主题属性
|
||||
updateHtmlThemeAttribute(newTheme)
|
||||
// 重新配置ApexCharts以适应新主题
|
||||
configureApexCharts()
|
||||
},
|
||||
)
|
||||
|
||||
// 加载背景图片
|
||||
await loadBackgroundImages()
|
||||
loadBackgroundImages()
|
||||
|
||||
// 移除加载动画
|
||||
// 使用优化后的加载界面移除逻辑
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
// 移除加载动画,显示页面
|
||||
animateAndRemoveLoader()
|
||||
}, 1500)
|
||||
})
|
||||
})
|
||||
|
||||
// 添加页面可见性变化监听
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadBackgroundImages()
|
||||
}
|
||||
})
|
||||
|
||||
// 添加PWA的页面恢复事件监听
|
||||
window.addEventListener('pageshow', event => {
|
||||
// persisted属性为true表示页面是从bfcache中恢复的
|
||||
if (event.persisted) {
|
||||
loadBackgroundImages()
|
||||
}
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
})
|
||||
// 启动心跳
|
||||
startHeartbeat()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 移除页面可见性监听
|
||||
document.removeEventListener('visibilitychange', () => {})
|
||||
// 移除PWA的页面恢复事件监听
|
||||
window.removeEventListener('pageshow', () => {})
|
||||
|
||||
// 清除轮换定时器
|
||||
if (backgroundRotationTimer) {
|
||||
clearInterval(backgroundRotationTimer)
|
||||
backgroundRotationTimer = null
|
||||
}
|
||||
// 清除背景轮换定时器
|
||||
removeBackgroundTimer('background-rotation')
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -216,13 +298,15 @@ onUnmounted(() => {
|
||||
class="background-image"
|
||||
:class="{ 'active': index === activeImageIndex }"
|
||||
:style="{ 'backgroundImage': `url(${imageUrl})` }"
|
||||
></div>
|
||||
/>
|
||||
<!-- 全局磨砂层 -->
|
||||
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
||||
</div>
|
||||
<!-- 页面内容 -->
|
||||
<VApp v-show="show" :class="{ 'transparent-app': isTransparentTheme }">
|
||||
<VApp>
|
||||
<RouterView />
|
||||
<!-- PWA安装提示 -->
|
||||
<PWAInstallPrompt />
|
||||
</VApp>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,6 +44,488 @@ import snippertsIniUrl from 'ace-builds/src-noconflict/snippets/ini?url'
|
||||
|
||||
import 'ace-builds/src-noconflict/ext-language_tools'
|
||||
|
||||
const aceModule = ace as typeof ace & {
|
||||
define?: (moduleName: string, deps: string[], payload: (...args: any[]) => void) => void
|
||||
}
|
||||
|
||||
function registerJinja2Mode() {
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_highlight_rules',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const Jinja2HighlightRules = function (this: any) {
|
||||
const tags =
|
||||
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
|
||||
const filters =
|
||||
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
|
||||
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
|
||||
const tests =
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
'keyword.control.jinja2': tags,
|
||||
'keyword.operator.jinja2': operators,
|
||||
'support.function.jinja2': [filters, functions, tests].join('|'),
|
||||
'constant.language.jinja2': 'false|False|none|None|null|true|True',
|
||||
},
|
||||
'identifier',
|
||||
)
|
||||
|
||||
const jinjaExpressionRules = [
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
push: 'jinja2-qstring',
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
push: 'jinja2-qqstring',
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\|)(\\s*)(${filters})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['support.function.jinja2', 'text', 'paren.lparen'],
|
||||
regex: `\\b(${functions})(\\s*)(\\()`,
|
||||
},
|
||||
{
|
||||
token: 'variable.language.jinja2',
|
||||
regex: `\\b(?:${contextVariables})\\b`,
|
||||
},
|
||||
{
|
||||
token: keywordMapper,
|
||||
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.assignment.jinja2',
|
||||
regex: /=|~/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.comparison.jinja2',
|
||||
regex: /==|!=|<=|>=|<|>/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.arithmetic.jinja2',
|
||||
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.other.jinja2',
|
||||
regex: /\.{2}|\||:/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator.jinja2',
|
||||
regex: /[.,;?]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[\[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
]
|
||||
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /\{#-?/,
|
||||
push: 'jinja2-comment',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /\{\{-?/,
|
||||
push: 'jinja2-expression',
|
||||
},
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /\{%-?/,
|
||||
push: 'jinja2-statement',
|
||||
},
|
||||
],
|
||||
'jinja2-comment': [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /-?#\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment.block.jinja2',
|
||||
},
|
||||
],
|
||||
'jinja2-expression': [
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /-?\}\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaExpressionRules,
|
||||
],
|
||||
'jinja2-statement': [
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /-?%\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaExpressionRules,
|
||||
],
|
||||
'jinja2-qqstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\"ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
'jinja2-qstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\'ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(Jinja2HighlightRules, TextHighlightRules)
|
||||
exports.Jinja2HighlightRules = Jinja2HighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const Jinja2HighlightRules = require('./jinja2_highlight_rules').Jinja2HighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = Jinja2HighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.$id = 'ace/mode/jinja2'
|
||||
this.blockComment = { start: '{#', end: '#}' }
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.('ace/snippets/jinja2', ['require', 'exports', 'module'], (_require: any, exports: any) => {
|
||||
exports.snippetText =
|
||||
'snippet if\n\t{% if ${1:condition} %}\n\t\t${0}\n\t{% endif %}\n' +
|
||||
'snippet for\n\t{% for ${1:item} in ${2:items} %}\n\t\t${0}\n\t{% endfor %}\n' +
|
||||
'snippet var\n\t{{ ${1:name} }}\n'
|
||||
exports.scope = 'jinja2'
|
||||
})
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_json_highlight_rules',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const Jinja2JsonHighlightRules = function (this: any) {
|
||||
const tags =
|
||||
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
|
||||
const filters =
|
||||
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
|
||||
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
|
||||
const tests =
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
'keyword.control.jinja2': tags,
|
||||
'keyword.operator.jinja2': operators,
|
||||
'support.function.jinja2': [filters, functions, tests].join('|'),
|
||||
'constant.language.jinja2': 'false|False|none|None|null|true|True',
|
||||
},
|
||||
'identifier',
|
||||
)
|
||||
|
||||
const jinjaRules = [
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
push: 'jinja2-json-qstring',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\|)(\\s*)(${filters})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['support.function.jinja2', 'text', 'paren.lparen'],
|
||||
regex: `\\b(${functions})(\\s*)(\\()`,
|
||||
},
|
||||
{
|
||||
token: 'variable.language.jinja2',
|
||||
regex: `\\b(?:${contextVariables})\\b`,
|
||||
},
|
||||
{
|
||||
token: keywordMapper,
|
||||
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.assignment.jinja2',
|
||||
regex: /=|~/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.comparison.jinja2',
|
||||
regex: /==|!=|<=|>=|<|>/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.arithmetic.jinja2',
|
||||
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.other.jinja2',
|
||||
regex: /\.{2}|\||:/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator.jinja2',
|
||||
regex: /[.,;?]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[\[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
]
|
||||
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'variable',
|
||||
regex: /"(?:(?:\\.)|(?:[^"\\]))*?"\s*(?=:)/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
push: 'json-string',
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /0[xX][0-9a-fA-F]+\b/,
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?\d+(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b/,
|
||||
},
|
||||
{
|
||||
token: 'constant.language.boolean',
|
||||
regex: /(?:true|false|null)\b/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /['](?:(?:\\.)|(?:[^'\\]))*?[']/,
|
||||
},
|
||||
{
|
||||
token: 'comment',
|
||||
regex: /\/\/.*$/,
|
||||
},
|
||||
{
|
||||
token: 'comment.start',
|
||||
regex: /\/\*/,
|
||||
push: 'comment',
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator',
|
||||
regex: /[:,]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
],
|
||||
'json-string': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
|
||||
},
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /\{#-?/,
|
||||
push: 'jinja2-json-comment',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /\{\{-?/,
|
||||
push: 'jinja2-json-expression',
|
||||
},
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /\{%-?/,
|
||||
push: 'jinja2-json-statement',
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: /"|$/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
comment: [
|
||||
{
|
||||
token: 'comment.end',
|
||||
regex: /\*\//,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment',
|
||||
},
|
||||
],
|
||||
'jinja2-json-comment': [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /-?#\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment.block.jinja2',
|
||||
},
|
||||
],
|
||||
'jinja2-json-expression': [
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /-?\}\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaRules,
|
||||
],
|
||||
'jinja2-json-statement': [
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /-?%\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaRules,
|
||||
],
|
||||
'jinja2-json-qstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\'ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(Jinja2JsonHighlightRules, TextHighlightRules)
|
||||
exports.Jinja2JsonHighlightRules = Jinja2JsonHighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_json',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_json_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const Jinja2JsonHighlightRules = require('./jinja2_json_highlight_rules').Jinja2JsonHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = Jinja2JsonHighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.lineCommentStart = '//'
|
||||
this.blockComment = { start: '/*', end: '*/' }
|
||||
this.$id = 'ace/mode/jinja2_json'
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
|
||||
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
|
||||
@@ -61,9 +543,10 @@ ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
|
||||
ace.config.setModuleUrl('ace/mode/css_worker', workerCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/yaml', snippetsYamlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
|
||||
|
||||
registerJinja2Mode()
|
||||
ace.require('ace/ext/language_tools')
|
||||
|
||||
@@ -26,6 +26,11 @@ export const storageAttributes = [
|
||||
icon: 'mdi-server-network-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
type: 'smb',
|
||||
icon: 'mdi-folder-network-outline',
|
||||
remote: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const storageIconDict = storageAttributes.reduce((dict, item) => {
|
||||
@@ -47,6 +52,10 @@ export const downloaderOptions = [
|
||||
value: 'transmission',
|
||||
title: i18n.global.t('setting.system.transmission'),
|
||||
},
|
||||
{
|
||||
value: 'rtorrent',
|
||||
title: i18n.global.t('setting.system.rtorrent'),
|
||||
},
|
||||
]
|
||||
|
||||
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
|
||||
@@ -71,6 +80,10 @@ export const mediaServerOptions = [
|
||||
value: 'trimemedia',
|
||||
title: i18n.global.t('setting.system.trimeMedia'),
|
||||
},
|
||||
{
|
||||
value: 'ugreen',
|
||||
title: i18n.global.t('setting.system.ugreen'),
|
||||
},
|
||||
]
|
||||
|
||||
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
|
||||
@@ -269,6 +282,10 @@ export const notificationSwitchOptions = [
|
||||
title: i18n.global.t('notificationSwitch.plugin'),
|
||||
value: '插件',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('notificationSwitch.agent'),
|
||||
value: '智能体',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('notificationSwitch.other'),
|
||||
value: '其它',
|
||||
@@ -335,6 +352,14 @@ export const actionStepOptions = [
|
||||
title: i18n.global.t('actionStep.transferFile'),
|
||||
value: '整理文件',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('actionStep.invokePlugin'),
|
||||
value: '调用插件',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('actionStep.note'),
|
||||
value: '备注',
|
||||
},
|
||||
]
|
||||
|
||||
// 操作步骤字典
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import axios from 'axios'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
@@ -17,6 +19,9 @@ declare global {
|
||||
// 将 API 实例暴露到全局,供插件使用
|
||||
window.MoviePilotAPI = api
|
||||
|
||||
// 初始化请求优化器(必须在其他拦截器之前)
|
||||
initializeRequestOptimizer(api)
|
||||
|
||||
// 添加请求拦截器
|
||||
api.interceptors.request.use(config => {
|
||||
// 认证 Store
|
||||
@@ -28,15 +33,47 @@ api.interceptors.request.use(config => {
|
||||
return config
|
||||
})
|
||||
|
||||
// 离线状态管理
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
|
||||
// 添加响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 成功响应时,清除应用离线状态并重置连续错误计数
|
||||
globalOfflineStatus.setAppOffline(false)
|
||||
globalOfflineStatus.resetConsecutiveErrors()
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
if (!error.response) {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error(error))
|
||||
// 网络错误或请求超时 - 通知离线状态管理系统
|
||||
const isNetworkError =
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
error.code === 'ERR_NETWORK' ||
|
||||
error.code === 'ECONNABORTED' ||
|
||||
error.name === 'NetworkError'
|
||||
|
||||
if (isNetworkError) {
|
||||
let reason = 'Network connection failed'
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
reason = 'Request timeout'
|
||||
}
|
||||
// 记录网络错误,只有连续三次才会设置为离线模式
|
||||
globalOfflineStatus.recordNetworkError(reason)
|
||||
}
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
|
||||
// 网络连接问题
|
||||
return Promise.reject(new Error('Network connection failed, please check your network status'))
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error('Request timeout, please try again later'))
|
||||
} else if (error.name === 'AbortError') {
|
||||
// 请求被中止(路由切换等)
|
||||
return Promise.reject(new Error('Request cancelled'))
|
||||
}
|
||||
// 其他网络错误
|
||||
return Promise.reject(new Error(error.message || 'Network error'))
|
||||
} else if (error.response.status === 403) {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
|
||||
179
src/api/types.ts
@@ -144,6 +144,42 @@ export interface SubscribeShare {
|
||||
episode_group?: string
|
||||
}
|
||||
|
||||
// 工作流分享
|
||||
export interface WorkflowShare {
|
||||
// 分享ID
|
||||
id?: string
|
||||
// 工作流ID
|
||||
workflow_id?: string
|
||||
// 分享标题
|
||||
share_title?: string
|
||||
// 分享说明
|
||||
share_comment?: string
|
||||
// 分享人
|
||||
share_user?: string
|
||||
// 分享人唯一ID
|
||||
share_uid?: string
|
||||
// 工作流名称
|
||||
name?: string
|
||||
// 工作流描述
|
||||
description?: string
|
||||
// 定时器
|
||||
timer?: string
|
||||
// 触发类型:timer-定时触发 event-事件触发 manual-手动触发
|
||||
trigger_type?: string
|
||||
// 事件类型(当trigger_type为event时使用)
|
||||
event_type?: string
|
||||
// 动作列表
|
||||
actions?: any[]
|
||||
// 动作流
|
||||
flows?: any[]
|
||||
// 上下文
|
||||
context?: string
|
||||
// 时间
|
||||
date?: string
|
||||
// 复用次数
|
||||
count?: number
|
||||
}
|
||||
|
||||
// 历史记录
|
||||
export interface TransferHistory {
|
||||
// ID
|
||||
@@ -278,6 +314,8 @@ export interface MediaInfo {
|
||||
production_countries?: any[]
|
||||
// 语种
|
||||
spoken_languages?: string[]
|
||||
// 数字/实体发行日期
|
||||
release_dates?: MediaRelease[]
|
||||
// 状态
|
||||
status?: string
|
||||
// 标签
|
||||
@@ -332,6 +370,18 @@ export interface TmdbSeason {
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
// 发行信息
|
||||
export interface MediaRelease {
|
||||
// 发行日期
|
||||
date: string
|
||||
// 发行地区
|
||||
iso_code: string
|
||||
// 备注
|
||||
note?: string
|
||||
// 发行类型
|
||||
type: number
|
||||
}
|
||||
|
||||
// TMDB集信息
|
||||
export interface TmdbEpisode {
|
||||
// 上映日期
|
||||
@@ -484,7 +534,7 @@ export interface SiteUserData {
|
||||
// 用户名
|
||||
username?: string
|
||||
// 用户ID
|
||||
userid?: number
|
||||
userid?: string
|
||||
// 用户等级
|
||||
user_level?: string
|
||||
// 加入时间
|
||||
@@ -565,9 +615,9 @@ export interface NotExistMediaInfo {
|
||||
|
||||
// 插件
|
||||
export interface Plugin {
|
||||
id?: string
|
||||
id: string
|
||||
// 插件名称
|
||||
plugin_name?: string
|
||||
plugin_name: string
|
||||
// 插件描述
|
||||
plugin_desc?: string
|
||||
// 插件图标
|
||||
@@ -606,6 +656,17 @@ export interface Plugin {
|
||||
page_open?: boolean
|
||||
}
|
||||
|
||||
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
|
||||
export interface PluginSidebarNavItem {
|
||||
plugin_id: string
|
||||
nav_key: string
|
||||
title: string
|
||||
icon: string
|
||||
section: 'start' | 'discovery' | 'subscribe' | 'organize' | 'system'
|
||||
permission?: 'subscribe' | 'discovery' | 'search' | 'manage' | 'admin' | null
|
||||
order: number
|
||||
}
|
||||
|
||||
// 渲染结构
|
||||
export interface RenderProps {
|
||||
component: string
|
||||
@@ -769,6 +830,8 @@ export interface MetaInfo {
|
||||
audio_term: string
|
||||
// 资源类型+特效
|
||||
edition: string
|
||||
// 流媒体平台
|
||||
web_source: string
|
||||
// 应用的自定义识别词
|
||||
apply_words: string[]
|
||||
}
|
||||
@@ -809,6 +872,16 @@ export interface User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 通行密钥
|
||||
export interface PassKey {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
aaguid?: string
|
||||
transports?: string
|
||||
}
|
||||
|
||||
// 存储空间
|
||||
export interface Storage {
|
||||
// 总空间
|
||||
@@ -823,8 +896,8 @@ export interface MediaStatistic {
|
||||
movie_count: number
|
||||
// 电视剧总数
|
||||
tv_count: number
|
||||
// 电视剧总集数
|
||||
episode_count: number
|
||||
// 电视剧总集数,未获取时为 null
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
}
|
||||
@@ -952,6 +1025,10 @@ export interface MediaServerPlayItem {
|
||||
link?: string
|
||||
// 播放百分比
|
||||
percent?: number
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
// 图片是否需要Cookies
|
||||
use_cookies?: boolean
|
||||
}
|
||||
|
||||
// 媒体服务器媒体库
|
||||
@@ -972,6 +1049,10 @@ export interface MediaServerLibrary {
|
||||
image_list?: string[]
|
||||
// 链接
|
||||
link?: string
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
// 图片是否需要Cookies
|
||||
use_cookies?: boolean
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
@@ -1008,6 +1089,8 @@ export interface SystemNotification {
|
||||
text: string
|
||||
// 通知时间
|
||||
date: string
|
||||
// 是否已读
|
||||
read?: boolean
|
||||
}
|
||||
|
||||
// 下载器配置
|
||||
@@ -1022,6 +1105,8 @@ export interface DownloaderConf {
|
||||
config: { [key: string]: any }
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
// 路径映射
|
||||
path_mapping?: Array<[storagePath: string, downloadPath: string]>
|
||||
}
|
||||
|
||||
// 通知配置
|
||||
@@ -1060,7 +1145,7 @@ export interface StorageConf {
|
||||
export interface MediaServerConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 emby/jellyfin/plex
|
||||
// 类型 emby/jellyfin/plex/trimemedia/ugreen
|
||||
type: string
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
@@ -1288,6 +1373,10 @@ export interface Workflow {
|
||||
description?: string
|
||||
// 定时器
|
||||
timer?: string
|
||||
// 触发类型:timer-定时触发 event-事件触发 manual-手动触发
|
||||
trigger_type?: string
|
||||
// 事件类型(当trigger_type为event时使用)
|
||||
event_type?: string
|
||||
// 状态
|
||||
state?: string
|
||||
// 当前执行动作
|
||||
@@ -1305,3 +1394,81 @@ export interface Workflow {
|
||||
// 最后执行时间
|
||||
last_time?: string
|
||||
}
|
||||
|
||||
// 种子缓存项
|
||||
export interface TorrentCacheItem {
|
||||
// 种子hash(用于操作标识)
|
||||
hash: string
|
||||
// 站点域名
|
||||
domain: string
|
||||
// 种子标题
|
||||
title: string
|
||||
// 种子描述
|
||||
description?: string
|
||||
// 种子大小
|
||||
size: number
|
||||
// 发布时间
|
||||
pubdate?: string
|
||||
// 站点名称
|
||||
site_name?: string
|
||||
// 识别的媒体名称
|
||||
media_name?: string
|
||||
// 识别的媒体年份
|
||||
media_year?: string
|
||||
// 识别的媒体类型
|
||||
media_type?: string
|
||||
// 季集信息
|
||||
season_episode?: string
|
||||
// 资源信息
|
||||
resource_term?: string
|
||||
// 种子链接
|
||||
enclosure?: string
|
||||
// 详情页面
|
||||
page_url?: string
|
||||
// 海报图片
|
||||
poster_path?: string
|
||||
// 背景图片
|
||||
backdrop_path?: string
|
||||
}
|
||||
|
||||
// 种子缓存数据
|
||||
export interface TorrentCacheData {
|
||||
// 缓存数量
|
||||
count: number
|
||||
// 站点数量
|
||||
sites: number
|
||||
// 缓存数据
|
||||
data: TorrentCacheItem[]
|
||||
}
|
||||
|
||||
// 订阅分享统计
|
||||
export interface SubscribeShareStatistics {
|
||||
// 分享人
|
||||
share_user?: string
|
||||
// 分享数量
|
||||
share_count?: number
|
||||
// 总复用人次
|
||||
total_reuse_count?: number
|
||||
}
|
||||
|
||||
// 通用API响应
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 分类规则
|
||||
export interface CategoryRule {
|
||||
genre_ids?: string
|
||||
original_language?: string
|
||||
production_countries?: string
|
||||
origin_country?: string
|
||||
release_year?: string
|
||||
}
|
||||
|
||||
// 分类配置
|
||||
export interface CategoryConfig {
|
||||
movie?: { [key: string]: CategoryRule }
|
||||
tv?: { [key: string]: CategoryRule }
|
||||
}
|
||||
|
||||
BIN
src/assets/images/logos/discord.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/images/logos/notification.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/images/logos/python.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/images/logos/qq.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/images/logos/rtorrent.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/assets/images/logos/ugreen.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
10
src/assets/images/misc/openlist.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/assets/images/misc/smb.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
1
src/assets/images/pages/404.svg
Normal file
|
After Width: | Height: | Size: 41 KiB |
@@ -3,8 +3,15 @@ import FileList from './filebrowser/FileList.vue'
|
||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// LocalStorage keys
|
||||
const SORT_KEY = 'fileBrowser.sort'
|
||||
const SHOW_TREE_KEY = 'fileBrowser.showDirTree'
|
||||
const NAV_WIDTH_KEY = 'fileBrowser.navigatorWidth'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -12,7 +19,7 @@ const props = defineProps({
|
||||
tree: Boolean,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
axiosconfig: Object,
|
||||
@@ -28,12 +35,9 @@ const props = defineProps({
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['pathchanged'])
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
const route = useRoute()
|
||||
const { appMode } = usePWA()
|
||||
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
|
||||
|
||||
const fileIcons = {
|
||||
// 压缩包
|
||||
@@ -124,17 +128,46 @@ const fileIcons = {
|
||||
other: 'mdi-file-outline',
|
||||
}
|
||||
|
||||
function openNewFolderDialog() {
|
||||
toolbarRef.value?.openNewFolderDialog()
|
||||
}
|
||||
|
||||
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
|
||||
|
||||
useDynamicButton({
|
||||
icon: 'mdi-folder-plus-outline',
|
||||
onClick: openNewFolderDialog,
|
||||
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
|
||||
})
|
||||
|
||||
// 加载次数
|
||||
const loading = ref(0)
|
||||
// 当前存储
|
||||
const activeStorage = ref('local')
|
||||
|
||||
// 刷新
|
||||
const refreshPending = ref(false)
|
||||
// 排序
|
||||
const sort = ref('name')
|
||||
// 排序 - 从localStorage恢复
|
||||
const sort = ref(localStorage.getItem(SORT_KEY) || 'name')
|
||||
|
||||
// 是否显示目录树
|
||||
const showDirTree = ref(false)
|
||||
// 是否显示目录树 - 从localStorage恢复
|
||||
const showDirTree = ref(localStorage.getItem(SHOW_TREE_KEY) === 'true')
|
||||
|
||||
// 拖动分隔条相关 - 从localStorage恢复宽度
|
||||
const navigatorWidth = ref(parseInt(localStorage.getItem(NAV_WIDTH_KEY) || '280'))
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartWidth = ref(0)
|
||||
|
||||
watch(sort, (val) => {
|
||||
localStorage.setItem(SORT_KEY, val)
|
||||
})
|
||||
|
||||
watch(showDirTree, (val) => {
|
||||
localStorage.setItem(SHOW_TREE_KEY, String(val))
|
||||
})
|
||||
|
||||
watch(navigatorWidth, (val) => {
|
||||
localStorage.setItem(NAV_WIDTH_KEY, String(val))
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const storagesArray = computed(() => {
|
||||
@@ -145,15 +178,15 @@ const storagesArray = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
// 方法
|
||||
function loadingChanged(loading: number) {
|
||||
if (loading) loading++
|
||||
else if (loading > 0) loading--
|
||||
function loadingChanged(isLoading: number) {
|
||||
if (isLoading) loading.value++
|
||||
else if (loading.value > 0) loading.value--
|
||||
}
|
||||
|
||||
// 存储切换
|
||||
async function storageChanged(storage: string) {
|
||||
activeStorage.value = storage
|
||||
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
|
||||
}
|
||||
|
||||
@@ -181,56 +214,101 @@ function fileListUpdated(items: FileItem[]) {
|
||||
fileListItems.value = items
|
||||
}
|
||||
|
||||
// 外层DIV大小控制
|
||||
const scrollStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom) - 6.5rem)'
|
||||
: 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
// 阻止选择事件
|
||||
function preventSelect(event: Event) {
|
||||
event.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
// 文件列表大小限制
|
||||
const fileListStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 14rem - env(safe-area-inset-bottom) - 7rem)'
|
||||
: 'height: calc(100vh - 14rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
// 拖动分隔条相关方法
|
||||
function startDrag(event: MouseEvent) {
|
||||
event.preventDefault() // 阻止默认行为
|
||||
event.stopPropagation() // 阻止事件冒泡
|
||||
|
||||
isDragging.value = true
|
||||
dragStartX.value = event.clientX
|
||||
dragStartWidth.value = navigatorWidth.value
|
||||
|
||||
document.addEventListener('mousemove', handleDrag, { passive: false })
|
||||
document.addEventListener('mouseup', stopDrag, { passive: false })
|
||||
document.addEventListener('selectstart', preventSelect) // 阻止选择开始
|
||||
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
;(document.body.style as any).webkitUserSelect = 'none' // Safari兼容
|
||||
;(document.body.style as any).mozUserSelect = 'none' // Firefox兼容
|
||||
}
|
||||
|
||||
function handleDrag(event: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
event.preventDefault() // 阻止默认行为
|
||||
|
||||
const deltaX = event.clientX - dragStartX.value
|
||||
const newWidth = dragStartWidth.value + deltaX
|
||||
|
||||
// 设置最小和最大宽度限制
|
||||
const minWidth = 200
|
||||
const maxWidth = window.innerWidth * 0.6
|
||||
|
||||
navigatorWidth.value = Math.max(minWidth, Math.min(maxWidth, newWidth))
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', handleDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('selectstart', preventSelect)
|
||||
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
;(document.body.style as any).webkitUserSelect = ''
|
||||
;(document.body.style as any).mozUserSelect = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto" :loading="loading > 0">
|
||||
<div v-if="activeStorage && item">
|
||||
<div v-if="item">
|
||||
<FileToolbar
|
||||
ref="toolbarRef"
|
||||
:sort="sort"
|
||||
:item="item"
|
||||
:itemstack="itemstack"
|
||||
:storages="storagesArray"
|
||||
:storage="activeStorage"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:show-new-folder-button="!showFloatingNewFolderAction"
|
||||
@storagechanged="storageChanged"
|
||||
@pathchanged="pathChanged"
|
||||
@foldercreated="refreshPending = true"
|
||||
@sortchanged="sortChanged"
|
||||
/>
|
||||
<div class="flex" :style="scrollStyle">
|
||||
<div class="flex">
|
||||
<FileNavigator
|
||||
v-if="showDirTree"
|
||||
:storage="activeStorage"
|
||||
:storage="item.storage"
|
||||
:currentPath="item.path"
|
||||
:items="fileListItems"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:style="{ width: `${navigatorWidth}px`, minWidth: `${navigatorWidth}px` }"
|
||||
@navigate="pathChanged"
|
||||
/>
|
||||
<!-- 拖动分隔条 -->
|
||||
<div v-if="showDirTree" class="divider" :class="{ 'divider-dragging': isDragging }" @mousedown="startDrag">
|
||||
<div class="divider-line"></div>
|
||||
<VIcon class="divider-icon" size="small">mdi-drag-vertical</VIcon>
|
||||
</div>
|
||||
<FileList
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
:listStyle="fileListStyle"
|
||||
:showTree="showDirTree"
|
||||
:style="{ flex: 1 }"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@@ -242,4 +320,77 @@ const fileListStyle = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body" v-if="!appMode && showFloatingNewFolderAction">
|
||||
<div class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-folder-plus-outline"
|
||||
color="primary"
|
||||
appear
|
||||
class="compact-fab compact-fab--primary"
|
||||
@click="openNewFolderDialog"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.divider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
cursor: col-resize;
|
||||
inline-size: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.divider:hover {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.divider-dragging {
|
||||
background-color: rgba(var(--v-theme-primary), 0.12) !important;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
background-color: rgba(var(--v-theme-outline), 0.3);
|
||||
block-size: 100%;
|
||||
inline-size: 1px;
|
||||
transition: background-color 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.divider-dragging .divider-line {
|
||||
background-color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
.divider:hover .divider-line {
|
||||
background-color: rgba(var(--v-theme-primary), 0.8);
|
||||
}
|
||||
|
||||
.divider-icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
background-color: rgba(var(--v-theme-surface), 0.9);
|
||||
color: rgba(var(--v-theme-on-surface-variant), 0.6);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.divider-dragging .divider-icon {
|
||||
background-color: rgba(var(--v-theme-surface), 0.95);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.divider:hover .divider-icon {
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import page404 from '@images/pages/404.svg'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -19,16 +20,7 @@ interface Props {
|
||||
<div class="no-data-container">
|
||||
<!-- 图标容器 -->
|
||||
<div class="icon-wrapper">
|
||||
<div class="icon-glow"></div>
|
||||
<div class="icon-container">
|
||||
<VIcon
|
||||
:icon="props.icon || 'mdi-file-search-outline'"
|
||||
:color="props.iconColor || 'white'"
|
||||
size="48"
|
||||
class="main-icon"
|
||||
/>
|
||||
</div>
|
||||
<div class="pulse-ring"></div>
|
||||
<img :src="page404" alt="404" />
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
@@ -57,8 +49,7 @@ interface Props {
|
||||
justify-content: center;
|
||||
inline-size: 100%;
|
||||
min-block-size: 300px;
|
||||
padding-block: 3rem;
|
||||
padding-inline: 1rem;
|
||||
padding-block-start: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -68,109 +59,17 @@ interface Props {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
margin-block: 0 2rem;
|
||||
inline-size: 15rem;
|
||||
margin-block: 0 1rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.icon-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: pulse 3s infinite ease-in-out;
|
||||
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.8) 0%, rgba(var(--v-theme-primary), 0) 70%);
|
||||
block-size: 80px;
|
||||
filter: blur(15px);
|
||||
inline-size: 80px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.9), rgba(var(--v-theme-secondary), 0.8));
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
animation: slight-bounce 3s infinite ease-in-out;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 30%));
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border: 2px solid rgba(var(--v-theme-primary), 0.5);
|
||||
border-radius: 50%;
|
||||
animation: ripple 2s infinite ease-out;
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.pulse-ring::before {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(var(--v-theme-primary), 0.3);
|
||||
border-radius: 50%;
|
||||
animation: ripple 2s infinite 0.5s ease-out;
|
||||
block-size: 85px;
|
||||
content: '';
|
||||
inline-size: 85px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slight-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文字样式 */
|
||||
.error-title {
|
||||
position: relative;
|
||||
color: rgba(var(--v-theme-on-surface), 0.95);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
margin-block-end: 0.75rem;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
|
||||
}
|
||||
@@ -181,69 +80,15 @@ interface Props {
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-primary), 0.8), rgba(var(--v-theme-primary), 0.2));
|
||||
block-size: 3px;
|
||||
content: '';
|
||||
inline-size: 40px;
|
||||
margin-block: 0.5rem 0;
|
||||
inline-size: 60px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
color: rgba(var(--v-theme-on-surface), 0.75);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-block-end: 1.5rem;
|
||||
font-size: 1rem;
|
||||
margin-block-end: 1rem;
|
||||
margin-inline: auto;
|
||||
max-inline-size: 80%;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
margin-block-start: 1.5rem;
|
||||
}
|
||||
|
||||
.actions-container :deep(.v-btn) {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.actions-container :deep(.v-btn:hover) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (width <= 600px) {
|
||||
.no-data-container {
|
||||
padding-block: 2rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block-end: 1.5rem;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
block-size: 70px;
|
||||
inline-size: 70px;
|
||||
}
|
||||
|
||||
.icon-glow {
|
||||
block-size: 70px;
|
||||
inline-size: 70px;
|
||||
}
|
||||
|
||||
.pulse-ring,
|
||||
.pulse-ring::before {
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 0.95rem;
|
||||
max-inline-size: 90%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
201
src/components/PWAInstallPrompt.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { usePWAInstall } from '@/composables/usePWAInstall'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
const { t, locale, messages } = useI18n()
|
||||
const { isInstalled, showInstallPrompt, getInstallInstructions } = usePWAInstall()
|
||||
|
||||
const showBanner = ref(false)
|
||||
const showInstructions = ref(false)
|
||||
const dismissed = ref(false)
|
||||
|
||||
// 检查是否登录
|
||||
const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
|
||||
// 检查当前是不是https环境
|
||||
const isHttps = computed(() => {
|
||||
return window.location.protocol === 'https:'
|
||||
})
|
||||
|
||||
// 检查是否应该显示横幅
|
||||
const shouldShowBanner = computed(() => {
|
||||
return !isInstalled.value && !dismissed.value && !showInstructions.value && isLogin.value && isHttps.value
|
||||
})
|
||||
|
||||
// 显示延迟(避免立即显示)
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
// 检查本地存储,看用户是否已经关闭过提示
|
||||
const dismissedTime = localStorage.getItem('pwa-install-dismissed')
|
||||
if (dismissedTime) {
|
||||
const dismissedDate = new Date(dismissedTime)
|
||||
const now = new Date()
|
||||
const daysDiff = (now.getTime() - dismissedDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
|
||||
// 如果距离上次关闭不到30天,不显示
|
||||
if (daysDiff < 30) {
|
||||
dismissed.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
showBanner.value = true
|
||||
}, 5000) // 5秒后显示
|
||||
})
|
||||
|
||||
// 处理安装
|
||||
const handleInstall = async () => {
|
||||
const installed = await showInstallPrompt()
|
||||
if (installed) {
|
||||
showBanner.value = false
|
||||
// 显示成功消息
|
||||
useToast().success(t('pwa.installSuccess'))
|
||||
} else {
|
||||
// 如果用户拒绝,显示手动安装说明
|
||||
showInstructions.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭横幅
|
||||
const dismissBanner = () => {
|
||||
showBanner.value = false
|
||||
dismissed.value = true
|
||||
// 记录关闭时间
|
||||
localStorage.setItem('pwa-install-dismissed', new Date().toISOString())
|
||||
}
|
||||
|
||||
// 获取平台特定的安装说明
|
||||
const instructions = computed(() => {
|
||||
const rawInstructions = getInstallInstructions()
|
||||
const platformKey = rawInstructions.platformKey
|
||||
|
||||
// 获取平台显示名称
|
||||
const platformName = t(`pwa.platforms.${platformKey}`)
|
||||
|
||||
// 直接使用t函数获取安装步骤,避免编译对象的问题
|
||||
const steps = []
|
||||
const maxSteps = 10 // 最大步骤数,防止无限循环
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
try {
|
||||
const stepKey = `pwa.installSteps.${platformKey}.${i}`
|
||||
const stepText = t(stepKey)
|
||||
|
||||
// 如果返回的是键名本身,说明没有找到对应的翻译
|
||||
if (stepText === stepKey) {
|
||||
break
|
||||
}
|
||||
|
||||
steps.push(stepText)
|
||||
} catch (error) {
|
||||
// 如果出现错误,说明没有更多步骤
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
platform: platformName,
|
||||
steps,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 安装横幅 -->
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300"
|
||||
enter-from-class="translate-y-full opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-300"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-full opacity-0"
|
||||
>
|
||||
<VCard v-if="shouldShowBanner && showBanner" class="pwa-install-banner">
|
||||
<div class="banner-content">
|
||||
<VIcon icon="mdi-cellphone-link" size="24" class="me-3" />
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-weight-medium">{{ t('pwa.installApp') }}</div>
|
||||
<div class="text-sm opacity-70">{{ t('pwa.installDescription') }}</div>
|
||||
</div>
|
||||
<VBtn color="primary" size="small" variant="flat" @click="handleInstall">
|
||||
{{ t('pwa.install') }}
|
||||
</VBtn>
|
||||
<VBtn icon size="small" variant="text" @click="dismissBanner">
|
||||
<VIcon icon="mdi-close" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 手动安装说明对话框 -->
|
||||
<VDialog v-model="showInstructions" max-width="500">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-flex align-center">
|
||||
<VIcon icon="mdi-information-outline" class="me-2" />
|
||||
{{ t('pwa.installGuide') }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-1 mb-2">
|
||||
{{ t('pwa.installInstructions', { platform: instructions.platform }) }}
|
||||
</div>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="(step, index) in instructions.steps"
|
||||
:key="index"
|
||||
:prepend-icon="`mdi-numeric-${index + 1}-circle`"
|
||||
>
|
||||
<VListItemTitle>{{ step }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
|
||||
<VAlert type="info" variant="tonal" density="compact">
|
||||
{{ t('pwa.installNote') }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="text" @click="showInstructions = false">
|
||||
{{ t('pwa.gotIt') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pwa-install-banner {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 12px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 10%);
|
||||
inset-block-end: 5rem;
|
||||
inset-inline: 20px;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (width >= 600px) {
|
||||
.pwa-install-banner {
|
||||
inset-inline: auto 20px;
|
||||
max-inline-size: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaServerPlayItem>,
|
||||
@@ -9,21 +11,35 @@ const props = defineProps({
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 图片加载失败响应
|
||||
function imageErrorHandler() {
|
||||
imageLoadError.value = true
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
function goPlay() {
|
||||
if (props.media?.link) window.open(props.media?.link, '_blank')
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算图片地址
|
||||
const getImgUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const use_cookies = props.media?.use_cookies
|
||||
if (use_cookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return url
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -42,22 +58,24 @@ const getImgUrl = computed(() => {
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler">
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<h1
|
||||
class="mb-1 text-white text-shadow font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||
<template #default>
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<span class="text-shadow">{{ props.media?.subtitle }}</span>
|
||||
</VCardText>
|
||||
<h1
|
||||
class="mb-1 text-white text-shadow font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<span class="text-shadow text-sm">{{ props.media?.subtitle }}</span>
|
||||
</VCardText>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="w-full absolute bottom-0">
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import filter_svg from '@images/svg/filter.svg'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -83,6 +87,12 @@ function saveRuleInfo() {
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 验证规则ID输入
|
||||
function validateRuleId() {
|
||||
// 只允许英文和数字,不允许空格
|
||||
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
emit('close')
|
||||
@@ -91,23 +101,37 @@ function onClose() {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openRuleInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.rule.name }}</h5>
|
||||
<div class="text-body-1 mb-3">{{ props.rule.id }}</div>
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
|
||||
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="filter_svg" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem">
|
||||
<VCard :title="t('customRule.title', { id: props.rule.id })">
|
||||
<VDialog
|
||||
v-if="ruleInfoDialog"
|
||||
v-model="ruleInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-filter-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="ruleInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
@@ -121,6 +145,8 @@ function onClose() {
|
||||
:hint="t('customRule.hint.ruleId')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
@input="validateRuleId"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -131,6 +157,7 @@ function onClose() {
|
||||
:hint="t('customRule.hint.ruleName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
@@ -141,6 +168,7 @@ function onClose() {
|
||||
:hint="t('customRule.hint.include')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-plus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
@@ -151,6 +179,7 @@ function onClose() {
|
||||
:hint="t('customRule.hint.exclude')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-minus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
@@ -161,6 +190,7 @@ function onClose() {
|
||||
:hint="t('customRule.hint.sizeRange')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-harddisk"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
@@ -171,6 +201,7 @@ function onClose() {
|
||||
:hint="t('customRule.hint.seeders')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
@@ -181,13 +212,14 @@ function onClose() {
|
||||
:hint="t('customRule.hint.publishTime')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-calendar-clock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveRuleInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">{{
|
||||
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">{{
|
||||
t('customRule.action.confirm')
|
||||
}}</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -195,7 +195,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height">
|
||||
<VCard variant="tonal" class="app-card-shell" :width="props.width" :height="props.height">
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VTextField
|
||||
@@ -204,8 +204,8 @@ watch(
|
||||
:label="t('directory.alias')"
|
||||
class="me-20 text-high-emphasis font-weight-bold"
|
||||
/>
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
@@ -214,7 +214,7 @@ watch(
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="6">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="props.directory.media_type"
|
||||
variant="underlined"
|
||||
:items="typeItems"
|
||||
@@ -223,7 +223,7 @@ watch(
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="props.directory.media_category"
|
||||
variant="underlined"
|
||||
:items="getCategories"
|
||||
@@ -231,7 +231,7 @@ watch(
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="props.directory.storage"
|
||||
variant="underlined"
|
||||
:items="resourceStorageOptions"
|
||||
@@ -277,7 +277,7 @@ watch(
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="props.directory.library_storage"
|
||||
variant="underlined"
|
||||
:items="libraryStorageOptions"
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
import api from '@/api'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import qbittorrent_image from '@images/logos/qbittorrent.png'
|
||||
import transmission_image from '@images/logos/transmission.png'
|
||||
import custom_image from '@images/logos/downloader.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { downloaderDict } from '@/api/constants'
|
||||
import { downloaderDict, storageAttributes } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
const { useConditionalDataRefresh } = useBackgroundOptimization()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -39,9 +43,6 @@ const emit = defineEmits(['close', 'done', 'change'])
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// timeout定时器
|
||||
let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||
|
||||
// 上传速率
|
||||
const upload_rate = ref(0)
|
||||
|
||||
@@ -51,6 +52,54 @@ const download_rate = ref(0)
|
||||
// 下载器详情弹窗
|
||||
const downloaderInfoDialog = ref(false)
|
||||
|
||||
// 表单
|
||||
const downloaderForm = ref()
|
||||
|
||||
// 路径前缀选项
|
||||
const prefixOptions = computed(() => {
|
||||
return storageAttributes.map(item => ({
|
||||
title: t(`storage.${item.type}`),
|
||||
value: item.type,
|
||||
}))
|
||||
})
|
||||
|
||||
function getStorageType(path: string) {
|
||||
if (!path) return 'local'
|
||||
// 查找匹配的存储类型
|
||||
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
|
||||
return storage?.type || 'local'
|
||||
}
|
||||
|
||||
function storage2Prefix(storage: string) {
|
||||
return storage === 'local' ? '' : storage + ':'
|
||||
}
|
||||
|
||||
// 获取存储路径前后缀
|
||||
function parseStoragePath(path: string): [prefix: string, suffix: string] {
|
||||
if (!path) return ['', '']
|
||||
const storage = getStorageType(path)
|
||||
const prefix = storage2Prefix(storage)
|
||||
return [prefix, path.slice(prefix.length)]
|
||||
}
|
||||
|
||||
// 更新存储路径前缀
|
||||
function updateStoragePrefix(row: PathMappingRow, storage: string) {
|
||||
const [, currentSuffix] = parseStoragePath(row.storage)
|
||||
const prefix = storage2Prefix(storage)
|
||||
row.storage = prefix + currentSuffix
|
||||
}
|
||||
|
||||
// 更新存储路径后缀
|
||||
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
|
||||
const [currentPrefix] = parseStoragePath(row.storage)
|
||||
row.storage = currentPrefix + suffix
|
||||
}
|
||||
|
||||
const pathValidationRules = [
|
||||
(v: string) => !!v || t('downloader.pathMappingRequired'),
|
||||
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
|
||||
]
|
||||
|
||||
// 下载器详情
|
||||
const downloaderInfo = ref<DownloaderConf>({
|
||||
name: '',
|
||||
@@ -58,11 +107,33 @@ const downloaderInfo = ref<DownloaderConf>({
|
||||
default: false,
|
||||
enabled: false,
|
||||
config: {},
|
||||
path_mapping: [],
|
||||
})
|
||||
|
||||
// 路径映射行定义
|
||||
interface PathMappingRow {
|
||||
id: string
|
||||
storage: string
|
||||
download: string
|
||||
}
|
||||
|
||||
// 路径映射行数据
|
||||
const pathMappingRows = ref<PathMappingRow[]>([])
|
||||
|
||||
// 生成随机ID
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
// 下载器是否应该刷新数据的计算属性
|
||||
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
|
||||
|
||||
// 调用API查询下载器数据
|
||||
async function loadDownloaderInfo() {
|
||||
if (!props.allowRefresh) {
|
||||
if (!shouldRefresh.value) {
|
||||
// 当下载器被禁用时,重置速率数据
|
||||
upload_rate.value = 0
|
||||
download_rate.value = 0
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -75,11 +146,6 @@ async function loadDownloaderInfo() {
|
||||
if (res) {
|
||||
upload_rate.value = res.upload_speed
|
||||
download_rate.value = res.download_speed
|
||||
// 定时查询
|
||||
clearTimeout(timeoutTimer)
|
||||
if (props.downloader.enabled) {
|
||||
timeoutTimer = setTimeout(loadDownloaderInfo, 3000)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
@@ -90,11 +156,24 @@ async function loadDownloaderInfo() {
|
||||
function openDownloaderInfoDialog() {
|
||||
// 深复制
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
// 初始化路径映射行数据
|
||||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||||
id: generateId(),
|
||||
storage: item[0],
|
||||
download: item[1],
|
||||
}))
|
||||
downloaderInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveDownloaderInfo() {
|
||||
async function saveDownloaderInfo() {
|
||||
// 表单校验
|
||||
const { valid } = await downloaderForm.value?.validate()
|
||||
if (!valid) return
|
||||
|
||||
// 同步路径映射数据
|
||||
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
|
||||
|
||||
// 为空不保存,跳出警告框
|
||||
if (!downloaderInfo.value.name) {
|
||||
$toast.error(t('downloader.nameRequired'))
|
||||
@@ -124,47 +203,68 @@ function saveDownloaderInfo() {
|
||||
const getIcon = computed(() => {
|
||||
switch (props.downloader.type) {
|
||||
case 'qbittorrent':
|
||||
return qbittorrent_image
|
||||
return getLogoUrl('qbittorrent')
|
||||
case 'transmission':
|
||||
return transmission_image
|
||||
return getLogoUrl('transmission')
|
||||
case 'rtorrent':
|
||||
return getLogoUrl('rtorrent')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('downloader')
|
||||
}
|
||||
})
|
||||
|
||||
// 添加路径映射
|
||||
function addPathMapping() {
|
||||
pathMappingRows.value.push({
|
||||
id: generateId(),
|
||||
storage: '',
|
||||
download: '',
|
||||
})
|
||||
}
|
||||
|
||||
// 移除路径映射
|
||||
function removePathMapping(index: number) {
|
||||
pathMappingRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.downloader.enabled) {
|
||||
await loadDownloaderInfo()
|
||||
}
|
||||
})
|
||||
// 使用条件性数据刷新定时器(只在下载器启用时运行)
|
||||
const { stop: stopRefresh } = useConditionalDataRefresh(
|
||||
`downloader-${props.downloader.name}`,
|
||||
loadDownloaderInfo,
|
||||
shouldRefresh, // 响应式条件:只有当allowRefresh为true且downloader启用时才运行
|
||||
3000, // 3秒间隔
|
||||
true, // 立即执行一次
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||
stopRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
variant="tonal"
|
||||
class="app-card-shell"
|
||||
@click="openDownloaderInfoDialog"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VCardText class="flex justify-space-between align-center gap-4">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="flex items-center">
|
||||
<VCardText class="app-card-summary app-card-summary--double-action">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title-row">
|
||||
<VBadge
|
||||
v-if="props.downloader.default && props.downloader.enabled"
|
||||
dot
|
||||
@@ -172,28 +272,45 @@ onUnmounted(() => {
|
||||
color="success"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="text-h6">{{ downloader.name }}</span>
|
||||
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
|
||||
</div>
|
||||
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="mt-1 flex flex-wrap text-sm">
|
||||
<span class="me-2">{{ `↑ ${formatFileSize(upload_rate, 1)}/s ` }}</span>
|
||||
<span>{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
<div
|
||||
v-if="downloaderDict[downloader.type] && props.downloader.enabled"
|
||||
class="app-card-summary__meta text-sm"
|
||||
>
|
||||
<span class="app-card-summary__meta-item">{{ `↑ ${formatFileSize(upload_rate, 1)}/s` }}</span>
|
||||
<span class="app-card-summary__meta-item">{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
</div>
|
||||
<div v-else-if="!downloaderDict[downloader.type]" class="mt-1 flex flex-wrap text-sm">
|
||||
<span class="me-2">自定义下载器</span>
|
||||
<div v-else-if="!downloaderDict[downloader.type]" class="app-card-summary__subtitle text-sm">
|
||||
自定义下载器
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="getIcon" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VHover>
|
||||
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem">
|
||||
<VCard :title="`${props.downloader.name} - ${t('downloader.title')}`">
|
||||
|
||||
<VDialog
|
||||
v-if="downloaderInfoDialog"
|
||||
v-model="downloaderInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('common.config') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VForm ref="downloaderForm">
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
|
||||
@@ -215,6 +332,7 @@ onUnmounted(() => {
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -225,6 +343,18 @@ onUnmounted(() => {
|
||||
:hint="t('downloader.host')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.apikey"
|
||||
type="password"
|
||||
:label="t('downloader.apiKey')"
|
||||
:hint="t('downloader.qbittorrentApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -232,8 +362,10 @@ onUnmounted(() => {
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -242,8 +374,10 @@ onUnmounted(() => {
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -292,6 +426,7 @@ onUnmounted(() => {
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -302,6 +437,7 @@ onUnmounted(() => {
|
||||
:hint="t('downloader.host')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -311,6 +447,7 @@ onUnmounted(() => {
|
||||
:hint="t('downloader.username')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -321,6 +458,52 @@ onUnmounted(() => {
|
||||
:hint="t('downloader.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port/RPC2"
|
||||
:hint="t('downloader.rtorrentHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -332,6 +515,7 @@ onUnmounted(() => {
|
||||
:hint="t('downloader.customTypeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -341,13 +525,97 @@ onUnmounted(() => {
|
||||
:hint="t('downloader.nameRequired')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VDivider class="my-2">
|
||||
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
|
||||
</VDivider>
|
||||
|
||||
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
|
||||
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
|
||||
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
|
||||
</div>
|
||||
|
||||
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="my-2">
|
||||
<VCardText class="pa-3">
|
||||
<VRow align="center" no-gutters>
|
||||
<VCol cols="12" class="mb-2">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
|
||||
</div>
|
||||
<VRow no-gutters>
|
||||
<VCol cols="12" sm="4" class="pe-2">
|
||||
<VSelect
|
||||
:model-value="getStorageType(row.storage)"
|
||||
:items="prefixOptions"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="v => updateStoragePrefix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="8">
|
||||
<VTextField
|
||||
:model-value="parseStoragePath(row.storage)[1]"
|
||||
:placeholder="'/path/to/storage'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
@update:model-value="v => updateStorageSuffix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="mb-1">
|
||||
<div class="d-flex align-center justify-center my-1">
|
||||
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
|
||||
</div>
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
|
||||
</div>
|
||||
<VTextField
|
||||
v-model="row.download"
|
||||
:placeholder="'/path/to/download'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="d-flex justify-end pt-1">
|
||||
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
|
||||
<VIcon icon="mdi-delete-outline" />
|
||||
</IconBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
@click="addPathMapping"
|
||||
class="mt-1"
|
||||
size="small"
|
||||
>
|
||||
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveDownloaderInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatFileSize } from '@/@core/utils/formatters'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
info: Object as PropType<DownloadingInfo>,
|
||||
downloaderName: String,
|
||||
})
|
||||
|
||||
// 是否显示卡片
|
||||
@@ -42,16 +43,15 @@ function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 计算文本类
|
||||
function getTextClass() {
|
||||
return imageLoaded.value ? 'text-white' : ''
|
||||
}
|
||||
|
||||
// 下载状态控制
|
||||
async function toggleDownload() {
|
||||
const operation = isDownloading.value ? 'stop' : 'start'
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`)
|
||||
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`, {
|
||||
params: {
|
||||
name: props.downloaderName,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) isDownloading.value = !isDownloading.value
|
||||
} catch (error) {
|
||||
@@ -62,7 +62,7 @@ async function toggleDownload() {
|
||||
// 删除下截
|
||||
async function deleteDownload() {
|
||||
try {
|
||||
await api.delete(`download/${props.info?.hash}`)
|
||||
await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName } })
|
||||
cardState.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -71,35 +71,52 @@ async function deleteDownload() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard v-if="cardState" :key="props.info?.hash">
|
||||
<VCard v-if="cardState" :key="props.info?.hash" class="flex flex-col h-full" min-height="150">
|
||||
<template #image>
|
||||
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover class="brightness-50" @load="imageLoadHandler" />
|
||||
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none downloading-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
|
||||
<VCardTitle class="break-words whitespace-normal" :class="getTextClass()">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
<div>
|
||||
<VCardTitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
|
||||
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
<VCardSubtitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()">
|
||||
{{ getSpeedText() }}
|
||||
</VCardText>
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
|
||||
{{ getSpeedText() }}
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="getPercentage() > 0" :class="getTextClass()">
|
||||
<VProgressLinear :model-value="getPercentage()" />
|
||||
</VCardText>
|
||||
<VCardText v-if="getPercentage() > 0" class="text-white">
|
||||
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.downloading-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -45,18 +45,18 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
|
||||
<VCardTitle class="pr-8">{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="props.rules"
|
||||
variant="underlined"
|
||||
:items="selectFilterOptions"
|
||||
|
||||
@@ -3,11 +3,15 @@ import draggable from 'vuedraggable'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import filter_group_svg from '@images/svg/filter-group.svg'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
@@ -201,25 +205,33 @@ function onClose() {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="opengroupInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="opengroupInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
|
||||
<div class="text-body-1 mb-3">
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
|
||||
<div class="app-card-summary__subtitle text-body-1">
|
||||
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
|
||||
<span v-else>{{ props.group.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="filter_group_svg" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem">
|
||||
<VDialog
|
||||
v-if="groupInfoDialog"
|
||||
v-model="groupInfoDialog"
|
||||
scrollable
|
||||
max-width="80rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
|
||||
<VDialogCloseBtn v-model="groupInfoDialog" />
|
||||
<VDivider />
|
||||
@@ -233,26 +245,29 @@ function onClose() {
|
||||
:hint="t('filterRule.groupName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="groupInfo.media_type"
|
||||
:label="t('filterRule.mediaType')"
|
||||
:items="mediaTypeItems"
|
||||
:hint="t('filterRule.mediaType')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-movie-open"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="groupInfo.category"
|
||||
:items="getCategories"
|
||||
:label="t('filterRule.category')"
|
||||
:hint="t('filterRule.category')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-folder-open"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -280,17 +295,17 @@ function onClose() {
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" variant="tonal" @click="addFilterCard">
|
||||
<VBtn color="primary" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VBtn color="success" variant="tonal" @click="importRules('priority')">
|
||||
<VBtn color="success" @click="importRules('priority')">
|
||||
<VIcon icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn color="info" variant="tonal" @click="shareRules">
|
||||
<VBtn color="info" @click="shareRules">
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveGroupInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { MediaServerLibrary } from '@/api/types'
|
||||
import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import trimemedia from '@images/logos/trimemedia.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -32,57 +33,67 @@ function imageLoadHandler() {
|
||||
// 图片加载错误
|
||||
function imageErrorHandler() {
|
||||
imageError.value = true
|
||||
imgUrl.value = getDefaultImage()
|
||||
}
|
||||
|
||||
// 默认图片
|
||||
function getDefaultImage() {
|
||||
if (props.media?.server === 'plex') return plex
|
||||
else if (props.media?.server === 'emby') return emby
|
||||
else if (props.media?.server === 'jellyfin') return jellyfin
|
||||
else if (props.media?.server === 'trimemedia') return trimemedia
|
||||
if (props.media?.server_type === 'plex') return plex
|
||||
else if (props.media?.server_type === 'emby') return emby
|
||||
else if (props.media?.server_type === 'jellyfin') return jellyfin
|
||||
else if (props.media?.server_type === 'trimemedia') return getLogoUrl('trimemedia')
|
||||
else if (props.media?.server_type === 'ugreen') return getLogoUrl('ugreen')
|
||||
else return plex
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
function goPlay() {
|
||||
if (props.media?.link) window.open(props.media?.link, '_blank')
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成图片代理路径
|
||||
function getImgUrl(url: string) {
|
||||
if (!url) return getDefaultImage()
|
||||
else return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url || imageError.value) return getDefaultImage()
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
if (use_cookies) {
|
||||
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return imgurl
|
||||
}
|
||||
|
||||
// 根据多张图片生成媒体库封面
|
||||
async function drawImages(imageList: string[]) {
|
||||
async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 图片
|
||||
const IMAGES = imageList
|
||||
const IMAGES = [...imageList]
|
||||
if (IMAGES.length === 0) return getDefaultImage()
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
for (let i = 0; i < IMAGES.length; i++)
|
||||
for (let i = 0; i < IMAGES.length; i++) {
|
||||
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
|
||||
if (use_cookies) {
|
||||
IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
}
|
||||
|
||||
// canvas
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return getDefaultImage()
|
||||
|
||||
// 画布参数
|
||||
const POSTER_WIDTH = (canvas.width - 32) / 4
|
||||
const POSTER_HEIGHT = canvas.height * 0.75 - 8
|
||||
const MARGIN_WIDTH = 4
|
||||
const MARGIN_HEIGHT = 4
|
||||
const REFLECTION_HEIGHT = POSTER_HEIGHT / 2
|
||||
const REFLECTION_SHOW_HEIGHT = canvas.height / 4
|
||||
const POSTER_WIDTH = (canvas.width - 40) / 4 // 左右边框8px + 3个间隔24px = 40px
|
||||
const POSTER_HEIGHT = 256 // 上方海报高256
|
||||
const MARGIN_WIDTH = 8 // 左右间隔为8
|
||||
const MARGIN_HEIGHT = 4 // 海报和倒影之间的间隔为4
|
||||
const REFLECTION_HEIGHT = canvas.height - POSTER_HEIGHT - MARGIN_HEIGHT // 下方倒影使用剩余全部高度
|
||||
|
||||
// 获取画布上下文
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return getDefaultImage()
|
||||
|
||||
// 设置背景色为黑色
|
||||
ctx.fillStyle = '#000000'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
// 设置背景色为透明
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 绘制图片
|
||||
async function drawImageWithReflection(imgSrc: string, index: number) {
|
||||
@@ -101,36 +112,27 @@ async function drawImages(imageList: string[]) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ctx.fillStyle = '#e5e7eb'
|
||||
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), MARGIN_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT)
|
||||
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), 0, POSTER_WIDTH, POSTER_HEIGHT)
|
||||
return
|
||||
}
|
||||
|
||||
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
|
||||
const y = MARGIN_HEIGHT
|
||||
const y = 0 // 海报紧贴顶部
|
||||
|
||||
ctx.drawImage(img, x, y, POSTER_WIDTH, POSTER_HEIGHT)
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(0, canvas.height)
|
||||
ctx.scale(1, -1)
|
||||
ctx.drawImage(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
img.width,
|
||||
img.height,
|
||||
x,
|
||||
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
|
||||
POSTER_WIDTH,
|
||||
REFLECTION_HEIGHT,
|
||||
)
|
||||
ctx.drawImage(img, 0, 0, img.width, img.height, x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT, 0, REFLECTION_HEIGHT)
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height - (POSTER_HEIGHT + MARGIN_HEIGHT))
|
||||
|
||||
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)')
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.7)')
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_SHOW_HEIGHT)
|
||||
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
@@ -145,8 +147,8 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.media?.image_list && props.media?.image_list.length > 0)
|
||||
imgUrl.value = await drawImages(props.media?.image_list || [])
|
||||
else imgUrl.value = getImgUrl(props.media?.image || '')
|
||||
imgUrl.value = await drawImages(props.media?.image_list || [], props.media?.use_cookies)
|
||||
else imgUrl.value = getImgUrl(props.media?.image || '', props.media?.use_cookies)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -163,20 +165,22 @@ onMounted(async () => {
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" class="w-full h-full hidden" />
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.name }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
<template #default>
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.name }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
</VCard>
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import tmdbImage from '@images/logos/tmdb.png'
|
||||
import doubanImage from '@images/logos/douban-black.png'
|
||||
import bangumiImage from '@images/logos/bangumi.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
|
||||
import router, { registerAbortController } from '@/router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import router from '@/router'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
interface MediaCardMedia extends MediaInfo {
|
||||
total_episode?: number
|
||||
episode_count?: number
|
||||
}
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaInfo>,
|
||||
media: Object as PropType<MediaCardMedia>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
@@ -61,9 +67,9 @@ const seasonsSelected = ref<MediaSeason[]>([])
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
themoviedb: tmdbImage,
|
||||
douban: doubanImage,
|
||||
bangumi: bangumiImage,
|
||||
themoviedb: getLogoUrl('tmdb'),
|
||||
douban: getLogoUrl('douban-black'),
|
||||
bangumi: getLogoUrl('bangumi'),
|
||||
}
|
||||
|
||||
// 绑定MediaCard元素
|
||||
@@ -137,7 +143,7 @@ async function handleAddSubscribe() {
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
async function addSubscribe(season: number | null = null, best_version: number = 0) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
@@ -152,7 +158,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season,
|
||||
season: props.media?.type === '电影' ? null : season,
|
||||
best_version,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
@@ -182,8 +188,8 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
}
|
||||
|
||||
// 弹出添加订阅提示
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
|
||||
if (season) title = `${title} ${formatSeason(season.toString())}`
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
let subname = t('subscribe.normalSub')
|
||||
if (best_version > 0) subname = t('subscribe.versionSub')
|
||||
@@ -221,7 +227,7 @@ async function removeSubscribe() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
const result = await checkSubscribe(props.media?.season)
|
||||
const result = await checkSubscribe(props.media?.season ?? null)
|
||||
if (result) isSubscribed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -231,9 +237,6 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
registerAbortController(abortController)
|
||||
const { signal } = abortController
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
@@ -242,7 +245,6 @@ async function handleCheckExists() {
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
@@ -252,18 +254,15 @@ async function handleCheckExists() {
|
||||
}
|
||||
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season = 0) {
|
||||
async function checkSubscribe(season: number | null) {
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
registerAbortController(abortController)
|
||||
const { signal } = abortController
|
||||
// AbortController 现在由全局请求优化器自动管理
|
||||
const mediaid = getMediaId()
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season,
|
||||
title: props.media?.title,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
@@ -306,7 +305,7 @@ function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
|
||||
addSubscribe(season.season_number, best_version)
|
||||
addSubscribe(season.season_number ?? null, best_version)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -472,17 +471,29 @@ onBeforeUnmount(() => {
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
>
|
||||
<span class="font-bold">{{ props.media?.year }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
<span class="font-semibold text-sm">{{ props.media?.year }}</span>
|
||||
<h1 class="media-card-title font-bold mb-2 text-white line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
|
||||
<p class="media-card-overview line-clamp-3 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.overview }}
|
||||
</p>
|
||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||
<div v-else class="flex align-center justify-between">
|
||||
<IconBtn icon="mdi-magnify" color="white" @click.stop="clickSearch" />
|
||||
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
|
||||
<IconBtn
|
||||
v-if="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
|
||||
icon="mdi-magnify"
|
||||
color="white"
|
||||
size="small"
|
||||
@click.stop="clickSearch"
|
||||
/>
|
||||
<VSpacer />
|
||||
<IconBtn
|
||||
icon="mdi-heart"
|
||||
:color="isSubscribed ? 'error' : 'white'"
|
||||
size="small"
|
||||
@click.stop="handleSubscribe"
|
||||
/>
|
||||
</div>
|
||||
</VCardText>
|
||||
<!-- 类型角标 -->
|
||||
@@ -548,3 +559,14 @@ onBeforeUnmount(() => {
|
||||
@close="chooseSiteDialog = false"
|
||||
/>
|
||||
</template>
|
||||
<style scoped>
|
||||
.media-card-title {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.media-card-overview {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,10 +47,12 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem class="pb-1">
|
||||
<VCardTitle class="text-center text-md-left">
|
||||
<div class="text-center text-md-left text-h6 font-weight-bold line-clamp-2 overflow-hidden text-ellipsis">
|
||||
{{ context?.media_info?.title || context?.meta_info?.name }}
|
||||
{{ context?.meta_info?.season_episode }}
|
||||
</VCardTitle>
|
||||
<span v-if="context?.meta_info?.season_episode" class="text-sm text-medium-emphasis align-top">
|
||||
{{ context?.meta_info?.season_episode }}
|
||||
</span>
|
||||
</div>
|
||||
<VCardSubtitle class="text-center text-md-left">
|
||||
{{ context?.media_info?.year || context?.meta_info?.year }}
|
||||
</VCardSubtitle>
|
||||
@@ -87,6 +89,9 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
{{ context?.media_info?.tmdb_id }}
|
||||
</VChip>
|
||||
<!-- meta_info -->
|
||||
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
|
||||
{{ context?.meta_info?.web_source }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.meta_info?.edition }}
|
||||
</VChip>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import emby_image from '@images/logos/emby.png'
|
||||
import jellyfin_image from '@images/logos/jellyfin.png'
|
||||
import plex_image from '@images/logos/plex.png'
|
||||
import trimemedia_image from '@images/logos/trimemedia.png'
|
||||
import custom_image from '@images/logos/mediaserver.png'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaServerDict } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
@@ -61,6 +61,12 @@ const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
|
||||
},
|
||||
])
|
||||
|
||||
const ugreenScanModeOptions = computed(() => [
|
||||
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
|
||||
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
|
||||
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
|
||||
])
|
||||
|
||||
// 媒体服务器详情弹窗
|
||||
const mediaServerInfoDialog = ref(false)
|
||||
|
||||
@@ -77,6 +83,15 @@ function openMediaServerInfoDialog() {
|
||||
loadLibrary(props.mediaserver.name)
|
||||
// 深复制
|
||||
mediaServerInfo.value = cloneDeep(props.mediaserver)
|
||||
if (mediaServerInfo.value.type === 'ugreen') {
|
||||
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
|
||||
if (!mediaServerInfo.value.config.scan_mode) {
|
||||
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
|
||||
}
|
||||
if (mediaServerInfo.value.config.verify_ssl === undefined) {
|
||||
mediaServerInfo.value.config.verify_ssl = true
|
||||
}
|
||||
}
|
||||
mediaServerInfoDialog.value = true
|
||||
if (!props.mediaserver.sync_libraries) {
|
||||
mediaServerInfo.value.sync_libraries = ['all']
|
||||
@@ -105,15 +120,17 @@ function saveMediaServerInfo() {
|
||||
const getIcon = computed(() => {
|
||||
switch (props.mediaserver.type) {
|
||||
case 'emby':
|
||||
return emby_image
|
||||
return getLogoUrl('emby')
|
||||
case 'jellyfin':
|
||||
return jellyfin_image
|
||||
return getLogoUrl('jellyfin')
|
||||
case 'trimemedia':
|
||||
return trimemedia_image
|
||||
return getLogoUrl('trimemedia')
|
||||
case 'ugreen':
|
||||
return getLogoUrl('ugreen')
|
||||
case 'plex':
|
||||
return plex_image
|
||||
return getLogoUrl('plex')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('mediaserver')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -182,25 +199,45 @@ onMounted(() => {
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openMediaServerInfoDialog">
|
||||
<VCard variant="tonal" class="app-card-shell" @click="openMediaServerInfoDialog">
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
|
||||
<div v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled" class="text-sm mt-5 flex flex-wrap">
|
||||
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
|
||||
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
|
||||
<VCardText class="app-card-summary app-card-summary--single-action">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title text-h6">{{ mediaserver.name }}</div>
|
||||
<div
|
||||
v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled"
|
||||
class="grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis"
|
||||
>
|
||||
<span v-for="item in infoItems" :key="item.title" class="flex min-w-0 items-center">
|
||||
<VIcon rounded :icon="item.avatar" class="me-1 shrink-0" />
|
||||
<span class="truncate">{{ item.amount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="!mediaServerDict[mediaserver.type]" class="text-sm mt-5 flex flex-wrap">
|
||||
<span class="me-2 mb-1">自定义媒体服务器</span>
|
||||
<div v-else-if="!mediaServerDict[mediaserver.type]" class="app-card-summary__subtitle text-sm">
|
||||
自定义媒体服务器
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="getIcon" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem">
|
||||
<VCard :title="`${props.mediaserver.name} - ${t('common.config')}`">
|
||||
|
||||
<VDialog
|
||||
v-if="mediaServerInfoDialog"
|
||||
v-model="mediaServerInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('common.config') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
@@ -219,6 +256,7 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -229,6 +267,7 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -239,6 +278,17 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -248,10 +298,11 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.embyApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
@@ -262,6 +313,7 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -275,6 +327,7 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -285,6 +338,7 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -295,6 +349,7 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -304,10 +359,11 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.jellyfinApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
@@ -318,6 +374,7 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -331,6 +388,7 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -341,6 +399,7 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
@@ -351,10 +410,16 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="mediaServerInfo.config.username" :label="t('mediaserver.username')" active />
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
@@ -362,10 +427,11 @@ onMounted(() => {
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
@@ -376,10 +442,100 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="mediaServerInfo.config.scan_mode"
|
||||
:label="t('mediaserver.scanMode')"
|
||||
:items="ugreenScanModeOptions"
|
||||
:hint="t('mediaserver.scanModeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-radar"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaServerInfo.config.verify_ssl"
|
||||
:label="t('mediaserver.verifySsl')"
|
||||
:hint="t('mediaserver.verifySslHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
inset
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'plex'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
@@ -389,6 +545,7 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -399,6 +556,7 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -409,6 +567,7 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -418,25 +577,11 @@ onMounted(() => {
|
||||
:hint="t('mediaserver.plexTokenHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
@@ -447,6 +592,7 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -458,16 +604,22 @@ onMounted(() => {
|
||||
:label="t('mediaserver.type')"
|
||||
:hint="t('mediaserver.customTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField :label="t('common.name')" :hint="t('mediaserver.nameRequired')" persistent-hint />
|
||||
<VTextField
|
||||
:label="t('common.name')"
|
||||
:hint="t('mediaserver.nameRequired')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveMediaServerInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
@@ -10,15 +12,35 @@ const props = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['imageload'])
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
emit('imageload')
|
||||
}
|
||||
|
||||
// 链接打开新窗口
|
||||
@@ -38,10 +60,10 @@ function noteToJson() {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 将\n转换为html属性的换行符
|
||||
function replaceNewLine(value: string) {
|
||||
// 渲染 Markdown
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return value.replace(/\n/g, '<br/>')
|
||||
return md.render(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -55,7 +77,14 @@ function replaceNewLine(value: string) {
|
||||
position="top"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
min-height="10rem"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
@@ -74,19 +103,23 @@ function replaceNewLine(value: string) {
|
||||
</VCardTitle>
|
||||
<div
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
|
||||
>
|
||||
<p class="mb-0">{{ props.message?.text }}</p>
|
||||
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
|
||||
</div>
|
||||
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
|
||||
<VCardText
|
||||
v-if="props.message?.text && props.message?.action === 1"
|
||||
class="markdown-body"
|
||||
v-html="renderMarkdown(props.message?.text)"
|
||||
/>
|
||||
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title_year }}
|
||||
{{ Number(key) + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
||||
@@ -105,3 +138,89 @@ function replaceNewLine(value: string) {
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-body {
|
||||
word-break: break-all;
|
||||
|
||||
p {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
margin-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
font-family: monospace;
|
||||
padding-block: 0.2rem;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
margin-block-end: 0.5rem;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
|
||||
font-style: italic;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
margin-block-end: 1rem;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.1);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: rgba(var(--v-border-color), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { NotificationConf } from '@/api/types'
|
||||
import wechat_image from '@images/logos/wechat.png'
|
||||
import telegram_image from '@images/logos/telegram.webp'
|
||||
import vocechat_image from '@images/logos/vocechat.png'
|
||||
import synologychat_image from '@images/logos/synologychat.png'
|
||||
import slack_image from '@images/logos/slack.webp'
|
||||
import chrome_image from '@images/logos/chrome.png'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -47,10 +46,13 @@ const notificationInfo = ref<NotificationConf>({
|
||||
const notificationTypeNames: { [key: string]: string } = {
|
||||
wechat: t('notification.wechat.name'),
|
||||
telegram: t('notification.telegram.name'),
|
||||
qqbot: t('notification.qqbot.name'),
|
||||
vocechat: t('notification.vocechat.name'),
|
||||
synologychat: t('notification.synologychat.name'),
|
||||
slack: t('notification.slack.name'),
|
||||
discord: t('notification.discord.name'),
|
||||
webpush: t('notification.webpush.name'),
|
||||
custom: t('setting.notification.custom'),
|
||||
}
|
||||
|
||||
// 消息类型下拉字典
|
||||
@@ -62,13 +64,43 @@ const notificationTypes = [
|
||||
{ value: '媒体服务器', title: t('notificationSwitch.mediaServer') },
|
||||
{ value: '手动处理', title: t('notificationSwitch.manual') },
|
||||
{ value: '插件', title: t('notificationSwitch.plugin') },
|
||||
{ value: '智能体', title: t('notificationSwitch.agent') },
|
||||
{ value: '其它', title: t('notificationSwitch.other') },
|
||||
]
|
||||
|
||||
function ensureWechatConfigDefaults(notification: NotificationConf) {
|
||||
if (notification.type !== 'wechat') {
|
||||
return
|
||||
}
|
||||
if (!notification.config) {
|
||||
notification.config = {}
|
||||
}
|
||||
if (!notification.config.WECHAT_MODE) {
|
||||
notification.config.WECHAT_MODE = 'app'
|
||||
}
|
||||
if (!notification.config.WECHAT_BOT_WS_URL) {
|
||||
notification.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||
}
|
||||
}
|
||||
|
||||
const isWechatBotMode = computed({
|
||||
get: () => notificationInfo.value.config?.WECHAT_MODE === 'bot',
|
||||
set: value => {
|
||||
if (!notificationInfo.value.config) {
|
||||
notificationInfo.value.config = {}
|
||||
}
|
||||
notificationInfo.value.config.WECHAT_MODE = value ? 'bot' : 'app'
|
||||
if (value && !notificationInfo.value.config.WECHAT_BOT_WS_URL) {
|
||||
notificationInfo.value.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 打开详情弹窗
|
||||
function openNotificationInfoDialog() {
|
||||
// 替换成深复制,避免修改时影响原数据
|
||||
notificationInfo.value = cloneDeep(props.notification)
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = true
|
||||
}
|
||||
|
||||
@@ -84,6 +116,7 @@ function saveNotificationInfo() {
|
||||
$toast.error(t('notification.channel') + `【${notificationInfo.value.name}】` + t('common.exists'))
|
||||
return
|
||||
}
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = false
|
||||
emit('change', notificationInfo.value, props.notification.name)
|
||||
emit('done')
|
||||
@@ -93,19 +126,23 @@ function saveNotificationInfo() {
|
||||
const getIcon = computed(() => {
|
||||
switch (props.notification.type) {
|
||||
case 'wechat':
|
||||
return wechat_image
|
||||
return getLogoUrl('wechat')
|
||||
case 'telegram':
|
||||
return telegram_image
|
||||
return getLogoUrl('telegram')
|
||||
case 'qqbot':
|
||||
return getLogoUrl('qq')
|
||||
case 'vocechat':
|
||||
return vocechat_image
|
||||
return getLogoUrl('vocechat')
|
||||
case 'synologychat':
|
||||
return synologychat_image
|
||||
return getLogoUrl('synologychat')
|
||||
case 'slack':
|
||||
return slack_image
|
||||
return getLogoUrl('slack')
|
||||
case 'discord':
|
||||
return getLogoUrl('discord')
|
||||
case 'webpush':
|
||||
return chrome_image
|
||||
return getLogoUrl('chrome')
|
||||
default:
|
||||
return wechat_image
|
||||
return getLogoUrl('notification')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -116,27 +153,43 @@ function onClose() {
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openNotificationInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="openNotificationInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<div class="flex items-center">
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title-row">
|
||||
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
|
||||
<span class="text-h6">{{ props.notification.name }}</span>
|
||||
<span class="app-card-summary__title text-h6">{{ props.notification.name }}</span>
|
||||
</div>
|
||||
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
|
||||
<div class="app-card-summary__subtitle text-body-1">{{ notificationTypeNames[notification.type] }}</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="getIcon" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem">
|
||||
<VCard :title="`${props.notification.name} - ${t('notification.config')}`">
|
||||
<VDialogCloseBtn v-model="notificationInfoDialog" />
|
||||
|
||||
<VDialog
|
||||
v-if="notificationInfoDialog"
|
||||
v-model="notificationInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('common.config') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.notification.name }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="notificationInfoDialog = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
@@ -145,7 +198,7 @@ function onClose() {
|
||||
<VSwitch v-model="notificationInfo.enabled" :label="t('notification.enabled')" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="notificationInfo.switchs"
|
||||
:items="notificationTypes"
|
||||
:label="t('notification.type')"
|
||||
@@ -154,6 +207,7 @@ function onClose() {
|
||||
clearable
|
||||
chips
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-bell-outline"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -165,67 +219,135 @@ function onClose() {
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_CORPID"
|
||||
:label="t('notification.wechat.corpId')"
|
||||
:hint="t('notification.wechat.corpIdHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_ID"
|
||||
:label="t('notification.wechat.appId')"
|
||||
:hint="t('notification.wechat.appIdHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_SECRET"
|
||||
:label="t('notification.wechat.appSecret')"
|
||||
:hint="t('notification.wechat.appSecretHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_PROXY"
|
||||
:label="t('notification.wechat.proxy')"
|
||||
:hint="t('notification.wechat.proxyHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_TOKEN"
|
||||
:label="t('notification.wechat.token')"
|
||||
:hint="t('notification.wechat.tokenHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
|
||||
:label="t('notification.wechat.encodingAesKey')"
|
||||
:hint="t('notification.wechat.encodingAesKeyHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
:label="t('notification.wechat.admins')"
|
||||
:placeholder="t('notification.wechat.adminsPlaceholder')"
|
||||
:hint="t('notification.wechat.adminsHint')"
|
||||
<VSwitch
|
||||
v-model="isWechatBotMode"
|
||||
:label="t('notification.wechat.useBotMode')"
|
||||
:hint="t('notification.wechat.useBotModeHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
<template v-if="isWechatBotMode">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_ID"
|
||||
:label="t('notification.wechat.botId')"
|
||||
:hint="t('notification.wechat.botIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-robot"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_SECRET"
|
||||
:label="t('notification.wechat.botSecret')"
|
||||
:hint="t('notification.wechat.botSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_CHAT_ID"
|
||||
:label="t('notification.wechat.botChatId')"
|
||||
:placeholder="t('notification.wechat.botChatIdPlaceholder')"
|
||||
:hint="t('notification.wechat.botChatIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-chat-processing"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_WS_URL"
|
||||
:label="t('notification.wechat.botWsUrl')"
|
||||
:hint="t('notification.wechat.botWsUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lan-connect"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
:label="t('notification.wechat.admins')"
|
||||
:placeholder="t('notification.wechat.adminsPlaceholder')"
|
||||
:hint="t('notification.wechat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
<template v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_CORPID"
|
||||
:label="t('notification.wechat.corpId')"
|
||||
:hint="t('notification.wechat.corpIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-domain"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_ID"
|
||||
:label="t('notification.wechat.appId')"
|
||||
:hint="t('notification.wechat.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_SECRET"
|
||||
:label="t('notification.wechat.appSecret')"
|
||||
:hint="t('notification.wechat.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_PROXY"
|
||||
:label="t('notification.wechat.proxy')"
|
||||
:hint="t('notification.wechat.proxyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_TOKEN"
|
||||
:label="t('notification.wechat.token')"
|
||||
:hint="t('notification.wechat.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
|
||||
:label="t('notification.wechat.encodingAesKey')"
|
||||
:hint="t('notification.wechat.encodingAesKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
:label="t('notification.wechat.admins')"
|
||||
:placeholder="t('notification.wechat.adminsPlaceholder')"
|
||||
:hint="t('notification.wechat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'telegram'">
|
||||
<VRow v-else-if="notificationInfo.type == 'telegram'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
@@ -233,6 +355,7 @@ function onClose() {
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -241,6 +364,7 @@ function onClose() {
|
||||
:label="t('notification.telegram.token')"
|
||||
:hint="t('notification.telegram.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -249,6 +373,7 @@ function onClose() {
|
||||
:label="t('notification.telegram.chatId')"
|
||||
:hint="t('notification.telegram.chatIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-chat"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -258,6 +383,7 @@ function onClose() {
|
||||
:placeholder="t('notification.telegram.usersPlaceholder')"
|
||||
:hint="t('notification.telegram.usersHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -267,10 +393,21 @@ function onClose() {
|
||||
:placeholder="t('notification.telegram.adminsPlaceholder')"
|
||||
:hint="t('notification.telegram.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.API_URL"
|
||||
:label="t('notification.telegram.apiUrl')"
|
||||
:placeholder="t('notification.telegram.apiUrlPlaceholder')"
|
||||
:hint="t('notification.telegram.apiUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-web"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'slack'">
|
||||
<VRow v-else-if="notificationInfo.type == 'slack'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
@@ -278,6 +415,7 @@ function onClose() {
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -287,6 +425,7 @@ function onClose() {
|
||||
:placeholder="t('notification.slack.oauthTokenPlaceholder')"
|
||||
:hint="t('notification.slack.oauthTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -296,6 +435,7 @@ function onClose() {
|
||||
:placeholder="t('notification.slack.appTokenPlaceholder')"
|
||||
:hint="t('notification.slack.appTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -305,10 +445,11 @@ function onClose() {
|
||||
:placeholder="t('notification.slack.channelPlaceholder')"
|
||||
:hint="t('notification.slack.channelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'synologychat'">
|
||||
<VRow v-else-if="notificationInfo.type == 'discord'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
@@ -316,6 +457,48 @@ function onClose() {
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.DISCORD_BOT_TOKEN"
|
||||
:label="t('notification.discord.botToken')"
|
||||
:hint="t('notification.discord.botTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.DISCORD_GUILD_ID"
|
||||
:label="t('notification.discord.guildId')"
|
||||
:placeholder="t('notification.discord.guildIdPlaceholder')"
|
||||
:hint="t('notification.discord.guildIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.DISCORD_CHANNEL_ID"
|
||||
:label="t('notification.discord.channelId')"
|
||||
:placeholder="t('notification.discord.channelIdPlaceholder')"
|
||||
:hint="t('notification.discord.channelIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound-box"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'synologychat'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -324,6 +507,7 @@ function onClose() {
|
||||
:label="t('notification.synologychat.webhook')"
|
||||
:hint="t('notification.synologychat.webhookHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-webhook"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -332,10 +516,11 @@ function onClose() {
|
||||
:label="t('notification.synologychat.token')"
|
||||
:hint="t('notification.synologychat.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'vocechat'">
|
||||
<VRow v-else-if="notificationInfo.type == 'vocechat'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
@@ -343,6 +528,7 @@ function onClose() {
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -351,6 +537,7 @@ function onClose() {
|
||||
:label="t('notification.vocechat.host')"
|
||||
:hint="t('notification.vocechat.hostHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -359,6 +546,7 @@ function onClose() {
|
||||
:label="t('notification.vocechat.apiKey')"
|
||||
:hint="t('notification.vocechat.apiKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -368,10 +556,11 @@ function onClose() {
|
||||
:placeholder="t('notification.vocechat.channelIdPlaceholder')"
|
||||
:hint="t('notification.vocechat.channelIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'webpush'">
|
||||
<VRow v-else-if="notificationInfo.type == 'qqbot'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
@@ -379,6 +568,57 @@ function onClose() {
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_APP_ID"
|
||||
:label="t('notification.qqbot.appId')"
|
||||
:hint="t('notification.qqbot.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_APP_SECRET"
|
||||
:label="t('notification.qqbot.appSecret')"
|
||||
:hint="t('notification.qqbot.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_OPENID"
|
||||
:label="t('notification.qqbot.openId')"
|
||||
:placeholder="t('notification.qqbot.openIdPlaceholder')"
|
||||
:hint="t('notification.qqbot.openIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_GROUP_OPENID"
|
||||
:label="t('notification.qqbot.groupOpenId')"
|
||||
:placeholder="t('notification.qqbot.groupOpenIdPlaceholder')"
|
||||
:hint="t('notification.qqbot.groupOpenIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'webpush'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -387,13 +627,35 @@ function onClose() {
|
||||
:label="t('notification.webpush.username')"
|
||||
:hint="t('notification.webpush.usernameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.type"
|
||||
:label="t('notification.type')"
|
||||
:hint="t('notification.customTypeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:hint="t('notification.nameRequired')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveNotificationInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import personIcon from '@images/misc/person-icon.png'
|
||||
import type { Person } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
const personProps = defineProps({
|
||||
person: Object as PropType<Person>,
|
||||
@@ -10,7 +11,9 @@ const personProps = defineProps({
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前人物
|
||||
const personInfo = ref(personProps.person)
|
||||
@@ -87,7 +90,7 @@ function goPersonDetail() {
|
||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
||||
<VAvatar
|
||||
size="120"
|
||||
size="100"
|
||||
:class="{
|
||||
'ring-1 ring-gray-700': isImageLoaded,
|
||||
}"
|
||||
@@ -98,10 +101,7 @@ function goPersonDetail() {
|
||||
<div class="w-full truncate text-center font-bold">
|
||||
{{ getPersonName() }}
|
||||
</div>
|
||||
<div
|
||||
class="overflow-hidden whitespace-normal text-center text-sm"
|
||||
style="display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2"
|
||||
>
|
||||
<div class="overflow-hidden whitespace-normal text-center text-sm text-ellipsis line-clamp-2">
|
||||
{{ getPersonCharacter() }}
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import VersionHistory from '../misc/VersionHistory.vue'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -36,7 +37,17 @@ const $toast = useToast()
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度框文本
|
||||
const progressText = ref('正在安装插件...')
|
||||
const progressText = ref('')
|
||||
|
||||
// 获取当前插件的标签
|
||||
const pluginLabels = computed(() => {
|
||||
if (!props.plugin?.plugin_label) return []
|
||||
|
||||
return props.plugin.plugin_label
|
||||
.split(',')
|
||||
.map(tag => tag.trim())
|
||||
.filter(tag => tag.length > 0)
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
@@ -93,10 +104,12 @@ async function installPlugin() {
|
||||
|
||||
// 计算图标路径
|
||||
const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
props.plugin?.plugin_icon,
|
||||
)}&cache=true`
|
||||
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
})
|
||||
@@ -105,6 +118,9 @@ const iconPath: Ref<string> = computed(() => {
|
||||
function visitPluginPage() {
|
||||
// 将raw.githubusercontent.com转换为项目地址
|
||||
let repoUrl = props.plugin?.repo_url
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
@@ -167,47 +183,77 @@ const dropdownItems = ref([
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ..."
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
<VCardText class="text-white text-sm px-2 py-0 text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</VCardText>
|
||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div
|
||||
class="text-white text-sm px-2 py-1 text-shadow overflow-hidden ..."
|
||||
:class="{ 'line-clamp-3': !props.plugin?.plugin_label, 'line-clamp-2': props.plugin?.plugin_label }"
|
||||
>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
<!-- 插件标签 -->
|
||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2">
|
||||
<VChip
|
||||
v-for="tag in pluginLabels"
|
||||
:key="tag"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="me-1 mb-1"
|
||||
tile
|
||||
>
|
||||
{{ tag }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3">
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span>
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
<div class="me-n3 absolute bottom-1 right-3">
|
||||
<VCardText
|
||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||
>
|
||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VIcon size="small" icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem v-for="(item, i) in dropdownItems" v-show="item.show" :key="i" @click="item.props.click">
|
||||
@@ -285,7 +331,7 @@ const dropdownItems = ref([
|
||||
}}</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />{{
|
||||
t('plugin.totalDownloads', { count: props.count?.toLocaleString() })
|
||||
t('plugin.totalDownloads', { count: formatDownloadCount(props.count) })
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@core/utils'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
|
||||
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
|
||||
import LoggingView from '@/views/system/LoggingView.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -54,6 +60,9 @@ const progressDialog = ref(false)
|
||||
// 插件数据页面
|
||||
const pluginInfoDialog = ref(false)
|
||||
|
||||
// 实时日志弹窗
|
||||
const loggingDialog = ref(false)
|
||||
|
||||
// 进度框文本
|
||||
const progressText = ref('正在更新插件...')
|
||||
|
||||
@@ -69,6 +78,18 @@ const imageLoadError = ref(false)
|
||||
// 更新日志弹窗
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
// 插件分身对话框
|
||||
const pluginCloneDialog = ref(false)
|
||||
|
||||
// 插件分身表单
|
||||
const cloneForm = ref({
|
||||
suffix: '',
|
||||
name: '',
|
||||
description: '',
|
||||
version: '',
|
||||
icon: '',
|
||||
})
|
||||
|
||||
// 监听动作标识,如为true则打开详情
|
||||
watch(
|
||||
() => props.action,
|
||||
@@ -120,7 +141,12 @@ async function uninstallPlugin() {
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
} else {
|
||||
$toast.error(t('plugin.uninstallFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
$toast.error(
|
||||
t('plugin.uninstallFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: result.message,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -142,10 +168,12 @@ async function showPluginConfig() {
|
||||
|
||||
// 计算图标路径
|
||||
const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
props.plugin?.plugin_icon,
|
||||
)}&cache=true`
|
||||
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
})
|
||||
@@ -155,7 +183,7 @@ const authorPath: Ref<string> = computed(() => {
|
||||
// 网络图片则使用代理后返回
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
props.plugin?.author_url + '.png',
|
||||
)}`
|
||||
)}&cache=true`
|
||||
})
|
||||
|
||||
// 重置插件
|
||||
@@ -174,7 +202,12 @@ async function resetPlugin() {
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(t('plugin.resetFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
$toast.error(
|
||||
t('plugin.resetFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: result.message,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -205,7 +238,12 @@ async function updatePlugin() {
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(t('plugin.updateFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
$toast.error(
|
||||
t('plugin.updateFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: result.message,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -237,6 +275,54 @@ function configDone() {
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// 显示插件分身对话框
|
||||
function showPluginClone() {
|
||||
cloneForm.value = {
|
||||
suffix: '',
|
||||
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
|
||||
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
|
||||
version: props.plugin?.plugin_version || '1.0',
|
||||
icon: props.plugin?.plugin_icon || '',
|
||||
}
|
||||
pluginCloneDialog.value = true
|
||||
}
|
||||
|
||||
// 执行插件分身
|
||||
async function executePluginClone() {
|
||||
if (!cloneForm.value.suffix.trim()) {
|
||||
$toast.error(t('plugin.suffixRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
progressDialog.value = true
|
||||
progressText.value = t('plugin.cloning', { name: props.plugin?.plugin_name })
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
||||
suffix: cloneForm.value.suffix.trim(),
|
||||
name: cloneForm.value.name.trim(),
|
||||
description: cloneForm.value.description.trim(),
|
||||
version: cloneForm.value.version.trim(),
|
||||
icon: cloneForm.value.icon.trim(),
|
||||
})
|
||||
|
||||
progressDialog.value = false
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.value.name }))
|
||||
pluginCloneDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
} else {
|
||||
$toast.error(t('plugin.cloneFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
progressDialog.value = false
|
||||
$toast.error(t('plugin.cloneFailedGeneral'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -257,6 +343,16 @@ const dropdownItems = ref([
|
||||
click: showPluginConfig,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('plugin.clone'),
|
||||
value: 8,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-content-copy',
|
||||
color: 'info',
|
||||
click: showPluginClone,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('plugin.update'),
|
||||
value: 3,
|
||||
@@ -294,7 +390,7 @@ const dropdownItems = ref([
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
click: () => {
|
||||
openLoggerWindow()
|
||||
loggingDialog.value = true
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -328,7 +424,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="h-full">
|
||||
<!-- 插件卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
@@ -344,46 +440,63 @@ watch(
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<VBadge v-if="props.plugin?.state" dot inline color="success" />
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
<VCardText class="px-2 py-0 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center cursor-move">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</VCardText>
|
||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div class="px-2 py-1 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span class="author-info">
|
||||
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
||||
<VIcon v-if="!isAvatarLoaded" icon="mdi-github" class="me-1" />
|
||||
</VImg>
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
<div class="me-n3 absolute bottom-1 right-3">
|
||||
<VCardText
|
||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||
>
|
||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
||||
<template #default>
|
||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||
</template>
|
||||
</VImg>
|
||||
<a
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</div>
|
||||
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
@@ -435,7 +548,7 @@ watch(
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
|
||||
<!-- 更新日志 -->
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" max-height="85vh" scrollable>
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VDivider />
|
||||
@@ -451,6 +564,144 @@ watch(
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 实时日志弹窗 -->
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="72rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="loggingDialog = false" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
<VIcon icon="mdi-file-document" class="me-2" />
|
||||
{{ t('plugin.logTitle') }}
|
||||
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 插件分身对话框 -->
|
||||
<VDialog
|
||||
v-if="pluginCloneDialog"
|
||||
v-model="pluginCloneDialog"
|
||||
width="600"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-copy" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="pluginCloneDialog = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.suffix"
|
||||
:label="t('plugin.suffix') + ' *'"
|
||||
:placeholder="t('plugin.suffixPlaceholder')"
|
||||
:hint="t('plugin.suffixHint')"
|
||||
persistent-hint
|
||||
:rules="[
|
||||
v => !!v || t('plugin.suffixRequired'),
|
||||
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
|
||||
v => v.length <= 20 || t('plugin.suffixLengthError'),
|
||||
]"
|
||||
required
|
||||
prepend-inner-icon="mdi-tag"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.name"
|
||||
:label="t('plugin.cloneName')"
|
||||
:placeholder="t('plugin.cloneNamePlaceholder')"
|
||||
:hint="t('plugin.cloneNameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-rename-box"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="cloneForm.description"
|
||||
:label="t('plugin.cloneDescriptionLabel')"
|
||||
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
|
||||
:hint="t('plugin.cloneDescriptionHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-text"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.version"
|
||||
:label="t('plugin.cloneVersion')"
|
||||
:placeholder="t('plugin.cloneVersionPlaceholder')"
|
||||
:hint="t('plugin.cloneVersionHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-numeric"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.icon"
|
||||
:label="t('plugin.cloneIcon')"
|
||||
:placeholder="t('plugin.cloneIconPlaceholder')"
|
||||
:hint="t('plugin.cloneIconHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-image"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<!-- 重要提醒 -->
|
||||
<VCol cols="12">
|
||||
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
|
||||
<div class="text-body-2">
|
||||
<strong>{{ t('common.notice') }}</strong
|
||||
>:{{ t('plugin.cloneNotice') }}
|
||||
</div>
|
||||
</VAlert>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="executePluginClone"
|
||||
prepend-icon="mdi-content-copy"
|
||||
class="px-5"
|
||||
:disabled="!cloneForm.suffix.trim()"
|
||||
>
|
||||
{{ t('plugin.createClone') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -465,11 +716,6 @@ watch(
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.author-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
border-radius: 50%;
|
||||
block-size: 24px;
|
||||
|
||||