Compare commits
578 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6083887675 | ||
|
|
beb0506b0c | ||
|
|
0f906f791a | ||
|
|
7614696e61 | ||
|
|
4235d3687c | ||
|
|
2960e7cfde | ||
|
|
e0ebc35178 | ||
|
|
07c9442ac8 | ||
|
|
ccc820e8d2 | ||
|
|
68bb568400 | ||
|
|
13cd214e6d | ||
|
|
311880bcd3 | ||
|
|
088ebbe0bb | ||
|
|
de3523056a | ||
|
|
cf139a938e | ||
|
|
be2f4d0170 | ||
|
|
79493665c1 | ||
|
|
106062da82 | ||
|
|
50e54e943d | ||
|
|
6b811f2250 | ||
|
|
fa7f2a6c7c | ||
|
|
e362f3cbdd | ||
|
|
f4c4d7495f | ||
|
|
5b850d9464 | ||
|
|
d7f74a3a8a | ||
|
|
91dbf065db | ||
|
|
1759e666ba | ||
|
|
65230f1ae8 | ||
|
|
508cf5d08f | ||
|
|
0e9ddc9da2 | ||
|
|
48e6fc4466 | ||
|
|
30a4c55050 | ||
|
|
dee5d9d213 | ||
|
|
c5e2b1349f | ||
|
|
0e005c3c7e | ||
|
|
348ae6b313 | ||
|
|
122ecc82fd | ||
|
|
88fad5b764 | ||
|
|
f01971ee3a | ||
|
|
5e8489c620 | ||
|
|
6900042cf7 | ||
|
|
75862c026a | ||
|
|
bbe3368c69 | ||
|
|
587f06eb9f | ||
|
|
7114c63e8f | ||
|
|
2a6f9e3cc0 | ||
|
|
00d37d7bda | ||
|
|
546af84dab | ||
|
|
5953496d84 | ||
|
|
0fda7c70de | ||
|
|
48546e1999 | ||
|
|
06355ff91d | ||
|
|
523f8c4cc8 | ||
|
|
73f6e7482f | ||
|
|
81ab3f9da8 | ||
|
|
d520645a8b | ||
|
|
af67fddce0 | ||
|
|
6d89dad8de | ||
|
|
f3ab2a8eff | ||
|
|
74c980c7a5 | ||
|
|
52fc2557ec | ||
|
|
34124418f8 | ||
|
|
e2d36da299 | ||
|
|
9965428bae | ||
|
|
e62a0b5a8d | ||
|
|
3c926f7485 | ||
|
|
de3f4e6374 | ||
|
|
2e22f6ae86 | ||
|
|
99665c7d79 | ||
|
|
a4a00586c7 | ||
|
|
cf59a07d4b | ||
|
|
8a362d0740 | ||
|
|
b49385af29 | ||
|
|
b227412c96 | ||
|
|
b3c8faab70 | ||
|
|
9a480dd803 | ||
|
|
847fd13982 | ||
|
|
7fa4f4a2f0 | ||
|
|
4207a70716 | ||
|
|
c97247b92b | ||
|
|
e9bed7ff8a | ||
|
|
f25a619f13 | ||
|
|
2065b05143 | ||
|
|
eec1f2d7b3 | ||
|
|
17a343392c | ||
|
|
a2b2e8cd94 | ||
|
|
9703b2dbee | ||
|
|
310a501380 | ||
|
|
30bf895ae1 | ||
|
|
4f9dce70d3 | ||
|
|
f495e13667 | ||
|
|
f293681588 | ||
|
|
2f1a356e65 | ||
|
|
5909d2423c | ||
|
|
42f7df8f4a | ||
|
|
abaa40d819 | ||
|
|
0d05a104c4 | ||
|
|
e8708f8de7 | ||
|
|
7918b21b5b | ||
|
|
088db67089 | ||
|
|
62e0d8e9dc | ||
|
|
96d655155a | ||
|
|
a475085d7b | ||
|
|
58fdb77b37 | ||
|
|
8a25c6578d | ||
|
|
ef62bd6e98 | ||
|
|
876a46607b | ||
|
|
107f70abde | ||
|
|
090b9d735d | ||
|
|
dbeea6afcc | ||
|
|
2931f5df46 | ||
|
|
e14c81d178 | ||
|
|
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 |
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/
|
||||
|
||||
1
components.d.ts
vendored
@@ -10,7 +10,6 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ConfirmDialog: typeof import('./src/@core/components/ConfirmDialog.vue')['default']
|
||||
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
|
||||
DialogWrapper: typeof import('./src/@core/components/DialogWrapper.vue')['default']
|
||||
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
|
||||
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
|
||||
LoadingBanner: typeof import('./src/@core/components/LoadingBanner.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. 构建和部署
|
||||
|
||||
### 构建项目
|
||||
|
||||
5
env.d.ts
vendored
@@ -4,8 +4,13 @@ declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
action?: string
|
||||
subject?: string
|
||||
keepAlive?: boolean
|
||||
keepAliveKey?: string
|
||||
layoutWrapperClasses?: string
|
||||
navActiveLink?: RouteLocationRaw
|
||||
requiresAuth?: boolean
|
||||
subType?: string
|
||||
hideFooter?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
417
index.html
@@ -1,10 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" style="
|
||||
overflow: hidden auto;
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
<html lang="zh-CN" data-launch-loading="true" style="
|
||||
overflow: hidden;
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
--initial-loader-bg: #0E1116;
|
||||
--initial-loader-color: #9155FD;
|
||||
--initial-loader-height: 100svh;
|
||||
--initial-loader-width: 100vw;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
">
|
||||
|
||||
<head>
|
||||
@@ -12,7 +16,7 @@
|
||||
<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" />
|
||||
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" />
|
||||
@@ -33,7 +37,7 @@
|
||||
|
||||
<!-- iOS Safari PWA 优化 -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
|
||||
|
||||
<!-- iOS Safari 全屏模式 -->
|
||||
@@ -90,57 +94,99 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
|
||||
<!-- 预加载关键资源 -->
|
||||
<link rel="preload" href="/logo.png" as="image" />
|
||||
<link rel="modulepreload" href="/src/main.ts" />
|
||||
|
||||
<!-- 内联关键CSS -->
|
||||
<style>
|
||||
/* 关键路径CSS - 从loader.css内联 */
|
||||
html,
|
||||
body {
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"],
|
||||
html[data-launch-loading="true"] body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] body {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] #app {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
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;
|
||||
overflow: hidden;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
block-size: var(--initial-loader-height, 100svh);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
transition: opacity 0.12s ease-out, transform 0.12s ease-out;
|
||||
padding:
|
||||
calc(env(safe-area-inset-top, 0px) + 24px)
|
||||
24px
|
||||
calc(env(safe-area-inset-bottom, 0px) + 48px);
|
||||
}
|
||||
|
||||
.loading-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: min(160px, 36vw);
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 添加logo完成动画 - 放大虚化效果 */
|
||||
.loading-complete .loading-logo {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
.loading-logo img {
|
||||
display: block;
|
||||
block-size: auto;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
/* 添加加载背景消失动画 - 放大虚化效果 */
|
||||
.loading-complete {
|
||||
filter: blur(15px);
|
||||
.loading-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: clamp(72px, 14vh, 120px);
|
||||
}
|
||||
|
||||
.loading-complete .loading-shell,
|
||||
.loading-complete #loading-timeout {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
transform: translate3d(0, 6px, 0);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
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);
|
||||
block-size: 46px;
|
||||
inline-size: 46px;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
/* 完成时隐藏加载动画 */
|
||||
.loading-complete .loading {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -194,15 +240,107 @@
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
/* 超时通知样式 */
|
||||
#loading-timeout {
|
||||
position: absolute;
|
||||
z-index: 2500;
|
||||
display: none;
|
||||
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||
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);
|
||||
max-inline-size: calc(100% - 32px);
|
||||
white-space: normal;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
|
||||
const launchThemeBackgrounds = {
|
||||
light: '#F4F5FA',
|
||||
dark: '#0E1116',
|
||||
purple: '#28243D',
|
||||
transparent: '#1C1C1C',
|
||||
default: '#F4F5FA',
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const resolvedLaunchTheme = savedTheme === 'auto'
|
||||
? (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
|
||||
: savedTheme
|
||||
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
|| launchThemeBackgrounds[resolvedLaunchTheme]
|
||||
|| launchThemeBackgrounds.light
|
||||
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
if (!primaryColor) {
|
||||
primaryColor = '#9155FD'
|
||||
}
|
||||
|
||||
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去。
|
||||
function syncInitialViewport(force) {
|
||||
const viewport = window.visualViewport
|
||||
const nextHeight = Math.round(viewport?.height || window.innerHeight || document.documentElement.clientHeight || 0)
|
||||
const nextWidth = Math.round(viewport?.width || window.innerWidth || document.documentElement.clientWidth || 0)
|
||||
const currentHeight = parseInt(
|
||||
document.documentElement.style.getPropertyValue('--initial-loader-height') || '0',
|
||||
10,
|
||||
)
|
||||
|
||||
if (!nextHeight || !nextWidth) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && currentHeight && Math.abs(nextHeight - currentHeight) < 120) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty('--initial-loader-height', `${nextHeight}px`)
|
||||
document.documentElement.style.setProperty('--initial-loader-width', `${nextWidth}px`)
|
||||
}
|
||||
|
||||
// 应用主题色彩
|
||||
document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme)
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
document.documentElement.style.backgroundColor = loaderColor
|
||||
syncInitialViewport(true)
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.style.backgroundColor = loaderColor
|
||||
})
|
||||
|
||||
window.addEventListener('orientationchange', () => {
|
||||
window.setTimeout(() => syncInitialViewport(true), 160)
|
||||
})
|
||||
|
||||
// 状态栏适配
|
||||
if (window.navigator.standalone) {
|
||||
@@ -212,7 +350,8 @@
|
||||
// 安全区域适配
|
||||
function updateSafeArea() {
|
||||
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')
|
||||
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)')
|
||||
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)
|
||||
@@ -221,121 +360,95 @@
|
||||
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; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch;">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
<div class="loading-shell">
|
||||
<div class="loading-main">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-footer">
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 超时提示 - 默认隐藏 -->
|
||||
<div id="loading-timeout"></div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
20
package.json
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.6.4",
|
||||
"version": "2.12.3",
|
||||
"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,19 +42,23 @@
|
||||
"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",
|
||||
@@ -66,16 +72,24 @@
|
||||
"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-json/tabler": "^1.2.23",
|
||||
"@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",
|
||||
@@ -114,4 +128,4 @@
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.18"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 102 KiB |
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 |
@@ -49,7 +49,7 @@ http {
|
||||
root html;
|
||||
}
|
||||
|
||||
location ~ ^/api/v1/system/(message|progress/) {
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
|
||||
# SSE MIME类型设置
|
||||
default_type text/event-stream;
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
|
||||
<div class="status-badge">
|
||||
<span class="status-dot"></span>
|
||||
<span>离线模式</span>
|
||||
<span>离线状态</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -157,4 +157,4 @@
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</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';
|
||||
|
||||
@@ -59,7 +59,7 @@ function handleCancel() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogWrapper :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" :max-width="width">
|
||||
<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">
|
||||
@@ -82,5 +82,5 @@ function handleCancel() {
|
||||
</VCardActions>
|
||||
<VDialogCloseBtn @click="handleCancel" />
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</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,70 +0,0 @@
|
||||
<template>
|
||||
<VDialog v-model="dialogModel" v-bind="$attrs" @update:model-value="handleDialogChange">
|
||||
<slot />
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useScrollLockWithWatch } from '@/composables/useScrollLock'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
modelValue?: boolean
|
||||
// 滚动锁定配置
|
||||
scrollLock?: boolean
|
||||
preserveScrollPosition?: boolean
|
||||
preventTouchScroll?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scrollLock: true,
|
||||
preserveScrollPosition: true,
|
||||
preventTouchScroll: true,
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
// 计算属性
|
||||
const dialogModel = computed({
|
||||
get: () => props.modelValue || false,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// 使用滚动锁定
|
||||
const { isLocked, lockScroll, restoreScroll } = useScrollLockWithWatch(dialogModel, {
|
||||
autoRestore: true,
|
||||
preserveScrollPosition: props.preserveScrollPosition,
|
||||
preventTouchScroll: props.preventTouchScroll,
|
||||
})
|
||||
|
||||
// 处理弹窗状态变化
|
||||
const handleDialogChange = (value: boolean) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// 监听弹窗状态变化
|
||||
watch(
|
||||
dialogModel,
|
||||
newValue => {
|
||||
if (props.scrollLock) {
|
||||
if (newValue) {
|
||||
lockScroll()
|
||||
} else {
|
||||
restoreScroll()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 组件卸载时确保恢复滚动
|
||||
onBeforeUnmount(() => {
|
||||
if (isLocked.value) {
|
||||
restoreScroll()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -46,10 +46,9 @@ $header: ".layout-navbar";
|
||||
}
|
||||
|
||||
/* ℹ️ Ensure header styles are preserved when dialog is opened,
|
||||
regardless of scroll state
|
||||
but only if window was scrolled before dialog opened
|
||||
*/
|
||||
html.v-overlay-scroll-blocked &.window-scrolled.layout-navbar-fixed,
|
||||
html.dialog-scroll-locked &.layout-navbar-fixed {
|
||||
html.v-overlay-scroll-blocked &.window-scrolled.layout-navbar-fixed {
|
||||
|
||||
#{$header} {
|
||||
padding-inline: 1rem;
|
||||
|
||||
@@ -22,19 +22,19 @@ code {
|
||||
|
||||
%blurry-bg {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
|
||||
|
||||
@media (width >= 1280px) {
|
||||
@media (width >= 1280px) and (hover: hover) {
|
||||
background: rgba(var(--v-theme-background), 1);
|
||||
|
||||
.v-theme--transparent & {
|
||||
backdrop-filter: blur(5px);
|
||||
background: rgba(var(--v-theme-background), 0.1) !important;
|
||||
backdrop-filter: blur(var(--transparent-blur-light, 5px));
|
||||
background: rgba(var(--v-theme-background), var(--transparent-opacity-light, 0.1)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (width < 1280px) {
|
||||
@media (width < 1280px), (hover: none) {
|
||||
background: transparent;
|
||||
|
||||
&::before {
|
||||
@@ -46,7 +46,7 @@ code {
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease-in-out;
|
||||
transition: padding 0.3s ease-in-out;
|
||||
|
||||
.v-theme--light & {
|
||||
background: rgba(var(--v-theme-surface), 0.6);
|
||||
@@ -61,8 +61,9 @@ code {
|
||||
}
|
||||
|
||||
.v-theme--transparent & {
|
||||
background: rgba(var(--v-theme-background), 0.3);
|
||||
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
|
||||
background: rgba(var(--v-theme-background), var(--transparent-opacity-heavy, 0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use "sass:map";
|
||||
@use "vuetify/lib/styles/settings" as vuetify_settings;
|
||||
@use "vuetify/lib/styles/settings/_index.sass" as vuetify_settings;
|
||||
@use "@styles/variables/_vuetify.scss" as vuetify;
|
||||
|
||||
@mixin themed($property, $light-value, $dark-value) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import ColorThief from 'colorthief'
|
||||
|
||||
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
||||
const DOMINANT_COLOR_CACHE_LIMIT = 100
|
||||
const colorThief = new ColorThief()
|
||||
const dominantColorCache = new Map<string, Promise<string>>()
|
||||
|
||||
interface DominantColorOptions {
|
||||
fallback?: string
|
||||
quality?: number
|
||||
}
|
||||
|
||||
// 将 RGB 转换为十六进制
|
||||
function rgbStringToHex(rgbArray: number[]): string {
|
||||
if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format')
|
||||
@@ -14,11 +24,46 @@ function rgbStringToHex(rgbArray: number[]): string {
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
function getImageCacheKey(image: HTMLImageElement) {
|
||||
return image.currentSrc || image.src || ''
|
||||
}
|
||||
|
||||
function rememberDominantColor(key: string, colorPromise: Promise<string>) {
|
||||
if (!key) return colorPromise
|
||||
|
||||
if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) {
|
||||
const firstKey = dominantColorCache.keys().next().value
|
||||
if (firstKey) dominantColorCache.delete(firstKey)
|
||||
}
|
||||
|
||||
dominantColorCache.set(key, colorPromise)
|
||||
return colorPromise
|
||||
}
|
||||
|
||||
// 提取主要颜色
|
||||
export async function getDominantColor(image: HTMLImageElement): Promise<string> {
|
||||
const colorThief = new ColorThief()
|
||||
const dominantColor = colorThief.getColor(image)
|
||||
return rgbStringToHex(dominantColor)
|
||||
export async function getDominantColor(
|
||||
image: HTMLImageElement | undefined | null,
|
||||
options: DominantColorOptions = {},
|
||||
): Promise<string> {
|
||||
const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR
|
||||
|
||||
if (!image) return fallback
|
||||
|
||||
const cacheKey = getImageCacheKey(image)
|
||||
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
|
||||
if (cachedColor) return cachedColor
|
||||
|
||||
const colorPromise = Promise.resolve()
|
||||
.then(() => {
|
||||
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
|
||||
return rgbStringToHex(dominantColor)
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Failed to extract dominant color:', error)
|
||||
return fallback
|
||||
})
|
||||
|
||||
return rememberDominantColor(cacheKey, colorPromise)
|
||||
}
|
||||
|
||||
// 预加载图片
|
||||
|
||||
@@ -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) {
|
||||
@@ -84,3 +97,15 @@ export const isMobileDevice = (): boolean => {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createRequire } from 'node:module'
|
||||
|
||||
// Get current directory
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectSrcDir = join(__dirname, '..')
|
||||
|
||||
// Create require function for importing JSON files in ESM
|
||||
const require = createRequire(import.meta.url)
|
||||
@@ -86,33 +87,12 @@ const sources: BundleScriptConfig = {
|
||||
],
|
||||
|
||||
icons: [
|
||||
// 'mdi:home',
|
||||
// 'mdi:account',
|
||||
// 'mdi:login',
|
||||
// 'mdi:logout',
|
||||
// 'octicon:book-24',
|
||||
// 'octicon:code-square-24',
|
||||
'lucide:sparkles',
|
||||
'material-symbols:passkey',
|
||||
'line-md:loading-twotone-loop',
|
||||
],
|
||||
|
||||
json: [
|
||||
// Custom JSON file
|
||||
// 'json/gg.json',
|
||||
|
||||
// Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename)
|
||||
require.resolve('@iconify-json/mdi/icons.json'),
|
||||
|
||||
// Custom file with only few icons
|
||||
// {
|
||||
// filename: require.resolve('@iconify-json/line-md/icons.json'),
|
||||
// icons: [
|
||||
// 'home-twotone-alt',
|
||||
// 'github',
|
||||
// 'document-list',
|
||||
// 'document-code',
|
||||
// 'image-twotone',
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
json: [],
|
||||
}
|
||||
|
||||
// Iconify component (this changes import statement in generated file)
|
||||
@@ -130,6 +110,15 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(async function () {
|
||||
const scannedIcons = await collectUsedIcons(projectSrcDir)
|
||||
|
||||
if (sources.icons) {
|
||||
sources.icons.push(...scannedIcons)
|
||||
sources.icons = Array.from(new Set(sources.icons)).sort()
|
||||
} else {
|
||||
sources.icons = scannedIcons
|
||||
}
|
||||
|
||||
let bundle = commonJS
|
||||
? `const { addCollection } = require('${component}');\n\n`
|
||||
: `import { addCollection } from '${component}';\n\n`
|
||||
@@ -154,7 +143,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,
|
||||
@@ -269,8 +264,60 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
console.log(`Saved ${target} (${bundle.length} bytes)`)
|
||||
})().catch((err) => {
|
||||
console.error(err)
|
||||
// 构建图标失败时必须终止构建,避免继续发布上一次遗留的超大 icons-bundle。
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
async function collectUsedIcons(rootDir: string): Promise<string[]> {
|
||||
const icons = new Set<string>()
|
||||
const files = await walkDirectory(rootDir)
|
||||
const sourceFiles = files.filter(file => /\.(vue|ts|js|tsx|jsx)$/.test(file))
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
if (file.includes('/@iconify/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = await fs.readFile(file, 'utf8')
|
||||
|
||||
for (const match of content.matchAll(/\b(lucide|material-symbols|line-md|tabler):([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`${match[1]}:${match[2]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi:([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\btabler-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`tabler:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(icons).sort()
|
||||
}
|
||||
|
||||
async function walkDirectory(dir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkDirectory(fullPath)))
|
||||
continue
|
||||
}
|
||||
|
||||
files.push(fullPath)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove metadata from icon set
|
||||
*/
|
||||
|
||||
@@ -17,13 +17,45 @@ export default defineComponent({
|
||||
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
|
||||
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
const isDialogOpen = ref(false)
|
||||
const wasScrolledBeforeDialog = ref(false)
|
||||
let dialogObserver: MutationObserver | null = null
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
}
|
||||
|
||||
// 监听弹窗状态变化
|
||||
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
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
// 初始检查弹窗状态
|
||||
checkDialogState()
|
||||
|
||||
// 监听 DOM 变化以检测弹窗状态
|
||||
dialogObserver = new MutationObserver(checkDialogState)
|
||||
dialogObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
dialogObserver?.disconnect()
|
||||
dialogObserver = null
|
||||
})
|
||||
|
||||
return () => {
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
@@ -88,9 +120,6 @@ export default defineComponent({
|
||||
},
|
||||
})
|
||||
|
||||
// 检查是否有弹窗打开(通过CSS类名判断)
|
||||
const isDialogOpen = document.documentElement.classList.contains('dialog-scroll-locked')
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
@@ -99,7 +128,7 @@ export default defineComponent({
|
||||
'layout-navbar-fixed',
|
||||
mdAndDown.value && 'layout-overlay-nav',
|
||||
route.meta.layoutWrapperClasses,
|
||||
(scrollDistance.value || isDialogOpen) && 'window-scrolled',
|
||||
(scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
@@ -122,7 +151,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;
|
||||
@@ -204,7 +233,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) => {
|
||||
|
||||
247
src/App.vue
@@ -11,13 +11,28 @@ import { preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
|
||||
// 生效主题
|
||||
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
|
||||
|
||||
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
||||
function syncRootLaunchPalette() {
|
||||
const { background, primary } = globalTheme.current.value.colors
|
||||
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', background)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primary)
|
||||
document.documentElement.style.backgroundColor = background
|
||||
document.body.style.backgroundColor = background
|
||||
}
|
||||
|
||||
// 生效语言
|
||||
const localeValue = getBrowserLocale()
|
||||
setI18nLanguage(localeValue as SupportedLocale)
|
||||
@@ -25,6 +40,7 @@ setI18nLanguage(localeValue as SupportedLocale)
|
||||
// 检查是否登录
|
||||
const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
const route = useRoute()
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -36,49 +52,60 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
)
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
let authenticatedStateTimer: number | null = null
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
interface Window {
|
||||
Apex: any
|
||||
}
|
||||
function getStoredNumber(key: string, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseFloat(localStorage.getItem(key) || '')
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
|
||||
// 配置 ApexCharts 全局选项
|
||||
function configureApexCharts() {
|
||||
if (typeof window !== 'undefined' && window.Apex) {
|
||||
try {
|
||||
// 获取当前主题
|
||||
const currentTheme = globalTheme.name.value
|
||||
const isDark = currentTheme === 'dark' || currentTheme === 'transparent'
|
||||
function applyTransparentBackgroundSettings() {
|
||||
document.documentElement.style.setProperty(
|
||||
'--transparent-background-poster-opacity',
|
||||
(1 - getStoredNumber('transparency-background-poster-opacity', 0, 0, 1)).toString(),
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--transparent-background-blur',
|
||||
`${getStoredNumber('transparency-background-blur', 16, 0, 30)}px`,
|
||||
)
|
||||
}
|
||||
|
||||
// 数据标签
|
||||
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',
|
||||
applyTransparentBackgroundSettings()
|
||||
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
if (heartbeatInterval) {
|
||||
stopHeartbeat()
|
||||
}
|
||||
|
||||
// 开始心跳任务
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
try {
|
||||
if (isLogin.value) {
|
||||
await api.get('dashboard/cpu')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ApexCharts 全局配置失败:', error)
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatInterval) {
|
||||
window.clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,14 +113,16 @@ function configureApexCharts() {
|
||||
function updateHtmlThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
document.body.setAttribute('data-theme', themeName)
|
||||
syncRootLaunchPalette()
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImages() {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = new AbortController()
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`, {
|
||||
signal: controller.signal,
|
||||
signal: backgroundRequestController.signal,
|
||||
})
|
||||
activeImageIndex.value = 0
|
||||
} catch (e) {
|
||||
@@ -135,12 +164,56 @@ function startBackgroundRotation() {
|
||||
}
|
||||
}
|
||||
|
||||
function stopBackgroundLoading() {
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = null
|
||||
|
||||
if (backgroundRetryTimer) {
|
||||
window.clearTimeout(backgroundRetryTimer)
|
||||
backgroundRetryTimer = null
|
||||
}
|
||||
|
||||
removeBackgroundTimer('background-rotation')
|
||||
}
|
||||
|
||||
async function initializeAuthenticatedState() {
|
||||
if (!isLogin.value) return
|
||||
|
||||
try {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', true)
|
||||
await globalSettingsStore.initialize()
|
||||
await globalSettingsStore.loadUserSettings()
|
||||
} finally {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAuthenticatedStateInitialization() {
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
}
|
||||
|
||||
// 登录后会立刻发生路由切换,稍后再拉取设置可避开导航中止请求。
|
||||
authenticatedStateTimer = window.setTimeout(() => {
|
||||
authenticatedStateTimer = null
|
||||
initializeAuthenticatedState()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
// 添加logo动画效果并延迟移除加载界面
|
||||
function animateAndRemoveLoader() {
|
||||
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
||||
if (loadingBg) {
|
||||
removeEl('#loading-bg')
|
||||
document.documentElement.style.removeProperty('background')
|
||||
// 只收掉启动内容,背景层保持实色直到节点被移除,避免底部 safe area 先透出页面内容。
|
||||
loadingBg.classList.add('loading-complete')
|
||||
window.setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
|
||||
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
|
||||
document.documentElement.removeAttribute('data-launch-loading')
|
||||
document.documentElement.style.removeProperty('overflow')
|
||||
document.body.style.removeProperty('overflow')
|
||||
}, 120)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +222,6 @@ 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
|
||||
@@ -159,18 +230,7 @@ async function removeLoadingWithStateCheck() {
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
// 并行加载关键资源
|
||||
await Promise.all([
|
||||
globalSettingsStore.initialize().then(() => {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
globalLoadingStateManager.setLoadingState('background-images', false)
|
||||
resolve(void 0)
|
||||
}, 50)
|
||||
}),
|
||||
])
|
||||
await initializeAuthenticatedState()
|
||||
|
||||
// 等待所有加载完成
|
||||
await globalLoadingStateManager.waitForAllComplete()
|
||||
@@ -179,7 +239,9 @@ async function removeLoadingWithStateCheck() {
|
||||
animateAndRemoveLoader()
|
||||
|
||||
// 检查未读消息
|
||||
checkAndEmitUnreadMessages()
|
||||
if (isLogin.value) {
|
||||
checkAndEmitUnreadMessages()
|
||||
}
|
||||
} catch (error) {
|
||||
// 即使出错也要移除加载界面
|
||||
globalLoadingStateManager.reset()
|
||||
@@ -198,7 +260,8 @@ async function loadBackgroundImages(retryCount = 0) {
|
||||
if (retryCount < maxRetries) {
|
||||
const baseDelay = isAbortError ? 1000 : 3000
|
||||
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
|
||||
setTimeout(() => {
|
||||
backgroundRetryTimer = window.setTimeout(() => {
|
||||
backgroundRetryTimer = null
|
||||
loadBackgroundImages(retryCount + 1)
|
||||
}, retryDelay)
|
||||
}
|
||||
@@ -206,12 +269,23 @@ async function loadBackgroundImages(retryCount = 0) {
|
||||
}
|
||||
|
||||
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()
|
||||
configureApexChartsTheme(globalTheme.name.value)
|
||||
|
||||
// 初始化data-theme属性
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
|
||||
// 初始化主题管理器 - 统一处理主题初始化
|
||||
await themeManager.setTheme(themeValue)
|
||||
|
||||
// 监听主题变化
|
||||
watch(
|
||||
() => globalTheme.name.value,
|
||||
@@ -219,29 +293,68 @@ onMounted(async () => {
|
||||
// 更新HTML主题属性
|
||||
updateHtmlThemeAttribute(newTheme)
|
||||
// 重新配置ApexCharts以适应新主题
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(newTheme)
|
||||
},
|
||||
)
|
||||
|
||||
// 加载背景图片
|
||||
loadBackgroundImages()
|
||||
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
|
||||
watch(
|
||||
shouldLoadBackgroundImages,
|
||||
shouldLoad => {
|
||||
stopBackgroundLoading()
|
||||
if (shouldLoad) {
|
||||
loadBackgroundImages()
|
||||
} else if (!isTransparentTheme.value) {
|
||||
backgroundImages.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 使用优化后的加载界面移除逻辑
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
})
|
||||
// 启动心跳
|
||||
if (isLogin.value) {
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
// 登录状态可能在当前单页会话中变化,这里按需补齐登录后初始化和心跳。
|
||||
watch(isLogin, loggedIn => {
|
||||
if (loggedIn) {
|
||||
startHeartbeat()
|
||||
scheduleAuthenticatedStateInitialization()
|
||||
} else {
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
stopHeartbeat()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清除背景轮换定时器
|
||||
removeBackgroundTimer('background-rotation')
|
||||
stopBackgroundLoading()
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-wrapper">
|
||||
<!-- 透明主题背景 -->
|
||||
<div v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)" class="background-container">
|
||||
<div
|
||||
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
|
||||
class="background-container"
|
||||
:class="{ 'is-transparent-theme': isTransparentTheme && isLogin }"
|
||||
>
|
||||
<div
|
||||
v-for="(imageUrl, index) in backgroundImages"
|
||||
:key="`bg-${index}-${loginStateKey}`"
|
||||
@@ -255,6 +368,8 @@ onUnmounted(() => {
|
||||
<!-- 页面内容 -->
|
||||
<VApp>
|
||||
<RouterView />
|
||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||
<SharedDialogHost />
|
||||
<!-- PWA安装提示 -->
|
||||
<PWAInstallPrompt />
|
||||
</VApp>
|
||||
@@ -306,11 +421,15 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.background-container.is-transparent-theme .background-image.active {
|
||||
opacity: var(--transparent-background-poster-opacity, 1);
|
||||
}
|
||||
|
||||
/* 全局磨砂层 */
|
||||
.global-blur-layer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(var(--transparent-background-blur, 16px));
|
||||
background-color: rgba(128, 128, 128, 30%);
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -14,6 +14,10 @@ import modeIniUrl from 'ace-builds/src-noconflict/mode-ini?url'
|
||||
|
||||
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
|
||||
|
||||
import themeGithubDarkUrl from 'ace-builds/src-noconflict/theme-github_dark?url'
|
||||
|
||||
import themeGithubLightDefaultUrl from 'ace-builds/src-noconflict/theme-github_light_default?url'
|
||||
|
||||
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
|
||||
|
||||
import themeMonokaiUrl from 'ace-builds/src-noconflict/theme-monokai?url'
|
||||
@@ -44,6 +48,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)
|
||||
@@ -51,6 +537,8 @@ ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
|
||||
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
|
||||
ace.config.setModuleUrl('ace/mode/ini', modeIniUrl)
|
||||
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
|
||||
ace.config.setModuleUrl('ace/theme/github_dark', themeGithubDarkUrl)
|
||||
ace.config.setModuleUrl('ace/theme/github_light_default', themeGithubLightDefaultUrl)
|
||||
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
|
||||
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
|
||||
ace.config.setModuleUrl('ace/mode/base', workerBaseUrl)
|
||||
@@ -61,9 +549,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')
|
||||
|
||||
@@ -52,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) => {
|
||||
@@ -64,6 +68,10 @@ export const mediaServerOptions = [
|
||||
value: 'emby',
|
||||
title: i18n.global.t('setting.system.emby'),
|
||||
},
|
||||
{
|
||||
value: 'zspace',
|
||||
title: i18n.global.t('setting.system.zspace'),
|
||||
},
|
||||
{
|
||||
value: 'jellyfin',
|
||||
title: i18n.global.t('setting.system.jellyfin'),
|
||||
@@ -76,6 +84,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) => {
|
||||
@@ -274,6 +286,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: '其它',
|
||||
|
||||
@@ -39,8 +39,9 @@ const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
// 添加响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 成功响应时,清除应用离线状态
|
||||
// 成功响应时,清除应用离线状态并重置连续错误计数
|
||||
globalOfflineStatus.setAppOffline(false)
|
||||
globalOfflineStatus.resetConsecutiveErrors()
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
@@ -57,7 +58,8 @@ api.interceptors.response.use(
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
reason = 'Request timeout'
|
||||
}
|
||||
globalOfflineStatus.setAppOffline(true, reason)
|
||||
// 记录网络错误,只有连续三次才会设置为离线模式
|
||||
globalOfflineStatus.recordNetworkError(reason)
|
||||
}
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
|
||||
|
||||
152
src/api/types.ts
@@ -58,6 +58,8 @@ export interface Subscribe {
|
||||
sites: number[]
|
||||
// 是否洗版,数字或者boolean
|
||||
best_version: any
|
||||
// 是否只洗全集整包,数字或者boolean
|
||||
best_version_full?: any
|
||||
// 使用 imdbid 搜索
|
||||
search_imdbid?: any
|
||||
// 当前优先级
|
||||
@@ -164,6 +166,10 @@ export interface WorkflowShare {
|
||||
description?: string
|
||||
// 定时器
|
||||
timer?: string
|
||||
// 触发类型:timer-定时触发 event-事件触发 manual-手动触发
|
||||
trigger_type?: string
|
||||
// 事件类型(当trigger_type为event时使用)
|
||||
event_type?: string
|
||||
// 动作列表
|
||||
actions?: any[]
|
||||
// 动作流
|
||||
@@ -310,6 +316,8 @@ export interface MediaInfo {
|
||||
production_countries?: any[]
|
||||
// 语种
|
||||
spoken_languages?: string[]
|
||||
// 数字/实体发行日期
|
||||
release_dates?: MediaRelease[]
|
||||
// 状态
|
||||
status?: string
|
||||
// 标签
|
||||
@@ -364,6 +372,18 @@ export interface TmdbSeason {
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
// 发行信息
|
||||
export interface MediaRelease {
|
||||
// 发行日期
|
||||
date: string
|
||||
// 发行地区
|
||||
iso_code: string
|
||||
// 备注
|
||||
note?: string
|
||||
// 发行类型
|
||||
type: number
|
||||
}
|
||||
|
||||
// TMDB集信息
|
||||
export interface TmdbEpisode {
|
||||
// 上映日期
|
||||
@@ -516,7 +536,7 @@ export interface SiteUserData {
|
||||
// 用户名
|
||||
username?: string
|
||||
// 用户ID
|
||||
userid?: number
|
||||
userid?: string
|
||||
// 用户等级
|
||||
user_level?: string
|
||||
// 加入时间
|
||||
@@ -626,6 +646,12 @@ export interface Plugin {
|
||||
has_page?: boolean
|
||||
// 是否有新版本
|
||||
has_update?: boolean
|
||||
// 主系统版本是否兼容
|
||||
system_version_compatible?: boolean
|
||||
// 主系统版本兼容提示
|
||||
system_version_message?: string
|
||||
// 主系统版本限定范围
|
||||
system_version?: string
|
||||
// 是否本地插件
|
||||
is_local?: boolean
|
||||
// 插件仓库地址
|
||||
@@ -638,6 +664,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
|
||||
@@ -843,6 +880,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 {
|
||||
// 总空间
|
||||
@@ -857,8 +904,8 @@ export interface MediaStatistic {
|
||||
movie_count: number
|
||||
// 电视剧总数
|
||||
tv_count: number
|
||||
// 电视剧总集数
|
||||
episode_count: number
|
||||
// 电视剧总集数,未获取时为 null
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
}
|
||||
@@ -986,6 +1033,10 @@ export interface MediaServerPlayItem {
|
||||
link?: string
|
||||
// 播放百分比
|
||||
percent?: number
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
// 图片是否需要Cookies
|
||||
use_cookies?: boolean
|
||||
}
|
||||
|
||||
// 媒体服务器媒体库
|
||||
@@ -1006,6 +1057,10 @@ export interface MediaServerLibrary {
|
||||
image_list?: string[]
|
||||
// 链接
|
||||
link?: string
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
// 图片是否需要Cookies
|
||||
use_cookies?: boolean
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
@@ -1058,6 +1113,8 @@ export interface DownloaderConf {
|
||||
config: { [key: string]: any }
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
// 路径映射
|
||||
path_mapping?: Array<[storagePath: string, downloadPath: string]>
|
||||
}
|
||||
|
||||
// 通知配置
|
||||
@@ -1096,7 +1153,7 @@ export interface StorageConf {
|
||||
export interface MediaServerConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 emby/jellyfin/plex
|
||||
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen
|
||||
type: string
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
@@ -1262,6 +1319,57 @@ export interface TransferForm {
|
||||
library_category_folder?: boolean
|
||||
// 剧集组编号
|
||||
episode_group?: string
|
||||
// 预览模式
|
||||
preview?: boolean
|
||||
}
|
||||
|
||||
// 手动整理请求
|
||||
export interface ManualTransferPayload extends TransferForm {}
|
||||
|
||||
// 手动整理预览统计
|
||||
export interface ManualTransferPreviewSummary {
|
||||
// 总数
|
||||
total: number
|
||||
// 成功数
|
||||
success: number
|
||||
// 失败数
|
||||
failed: number
|
||||
}
|
||||
|
||||
// 手动整理预览项
|
||||
export interface ManualTransferPreviewItem {
|
||||
// 原始路径
|
||||
source?: string
|
||||
// 目标路径
|
||||
target?: string
|
||||
// 目标目录
|
||||
target_dir?: string
|
||||
// 是否成功
|
||||
success?: boolean
|
||||
// 提示信息
|
||||
message?: string
|
||||
// 媒体类型
|
||||
type?: string
|
||||
// 媒体标题
|
||||
title?: string
|
||||
// 季
|
||||
season?: number | string
|
||||
// 开始集
|
||||
episode?: number | string
|
||||
// 结束集
|
||||
episode_end?: number | string
|
||||
// Part
|
||||
part?: string
|
||||
}
|
||||
|
||||
// 手动整理预览数据
|
||||
export interface ManualTransferPreviewData {
|
||||
// 统计信息
|
||||
summary: ManualTransferPreviewSummary
|
||||
// 预览结果
|
||||
items: ManualTransferPreviewItem[]
|
||||
// 额外消息
|
||||
message?: string
|
||||
}
|
||||
|
||||
// 整理队列
|
||||
@@ -1324,6 +1432,10 @@ export interface Workflow {
|
||||
description?: string
|
||||
// 定时器
|
||||
timer?: string
|
||||
// 触发类型:timer-定时触发 event-事件触发 manual-手动触发
|
||||
trigger_type?: string
|
||||
// 事件类型(当trigger_type为event时使用)
|
||||
event_type?: string
|
||||
// 状态
|
||||
state?: string
|
||||
// 当前执行动作
|
||||
@@ -1387,3 +1499,35 @@ export interface TorrentCacheData {
|
||||
// 缓存数据
|
||||
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/clawbot.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
src/assets/images/logos/discord.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/images/logos/feishu.png
Normal file
|
After Width: | Height: | Size: 6.1 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 |
BIN
src/assets/images/logos/zspace.webp
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
@@ -4,6 +4,14 @@ import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
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({
|
||||
@@ -11,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,
|
||||
@@ -23,10 +31,17 @@ const props = defineProps({
|
||||
type: Array as PropType<FileItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['pathchanged'])
|
||||
const route = useRoute()
|
||||
const { appMode } = usePWA()
|
||||
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
|
||||
|
||||
const fileIcons = {
|
||||
// 压缩包
|
||||
@@ -117,24 +132,47 @@ 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')
|
||||
|
||||
// 拖动分隔条相关
|
||||
const navigatorWidth = ref(280) // 初始宽度
|
||||
// 拖动分隔条相关 - 从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(() => {
|
||||
return props.storages?.map(item => ({
|
||||
@@ -144,15 +182,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' })
|
||||
}
|
||||
|
||||
@@ -235,14 +273,16 @@ function stopDrag() {
|
||||
|
||||
<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"
|
||||
@@ -251,7 +291,7 @@ function stopDrag() {
|
||||
<div class="flex">
|
||||
<FileNavigator
|
||||
v-if="showDirTree"
|
||||
:storage="activeStorage"
|
||||
:storage="item.storage"
|
||||
:currentPath="item.path"
|
||||
:items="fileListItems"
|
||||
:endpoints="endpoints"
|
||||
@@ -266,13 +306,13 @@ function stopDrag() {
|
||||
</div>
|
||||
<FileList
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
:showTree="showDirTree"
|
||||
:active="active"
|
||||
:style="{ flex: 1 }"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@@ -285,6 +325,18 @@ function stopDrag() {
|
||||
</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>
|
||||
|
||||
@@ -133,7 +133,7 @@ const instructions = computed(() => {
|
||||
</Teleport>
|
||||
|
||||
<!-- 手动安装说明对话框 -->
|
||||
<DialogWrapper v-model="showInstructions" max-width="500">
|
||||
<VDialog v-model="showInstructions" max-width="500">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-flex align-center">
|
||||
@@ -170,7 +170,7 @@ const instructions = computed(() => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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,7 +58,7 @@ 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" />
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import type { CustomRule } from '@/api/types'
|
||||
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'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const CustomRuleInfoDialog = defineAsyncComponent(() => import('@/components/dialog/CustomRuleInfoDialog.vue'))
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -24,197 +21,52 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const ruleInfoDialog = ref(false)
|
||||
|
||||
// 规则详情
|
||||
const ruleInfo = ref<CustomRule>({
|
||||
id: '',
|
||||
name: '',
|
||||
include: '',
|
||||
exclude: '',
|
||||
size_range: '',
|
||||
seeders: '',
|
||||
publish_time: '',
|
||||
})
|
||||
|
||||
// 打开详情弹窗
|
||||
/** 打开共享自定义规则配置弹窗。 */
|
||||
function openRuleInfoDialog() {
|
||||
// 深复制
|
||||
ruleInfo.value = cloneDeep(props.rule)
|
||||
ruleInfoDialog.value = true
|
||||
openSharedDialog(
|
||||
CustomRuleInfoDialog,
|
||||
{
|
||||
rule: props.rule,
|
||||
rules: props.rules,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
done: () => emit('done'),
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveRuleInfo() {
|
||||
// 有空值
|
||||
if (!ruleInfo.value.id || !ruleInfo.value.name) {
|
||||
if (!ruleInfo.value.id && !ruleInfo.value.name) {
|
||||
$toast.error(t('customRule.error.emptyIdName'))
|
||||
}
|
||||
return
|
||||
}
|
||||
// 检查ID是否在内置的规则中
|
||||
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
|
||||
$toast.error(t('customRule.error.idOccupied'))
|
||||
return
|
||||
}
|
||||
// 检查规则名称是否在内置的规则中
|
||||
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
|
||||
$toast.error(t('customRule.error.nameOccupied'))
|
||||
return
|
||||
}
|
||||
// ID已存在
|
||||
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
|
||||
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
|
||||
return
|
||||
}
|
||||
// 规则名称已存在
|
||||
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
|
||||
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
|
||||
return
|
||||
}
|
||||
// 保存数据
|
||||
ruleInfoDialog.value = false
|
||||
emit('change', ruleInfo.value, props.rule.id)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
/** 关闭自定义规则卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openRuleInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<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>
|
||||
</div>
|
||||
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<DialogWrapper
|
||||
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>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.id"
|
||||
:label="t('customRule.field.ruleId')"
|
||||
:placeholder="t('customRule.placeholder.ruleId')"
|
||||
:hint="t('customRule.hint.ruleId')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.name"
|
||||
:label="t('customRule.field.ruleName')"
|
||||
:placeholder="t('customRule.placeholder.ruleName')"
|
||||
:hint="t('customRule.hint.ruleName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.include"
|
||||
:label="t('customRule.field.include')"
|
||||
:placeholder="t('customRule.placeholder.include')"
|
||||
:hint="t('customRule.hint.include')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-plus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.exclude"
|
||||
:label="t('customRule.field.exclude')"
|
||||
:placeholder="t('customRule.placeholder.exclude')"
|
||||
:hint="t('customRule.hint.exclude')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-minus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.size_range"
|
||||
:label="t('customRule.field.sizeRange')"
|
||||
:placeholder="t('customRule.placeholder.sizeRange')"
|
||||
:hint="t('customRule.hint.sizeRange')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-harddisk"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.seeders"
|
||||
:label="t('customRule.field.seeders')"
|
||||
:placeholder="t('customRule.placeholder.seeders')"
|
||||
:hint="t('customRule.hint.seeders')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.publish_time"
|
||||
:label="t('customRule.field.publishTime')"
|
||||
:placeholder="t('customRule.placeholder.publishTime')"
|
||||
: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" prepend-icon="mdi-content-save" class="px-5">{{
|
||||
t('customRule.action.confirm')
|
||||
}}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@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="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 ref="imageRef" :src="filter_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -5,8 +5,20 @@ import { nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storageRemoteDict } from '@/api/constants'
|
||||
|
||||
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
|
||||
const STORAGE_ACCENT_COLOR_MAP = {
|
||||
local: '#FFB400',
|
||||
alipan: '#00A7F2',
|
||||
u115: '#17B26A',
|
||||
rclone: '#6675FF',
|
||||
alist: '#12B8D7',
|
||||
smb: '#3B82F6',
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const downloadAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
|
||||
const libraryAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -63,6 +75,47 @@ const transferSourceItems = computed(() => [
|
||||
{ title: t('directory.manualTransfer'), value: 'manual' },
|
||||
])
|
||||
|
||||
function hasKnownStorageType(storageType?: string): storageType is keyof typeof STORAGE_ACCENT_COLOR_MAP {
|
||||
return !!storageType && Object.prototype.hasOwnProperty.call(STORAGE_ACCENT_COLOR_MAP, storageType)
|
||||
}
|
||||
|
||||
function hexToRgbString(hexColor: string) {
|
||||
const normalizedColor = hexColor.replace('#', '')
|
||||
const colorValue = Number.parseInt(normalizedColor, 16)
|
||||
|
||||
if (Number.isNaN(colorValue) || normalizedColor.length !== 6) return DEFAULT_DIRECTORY_ACCENT_RGB
|
||||
|
||||
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
|
||||
}
|
||||
|
||||
function getCustomStoragePaletteColor(storageType?: string) {
|
||||
const customStorageIndex = Math.max(Number(storageType?.match(/\d+$/)?.[0] ?? 1) - 1, 0)
|
||||
const customStorageColors = ['#F97316', '#8B5CF6', '#06B6D4', '#84CC16', '#EC4899', '#14B8A6']
|
||||
|
||||
return customStorageColors[customStorageIndex % customStorageColors.length]
|
||||
}
|
||||
|
||||
function getStorageAccentColor(storageType?: string) {
|
||||
if (hasKnownStorageType(storageType)) return STORAGE_ACCENT_COLOR_MAP[storageType]
|
||||
|
||||
// 自定义存储没有固定品牌图标,使用离散调色板,保证连续 custom1/custom2 也能明显区分。
|
||||
return getCustomStoragePaletteColor(storageType)
|
||||
}
|
||||
|
||||
// 目录卡片用下载存储和媒体库存储两端的图标主色生成轻渐变,体现整理链路的两个存储端点。
|
||||
const directoryAccentStyle = computed(() => ({
|
||||
'--app-card-accent-rgb': downloadAccentRgb.value,
|
||||
'--app-card-accent-end-rgb': libraryAccentRgb.value,
|
||||
}))
|
||||
|
||||
function updateDirectoryAccentColors() {
|
||||
const downloadStorage = props.directory.storage
|
||||
const libraryStorage = props.directory.library_storage || props.directory.storage
|
||||
|
||||
downloadAccentRgb.value = hexToRgbString(getStorageAccentColor(downloadStorage))
|
||||
libraryAccentRgb.value = hexToRgbString(getStorageAccentColor(libraryStorage))
|
||||
}
|
||||
|
||||
// 监控模式下拉字典
|
||||
const MonitorModeItems = computed(() => [
|
||||
{ title: t('directory.performanceMode'), value: 'fast' },
|
||||
@@ -168,6 +221,15 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 存储类型切换后主动重新提取图标色,避免图片缓存导致 load 事件不触发。
|
||||
watch(
|
||||
[() => props.directory.storage, () => props.directory.library_storage],
|
||||
() => {
|
||||
updateDirectoryAccentColors()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 媒体类别和类型变更非空时,将按类型分类和按类别分类置为false
|
||||
watch(
|
||||
[() => props.directory.media_type, () => props.directory.media_category],
|
||||
@@ -195,7 +257,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height">
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="directoryAccentStyle"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VTextField
|
||||
@@ -204,8 +272,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>
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
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 { cloneDeep } from 'lodash-es'
|
||||
import type { DownloaderConf, DownloaderInfo } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { downloaderDict } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const DownloaderInfoDialog = defineAsyncComponent(() => import('@/components/dialog/DownloaderInfoDialog.vue'))
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
const { useConditionalDataRefresh } = useBackgroundOptimization()
|
||||
const { useConditionalDataRefresh } = useBackground()
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -42,34 +38,18 @@ const props = defineProps({
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'done', 'change'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 上传速率
|
||||
const upload_rate = ref(0)
|
||||
|
||||
// 下载速度
|
||||
const download_rate = ref(0)
|
||||
|
||||
// 下载器详情弹窗
|
||||
const downloaderInfoDialog = ref(false)
|
||||
|
||||
// 下载器详情
|
||||
const downloaderInfo = ref<DownloaderConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
default: false,
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 下载器是否应该刷新数据的计算属性
|
||||
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
|
||||
|
||||
// 调用API查询下载器数据
|
||||
/** 调用 API 查询下载器实时速率数据。 */
|
||||
async function loadDownloaderInfo() {
|
||||
if (!shouldRefresh.value) {
|
||||
// 当下载器被禁用时,重置速率数据
|
||||
upload_rate.value = 0
|
||||
download_rate.value = 0
|
||||
return
|
||||
@@ -90,53 +70,37 @@ async function loadDownloaderInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
/** 打开共享下载器配置弹窗。 */
|
||||
function openDownloaderInfoDialog() {
|
||||
// 深复制
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
downloaderInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveDownloaderInfo() {
|
||||
// 为空不保存,跳出警告框
|
||||
if (!downloaderInfo.value.name) {
|
||||
$toast.error(t('downloader.nameRequired'))
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
|
||||
$toast.error(t('downloader.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
// 默认下载器去重
|
||||
if (downloaderInfo.value.default) {
|
||||
props.downloaders.forEach(item => {
|
||||
if (item.default && item !== props.downloader) {
|
||||
item.default = false
|
||||
$toast.info(t('downloader.defaultChanged'))
|
||||
}
|
||||
})
|
||||
}
|
||||
// 执行保存
|
||||
downloaderInfoDialog.value = false
|
||||
emit('change', downloaderInfo.value, props.downloader.name)
|
||||
emit('done')
|
||||
openSharedDialog(
|
||||
DownloaderInfoDialog,
|
||||
{
|
||||
downloader: props.downloader,
|
||||
downloaders: props.downloaders,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
done: () => emit('done'),
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
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 onClose() {
|
||||
emit('close')
|
||||
}
|
||||
@@ -145,244 +109,55 @@ function onClose() {
|
||||
const { stop: stopRefresh } = useConditionalDataRefresh(
|
||||
`downloader-${props.downloader.name}`,
|
||||
loadDownloaderInfo,
|
||||
shouldRefresh, // 响应式条件:只有当allowRefresh为true且downloader启用时才运行
|
||||
3000, // 3秒间隔
|
||||
true // 立即执行一次
|
||||
shouldRefresh,
|
||||
3000,
|
||||
true,
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRefresh()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
variant="tonal"
|
||||
@click="openDownloaderInfoDialog"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<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">
|
||||
<VBadge
|
||||
v-if="props.downloader.default && props.downloader.enabled"
|
||||
dot
|
||||
inline
|
||||
color="success"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="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>
|
||||
<div v-else-if="!downloaderDict[downloader.type]" class="mt-1 flex flex-wrap text-sm">
|
||||
<span class="me-2">自定义下载器</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VHover>
|
||||
|
||||
<DialogWrapper
|
||||
v-if="downloaderInfoDialog"
|
||||
v-model="downloaderInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
<template>
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@click="openDownloaderInfoDialog"
|
||||
>
|
||||
<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>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.default"
|
||||
:label="t('downloader.default')"
|
||||
:disabled="!downloaderInfo.enabled"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
|
||||
<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"
|
||||
:hint="t('downloader.host')"
|
||||
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>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.category"
|
||||
:label="t('downloader.category')"
|
||||
:hint="t('downloader.category')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.sequentail"
|
||||
:label="t('downloader.sequentail')"
|
||||
:hint="t('downloader.sequentail')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.force_resume"
|
||||
:label="t('downloader.force_resume')"
|
||||
:hint="t('downloader.force_resume')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.first_last_piece"
|
||||
:label="t('downloader.first_last_piece')"
|
||||
:hint="t('downloader.first_last_piece')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'transmission'">
|
||||
<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"
|
||||
:hint="t('downloader.host')"
|
||||
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>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.type"
|
||||
:label="t('downloader.type')"
|
||||
:hint="t('downloader.customTypeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:hint="t('downloader.nameRequired')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<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="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
|
||||
inline
|
||||
color="success"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
|
||||
</div>
|
||||
<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="app-card-summary__subtitle text-sm">
|
||||
{{ t('setting.system.custom') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
@@ -43,19 +43,14 @@ 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}`, {
|
||||
params: {
|
||||
name: props.downloaderName
|
||||
}
|
||||
name: props.downloaderName,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) isDownloading.value = !isDownloading.value
|
||||
@@ -67,7 +62,7 @@ async function toggleDownload() {
|
||||
// 删除下截
|
||||
async function deleteDownload() {
|
||||
try {
|
||||
await api.delete(`download/${props.info?.hash}`, {params: {name: props.downloaderName}})
|
||||
await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName } })
|
||||
cardState.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -76,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>
|
||||
|
||||
@@ -28,32 +28,31 @@ function filtersChanged(value: string[]) {
|
||||
}
|
||||
|
||||
// 过滤规则下拉框
|
||||
const selectFilterOptions = ref<{ [key: string]: string }[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
selectFilterOptions.value = cloneDeep(innerFilterRules)
|
||||
if (props.custom_rules) {
|
||||
console.log(props.custom_rules)
|
||||
props.custom_rules.map(rule => {
|
||||
selectFilterOptions.value.push({
|
||||
title: rule.name,
|
||||
value: rule.id,
|
||||
})
|
||||
// 同时包含内置规则与用户自定义规则;使用 computed 而非 onMounted 一次性赋值,
|
||||
// 是为了在父组件异步加载完 custom_rules 或后续新增/删除规则时,
|
||||
// 选项与已选 chip 的显示名(title)能跟随刷新,避免回退到原始 ID(如 "zhong")。
|
||||
const selectFilterOptions = computed<{ [key: string]: string }[]>(() => {
|
||||
const options = cloneDeep(innerFilterRules)
|
||||
props.custom_rules?.forEach(rule => {
|
||||
options.push({
|
||||
title: rule.name,
|
||||
value: rule.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
return options
|
||||
})
|
||||
</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>
|
||||
<VAutocomplete
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
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-toastification'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import type { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import filter_group_svg from '@images/svg/filter-group.svg'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const FilterRuleGroupInfoDialog = defineAsyncComponent(() => import('@/components/dialog/FilterRuleGroupInfoDialog.vue'))
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -37,285 +32,57 @@ const props = defineProps({
|
||||
custom_rules: Array as PropType<CustomRule[]>,
|
||||
})
|
||||
|
||||
// 规则卡片类型
|
||||
interface FilterCard {
|
||||
// 优先级
|
||||
pri: string
|
||||
// 已选规则
|
||||
rules: string[]
|
||||
}
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const groupInfoDialog = ref(false)
|
||||
|
||||
// 规则详情
|
||||
const groupInfo = ref<FilterRuleGroup>({
|
||||
name: props.group?.name ?? '',
|
||||
rule_string: props.group?.rule_string ?? '',
|
||||
media_type: props.group?.media_type ?? '',
|
||||
category: props.group?.category ?? '',
|
||||
})
|
||||
|
||||
// 媒体类型字典
|
||||
const mediaTypeItems = [
|
||||
{ title: t('common.all'), value: '' },
|
||||
{ title: t('mediaType.movie'), value: '电影' },
|
||||
{ title: t('mediaType.tv'), value: '电视剧' },
|
||||
]
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: t('common.all'), value: '' }]
|
||||
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
|
||||
return default_value
|
||||
}
|
||||
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
|
||||
})
|
||||
|
||||
// 规则组规则卡片列表
|
||||
const filterRuleCards = ref<FilterCard[]>([])
|
||||
|
||||
// 导入代码弹窗
|
||||
const importCodeDialog = ref(false)
|
||||
|
||||
// 导入代码类型
|
||||
const importCodeType = ref('')
|
||||
|
||||
// 更新规则卡片的值
|
||||
function updateFilterCardValue(pri: string, rules: string[]) {
|
||||
const card = filterRuleCards.value.find(card => card.pri === pri)
|
||||
if (card && Array.isArray(rules)) card.rules = rules
|
||||
/** 打开共享过滤规则组配置弹窗。 */
|
||||
function openGroupInfoDialog() {
|
||||
openSharedDialog(
|
||||
FilterRuleGroupInfoDialog,
|
||||
{
|
||||
group: props.group,
|
||||
groups: props.groups,
|
||||
categories: props.categories,
|
||||
custom_rules: props.custom_rules,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
done: () => emit('done'),
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 移除卡片
|
||||
function filterCardClose(pri: string) {
|
||||
filterRuleCards.value = filterRuleCards.value
|
||||
.filter(card => card.pri !== pri)
|
||||
.map((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
return card
|
||||
})
|
||||
}
|
||||
|
||||
// 分享规则
|
||||
async function shareRules() {
|
||||
if (filterRuleCards.value.length === 0) return
|
||||
|
||||
const value = filterRuleCards.value
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
|
||||
try {
|
||||
let success
|
||||
success = copyToClipboard(value)
|
||||
if (await success) $toast.success(t('filterRule.shareSuccess'))
|
||||
else $toast.error(t('filterRule.shareFailed'))
|
||||
} catch (error) {
|
||||
$toast.error(t('filterRule.shareFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入规则
|
||||
async function importRules(ruleType: string) {
|
||||
importCodeType.value = ruleType
|
||||
importCodeDialog.value = true
|
||||
}
|
||||
|
||||
// 保存导入的代码,直接覆盖原有值
|
||||
function saveCodeString(type: string, code: any) {
|
||||
try {
|
||||
code = code.value
|
||||
if (type === 'priority') {
|
||||
// 解析值
|
||||
if (!code) return
|
||||
// 首尾增加空格
|
||||
if (!code.startsWith(' ')) code = ` ${code}`
|
||||
if (!code.endsWith(' ')) code = `${code} `
|
||||
const groups = code.split('>')
|
||||
filterRuleCards.value = groups.map((group: string, index: number) => ({
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&').filter(rule => rule),
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(t('filterRule.importFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 增加卡片
|
||||
function addFilterCard() {
|
||||
const pri = (filterRuleCards.value.length + 1).toString()
|
||||
const newCard: FilterCard = { pri, rules: [] }
|
||||
filterRuleCards.value.push(newCard)
|
||||
}
|
||||
|
||||
// 根据列表的拖动顺序更新优先级
|
||||
function dragOrderEnd() {
|
||||
filterRuleCards.value.forEach((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
})
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
function opengroupInfoDialog() {
|
||||
groupInfo.value = cloneDeep(props.group)
|
||||
if (props.group.rule_string) {
|
||||
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&').filter(rule => rule),
|
||||
}))
|
||||
}
|
||||
groupInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveGroupInfo() {
|
||||
if (!groupInfo.value.name.trim()) {
|
||||
$toast.error(t('filterRule.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
|
||||
$toast.error(t('filterRule.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
|
||||
groupInfoDialog.value = false
|
||||
groupInfo.value.rule_string = filterRuleCards.value
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
emit('change', groupInfo.value, props.group.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
/** 关闭过滤规则组卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="opengroupInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<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">
|
||||
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
|
||||
<span v-else>{{ props.group.category }}</span>
|
||||
</div>
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@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="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>
|
||||
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<DialogWrapper
|
||||
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 />
|
||||
<VCardItem class="pt-1">
|
||||
<VRow class="mt-1">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="groupInfo.name"
|
||||
:label="t('filterRule.groupName')"
|
||||
:placeholder="t('filterRule.nameRequired')"
|
||||
:hint="t('filterRule.groupName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<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">
|
||||
<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>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
v-model="filterRuleCards"
|
||||
handle=".cursor-move"
|
||||
item-key="pri"
|
||||
tag="div"
|
||||
@end="dragOrderEnd"
|
||||
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<FilterRuleCard
|
||||
:pri="element.pri"
|
||||
:maxpri="filterRuleCards.length.toString()"
|
||||
:rules="element.rules"
|
||||
:custom_rules="props.custom_rules"
|
||||
@changed="updateFilterCardValue"
|
||||
@close="filterCardClose(element.pri)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VBtn color="success" @click="importRules('priority')">
|
||||
<VIcon icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn color="info" @click="shareRules">
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
<ImportCodeDialog
|
||||
v-if="importCodeDialog"
|
||||
v-model="importCodeDialog"
|
||||
:title="t('filterRule.import')"
|
||||
:dataType="importCodeType"
|
||||
@close="importCodeDialog = false"
|
||||
@save="saveCodeString"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg ref="imageRef" :src="filter_group_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -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,68 @@ 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 === 'zspace') return getLogoUrl('zspace')
|
||||
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 +113,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 +148,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,7 +166,7 @@ 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">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<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-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
@@ -10,19 +8,32 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
|
||||
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'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeSeasonDialog = defineAsyncComponent(() => import('../dialog/SubscribeSeasonDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
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,
|
||||
})
|
||||
@@ -50,23 +61,14 @@ const isSubscribed = ref(false)
|
||||
// 本地存在状态
|
||||
const isExists = ref(false)
|
||||
|
||||
// 订阅季弹窗
|
||||
const subscribeSeasonDialog = ref(false)
|
||||
|
||||
// 订阅编辑弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 订阅ID
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
// 选中的订阅季
|
||||
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元素
|
||||
@@ -84,12 +86,48 @@ const selectedSites = ref<number[]>([])
|
||||
// 搜索菜单显示状态
|
||||
const searchMenuShow = ref(false)
|
||||
|
||||
// 选择站点对话框
|
||||
const chooseSiteDialog = ref(false)
|
||||
|
||||
// 选择的剧集组
|
||||
const episodeGroup = ref('')
|
||||
|
||||
// 打开订阅季选择弹窗,避免每个媒体卡片都持有弹窗实例。
|
||||
function openSubscribeSeasonDialog() {
|
||||
openSharedDialog(
|
||||
SubscribeSeasonDialog,
|
||||
{ media: props.media },
|
||||
{
|
||||
subscribe: subscribeSeasons,
|
||||
},
|
||||
{ closeOn: ['close', 'subscribe'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开订阅编辑弹窗,保存、关闭或删除时释放共享弹窗实例。
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
remove: onRemoveSubscribe,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
|
||||
function openSearchSiteDialog() {
|
||||
openSharedDialog(
|
||||
SearchSiteDialog,
|
||||
{
|
||||
sites: allSites.value,
|
||||
selected: selectedSites.value,
|
||||
},
|
||||
{
|
||||
search: searchSites,
|
||||
},
|
||||
{ closeOn: ['close', 'search'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 查询所有站点
|
||||
async function querySites() {
|
||||
try {
|
||||
@@ -120,6 +158,22 @@ function getMediaId() {
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
return `${getMediaId()}::${season ?? 'all'}`
|
||||
}
|
||||
|
||||
function getExistsStatusKey() {
|
||||
return [
|
||||
props.media?.tmdb_id ?? '',
|
||||
props.media?.title ?? '',
|
||||
props.media?.year ?? '',
|
||||
props.media?.season ?? '',
|
||||
props.media?.type ?? '',
|
||||
props.media?.mediaid_prefix ?? '',
|
||||
props.media?.media_id ?? '',
|
||||
].join('::')
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -132,7 +186,7 @@ async function handleAddSubscribe() {
|
||||
if (props.media?.type === '电视剧') {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
subscribeSeasonDialog.value = true
|
||||
openSubscribeSeasonDialog()
|
||||
} else {
|
||||
// 电影
|
||||
addSubscribe()
|
||||
@@ -140,7 +194,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 {
|
||||
@@ -155,7 +209,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,
|
||||
})
|
||||
@@ -164,6 +218,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
@@ -173,8 +228,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
if (result.success && seasonsSelected.value.length <= 1) {
|
||||
const show_edit_dialog = await queryDefaultSubscribeConfig()
|
||||
if (show_edit_dialog) {
|
||||
subscribeId.value = result.data.id
|
||||
subscribeEditDialog.value = true
|
||||
openSubscribeEditDialog(result.data.id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -185,8 +239,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')
|
||||
@@ -210,6 +264,7 @@ async function removeSubscribe() {
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
@@ -224,8 +279,10 @@ async function removeSubscribe() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
const result = await checkSubscribe(props.media?.season)
|
||||
if (result) isSubscribed.value = true
|
||||
const subscribed = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), () =>
|
||||
checkSubscribe(props.media?.season ?? null),
|
||||
)
|
||||
isSubscribed.value = subscribed
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -234,24 +291,29 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.success)
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
isExists.value = exists
|
||||
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season = 0) {
|
||||
async function checkSubscribe(season: number | null) {
|
||||
try {
|
||||
// AbortController 现在由全局请求优化器自动管理
|
||||
const mediaid = getMediaId()
|
||||
@@ -262,12 +324,14 @@ async function checkSubscribe(season = 0) {
|
||||
},
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
@@ -294,7 +358,6 @@ function handleSubscribe() {
|
||||
|
||||
// 订阅多季
|
||||
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
|
||||
subscribeSeasonDialog.value = false
|
||||
episodeGroup.value = groudId
|
||||
seasonsSelected.value = seasons || []
|
||||
seasonsSelected.value.forEach(season => {
|
||||
@@ -302,7 +365,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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,7 +402,7 @@ async function clickSearch() {
|
||||
await querySelectedSites()
|
||||
}
|
||||
if (allSites.value?.length > 0) {
|
||||
chooseSiteDialog.value = true
|
||||
openSearchSiteDialog()
|
||||
} else {
|
||||
handleSearch()
|
||||
}
|
||||
@@ -363,7 +426,6 @@ function handleSearch() {
|
||||
|
||||
// 搜索多站点
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
handleSearch()
|
||||
}
|
||||
@@ -413,7 +475,7 @@ const getImgUrl: Ref<string> = computed(() => {
|
||||
|
||||
// 移除订阅
|
||||
function onRemoveSubscribe() {
|
||||
subscribeEditDialog.value = false
|
||||
isSubscribed.value = false
|
||||
}
|
||||
|
||||
// 获取媒体类型文本
|
||||
@@ -529,32 +591,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅季弹窗 -->
|
||||
<subscribeSeasonDialog
|
||||
v-if="subscribeSeasonDialog"
|
||||
v-model="subscribeSeasonDialog"
|
||||
:media="media"
|
||||
@subscribe="subscribeSeasons"
|
||||
@close="subscribeSeasonDialog = false"
|
||||
/>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="onRemoveSubscribe"
|
||||
/>
|
||||
<!-- 站点选择对话框 -->
|
||||
<SearchSiteDialog
|
||||
v-if="chooseSiteDialog"
|
||||
v-model="chooseSiteDialog"
|
||||
:sites="allSites"
|
||||
:selected="selectedSites"
|
||||
@search="searchSites"
|
||||
@close="chooseSiteDialog = false"
|
||||
/>
|
||||
</template>
|
||||
<style scoped>
|
||||
.media-card-title {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
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 api from '@/api'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import type { MediaServerConf, MediaStatistic } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaServerDict } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const MediaServerInfoDialog = defineAsyncComponent(() => import('@/components/dialog/MediaServerInfoDialog.vue'))
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#56CA00')
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -32,9 +27,6 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'done', 'change'])
|
||||
|
||||
@@ -57,76 +49,48 @@ const infoItems = ref([
|
||||
},
|
||||
])
|
||||
|
||||
// 同步媒体库选项
|
||||
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
|
||||
{
|
||||
title: t('common.all'),
|
||||
value: 'all',
|
||||
},
|
||||
])
|
||||
|
||||
// 媒体服务器详情弹窗
|
||||
const mediaServerInfoDialog = ref(false)
|
||||
|
||||
// 媒体服务器详情
|
||||
const mediaServerInfo = ref<MediaServerConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 打开详情弹窗
|
||||
/** 打开共享媒体服务器配置弹窗。 */
|
||||
function openMediaServerInfoDialog() {
|
||||
loadLibrary(props.mediaserver.name)
|
||||
// 深复制
|
||||
mediaServerInfo.value = cloneDeep(props.mediaserver)
|
||||
mediaServerInfoDialog.value = true
|
||||
if (!props.mediaserver.sync_libraries) {
|
||||
mediaServerInfo.value.sync_libraries = ['all']
|
||||
}
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveMediaServerInfo() {
|
||||
// 为空不保存,跳出警告框
|
||||
if (!mediaServerInfo.value.name) {
|
||||
$toast.error(t('common.nameRequired'))
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
|
||||
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
|
||||
return
|
||||
}
|
||||
// 执行保存
|
||||
mediaServerInfoDialog.value = false
|
||||
emit('change', mediaServerInfo.value, props.mediaserver.name)
|
||||
emit('done')
|
||||
openSharedDialog(
|
||||
MediaServerInfoDialog,
|
||||
{
|
||||
mediaserver: props.mediaserver,
|
||||
mediaservers: props.mediaservers,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
done: () => emit('done'),
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.mediaserver.type) {
|
||||
case 'emby':
|
||||
return emby_image
|
||||
return getLogoUrl('emby')
|
||||
case 'zspace':
|
||||
return getLogoUrl('zspace')
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
// 按钮点击
|
||||
/** 关闭媒体服务器卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 调用API加载媒体统计数据
|
||||
/** 调用 API 加载媒体服务器统计数据。 */
|
||||
async function loadMediaStatistic() {
|
||||
try {
|
||||
const res: MediaStatistic = await api.get('dashboard/statistic', {
|
||||
@@ -159,353 +123,38 @@ async function loadMediaStatistic() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API查询媒体库
|
||||
async function loadLibrary(server: string) {
|
||||
try {
|
||||
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
|
||||
if (result && result.length > 0) {
|
||||
librariesOptions.value = result.map(item => ({
|
||||
title: item.name,
|
||||
value: item.id?.toString(),
|
||||
}))
|
||||
} else {
|
||||
librariesOptions.value = []
|
||||
}
|
||||
librariesOptions.value.unshift({
|
||||
title: t('common.all'),
|
||||
value: 'all',
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMediaStatistic()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @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 }}
|
||||
</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>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<DialogWrapper
|
||||
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>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'emby'">
|
||||
<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" md="6">
|
||||
<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.apikey"
|
||||
:label="t('mediaserver.apiKey')"
|
||||
:hint="t('mediaserver.embyApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</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>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
|
||||
<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" md="6">
|
||||
<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.apikey"
|
||||
:label="t('mediaserver.apiKey')"
|
||||
:hint="t('mediaserver.jellyfinApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</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>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'trimemedia'">
|
||||
<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>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'plex'">
|
||||
<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" md="6">
|
||||
<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.token"
|
||||
:label="t('mediaserver.plexToken')"
|
||||
:hint="t('mediaserver.plexTokenHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</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>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.type"
|
||||
: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
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
<template>
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@click="openMediaServerInfoDialog"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<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="app-card-summary__subtitle text-sm">
|
||||
{{ t('setting.system.custom') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -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'
|
||||
@@ -19,6 +21,22 @@ 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
|
||||
@@ -42,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>
|
||||
|
||||
@@ -85,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 }}
|
||||
@@ -116,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,21 +1,14 @@
|
||||
<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 custom_image from '@images/logos/notification.png'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import type { NotificationConf } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const NotificationChannelInfoDialog = defineAsyncComponent(() => import('@/components/dialog/NotificationChannelInfoDialog.vue'))
|
||||
|
||||
const { t } = useI18n()
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -34,448 +27,95 @@ const props = defineProps({
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'change', 'done'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 通知详情弹窗
|
||||
const notificationInfoDialog = ref(false)
|
||||
|
||||
// 通知详情
|
||||
const notificationInfo = ref<NotificationConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 各通知类型的名称字典
|
||||
const notificationTypeNames: { [key: string]: string } = {
|
||||
wechat: t('notification.wechat.name'),
|
||||
feishu: t('notification.feishu.name'),
|
||||
wechatclawbot: t('notification.wechatclawbot.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'),
|
||||
}
|
||||
|
||||
// 消息类型下拉字典
|
||||
const notificationTypes = [
|
||||
{ value: '资源下载', title: t('notificationSwitch.resourceDownload') },
|
||||
{ value: '整理入库', title: t('notificationSwitch.organize') },
|
||||
{ value: '订阅', title: t('notificationSwitch.subscribe') },
|
||||
{ value: '站点', title: t('notificationSwitch.site') },
|
||||
{ value: '媒体服务器', title: t('notificationSwitch.mediaServer') },
|
||||
{ value: '手动处理', title: t('notificationSwitch.manual') },
|
||||
{ value: '插件', title: t('notificationSwitch.plugin') },
|
||||
{ value: '其它', title: t('notificationSwitch.other') },
|
||||
]
|
||||
|
||||
// 打开详情弹窗
|
||||
/** 打开共享通知渠道配置弹窗。 */
|
||||
function openNotificationInfoDialog() {
|
||||
// 替换成深复制,避免修改时影响原数据
|
||||
notificationInfo.value = cloneDeep(props.notification)
|
||||
notificationInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveNotificationInfo() {
|
||||
// 为空不保存,跳出警告框
|
||||
if (!notificationInfo.value.name) {
|
||||
$toast.error(t('notification.name') + t('common.required'))
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
|
||||
$toast.error(t('notification.channel') + `【${notificationInfo.value.name}】` + t('common.exists'))
|
||||
return
|
||||
}
|
||||
notificationInfoDialog.value = false
|
||||
emit('change', notificationInfo.value, props.notification.name)
|
||||
emit('done')
|
||||
openSharedDialog(
|
||||
NotificationChannelInfoDialog,
|
||||
{
|
||||
notification: props.notification,
|
||||
notifications: props.notifications,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
done: () => emit('done'),
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.notification.type) {
|
||||
case 'wechat':
|
||||
return wechat_image
|
||||
return getLogoUrl('wechat')
|
||||
case 'wechatclawbot':
|
||||
return getLogoUrl('wechatclawbot')
|
||||
case 'feishu':
|
||||
return getLogoUrl('feishu')
|
||||
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 custom_image
|
||||
return getLogoUrl('notification')
|
||||
}
|
||||
})
|
||||
|
||||
// 按钮点击
|
||||
/** 关闭通知渠道卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openNotificationInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<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">
|
||||
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
|
||||
<span class="text-h6">{{ props.notification.name }}</span>
|
||||
</div>
|
||||
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-7 me-1" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<DialogWrapper
|
||||
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>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="notificationInfo.enabled" :label="t('notification.enabled')" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="notificationInfo.switchs"
|
||||
:items="notificationTypes"
|
||||
:label="t('notification.type')"
|
||||
:hint="t('notification.typeHint')"
|
||||
multiple
|
||||
clearable
|
||||
chips
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-bell-outline"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'wechat'">
|
||||
<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">
|
||||
<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>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'telegram'">
|
||||
<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">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_TOKEN"
|
||||
:label="t('notification.telegram.token')"
|
||||
:hint="t('notification.telegram.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_CHAT_ID"
|
||||
:label="t('notification.telegram.chatId')"
|
||||
:hint="t('notification.telegram.chatIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-chat"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_USERS"
|
||||
:label="t('notification.telegram.users')"
|
||||
:placeholder="t('notification.telegram.usersPlaceholder')"
|
||||
:hint="t('notification.telegram.usersHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_ADMINS"
|
||||
:label="t('notification.telegram.admins')"
|
||||
: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-else-if="notificationInfo.type == 'slack'">
|
||||
<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">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SLACK_OAUTH_TOKEN"
|
||||
:label="t('notification.slack.oauthToken')"
|
||||
:placeholder="t('notification.slack.oauthTokenPlaceholder')"
|
||||
:hint="t('notification.slack.oauthTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SLACK_APP_TOKEN"
|
||||
:label="t('notification.slack.appToken')"
|
||||
:placeholder="t('notification.slack.appTokenPlaceholder')"
|
||||
:hint="t('notification.slack.appTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SLACK_CHANNEL"
|
||||
:label="t('notification.slack.channel')"
|
||||
:placeholder="t('notification.slack.channelPlaceholder')"
|
||||
:hint="t('notification.slack.channelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</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">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SYNOLOGYCHAT_WEBHOOK"
|
||||
:label="t('notification.synologychat.webhook')"
|
||||
:hint="t('notification.synologychat.webhookHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-webhook"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SYNOLOGYCHAT_TOKEN"
|
||||
:label="t('notification.synologychat.token')"
|
||||
:hint="t('notification.synologychat.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'vocechat'">
|
||||
<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">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.VOCECHAT_HOST"
|
||||
:label="t('notification.vocechat.host')"
|
||||
:hint="t('notification.vocechat.hostHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.VOCECHAT_API_KEY"
|
||||
:label="t('notification.vocechat.apiKey')"
|
||||
:hint="t('notification.vocechat.apiKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.VOCECHAT_CHANNEL_ID"
|
||||
:label="t('notification.vocechat.channelId')"
|
||||
:placeholder="t('notification.vocechat.channelIdPlaceholder')"
|
||||
:hint="t('notification.vocechat.channelIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</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">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WEBPUSH_USERNAME"
|
||||
: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" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
<template>
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@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="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="app-card-summary__title text-h6">{{ props.notification.name }}</span>
|
||||
</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 ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
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 ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/PluginVersionHistoryDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -29,15 +30,6 @@ const backgroundColor = ref('#28A9E1')
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度框文本
|
||||
const progressText = ref('')
|
||||
|
||||
// 获取当前插件的标签
|
||||
const pluginLabels = computed(() => {
|
||||
if (!props.plugin?.plugin_label) return []
|
||||
@@ -54,12 +46,6 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 更新日志弹窗
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
// 插件详情弹窗
|
||||
const detailDialog = ref(false)
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -68,45 +54,14 @@ async function imageLoaded() {
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
}
|
||||
|
||||
// 安装插件
|
||||
async function installPlugin() {
|
||||
try {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: props?.plugin?.plugin_version,
|
||||
})
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: props.plugin?.has_update,
|
||||
},
|
||||
})
|
||||
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
detailDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算图标路径
|
||||
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)}&cache=true`
|
||||
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}`
|
||||
})
|
||||
@@ -115,6 +70,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 += '/'
|
||||
@@ -136,7 +94,27 @@ function visitPluginPage() {
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory() {
|
||||
releaseDialog.value = true
|
||||
openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin },
|
||||
{},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
/** 打开共享插件市场详情弹窗。 */
|
||||
function showPluginDetail() {
|
||||
openSharedDialog(
|
||||
PluginMarketDetailDialog,
|
||||
{
|
||||
plugin: props.plugin,
|
||||
count: props.count,
|
||||
},
|
||||
{
|
||||
install: () => emit('install'),
|
||||
},
|
||||
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
@@ -160,6 +138,7 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -170,7 +149,7 @@ const dropdownItems = ref([
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="detailDialog = true"
|
||||
@click="showPluginDetail"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
@@ -242,11 +221,11 @@ const dropdownItems = ref([
|
||||
</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">{{ props.count?.toLocaleString() }}</span>
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<IconBtn @click.stop>
|
||||
<VIcon size="small" icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -264,77 +243,5 @@ const dropdownItems = ref([
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 安装插件进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
<!-- 更新日志 -->
|
||||
<DialogWrapper v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VDivider />
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
<!-- 插件详情-->
|
||||
<DialogWrapper v-if="detailDialog" v-model="detailDialog" max-width="30rem">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="detailDialog = false" />
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="mx-auto mt-5">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<VCardTitle class="text-center text-md-left">
|
||||
{{ props.plugin?.plugin_name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.version') }}:</span>
|
||||
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.author') }}:</span>
|
||||
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<div class="text-center text-md-left">
|
||||
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download">{{
|
||||
t('plugin.installToLocal')
|
||||
}}</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />{{
|
||||
t('plugin.totalDownloads', { count: props.count?.toLocaleString() })
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,18 +4,20 @@ 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 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 { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
||||
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
||||
const PluginDataDialog = defineAsyncComponent(() => import('../dialog/PluginDataDialog.vue'))
|
||||
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
||||
const PluginCloneDialog = defineAsyncComponent(() => import('../dialog/PluginCloneDialog.vue'))
|
||||
const PluginLogDialog = defineAsyncComponent(() => import('../dialog/PluginLogDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('../dialog/PluginVersionHistoryDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -24,6 +26,10 @@ const props = defineProps({
|
||||
action: Boolean, // 动作标识
|
||||
width: String,
|
||||
height: String,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -32,6 +38,9 @@ const emit = defineEmits(['remove', 'save', 'actionDone'])
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
|
||||
@@ -47,24 +56,9 @@ const createConfirm = useConfirm()
|
||||
// 本身是否可见
|
||||
const isVisible = ref(true)
|
||||
|
||||
// 插件配置页面
|
||||
const pluginConfigDialog = ref(false)
|
||||
|
||||
// 菜单显示状态
|
||||
const menuVisible = ref(false)
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 插件数据页面
|
||||
const pluginInfoDialog = ref(false)
|
||||
|
||||
// 实时日志弹窗
|
||||
const loggingDialog = ref(false)
|
||||
|
||||
// 进度框文本
|
||||
const progressText = ref('正在更新插件...')
|
||||
|
||||
// 用户头像是否加载完成
|
||||
const isAvatarLoaded = ref(false)
|
||||
|
||||
@@ -74,20 +68,20 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 更新日志弹窗
|
||||
const releaseDialog = ref(false)
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
// 插件分身对话框
|
||||
const pluginCloneDialog = ref(false)
|
||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||
function showPluginProgress(text: string) {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
|
||||
}
|
||||
|
||||
// 插件分身表单
|
||||
const cloneForm = ref({
|
||||
suffix: '',
|
||||
name: '',
|
||||
description: '',
|
||||
version: '',
|
||||
icon: '',
|
||||
})
|
||||
/** 关闭当前插件操作进度弹窗。 */
|
||||
function closePluginProgress() {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = null
|
||||
}
|
||||
|
||||
// 监听动作标识,如为true则打开详情
|
||||
watch(
|
||||
@@ -114,7 +108,12 @@ function showUpdateHistory() {
|
||||
if (isNullOrEmptyObject(props.plugin?.history)) {
|
||||
updatePlugin()
|
||||
} else {
|
||||
releaseDialog.value = true
|
||||
openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, showUpdateAction: true },
|
||||
{ update: updatePlugin },
|
||||
{ closeOn: ['close', 'update', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,11 +128,10 @@ async function uninstallPlugin() {
|
||||
|
||||
try {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = t('plugin.uninstalling', { name: props.plugin?.plugin_name })
|
||||
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
||||
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
closePluginProgress()
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
|
||||
|
||||
@@ -148,26 +146,39 @@ async function uninstallPlugin() {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示插件数据
|
||||
async function showPluginInfo() {
|
||||
pluginConfigDialog.value = false
|
||||
pluginInfoDialog.value = true
|
||||
openSharedDialog(
|
||||
PluginDataDialog,
|
||||
{ plugin: props.plugin },
|
||||
{
|
||||
switch: showPluginConfig,
|
||||
},
|
||||
{ closeOn: ['close', 'switch'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 显示插件配置
|
||||
async function showPluginConfig() {
|
||||
// 显示对话框
|
||||
pluginInfoDialog.value = false
|
||||
pluginConfigDialog.value = true
|
||||
openSharedDialog(
|
||||
PluginConfigDialog,
|
||||
{ plugin: props.plugin },
|
||||
{
|
||||
save: configDone,
|
||||
switch: showPluginInfo,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'switch'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 计算图标路径
|
||||
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(
|
||||
@@ -215,11 +226,14 @@ async function resetPlugin() {
|
||||
|
||||
// 更新插件
|
||||
async function updatePlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
releaseDialog.value = false
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = t('plugin.updating', { name: props.plugin?.plugin_name })
|
||||
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
@@ -229,7 +243,7 @@ async function updatePlugin() {
|
||||
})
|
||||
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
closePluginProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||
@@ -245,6 +259,7 @@ async function updatePlugin() {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -254,74 +269,77 @@ function visitAuthorPage() {
|
||||
window.open(props.plugin?.author_url, '_blank')
|
||||
}
|
||||
|
||||
// 查看日志URL
|
||||
function openLoggerWindow() {
|
||||
const url = `${
|
||||
import.meta.env.VITE_API_BASE_URL
|
||||
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 打开插件详情
|
||||
function openPluginDetail() {
|
||||
if (props.plugin?.has_page) showPluginInfo()
|
||||
else showPluginConfig()
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openPluginDetail()
|
||||
}
|
||||
|
||||
// 配置完成
|
||||
function configDone() {
|
||||
pluginConfigDialog.value = false
|
||||
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
|
||||
cloneDialogController?.close()
|
||||
cloneDialogController = openSharedDialog(
|
||||
PluginCloneDialog,
|
||||
{ plugin: props.plugin },
|
||||
{ clone: executePluginClone },
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 执行插件分身
|
||||
async function executePluginClone() {
|
||||
if (!cloneForm.value.suffix.trim()) {
|
||||
async function executePluginClone(cloneForm: { suffix: string; name: string; description: string; version: string; icon: string }) {
|
||||
if (!cloneForm.suffix.trim()) {
|
||||
$toast.error(t('plugin.suffixRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
progressDialog.value = true
|
||||
progressText.value = t('plugin.cloning', { name: props.plugin?.plugin_name })
|
||||
showPluginProgress(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(),
|
||||
suffix: cloneForm.suffix.trim(),
|
||||
name: cloneForm.name.trim(),
|
||||
description: cloneForm.description.trim(),
|
||||
version: cloneForm.version.trim(),
|
||||
icon: cloneForm.icon.trim(),
|
||||
})
|
||||
|
||||
progressDialog.value = false
|
||||
closePluginProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.value.name }))
|
||||
pluginCloneDialog.value = false
|
||||
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
|
||||
cloneDialogController?.close()
|
||||
cloneDialogController = null
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
} else {
|
||||
$toast.error(t('plugin.cloneFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
progressDialog.value = false
|
||||
closePluginProgress()
|
||||
$toast.error(t('plugin.cloneFailedGeneral'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
closePluginProgress()
|
||||
cloneDialogController?.close()
|
||||
})
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -389,7 +407,7 @@ const dropdownItems = ref([
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
click: () => {
|
||||
loggingDialog.value = true
|
||||
openSharedDialog(PluginLogDialog, { plugin: props.plugin }, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -419,6 +437,7 @@ watch(
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) openPluginDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -432,11 +451,13 @@ watch(
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="openPluginDetail"
|
||||
@click="handleCardClick"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
@@ -457,7 +478,10 @@ watch(
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
|
||||
<div
|
||||
class="relative flex-shrink-0 self-center pb-3"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
@@ -481,7 +505,11 @@ watch(
|
||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||
</template>
|
||||
</VImg>
|
||||
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
@@ -492,11 +520,11 @@ watch(
|
||||
</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">{{ props.count?.toLocaleString() }}</span>
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -524,183 +552,6 @@ watch(
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<!-- 插件配置页面 -->
|
||||
<PluginConfigDialog
|
||||
v-if="pluginConfigDialog"
|
||||
v-model="pluginConfigDialog"
|
||||
:plugin="props.plugin"
|
||||
@save="configDone"
|
||||
@close="pluginConfigDialog = false"
|
||||
@switch="showPluginInfo"
|
||||
/>
|
||||
|
||||
<!-- 插件数据页面 -->
|
||||
<PluginDataDialog
|
||||
v-if="pluginInfoDialog"
|
||||
v-model="pluginInfoDialog"
|
||||
:plugin="props.plugin"
|
||||
@close="pluginInfoDialog = false"
|
||||
@switch="showPluginConfig"
|
||||
/>
|
||||
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
|
||||
<!-- 更新日志 -->
|
||||
<DialogWrapper 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 />
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
<VDivider />
|
||||
<VCardItem>
|
||||
<VBtn @click="updatePlugin" block>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
{{ t('plugin.updateToLatest') }}
|
||||
</VBtn>
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
|
||||
<!-- 实时日志弹窗 -->
|
||||
<DialogWrapper
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="60rem"
|
||||
: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>
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
|
||||
<!-- 插件分身对话框 -->
|
||||
<DialogWrapper
|
||||
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>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
|
||||
const PluginFolderSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderSettingsDialog.vue'))
|
||||
|
||||
// 文件夹配置接口
|
||||
interface FolderConfig {
|
||||
@@ -25,6 +29,10 @@ const props = defineProps({
|
||||
},
|
||||
width: String,
|
||||
height: String,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -44,15 +52,7 @@ const createConfirm = useConfirm()
|
||||
|
||||
// 菜单显示状态
|
||||
const menuVisible = ref(false)
|
||||
|
||||
// 重命名对话框
|
||||
const renameDialog = ref(false)
|
||||
|
||||
// 设置对话框
|
||||
const settingDialog = ref(false)
|
||||
|
||||
// 新名称
|
||||
const newFolderName = ref('')
|
||||
let renameDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
// 默认颜色
|
||||
const defaultColor = '#2196F3'
|
||||
@@ -62,130 +62,76 @@ const defaultIcon = 'mdi-folder'
|
||||
const defaultGradient =
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8s) 100%)'
|
||||
|
||||
// 文件夹设置
|
||||
const folderSettings = ref<FolderConfig>({
|
||||
background: '',
|
||||
icon: defaultIcon,
|
||||
color: defaultColor,
|
||||
gradient: defaultGradient,
|
||||
showIcon: true,
|
||||
})
|
||||
|
||||
// 计算背景图片
|
||||
const backgroundImage = computed(() => {
|
||||
return props.folderConfig.background || folderSettings.value.background
|
||||
return props.folderConfig.background
|
||||
})
|
||||
|
||||
// 预设图标选项
|
||||
const iconOptions = [
|
||||
'mdi-folder',
|
||||
'mdi-folder-star',
|
||||
'mdi-folder-heart',
|
||||
'mdi-folder-cog',
|
||||
'mdi-folder-music',
|
||||
'mdi-folder-image',
|
||||
'mdi-folder-video',
|
||||
'mdi-folder-download',
|
||||
'mdi-folder-network',
|
||||
'mdi-folder-special',
|
||||
]
|
||||
|
||||
// 预设颜色选项
|
||||
const colorOptions = [
|
||||
'#2196F3', // 蓝色
|
||||
'#4CAF50', // 绿色
|
||||
'#FF9800', // 橙色
|
||||
'#9C27B0', // 紫色
|
||||
'#F44336', // 红色
|
||||
'#607D8B', // 蓝灰色
|
||||
'#795548', // 棕色
|
||||
'#E91E63', // 粉色
|
||||
]
|
||||
|
||||
// 预设渐变选项
|
||||
const gradientOptions = [
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
|
||||
]
|
||||
|
||||
// 计算背景渐变
|
||||
const backgroundGradient = computed(() => {
|
||||
const config = props.folderConfig || {}
|
||||
const settings = folderSettings.value
|
||||
|
||||
return config.gradient || settings.gradient || gradientOptions[0]
|
||||
return config.gradient || defaultGradient
|
||||
})
|
||||
|
||||
// 计算图标
|
||||
const folderIcon = computed(() => {
|
||||
const config = props.folderConfig || {}
|
||||
const settings = folderSettings.value
|
||||
|
||||
return config.icon || settings.icon || defaultIcon
|
||||
return config.icon || defaultIcon
|
||||
})
|
||||
|
||||
// 计算图标颜色
|
||||
const iconColor = computed(() => {
|
||||
const config = props.folderConfig || {}
|
||||
const settings = folderSettings.value
|
||||
|
||||
return config.color || settings.color || defaultColor
|
||||
return config.color || defaultColor
|
||||
})
|
||||
|
||||
// 计算是否显示图标
|
||||
const shouldShowIcon = computed(() => {
|
||||
const config = props.folderConfig || {}
|
||||
const settings = folderSettings.value
|
||||
|
||||
return config.showIcon !== undefined ? config.showIcon : settings.showIcon !== undefined ? settings.showIcon : true
|
||||
return config.showIcon !== undefined ? config.showIcon : true
|
||||
})
|
||||
|
||||
// 监听props变化,更新本地设置
|
||||
watch(
|
||||
() => props.folderConfig,
|
||||
newConfig => {
|
||||
if (newConfig) {
|
||||
folderSettings.value = {
|
||||
...folderSettings.value,
|
||||
...newConfig,
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
// 打开文件夹
|
||||
function openFolder() {
|
||||
emit('open', props.folderName)
|
||||
}
|
||||
|
||||
// 重命名文件夹
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openFolder()
|
||||
}
|
||||
|
||||
/** 打开文件夹重命名共享弹窗。 */
|
||||
function showRenameDialog() {
|
||||
newFolderName.value = props.folderName || ''
|
||||
renameDialog.value = true
|
||||
renameDialogController?.close()
|
||||
renameDialogController = openSharedDialog(
|
||||
PluginFolderRenameDialog,
|
||||
{ folderName: props.folderName },
|
||||
{ rename: confirmRename },
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 确认重命名
|
||||
async function confirmRename() {
|
||||
if (!newFolderName.value.trim()) {
|
||||
async function confirmRename(newFolderName: string) {
|
||||
if (!newFolderName.trim()) {
|
||||
$toast.error(t('folder.folderNameCannotBeEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
if (newFolderName.value === props.folderName) {
|
||||
renameDialog.value = false
|
||||
if (newFolderName === props.folderName) {
|
||||
renameDialogController?.close()
|
||||
renameDialogController = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
emit('rename', props.folderName, newFolderName.value)
|
||||
renameDialog.value = false
|
||||
emit('rename', props.folderName, newFolderName)
|
||||
renameDialogController?.close()
|
||||
renameDialogController = null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -209,28 +155,24 @@ async function deleteFolder() {
|
||||
|
||||
// 显示设置对话框
|
||||
function showSettingDialog() {
|
||||
folderSettings.value = {
|
||||
background: props.folderConfig?.background || '',
|
||||
icon: props.folderConfig?.icon || defaultIcon,
|
||||
color: props.folderConfig?.color || defaultColor,
|
||||
gradient: props.folderConfig?.gradient || gradientOptions[0],
|
||||
showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,
|
||||
}
|
||||
settingDialog.value = true
|
||||
openSharedDialog(
|
||||
PluginFolderSettingsDialog,
|
||||
{ folderConfig: props.folderConfig },
|
||||
{ save: saveSettings },
|
||||
{ closeOn: ['close', 'save', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
function saveSettings() {
|
||||
const config = {
|
||||
...props.folderConfig,
|
||||
...folderSettings.value,
|
||||
}
|
||||
|
||||
function saveSettings(config: FolderConfig) {
|
||||
emit('update-config', props.folderName, config)
|
||||
settingDialog.value = false
|
||||
$toast.success(t('folder.folderSettingsSaved'))
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
renameDialogController?.close()
|
||||
})
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -275,11 +217,12 @@ const dropdownItems = ref([
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="openFolder"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
@@ -302,14 +245,14 @@ const dropdownItems = ref([
|
||||
:icon="folderIcon"
|
||||
:size="display.mobile ? 56 : 72"
|
||||
:color="iconColor"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹信息 -->
|
||||
<div
|
||||
class="plugin-folder-card__info"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
>
|
||||
<!-- 文件夹名称 -->
|
||||
<h3 class="plugin-folder-card__name">
|
||||
@@ -321,7 +264,7 @@ const dropdownItems = ref([
|
||||
</div>
|
||||
|
||||
<!-- 更多菜单按钮 - 右下角 -->
|
||||
<div class="absolute top-0 right-0">
|
||||
<div v-if="!props.sortable" class="absolute top-0 right-0">
|
||||
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<IconBtn v-bind="menuProps" @click.stop>
|
||||
@@ -348,139 +291,6 @@ const dropdownItems = ref([
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<!-- 重命名对话框 -->
|
||||
<DialogWrapper v-if="renameDialog" v-model="renameDialog" max-width="400">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-pencil" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="renameDialog = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField
|
||||
v-model="newFolderName"
|
||||
:label="t('folder.folderName')"
|
||||
variant="outlined"
|
||||
autofocus
|
||||
@keyup.enter="confirmRename"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
|
||||
<!-- 设置对话框 -->
|
||||
<DialogWrapper
|
||||
v-if="settingDialog"
|
||||
v-model="settingDialog"
|
||||
max-width="600"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="settingDialog = false" />
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-palette" class="mr-2" />
|
||||
{{ t('folder.folderAppearanceSettings') }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<!-- 显示图标开关 -->
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="folderSettings.showIcon"
|
||||
:label="t('folder.showFolderIcon')"
|
||||
color="primary"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<!-- 图标选择 -->
|
||||
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.icon') }}</VCardSubtitle>
|
||||
<div class="icon-grid">
|
||||
<VBtn
|
||||
v-for="icon in iconOptions"
|
||||
icon
|
||||
:key="icon"
|
||||
:variant="folderSettings.icon === icon ? 'tonal' : 'text'"
|
||||
:color="folderSettings.icon === icon ? 'primary' : 'default'"
|
||||
size="large"
|
||||
class="ma-1"
|
||||
@click="folderSettings.icon = icon"
|
||||
>
|
||||
<VIcon :icon="icon" size="24" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<!-- 颜色选择 -->
|
||||
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.iconColor') }}</VCardSubtitle>
|
||||
<div class="color-grid">
|
||||
<VBtn
|
||||
v-for="color in colorOptions"
|
||||
:key="color"
|
||||
:variant="folderSettings.color === color ? 'tonal' : 'text'"
|
||||
:color="color"
|
||||
size="large"
|
||||
class="ma-1 color-btn"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="folderSettings.color = color"
|
||||
>
|
||||
<VIcon v-if="folderSettings.color === color" icon="mdi-check" color="white" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<!-- 渐变背景选择 -->
|
||||
<VCol cols="12">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.backgroundGradient') }}</VCardSubtitle>
|
||||
<div class="gradient-grid">
|
||||
<VBtn
|
||||
v-for="(gradient, index) in gradientOptions"
|
||||
:key="index"
|
||||
:variant="folderSettings.gradient === gradient ? 'tonal' : 'text'"
|
||||
class="ma-1 gradient-btn"
|
||||
:style="{ background: gradient }"
|
||||
size="large"
|
||||
@click="folderSettings.gradient = gradient"
|
||||
>
|
||||
<VIcon v-if="folderSettings.gradient === gradient" icon="mdi-check" color="white" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<!-- 自定义背景图片 -->
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="folderSettings.background"
|
||||
:label="t('folder.customBackgroundImageURL')"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
variant="outlined"
|
||||
:hint="t('folder.customBackgroundImageHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-image"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -491,6 +301,10 @@ const dropdownItems = ref([
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&--sortable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ interface Props {
|
||||
pluginStatistics?: { [key: string]: number }
|
||||
pluginActions?: { [key: string]: boolean }
|
||||
showRemoveButton?: boolean
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pluginStatistics: () => ({}),
|
||||
pluginActions: () => ({}),
|
||||
showRemoveButton: false,
|
||||
sortable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -36,7 +38,7 @@ const emit = defineEmits<{
|
||||
// 拖拽事件处理
|
||||
function handleDragOver(event: DragEvent) {
|
||||
// 只有当拖拽的是插件时才允许放入文件夹
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
@@ -46,14 +48,14 @@ function handleDragOver(event: DragEvent) {
|
||||
}
|
||||
|
||||
function handleDragEnter(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
@@ -62,7 +64,7 @@ function handleDragLeave(event: DragEvent) {
|
||||
}
|
||||
|
||||
function handleDropToFolder(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
@@ -89,6 +91,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:folder-name="item.data.name"
|
||||
:plugin-count="item.data.pluginCount"
|
||||
:folder-config="item.data.config"
|
||||
:sortable="sortable"
|
||||
@open="$emit('openFolder', item.id)"
|
||||
@delete="$emit('deleteFolder', item.id)"
|
||||
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
|
||||
@@ -102,6 +105,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:count="pluginStatistics[item.id] || 0"
|
||||
:plugin="item.data"
|
||||
:action="pluginActions[item.id] || false"
|
||||
:sortable="sortable"
|
||||
@remove="$emit('refreshData')"
|
||||
@save="$emit('refreshData')"
|
||||
@action-done="$emit('actionDone', item.id)"
|
||||
@@ -109,7 +113,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
|
||||
<!-- 移出文件夹按钮(仅在文件夹内显示) -->
|
||||
<VBtn
|
||||
v-if="showRemoveButton"
|
||||
v-if="showRemoveButton && !sortable"
|
||||
icon="mdi-folder-remove"
|
||||
variant="text"
|
||||
color="warning"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { PropType } from 'vue'
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -27,12 +28,19 @@ function getChipColor(type: string) {
|
||||
const getImgUrl = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
const image = props.media?.image || ''
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
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
|
||||
})
|
||||
|
||||
// 跳转播放
|
||||
function goPlay(isHovering: boolean | null = false) {
|
||||
if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
|
||||
async function goPlay(isHovering: boolean | null = false) {
|
||||
if (props.media?.link && isHovering) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import noImage from '@images/logos/site.webp'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
|
||||
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
|
||||
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue'
|
||||
import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
|
||||
import api from '@/api'
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const SiteAddEditDialog = defineAsyncComponent(() => import('../dialog/SiteAddEditDialog.vue'))
|
||||
const SiteCookieUpdateDialog = defineAsyncComponent(() => import('../dialog/SiteCookieUpdateDialog.vue'))
|
||||
const SiteResourceDialog = defineAsyncComponent(() => import('../dialog/SiteResourceDialog.vue'))
|
||||
const SiteUserDataDialog = defineAsyncComponent(() => import('../dialog/SiteUserDataDialog.vue'))
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -25,6 +28,10 @@ const cardProps = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
data: Object as PropType<SiteUserData>,
|
||||
stats: Object as PropType<SiteStatistic>,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -34,7 +41,8 @@ const emit = defineEmits(['update', 'remove', 'refresh-stats'])
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 图标
|
||||
const siteIcon = ref<string>('')
|
||||
const defaultSiteIcon = getLogoUrl('site')
|
||||
const siteIcon = ref<string>(defaultSiteIcon)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -45,26 +53,22 @@ const testButtonText = ref(t('site.testConnectivity'))
|
||||
// 测试按钮可用性
|
||||
const testButtonDisable = ref(false)
|
||||
|
||||
// 更新站点Cookie UA弹窗
|
||||
const siteCookieDialog = ref(false)
|
||||
|
||||
// 站点编辑弹窗
|
||||
const siteEditDialog = ref(false)
|
||||
|
||||
// 资源浏览弹窗
|
||||
const resourceDialog = ref(false)
|
||||
|
||||
// 用户数据弹窗
|
||||
const siteUserDataDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
const siteId = cardProps.site?.id
|
||||
if (!siteId) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
|
||||
if (!siteIcon.value) {
|
||||
siteIcon.value = noImage
|
||||
}
|
||||
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
|
||||
const response = await api.get(`site/icon/${siteId}`)
|
||||
|
||||
return response?.data?.icon || defaultSiteIcon
|
||||
})
|
||||
} catch (error) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -91,17 +95,44 @@ async function testSite() {
|
||||
|
||||
// 打开更新站点Cookie UA弹窗
|
||||
async function handleSiteUpdate() {
|
||||
siteCookieDialog.value = true
|
||||
openSharedDialog(
|
||||
SiteCookieUpdateDialog,
|
||||
{ site: cardProps.site },
|
||||
{
|
||||
done: onSiteCookieUpdated,
|
||||
},
|
||||
{ closeOn: ['close', 'done'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开资源浏览弹窗
|
||||
async function handleResourceBrowse() {
|
||||
resourceDialog.value = true
|
||||
openSharedDialog(
|
||||
SiteResourceDialog,
|
||||
{ site: cardProps.site },
|
||||
{
|
||||
close: onSiteResourceDone,
|
||||
},
|
||||
{ closeOn: ['close'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开站点用户数据弹窗
|
||||
async function handleSiteUserData() {
|
||||
siteUserDataDialog.value = true
|
||||
openSharedDialog(SiteUserDataDialog, { site: cardProps.site }, {}, { closeOn: ['close'] })
|
||||
}
|
||||
|
||||
// 打开站点编辑弹窗
|
||||
function handleSiteEdit() {
|
||||
openSharedDialog(
|
||||
SiteAddEditDialog,
|
||||
{ siteid: cardProps.site?.id },
|
||||
{
|
||||
save: saveSite,
|
||||
remove: () => emit('remove'),
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开站点页面
|
||||
@@ -109,6 +140,22 @@ function openSitePage() {
|
||||
window.open(cardProps.site?.url, '_blank')
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (cardProps.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
handleResourceBrowse()
|
||||
}
|
||||
|
||||
function handleSiteUrlClick() {
|
||||
if (cardProps.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openSitePage()
|
||||
}
|
||||
|
||||
// 调用API删除站点信息
|
||||
async function deleteSiteInfo() {
|
||||
const isConfirmed = await createConfirm({
|
||||
@@ -169,20 +216,17 @@ const getDownloadPercent = computed(() => {
|
||||
|
||||
// 保存站点
|
||||
function saveSite() {
|
||||
siteEditDialog.value = false
|
||||
emit('update')
|
||||
}
|
||||
|
||||
// 更新站点Cookie UA后的回调
|
||||
function onSiteCookieUpdated() {
|
||||
siteCookieDialog.value = false
|
||||
// Cookie更新后刷新统计数据
|
||||
emit('refresh-stats', cardProps.site?.domain)
|
||||
}
|
||||
|
||||
// 资源浏览弹窗关闭后的回调
|
||||
function onSiteResourceDone() {
|
||||
resourceDialog.value = false
|
||||
// 资源操作完成后刷新统计数据
|
||||
emit('refresh-stats', cardProps.site?.domain)
|
||||
}
|
||||
@@ -196,31 +240,40 @@ onMounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300 cursor-pointer hover:-translate-y-1"
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
rounded="lg"
|
||||
hover
|
||||
@click="handleResourceBrowse"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
|
||||
<!-- 主体部分 -->
|
||||
<div class="relative flex-1 flex flex-col p-3 z-1">
|
||||
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
|
||||
<!-- 顶部:图标和站点名称 -->
|
||||
<div class="flex items-center mb-1">
|
||||
<div class="mb-1 flex min-w-0 items-center gap-2">
|
||||
<!-- 站点图标 -->
|
||||
<VAvatar tile rounded="lg" size="32" class="me-2" :class="{ 'cursor-move': display.mdAndUp.value }">
|
||||
<VAvatar
|
||||
tile
|
||||
rounded="lg"
|
||||
size="32"
|
||||
class="shrink-0"
|
||||
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
@@ -231,22 +284,42 @@ onMounted(() => {
|
||||
</VAvatar>
|
||||
|
||||
<!-- 站点名称和特性图标 -->
|
||||
<div class="flex-1 min-w-0 flex items-center">
|
||||
<h3 class="text-lg font-semibold leading-tight truncate">{{ cardProps.site?.name }}</h3>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
|
||||
|
||||
<!-- 站点特性图标 -->
|
||||
<div class="flex items-center gap-2 ml-auto mr-10">
|
||||
<div v-if="cardProps.site?.limit_interval" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-speedometer" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<div v-if="cardProps.site?.limit_interval" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-speedometer"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.proxy" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-network-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.proxy" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-network-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.render" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-apple-safari" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.render" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-apple-safari"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.filter" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-filter-cog-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.filter" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-filter-cog-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,10 +327,10 @@ onMounted(() => {
|
||||
|
||||
<!-- 中间部分:网址 -->
|
||||
<div class="my-3">
|
||||
<div class="text-sm text-medium-emphasis truncate" @click.stop="openSitePage">
|
||||
{{ cardProps.site?.url }}
|
||||
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
|
||||
{{ cardProps.site?.url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:数据统计 -->
|
||||
<div class="flex-1 flex flex-col justify-end">
|
||||
@@ -289,7 +362,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作按钮区 -->
|
||||
<VSheet class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<VSheet v-if="!cardProps.sortable" class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<!-- 测试按钮 -->
|
||||
<VBtn
|
||||
icon
|
||||
@@ -327,11 +400,11 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
|
||||
<!-- 更多选项按钮 -->
|
||||
<VBtn icon variant="text" class="mt-auto" size="36">
|
||||
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" size="20" />
|
||||
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
|
||||
<VList>
|
||||
<VListItem @click="siteEditDialog = true" base-color="info">
|
||||
<VListItem @click="handleSiteEdit" base-color="info">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-file-edit-outline" size="20" />
|
||||
</template>
|
||||
@@ -348,35 +421,6 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VSheet>
|
||||
</VCard>
|
||||
|
||||
<!-- 对话框组件 -->
|
||||
<SiteCookieUpdateDialog
|
||||
v-if="siteCookieDialog"
|
||||
v-model="siteCookieDialog"
|
||||
:site="cardProps.site"
|
||||
@close="siteCookieDialog = false"
|
||||
@done="onSiteCookieUpdated"
|
||||
/>
|
||||
<SiteAddEditDialog
|
||||
v-if="siteEditDialog"
|
||||
v-model="siteEditDialog"
|
||||
:siteid="cardProps.site?.id"
|
||||
@save="saveSite"
|
||||
@remove="emit('remove')"
|
||||
@close="siteEditDialog = false"
|
||||
/>
|
||||
<SiteUserDataDialog
|
||||
v-if="siteUserDataDialog"
|
||||
v-model="siteUserDataDialog"
|
||||
:site="cardProps.site"
|
||||
@close="siteUserDataDialog = false"
|
||||
/>
|
||||
<SiteResourceDialog
|
||||
v-if="resourceDialog"
|
||||
v-model="resourceDialog"
|
||||
:site="cardProps.site"
|
||||
@close="onSiteResourceDone"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -412,7 +456,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:hover .site-status-indicator {
|
||||
.site-card:not(.site-card--sortable):hover .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { StorageConf } from '@/api/types'
|
||||
import type { StorageConf } from '@/api/types'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
import storage_png from '@images/misc/storage.png'
|
||||
import alipan_png from '@images/misc/alipan.webp'
|
||||
@@ -9,21 +9,22 @@ import alist_png from '@images/misc/openlist.svg'
|
||||
import custom_png from '@images/misc/database.png'
|
||||
import smb_png from '@images/misc/smb.png'
|
||||
import api from '@/api'
|
||||
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
|
||||
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
|
||||
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
|
||||
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
|
||||
import SmbConfigDialog from '../dialog/SmbConfigDialog.vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const AliyunAuthDialog = defineAsyncComponent(() => import('../dialog/AliyunAuthDialog.vue'))
|
||||
const U115AuthDialog = defineAsyncComponent(() => import('../dialog/U115AuthDialog.vue'))
|
||||
const RcloneConfigDialog = defineAsyncComponent(() => import('../dialog/RcloneConfigDialog.vue'))
|
||||
const AlistConfigDialog = defineAsyncComponent(() => import('../dialog/AlistConfigDialog.vue'))
|
||||
const SmbConfigDialog = defineAsyncComponent(() => import('../dialog/SmbConfigDialog.vue'))
|
||||
const StorageCustomConfigDialog = defineAsyncComponent(() => import('../dialog/StorageCustomConfigDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#FFB400')
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -50,53 +51,34 @@ const used = computed(() => {
|
||||
return total.value - available.value
|
||||
})
|
||||
|
||||
// 存储
|
||||
const storage_ref = ref(props.storage)
|
||||
|
||||
// 自定义存储名称
|
||||
const customName = ref(props.storage.name)
|
||||
|
||||
// 自定义存储类型
|
||||
const storageType = ref(props.storage.type)
|
||||
|
||||
// 阿里云盘认证对话框
|
||||
const aliyunAuthDialog = ref(false)
|
||||
// 115网盘认证对话框
|
||||
const u115AuthDialog = ref(false)
|
||||
// Rclone配置对话框
|
||||
const rcloneConfigDialog = ref(false)
|
||||
// AList配置对话框
|
||||
const aListConfigDialog = ref(false)
|
||||
// SMB配置对话框
|
||||
const smbConfigDialog = ref(false)
|
||||
// 自定义存储配置对话框
|
||||
const customConfigDialog = ref(false)
|
||||
|
||||
// 打开存储对话框
|
||||
/** 打开指定类型的共享存储配置弹窗。 */
|
||||
function openStorageDialog() {
|
||||
switch (props.storage.type) {
|
||||
case 'alipan':
|
||||
aliyunAuthDialog.value = true
|
||||
break
|
||||
case 'u115':
|
||||
u115AuthDialog.value = true
|
||||
break
|
||||
case 'rclone':
|
||||
rcloneConfigDialog.value = true
|
||||
break
|
||||
case 'alist':
|
||||
aListConfigDialog.value = true
|
||||
break
|
||||
case 'smb':
|
||||
smbConfigDialog.value = true
|
||||
break
|
||||
case 'local':
|
||||
$toast.info(t('storage.noConfigNeeded'))
|
||||
break
|
||||
default:
|
||||
customConfigDialog.value = true
|
||||
break
|
||||
const dialogMap: Record<string, Component> = {
|
||||
alipan: AliyunAuthDialog,
|
||||
u115: U115AuthDialog,
|
||||
rclone: RcloneConfigDialog,
|
||||
alist: AlistConfigDialog,
|
||||
smb: SmbConfigDialog,
|
||||
}
|
||||
|
||||
if (props.storage.type === 'local') {
|
||||
$toast.info(t('storage.noConfigNeeded'))
|
||||
return
|
||||
}
|
||||
|
||||
const dialog = dialogMap[props.storage.type] || StorageCustomConfigDialog
|
||||
const dialogProps = dialog === StorageCustomConfigDialog
|
||||
? { storage: props.storage }
|
||||
: { conf: props.storage.config || {} }
|
||||
|
||||
openSharedDialog(
|
||||
dialog,
|
||||
dialogProps,
|
||||
{
|
||||
done: handleDone,
|
||||
},
|
||||
{ closeOn: ['close', 'done', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
@@ -135,7 +117,7 @@ const usage = computed(() => {
|
||||
return Math.round((used.value / (total.value || 1)) * 1000) / 10
|
||||
})
|
||||
|
||||
// 查询存储信息
|
||||
/** 查询存储空间使用信息。 */
|
||||
async function queryStorage() {
|
||||
try {
|
||||
const data: { total: number; available: number } = await api.get(`storage/usage/${props.storage.type}`)
|
||||
@@ -146,123 +128,47 @@ async function queryStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 完成配置后的处理
|
||||
function handleDone() {
|
||||
aliyunAuthDialog.value = false
|
||||
u115AuthDialog.value = false
|
||||
rcloneConfigDialog.value = false
|
||||
aListConfigDialog.value = false
|
||||
smbConfigDialog.value = false
|
||||
customConfigDialog.value = false
|
||||
// 更新存储
|
||||
storage_ref.value.name = customName.value
|
||||
storage_ref.value.type = storageType.value
|
||||
emit('done', storage_ref.value)
|
||||
/** 完成配置后的处理并通知父级刷新。 */
|
||||
function handleDone(storage?: StorageConf) {
|
||||
emit('done', storage || props.storage)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryStorage()
|
||||
})
|
||||
|
||||
// 关闭
|
||||
/** 关闭存储卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openStorageDialog">
|
||||
<VDialogCloseBtn @click="onClose" class="absolute top-1 right-1" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
|
||||
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
|
||||
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-8" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@click="openStorageDialog"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
|
||||
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
|
||||
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
|
||||
</div>
|
||||
</VCard>
|
||||
<AliyunAuthDialog
|
||||
v-if="aliyunAuthDialog"
|
||||
v-model="aliyunAuthDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="aliyunAuthDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<U115AuthDialog
|
||||
v-if="u115AuthDialog"
|
||||
v-model="u115AuthDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="u115AuthDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<RcloneConfigDialog
|
||||
v-if="rcloneConfigDialog"
|
||||
v-model="rcloneConfigDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="rcloneConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<AlistConfigDialog
|
||||
v-if="aListConfigDialog"
|
||||
v-model="aListConfigDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="aListConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<SmbConfigDialog
|
||||
v-if="smbConfigDialog"
|
||||
v-model="smbConfigDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="smbConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<DialogWrapper
|
||||
v-if="customConfigDialog"
|
||||
v-model="customConfigDialog"
|
||||
scrollable
|
||||
max-width="30rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('storage.custom') }}</VCardTitle>
|
||||
<VDialogCloseBtn v-model="customConfigDialog" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="storageType"
|
||||
:label="t('storage.type')"
|
||||
:hint="t('storage.customTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-database"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="customName"
|
||||
:label="t('storage.name')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="getIcon"
|
||||
cover
|
||||
class="mt-8"
|
||||
max-width="3rem"
|
||||
min-width="3rem"
|
||||
@load="updateAccentColor"
|
||||
/>
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
|
||||
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'
|
||||
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
@@ -11,6 +8,11 @@ import router from '@/router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
|
||||
const SubscribeShareDialog = defineAsyncComponent(() => import('../dialog/SubscribeShareDialog.vue'))
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -21,6 +23,18 @@ const { t } = useI18n()
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<Subscribe>,
|
||||
batchMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
@@ -29,7 +43,7 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save'])
|
||||
const emit = defineEmits(['remove', 'save', 'select'])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
@@ -40,21 +54,31 @@ const $toast = useToast()
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 订阅弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 订阅文件信息弹窗
|
||||
const subscribeFilesDialog = ref(false)
|
||||
|
||||
// 分享订阅弹窗
|
||||
const subscribeShareDialog = ref(false)
|
||||
|
||||
// 当前的订阅状态
|
||||
const subscribeState = ref<string>(props.media?.state ?? 'P')
|
||||
|
||||
// 上一次更新时间
|
||||
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
|
||||
|
||||
// 判断后端数字/布尔开关是否启用
|
||||
function isEnabledFlag(value: any) {
|
||||
return value === true || value === 1 || value === '1'
|
||||
}
|
||||
|
||||
// 订阅列表接口通常返回中文媒体类型,插件或缓存数据可能只保留剧集字段
|
||||
function isTvSubscribe(media?: Subscribe) {
|
||||
return media?.type === '电视剧' || media?.type === 'tv' || !!media?.season || !!media?.total_episode
|
||||
}
|
||||
|
||||
// TV 洗版订阅在卡片上展示分集或全集短标签
|
||||
const bestVersionModeLabel = computed(() => {
|
||||
if (!isEnabledFlag(props.media?.best_version) || !isTvSubscribe(props.media)) return ''
|
||||
|
||||
return isEnabledFlag(props.media?.best_version_full)
|
||||
? t('subscribe.bestVersionWholeShort')
|
||||
: t('subscribe.bestVersionEpisodeShort')
|
||||
})
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -145,12 +169,22 @@ async function resetSubscribe() {
|
||||
|
||||
// 分享订阅
|
||||
async function shareSubscribe() {
|
||||
subscribeShareDialog.value = true
|
||||
if (!props.media) return
|
||||
|
||||
openSharedDialog(SubscribeShareDialog, { sub: props.media }, {}, { closeOn: ['close'] })
|
||||
}
|
||||
|
||||
// 编辑订阅响应
|
||||
async function editSubscribeDialog() {
|
||||
subscribeEditDialog.value = true
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid: props.media?.id },
|
||||
{
|
||||
remove: onSubscribeEditRemove,
|
||||
save: onSubscribeEditSave,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 获得mediaid
|
||||
@@ -176,7 +210,7 @@ async function viewMediaDetail() {
|
||||
|
||||
// 查看文件详情
|
||||
async function viewSubscribeFiles() {
|
||||
subscribeFilesDialog.value = true
|
||||
openSharedDialog(SubscribeFilesDialog, { subid: props.media?.id }, {}, { closeOn: ['close'] })
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
@@ -258,6 +292,7 @@ watch(
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) editSubscribeDialog()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听订阅状态
|
||||
@@ -288,15 +323,28 @@ const posterUrl = computed(() => {
|
||||
|
||||
// 订阅编辑保存
|
||||
function onSubscribeEditSave() {
|
||||
subscribeEditDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// 订阅编辑取消
|
||||
function onSubscribeEditRemove() {
|
||||
subscribeEditDialog.value = false
|
||||
emit('remove')
|
||||
}
|
||||
|
||||
// 处理卡片点击事件
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.batchMode) {
|
||||
// 批量模式下触发选择事件
|
||||
emit('select')
|
||||
} else {
|
||||
// 非批量模式下打开编辑弹窗
|
||||
editSubscribeDialog()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -306,8 +354,9 @@ function onSubscribeEditRemove() {
|
||||
<div
|
||||
class="w-full h-full rounded-lg overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
@@ -316,14 +365,15 @@ function onSubscribeEditRemove() {
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'opacity-70': subscribeState === 'S',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
rounded="0"
|
||||
min-height="150"
|
||||
@click="editSubscribeDialog"
|
||||
:ripple="false"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn>
|
||||
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -360,7 +410,7 @@ function onSubscribeEditRemove() {
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
|
||||
v-if="imageLoaded"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
@@ -378,21 +428,39 @@ function onSubscribeEditRemove() {
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex align-center">
|
||||
<VCardText class="flex min-w-0 justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex min-w-0 max-w-full align-center">
|
||||
<VIcon
|
||||
v-if="props.media?.total_episode && props.sortable"
|
||||
icon="mdi-progress-download"
|
||||
size="small"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-else-if="props.media?.total_episode"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
/>
|
||||
<div v-if="props.media?.season" class="text-subtitle-2 me-2 text-white">
|
||||
<div v-if="props.media?.season" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
||||
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.username" icon="mdi-account" size="small" color="white" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
|
||||
<VChip
|
||||
v-if="bestVersionModeLabel"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
class="me-2 flex-shrink-0"
|
||||
>
|
||||
{{ bestVersionModeLabel }}
|
||||
</VChip>
|
||||
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="flex-shrink-0 me-1" />
|
||||
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" class="flex-shrink-0" />
|
||||
<!-- 用户名过长时限制在卡片宽度内,并用省略号展示剩余内容 -->
|
||||
<span v-if="props.media?.username" class="min-w-0 truncate text-subtitle-2 text-white" :title="props.media?.username">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -417,30 +485,6 @@ function onSubscribeEditRemove() {
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="props.media?.id"
|
||||
@remove="onSubscribeEditRemove"
|
||||
@save="onSubscribeEditSave"
|
||||
@close="subscribeEditDialog = false"
|
||||
/>
|
||||
|
||||
<!-- 订阅文件信息弹窗 -->
|
||||
<SubscribeFilesDialog
|
||||
v-if="subscribeFilesDialog"
|
||||
v-model="subscribeFilesDialog"
|
||||
:subid="props.media?.id"
|
||||
@close="subscribeFilesDialog = false"
|
||||
/>
|
||||
<!-- 分享订阅弹窗 -->
|
||||
<SubscribeShareDialog
|
||||
v-if="subscribeShareDialog"
|
||||
v-model="subscribeShareDialog"
|
||||
:sub="props.media"
|
||||
@close="subscribeShareDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import type { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -22,15 +24,6 @@ const globalSettings = globalSettingsStore.globalSettings
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 订阅编辑弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 复用订阅弹窗
|
||||
const forkSubscribeDialog = ref(false)
|
||||
|
||||
// 订阅ID
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -78,19 +71,24 @@ async function viewMediaDetail() {
|
||||
|
||||
// 复用订阅
|
||||
function showForkSubscribe() {
|
||||
forkSubscribeDialog.value = true
|
||||
openSharedDialog(
|
||||
ForkSubscribeDialog,
|
||||
{ media: props.media },
|
||||
{
|
||||
fork: finishForkSubscribe,
|
||||
delete: doDelete,
|
||||
},
|
||||
{ closeOn: ['close', 'fork', 'delete'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 完成复用订阅
|
||||
function finishForkSubscribe(subid: number) {
|
||||
subscribeId.value = subid
|
||||
forkSubscribeDialog.value = false
|
||||
subscribeEditDialog.value = true
|
||||
openSharedDialog(SubscribeEditDialog, { subid }, {}, { closeOn: ['close', 'save', 'remove'] })
|
||||
}
|
||||
|
||||
// 删除订阅分享时处理
|
||||
function doDelete() {
|
||||
forkSubscribeDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('delete')
|
||||
}
|
||||
@@ -167,24 +165,6 @@ function doDelete() {
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="subscribeEditDialog = false"
|
||||
/>
|
||||
<!-- 复用订阅弹窗 -->
|
||||
<ForkSubscribeDialog
|
||||
v-if="forkSubscribeDialog"
|
||||
v-model="forkSubscribeDialog"
|
||||
:media="props.media"
|
||||
@close="forkSubscribeDialog = false"
|
||||
@fork="finishForkSubscribe"
|
||||
@delete="doDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -3,8 +3,13 @@ import type { PropType } from 'vue'
|
||||
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
||||
const TorrentMoreSourcesDialog = defineAsyncComponent(() => import('../dialog/TorrentMoreSourcesDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -14,9 +19,6 @@ const props = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 更多来源界面
|
||||
const showMoreTorrents = ref(false)
|
||||
|
||||
// 种子信息
|
||||
const torrent = ref(props.torrent?.torrent_info)
|
||||
|
||||
@@ -32,31 +34,36 @@ const downloadItem = ref(props.torrent)
|
||||
// 站点图标
|
||||
const siteIcons = ref<Record<number, string>>({})
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
markTorrentDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
addDownloadDialog.value = false
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon(site: number | undefined) {
|
||||
if (!site) return
|
||||
|
||||
try {
|
||||
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
|
||||
siteIcons.value[site] = await getCachedSiteIcon(site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
siteIcons.value[site] = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +73,21 @@ async function handleAddDownload(item: Context | null = null) {
|
||||
downloadItem.value = item
|
||||
}
|
||||
// 打开下载对话框
|
||||
addDownloadDialog.value = true
|
||||
openSharedDialog(
|
||||
AddDownloadDialog,
|
||||
{
|
||||
title: `${downloadItem.value?.media_info?.title_year || downloadItem.value?.meta_info?.name} ${
|
||||
downloadItem.value?.meta_info?.season_episode
|
||||
}`,
|
||||
media: downloadItem.value?.media_info,
|
||||
torrent: downloadItem.value?.torrent_info,
|
||||
},
|
||||
{
|
||||
done: addDownloadSuccess,
|
||||
error: addDownloadError,
|
||||
},
|
||||
{ closeOn: ['close', 'done', 'error'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开种子详情页面
|
||||
@@ -92,37 +113,46 @@ function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolum
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 获取优惠标签类
|
||||
function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||
if (!downloadVolumeFactor) return 'chip-free'
|
||||
if (downloadVolumeFactor === 0) return 'chip-free'
|
||||
else if (downloadVolumeFactor < 1) return 'chip-discount'
|
||||
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 打开更多来源对话框
|
||||
async function openMoreTorrentsDialog() {
|
||||
props.more?.forEach(t => {
|
||||
return getSiteIcon(t.torrent_info?.site)
|
||||
})
|
||||
showMoreTorrents.value = true
|
||||
openSharedDialog(
|
||||
TorrentMoreSourcesDialog,
|
||||
{
|
||||
items: props.more || [],
|
||||
siteIcons: siteIcons.value,
|
||||
},
|
||||
{
|
||||
download: handleAddDownload,
|
||||
detail: openTorrentDetail,
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon(props.torrent?.torrent_info?.site)
|
||||
})
|
||||
watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
downloadItem.value = value
|
||||
getSiteIcon(value?.torrent_info?.site)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
|
||||
:class="{ 'border-success border-2 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
@@ -136,8 +166,8 @@ onMounted(() => {
|
||||
|
||||
<!-- 媒体标题 -->
|
||||
<VCardItem class="pt-3 pb-0">
|
||||
<div class="d-flex flex-row flex-wrap justify-start mb-2 pr-8">
|
||||
<span class="text-h6 font-weight-bold text-truncate me-2">
|
||||
<div class="d-flex flex-row flex-wrap justify-start align-center mb-2 pr-8">
|
||||
<span class="text-h6 font-weight-bold me-2">
|
||||
{{ media?.title ?? meta?.name }}
|
||||
</span>
|
||||
<VChip
|
||||
@@ -183,14 +213,14 @@ onMounted(() => {
|
||||
<!-- 种子内容 -->
|
||||
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
|
||||
<!-- 种子标题 -->
|
||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1" :title="torrent?.title">
|
||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1 break-all" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<!-- 种子描述 -->
|
||||
<div
|
||||
v-if="meta?.subtitle || torrent?.description"
|
||||
class="text-body-2 text-medium-emphasis mb-2"
|
||||
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||
:title="meta?.subtitle || torrent?.description"
|
||||
>
|
||||
{{ meta?.subtitle || torrent?.description }}
|
||||
@@ -258,7 +288,7 @@ onMounted(() => {
|
||||
class="pa-1 d-flex align-center"
|
||||
@click.stop="openMoreTorrentsDialog"
|
||||
>
|
||||
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" size="small" class="mr-1"></VIcon>
|
||||
<VIcon icon="mdi-chevron-down" size="small" class="mr-1"></VIcon>
|
||||
更多来源 ({{ props.more.length }})
|
||||
</VBtn>
|
||||
</div>
|
||||
@@ -276,105 +306,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
|
||||
<!-- 更多来源对话框 -->
|
||||
<DialogWrapper v-model="showMoreTorrents" max-width="25rem" location="center">
|
||||
<VCard>
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<span>其他来源</span>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="showMoreTorrents = false"></VBtn>
|
||||
</VCardTitle>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText class="more-sources-content pa-0">
|
||||
<VList lines="one" density="compact">
|
||||
<VListItem
|
||||
v-for="(item, index) in props.more"
|
||||
:key="index"
|
||||
@click.stop="handleAddDownload(item)"
|
||||
class="hover:bg-primary-lighten-5"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="d-flex align-center gap-1">
|
||||
<VImg
|
||||
v-if="siteIcons[item.torrent_info?.site || 0]"
|
||||
:src="siteIcons[item.torrent_info?.site || 0]"
|
||||
:alt="item.torrent_info?.site_name"
|
||||
width="16"
|
||||
height="16"
|
||||
class="rounded"
|
||||
/>
|
||||
<VAvatar v-else size="16" class="text-caption bg-surface-variant">
|
||||
{{ item.torrent_info?.site_name?.substring(0, 1) }}
|
||||
</VAvatar>
|
||||
<span class="text-body-2 font-weight-bold">{{ item.torrent_info.site_name }}</span>
|
||||
|
||||
<VChip
|
||||
v-if="item.meta_info?.season_episode"
|
||||
class="chip-season rounded-sm ml-1"
|
||||
size="x-small"
|
||||
variant="elevated"
|
||||
>
|
||||
{{ item.meta_info.season_episode }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||
:class="
|
||||
getPromotionChipClass(
|
||||
item.torrent_info?.downloadvolumefactor,
|
||||
item.torrent_info?.uploadvolumefactor,
|
||||
)
|
||||
"
|
||||
size="x-small"
|
||||
variant="elevated"
|
||||
class="rounded-sm ml-1"
|
||||
>
|
||||
{{ item.torrent_info?.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<span class="text-caption font-weight-bold text-primary">
|
||||
{{ formatFileSize(item.torrent_info?.size) }}
|
||||
</span>
|
||||
<span class="d-flex align-center text-caption font-weight-bold">
|
||||
<VIcon size="small" color="success" icon="mdi-arrow-up" class="mr-1"></VIcon>
|
||||
{{ item.torrent_info?.seeders }}
|
||||
</span>
|
||||
<span>
|
||||
<VIcon
|
||||
@click.stop="openTorrentDetail(item)"
|
||||
size="small"
|
||||
color="secondary"
|
||||
icon="mdi-arrow-top-right"
|
||||
class="mr-1"
|
||||
></VIcon>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
:title="`${downloadItem?.media_info?.title_year || downloadItem?.meta_info?.name} ${
|
||||
downloadItem?.meta_info?.season_episode
|
||||
}`"
|
||||
:media="downloadItem?.media_info"
|
||||
:torrent="downloadItem?.torrent_info"
|
||||
@done="addDownloadSuccess"
|
||||
@error="addDownloadError"
|
||||
@close="addDownloadDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -385,11 +316,6 @@ onMounted(() => {
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.more-sources-content {
|
||||
max-block-size: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: 1px solid transparent;
|
||||
@@ -418,7 +344,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.chip-web-source {
|
||||
background-color: #8000FF;
|
||||
background-color: #8000ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { PropType } from 'vue'
|
||||
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -22,37 +26,28 @@ const meta = ref(props.torrent?.meta_info)
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 站点图标加载状态
|
||||
const iconLoading = ref(false)
|
||||
const iconError = ref(false)
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!torrent?.value?.site || iconLoading.value) {
|
||||
if (!torrent?.value?.site) {
|
||||
return
|
||||
}
|
||||
|
||||
iconLoading.value = true
|
||||
iconError.value = false
|
||||
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value.site}`)
|
||||
if (response && response.data && response.data.icon) {
|
||||
siteIcon.value = response.data.icon
|
||||
} else {
|
||||
iconError.value = true
|
||||
}
|
||||
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value?.site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
iconError.value = true
|
||||
} finally {
|
||||
iconLoading.value = false
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,19 +72,29 @@ function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadV
|
||||
// 询问并添加下载
|
||||
async function handleAddDownload() {
|
||||
// 打开下载对话框
|
||||
addDownloadDialog.value = true
|
||||
openSharedDialog(
|
||||
AddDownloadDialog,
|
||||
{
|
||||
title: `${media.value?.title_year || meta.value?.name} ${meta.value?.season_episode || ''}`,
|
||||
media: media.value,
|
||||
torrent: torrent.value,
|
||||
},
|
||||
{
|
||||
done: addDownloadSuccess,
|
||||
error: addDownloadError,
|
||||
},
|
||||
{ closeOn: ['close', 'done', 'error'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
markTorrentDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
addDownloadDialog.value = false
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
// 打开种子详情页面
|
||||
@@ -97,10 +102,16 @@ function openTorrentDetail() {
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
})
|
||||
watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
getSiteIcon()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,7 +119,7 @@ onMounted(() => {
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
@@ -121,16 +132,26 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<template v-slot:prepend>
|
||||
<div class="d-flex flex-column align-center pr-3">
|
||||
<VImg v-if="siteIcon" :src="siteIcon" :alt="torrent?.site_name" class="rounded mb-1" width="32" height="32" />
|
||||
<VAvatar v-else size="24" class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold">
|
||||
<div class="d-flex flex-column align-center pr-3" :title="torrent?.site_name">
|
||||
<VImg
|
||||
v-if="siteIcon"
|
||||
:src="siteIcon"
|
||||
:alt="torrent?.site_name"
|
||||
class="rounded mb-1 site-icon"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
<VAvatar
|
||||
v-else
|
||||
size="32"
|
||||
class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold site-icon"
|
||||
>
|
||||
{{ torrent?.site_name?.substring(0, 1) }}
|
||||
</VAvatar>
|
||||
<div class="font-weight-bold text-body-2 text-center d-none d-sm-block">{{ torrent?.site_name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VListItemTitle>
|
||||
<VListItemTitle class="whitespace-normal">
|
||||
<div class="d-flex flex-row flex-wrap align-center mb-2">
|
||||
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
|
||||
<VChip
|
||||
@@ -143,12 +164,12 @@ onMounted(() => {
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2" :title="torrent?.title">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-body-2 text-medium-emphasis mb-2"
|
||||
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||
:title="meta?.subtitle || torrent?.description || '暂无描述'"
|
||||
>
|
||||
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
|
||||
@@ -230,17 +251,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
|
||||
:media="media"
|
||||
:torrent="torrent"
|
||||
@done="addDownloadSuccess"
|
||||
@error="addDownloadError"
|
||||
@close="addDownloadDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -332,4 +342,12 @@ onMounted(() => {
|
||||
background-color: #9c27b0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.site-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.site-icon:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useUserStore } from '@/stores'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const UserAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/UserAddEditDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -46,9 +48,6 @@ const emit = defineEmits(['remove', 'save'])
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 用户信息弹窗
|
||||
const userEditDialog = ref(false)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -104,12 +103,22 @@ async function removeUser() {
|
||||
|
||||
// 编辑用户
|
||||
function editUser() {
|
||||
userEditDialog.value = true
|
||||
openSharedDialog(
|
||||
UserAddEditDialog,
|
||||
{
|
||||
username: props.user?.name,
|
||||
usernames: props.users.map(item => item.name),
|
||||
oper: 'edit',
|
||||
},
|
||||
{
|
||||
save: onUserUpdate,
|
||||
},
|
||||
{ closeOn: ['close', 'save'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 用户更新完成时
|
||||
function onUserUpdate() {
|
||||
userEditDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
@@ -123,10 +132,10 @@ onMounted(() => {
|
||||
'transition-transform duration-300 hover:-translate-y-1',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="flex flex-column"
|
||||
@click="userEditDialog = true"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<div class="user-card__body flex-grow flex-grow-1">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
<template v-slot:prepend>
|
||||
@@ -247,7 +256,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<!-- 独立的邮箱显示 -->
|
||||
<VDivider class="mx-4" />
|
||||
<div>
|
||||
<div class="user-card__footer">
|
||||
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
|
||||
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
|
||||
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
|
||||
@@ -294,20 +303,19 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 用户编辑弹窗 -->
|
||||
<UserAddEditDialog
|
||||
v-if="userEditDialog"
|
||||
v-model="userEditDialog"
|
||||
:username="props.user?.name"
|
||||
:usernames="props.users.map(item => item.name)"
|
||||
oper="edit"
|
||||
@save="onUserUpdate"
|
||||
@close="userEditDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
/* 让邮箱和订阅统计固定在卡片底部,保证同一行用户卡片视觉等高。 */
|
||||
.user-card__footer {
|
||||
flex-shrink: 0;
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.admin-decoration {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import type { WorkflowShare } from '@/api/types'
|
||||
import ForkWorkflowDialog from '../dialog/ForkWorkflowDialog.vue'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const ForkWorkflowDialog = defineAsyncComponent(() => import('../dialog/ForkWorkflowDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
workflow: Object as PropType<WorkflowShare>,
|
||||
eventTypes: {
|
||||
type: Array as PropType<Array<{ title: string; value: string }>>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
// 定义删除事件
|
||||
const emit = defineEmits(['delete', 'update'])
|
||||
|
||||
// 复用工作流弹窗
|
||||
const forkWorkflowDialog = ref(false)
|
||||
|
||||
// 工作流ID
|
||||
const workflowId = ref<string>()
|
||||
|
||||
@@ -61,19 +64,28 @@ onMounted(() => {
|
||||
|
||||
// 复用工作流
|
||||
function showForkWorkflow() {
|
||||
forkWorkflowDialog.value = true
|
||||
openSharedDialog(
|
||||
ForkWorkflowDialog,
|
||||
{
|
||||
workflow: props.workflow,
|
||||
eventTypes: props.eventTypes,
|
||||
},
|
||||
{
|
||||
fork: finishForkWorkflow,
|
||||
delete: doDelete,
|
||||
},
|
||||
{ closeOn: ['close', 'fork', 'delete'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 完成复用工作流
|
||||
function finishForkWorkflow(wid: string) {
|
||||
workflowId.value = wid
|
||||
forkWorkflowDialog.value = false
|
||||
emit('update')
|
||||
}
|
||||
|
||||
// 删除工作流分享时处理
|
||||
function doDelete() {
|
||||
forkWorkflowDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('delete')
|
||||
}
|
||||
@@ -130,14 +142,5 @@ function doDelete() {
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 复用工作流弹窗 -->
|
||||
<ForkWorkflowDialog
|
||||
v-if="forkWorkflowDialog"
|
||||
v-model="forkWorkflowDialog"
|
||||
:workflow="props.workflow"
|
||||
@close="forkWorkflowDialog = false"
|
||||
@fork="finishForkWorkflow"
|
||||
@delete="doDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
||||
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
|
||||
import WorkflowShareDialog from '@/components/dialog/WorkflowShareDialog.vue'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const WorkflowActionsDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowActionsDialog.vue'))
|
||||
const WorkflowAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowAddEditDialog.vue'))
|
||||
const WorkflowShareDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowShareDialog.vue'))
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -16,6 +18,10 @@ const props = defineProps({
|
||||
required: true,
|
||||
type: Object as PropType<Workflow>,
|
||||
},
|
||||
eventTypes: {
|
||||
type: Array as PropType<Array<{ title: string; value: string }>>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
@@ -27,38 +33,46 @@ const $toast = useToast()
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 编辑对话框
|
||||
const editDialog = ref(false)
|
||||
|
||||
// 流程对话框
|
||||
const flowDialog = ref(false)
|
||||
|
||||
// 分享对话框
|
||||
const shareDialog = ref(false)
|
||||
|
||||
// 加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 根据事件类型值获取显示文本
|
||||
const getEventTypeText = (eventTypeValue: string) => {
|
||||
const eventType = props.eventTypes.find(item => item.value === eventTypeValue)
|
||||
return eventType ? eventType.title : eventTypeValue
|
||||
}
|
||||
|
||||
// 编辑任务
|
||||
function handleEdit(item: Workflow) {
|
||||
editDialog.value = true
|
||||
openSharedDialog(
|
||||
WorkflowAddEditDialog,
|
||||
{ workflow: item },
|
||||
{
|
||||
save: editDone,
|
||||
},
|
||||
{ closeOn: ['close', 'save'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑流程
|
||||
function handleFlow(item: Workflow) {
|
||||
flowDialog.value = true
|
||||
openSharedDialog(
|
||||
WorkflowActionsDialog,
|
||||
{ workflow: item },
|
||||
{
|
||||
save: editDone,
|
||||
},
|
||||
{ closeOn: ['close', 'save'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 分享工作流
|
||||
function handleShare(item: Workflow) {
|
||||
shareDialog.value = true
|
||||
openSharedDialog(WorkflowShareDialog, { workflow: item }, {}, { closeOn: ['close'] })
|
||||
}
|
||||
|
||||
// 编辑完成
|
||||
function editDone() {
|
||||
editDialog.value = false
|
||||
flowDialog.value = false
|
||||
shareDialog.value = false
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
@@ -165,11 +179,36 @@ async function handleReset(item: Workflow) {
|
||||
|
||||
// 计算状态颜色
|
||||
const resolveStatusVariant = (status: string | undefined) => {
|
||||
if (status === 'S') return { color: 'success', text: t('workflow.task.status.success') }
|
||||
else if (status === 'R') return { color: 'primary', text: t('workflow.task.status.running') }
|
||||
else if (status === 'F') return { color: 'error', text: t('workflow.task.status.failed') }
|
||||
else if (status === 'P') return { color: 'secondary', text: t('workflow.task.status.paused') }
|
||||
else return { color: 'info', text: t('workflow.task.status.waiting') }
|
||||
if (status === 'S')
|
||||
return {
|
||||
color: 'success',
|
||||
bgColor: 'linear-gradient(to bottom right, rgba(76, 175, 80, 0.9), rgba(76, 175, 80, 0.7))',
|
||||
text: t('workflow.task.status.success'),
|
||||
}
|
||||
else if (status === 'R')
|
||||
return {
|
||||
color: 'primary',
|
||||
bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',
|
||||
text: t('workflow.task.status.running'),
|
||||
}
|
||||
else if (status === 'F')
|
||||
return {
|
||||
color: 'error',
|
||||
bgColor: 'linear-gradient(to bottom right, rgba(244, 67, 54, 0.9), rgba(244, 67, 54, 0.7))',
|
||||
text: t('workflow.task.status.failed'),
|
||||
}
|
||||
else if (status === 'P')
|
||||
return {
|
||||
color: 'warning',
|
||||
bgColor: 'linear-gradient(to bottom right, rgba(255, 152, 0, 0.9), rgba(255, 152, 0, 0.7))',
|
||||
text: t('workflow.task.status.paused'),
|
||||
}
|
||||
else
|
||||
return {
|
||||
color: 'info',
|
||||
bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',
|
||||
text: t('workflow.task.status.waiting'),
|
||||
}
|
||||
}
|
||||
|
||||
// 计算当前动作占比
|
||||
@@ -190,11 +229,9 @@ const resolveProgress = (item: Workflow) => {
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<VCardItem
|
||||
class="px-2"
|
||||
:class="{
|
||||
'py-0': workflow?.description,
|
||||
'py-2': !workflow?.description,
|
||||
[`bg-${resolveStatusVariant(workflow?.state).color}`]: true,
|
||||
class="px-2 py-2"
|
||||
:style="{
|
||||
background: resolveStatusVariant(workflow?.state).bgColor,
|
||||
}"
|
||||
>
|
||||
<template #prepend>
|
||||
@@ -209,9 +246,8 @@ const resolveProgress = (item: Workflow) => {
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle class="text-white text-lg">
|
||||
{{ workflow?.name }}
|
||||
<span :title="workflow?.description">{{ workflow?.name }}</span>
|
||||
</VCardTitle>
|
||||
<VCardSubtitle class="text-white">{{ workflow?.description }}</VCardSubtitle>
|
||||
<template #append>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
@@ -272,15 +308,28 @@ const resolveProgress = (item: Workflow) => {
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-3">
|
||||
<div class="d-flex flex-column gap-y-2">
|
||||
<div class="d-flex flex-column gap-y-3">
|
||||
<div class="d-flex flex-wrap gap-x-3">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">{{ t('workflow.task.info.timer') }}</div>
|
||||
<h5 class="text-lg">{{ workflow?.timer }}</h5>
|
||||
<div class="mb-1">{{ t('workflow.task.info.trigger') }}</div>
|
||||
<h5>
|
||||
<span v-if="workflow?.trigger_type === 'timer' || !workflow?.trigger_type">
|
||||
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
|
||||
{{ workflow?.timer }}
|
||||
</span>
|
||||
<span v-else-if="workflow?.trigger_type === 'event'">
|
||||
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
|
||||
{{ getEventTypeText(workflow?.event_type || '') }}
|
||||
</span>
|
||||
<span v-else-if="workflow?.trigger_type === 'manual'">
|
||||
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
|
||||
{{ t('workflow.task.info.manualTrigger') }}
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">{{ t('workflow.task.info.status') }}</div>
|
||||
<h5 class="text-lg" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
|
||||
<h5 :class="`text-${resolveStatusVariant(workflow?.state).color}`">
|
||||
{{ resolveStatusVariant(workflow?.state).text }}
|
||||
</h5>
|
||||
</div>
|
||||
@@ -289,14 +338,14 @@ const resolveProgress = (item: Workflow) => {
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">{{ t('workflow.task.info.actionCount') }}</div>
|
||||
<div>
|
||||
<VAvatar size="28" color="primary" variant="tonal">
|
||||
<span class="text-sm">{{ workflow?.actions?.length }}</span>
|
||||
<VAvatar size="24" color="primary" variant="tonal">
|
||||
<span class="text-xs">{{ workflow?.actions?.length }}</span>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">{{ t('workflow.task.info.runCount') }}</div>
|
||||
<h5 class="text-lg">{{ workflow?.run_count }}</h5>
|
||||
<h5>{{ workflow?.run_count }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-x-3">
|
||||
@@ -320,23 +369,5 @@ const resolveProgress = (item: Workflow) => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VHover>
|
||||
<!-- 流程对话框 -->
|
||||
<WorkflowActionsDialog
|
||||
v-if="flowDialog"
|
||||
v-model="flowDialog"
|
||||
@close="flowDialog = false"
|
||||
@save="editDone"
|
||||
:workflow="workflow"
|
||||
/>
|
||||
<!-- 编辑对话框 -->
|
||||
<WorkflowAddEditDialog
|
||||
v-if="editDialog"
|
||||
v-model="editDialog"
|
||||
@close="editDialog = false"
|
||||
@save="editDone"
|
||||
:workflow="workflow"
|
||||
/>
|
||||
<!-- 分享对话框 -->
|
||||
<WorkflowShareDialog v-if="shareDialog" v-model="shareDialog" :workflow="workflow" @close="shareDialog = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
524
src/components/dialog/AboutDialog.vue
Normal file
@@ -0,0 +1,524 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { clearCacheAndReload } from '@/composables/useVersionChecker'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// APP版本
|
||||
const appVersion = __APP_VERSION__
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 显示器
|
||||
const display = useDisplay()
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 系统环境变量
|
||||
const systemEnv = ref<any>({})
|
||||
|
||||
// 所有Release
|
||||
const allRelease = ref<any>([])
|
||||
|
||||
// 支持站点
|
||||
const supportingSites = ref<any>({})
|
||||
|
||||
// 支持站点折叠状态
|
||||
const sitesExpanded = ref(false)
|
||||
|
||||
// 去重后的支持站点
|
||||
const uniqueSupportingSites = computed(() => {
|
||||
const sitesMap = new Map()
|
||||
|
||||
Object.entries(supportingSites.value).forEach(([domain, site]: [string, any]) => {
|
||||
if (!sitesMap.has(site.name)) {
|
||||
sitesMap.set(site.name, {
|
||||
name: site.name,
|
||||
urls: [{ domain, url: site.url }],
|
||||
})
|
||||
} else {
|
||||
sitesMap.get(site.name).urls.push({ domain, url: site.url })
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(sitesMap.values())
|
||||
})
|
||||
|
||||
// 显示的支持站点(折叠时只显示前5个)
|
||||
const displayedSites = computed(() => {
|
||||
if (sitesExpanded.value) {
|
||||
return uniqueSupportingSites.value
|
||||
}
|
||||
return uniqueSupportingSites.value.slice(0, 5)
|
||||
})
|
||||
|
||||
// 变更日志对话框
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
// 最新版本
|
||||
const latestRelease = ref('')
|
||||
|
||||
// 变更日志对话框标题
|
||||
const releaseDialogTitle = ref('')
|
||||
|
||||
// 变更日志对话框内容
|
||||
const releaseDialogBody = ref('')
|
||||
|
||||
// 打开日志对话框
|
||||
function showReleaseDialog(title: string, body: string) {
|
||||
releaseDialogTitle.value = title
|
||||
releaseDialogBody.value = body ? md.render(body) : ''
|
||||
releaseDialog.value = true
|
||||
}
|
||||
|
||||
// 查询系统环境变量
|
||||
async function querySystemEnv() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env')
|
||||
|
||||
systemEnv.value = result.data
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有Release
|
||||
async function queryAllRelease() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/versions')
|
||||
|
||||
allRelease.value = result.data ?? []
|
||||
|
||||
// 最新版本
|
||||
if (allRelease.value.length > 0) latestRelease.value = allRelease.value[0].tag_name
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询支持站点
|
||||
async function querySupportingSites() {
|
||||
try {
|
||||
supportingSites.value = await api.get('site/supporting')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换站点列表展开状态
|
||||
function toggleSitesExpanded() {
|
||||
sitesExpanded.value = !sitesExpanded.value
|
||||
}
|
||||
|
||||
// 计算发布时间
|
||||
function releaseTime(releaseDate: string) {
|
||||
// 上一次更新时间
|
||||
return formatDateDifference(releaseDate)
|
||||
}
|
||||
|
||||
// 强制清除缓存
|
||||
async function clearCache() {
|
||||
await clearCacheAndReload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
querySystemEnv()
|
||||
queryAllRelease()
|
||||
querySupportingSites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-information" class="me-2" />
|
||||
{{ t('setting.about.title') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="px-3">
|
||||
<div class="section">
|
||||
<div class="section border-gray-800">
|
||||
<dl>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.softwareVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.VERSION }}</code>
|
||||
<a
|
||||
v-if="latestRelease === systemEnv.VERSION"
|
||||
href="https://github.com/jxxghp/MoviePilot/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 ml-2 !cursor-pointer transition hover:bg-green-400"
|
||||
>
|
||||
{{ t('setting.about.latest') }}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="systemEnv.FRONTEND_VERSION">
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.frontendVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.FRONTEND_VERSION }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.browserVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ appVersion }}</code>
|
||||
<VBtn size="x-small" variant="tonal" class="ms-2" @click="clearCache">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" size="14" />
|
||||
</template>
|
||||
{{ t('setting.about.clearCache') }}
|
||||
</VBtn>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.authVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.AUTH_VERSION }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.indexerVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.INDEXER_VERSION }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.configDir') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<code>{{ systemEnv.CONFIG_DIR }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.dataDir') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all"
|
||||
><code>{{ t('setting.about.dataDirectory') }}</code></span
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.timezone') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<code>{{ systemEnv.TZ }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2 mt-1 ms-1">
|
||||
<VChip v-for="site in displayedSites" :key="site.name" variant="outlined" size="small">
|
||||
<span class="truncate max-w-32">{{ site.name }}</span>
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="!sitesExpanded && uniqueSupportingSites.length > 5"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="toggleSitesExpanded"
|
||||
>
|
||||
<span> {{ uniqueSupportingSites.length }}+ ...</span>
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="sitesExpanded && uniqueSupportingSites.length > 5"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="toggleSitesExpanded"
|
||||
>
|
||||
<span>< {{ t('setting.about.collapse') }}</span>
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">{{ t('setting.about.support') }}</h3>
|
||||
</div>
|
||||
<div class="section border-t border-gray-800">
|
||||
<dl>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.documentation') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://movie-pilot.org"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://movie-pilot.org
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.feedback') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://github.com/jxxghp/MoviePilot/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://github.com/jxxghp/MoviePilot/issues/new/choose
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.channel') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://t.me/moviepilot_channel"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://t.me/moviepilot_channel
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">{{ t('setting.about.versions') }}</h3>
|
||||
<div class="section space-y-3">
|
||||
<div>
|
||||
<div
|
||||
v-for="release in allRelease"
|
||||
:key="release.tag_name"
|
||||
class="mb-3 flex w-full flex-col space-y-3 rounded-md px-4 py-2 ring-1 ring-gray-400 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||
>
|
||||
<div class="flex w-full flex-grow items-center justify-start space-x-2 truncate sm:justify-start">
|
||||
<span class="truncate text-lg font-bold">
|
||||
<span class="mr-2 whitespace-nowrap text-xs font-normal">{{
|
||||
releaseTime(release.published_at)
|
||||
}}</span>
|
||||
{{ release.tag_name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="release.tag_name === latestRelease"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-green-500 bg-opacity-80 border border-green-500 !text-green-100"
|
||||
>
|
||||
{{ t('setting.about.latestVersion') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="release.tag_name === systemEnv.VERSION"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100"
|
||||
>
|
||||
{{ t('setting.about.currentVersion') }}
|
||||
</span>
|
||||
</div>
|
||||
<VBtn @click.stop="showReleaseDialog(release.tag_name, release.body)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-text-box-outline" />
|
||||
</template>
|
||||
{{ t('setting.about.viewChangelog') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable max-height="85vh">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VCardTitle>{{ releaseDialogTitle }} {{ t('setting.about.changelog') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText class="markdown-body" v-html="releaseDialogBody" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style type="scss" scoped>
|
||||
.heading {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 2rem;
|
||||
|
||||
--tw-text-opacity: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-block: 0.5rem 2.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
font-weight: 600;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h2) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h3) {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol) {
|
||||
margin-block: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(li) {
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a) {
|
||||
color: rgb(99 102 241);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body :deep(code) {
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(127, 127, 127, 15%);
|
||||
font-size: 0.875em;
|
||||
padding-block: 0.15rem;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgba(127, 127, 127, 15%);
|
||||
margin-block: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-block: 0.75rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre code) {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body :deep(blockquote) {
|
||||
border-inline-start: 3px solid rgba(127, 127, 127, 40%);
|
||||
color: rgba(127, 127, 127, 80%);
|
||||
margin-block: 0.5rem;
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(hr) {
|
||||
border: none;
|
||||
border-block-start: 1px solid rgba(127, 127, 127, 30%);
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(table) {
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th),
|
||||
.markdown-body :deep(td) {
|
||||
border: 1px solid rgba(127, 127, 127, 30%);
|
||||
padding-block: 0.4rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th) {
|
||||
background-color: rgba(127, 127, 127, 10%);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body :deep(img) {
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -6,10 +6,20 @@ import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } fr
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
@@ -38,6 +48,18 @@ const directories = ref<TransferDirectoryConf[]>([])
|
||||
// 是否正在加载
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
|
||||
// 计算按钮图标
|
||||
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
|
||||
|
||||
@@ -56,9 +78,21 @@ async function loadDirectories() {
|
||||
}
|
||||
}
|
||||
|
||||
function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
}
|
||||
|
||||
// 获取保存目录
|
||||
const targetDirectories = computed(() => {
|
||||
const downloadDirectories = directories.value.map(item => item.download_path)
|
||||
const downloadDirectories = directories.value
|
||||
.map(item => convertToUri(item))
|
||||
.filter((item): item is string => item !== undefined)
|
||||
return [...new Set(downloadDirectories)]
|
||||
})
|
||||
|
||||
@@ -96,6 +130,14 @@ async function addDownload() {
|
||||
payload.media_in = props.media
|
||||
}
|
||||
|
||||
// 添加媒体ID辅助识别
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
}
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
|
||||
result = await api.post(endpoint, payload)
|
||||
@@ -132,7 +174,7 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<DialogWrapper max-width="35rem" scrollable>
|
||||
<VDialog max-width="35rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
@@ -181,7 +223,6 @@ onMounted(() => {
|
||||
<VSelect
|
||||
v-model="selectedDownloader"
|
||||
:items="downloaderOptions"
|
||||
size="small"
|
||||
:label="t('dialog.addDownload.downloader')"
|
||||
variant="underlined"
|
||||
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
|
||||
@@ -194,7 +235,6 @@ onMounted(() => {
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
:label="t('dialog.addDownload.saveDirectory')"
|
||||
size="small"
|
||||
:placeholder="t('dialog.addDownload.autoPlaceholder')"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@@ -202,6 +242,53 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow class="px-5 mt-2">
|
||||
<VCol cols="12">
|
||||
<VBtn
|
||||
variant="text"
|
||||
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click="showAdvancedOptions = !showAdvancedOptions"
|
||||
>
|
||||
{{
|
||||
showAdvancedOptions
|
||||
? t('dialog.addDownload.hideAdvancedOptions')
|
||||
: t('dialog.addDownload.showAdvancedOptions')
|
||||
}}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardText class="text-center">
|
||||
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
|
||||
@@ -209,5 +296,15 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
<!-- 媒体ID选择器 -->
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -70,7 +70,7 @@ async function savaAlistConfig() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogWrapper width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem>
|
||||
@@ -143,5 +143,5 @@ async function savaAlistConfig() {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -110,7 +110,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogWrapper width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem>
|
||||
@@ -148,5 +148,5 @@ onUnmounted(() => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
95
src/components/dialog/CacheReidentifyDialog.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
itemTitle?: string
|
||||
loading?: boolean
|
||||
modelValue?: boolean
|
||||
recognizeSource?: string
|
||||
}>(),
|
||||
{
|
||||
itemTitle: '',
|
||||
loading: false,
|
||||
modelValue: true,
|
||||
recognizeSource: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'confirm', payload: { doubanId?: string; tmdbId?: number }): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const tmdbId = ref<number | undefined>()
|
||||
const doubanId = ref<string | undefined>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 提交重新识别参数给缓存页执行接口调用。
|
||||
function submitReidentify() {
|
||||
emit('confirm', {
|
||||
doubanId: doubanId.value,
|
||||
tmdbId: tmdbId.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon>mdi-text-recognition</VIcon>
|
||||
</template>
|
||||
<VCardTitle>{{ t('setting.cache.reidentifyDialog.title') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.itemTitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="props.recognizeSource === 'themoviedb'"
|
||||
v-model="tmdbId"
|
||||
:label="t('setting.cache.reidentifyDialog.tmdbId')"
|
||||
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-id-card"
|
||||
persistent-hint
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('setting.cache.reidentifyDialog.doubanId')"
|
||||
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-id-card"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VAlert type="info" variant="tonal" class="mt-4">
|
||||
{{ t('setting.cache.reidentifyDialog.autoHint') }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" :loading="props.loading" prepend-icon="mdi-check" @click="submitReidentify">
|
||||
{{ t('setting.cache.reidentifyDialog.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
663
src/components/dialog/CategoryEditDialog.vue
Normal file
@@ -0,0 +1,663 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import type { CategoryConfig } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 定义输入参数
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
}>()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const activeTab = ref('movie')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const generateId = () => {
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now()
|
||||
}
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
rule: any
|
||||
}
|
||||
|
||||
const movieList = ref<CategoryItem[]>([])
|
||||
const tvList = ref<CategoryItem[]>([])
|
||||
|
||||
// TMDB 类型映射
|
||||
const genreOptions = [
|
||||
{ title: '动作 (Action)', value: '28' },
|
||||
{ title: '冒险 (Adventure)', value: '12' },
|
||||
{ title: '动画 (Animation)', value: '16' },
|
||||
{ title: '喜剧 (Comedy)', value: '35' },
|
||||
{ title: '犯罪 (Crime)', value: '80' },
|
||||
{ title: '纪录 (Documentary)', value: '99' },
|
||||
{ title: '剧情 (Drama)', value: '18' },
|
||||
{ title: '家庭 (Family)', value: '10751' },
|
||||
{ title: '奇幻 (Fantasy)', value: '14' },
|
||||
{ title: '历史 (History)', value: '36' },
|
||||
{ title: '恐怖 (Horror)', value: '27' },
|
||||
{ title: '音乐 (Music)', value: '10402' },
|
||||
{ title: '悬疑 (Mystery)', value: '9648' },
|
||||
{ title: '爱情 (Romance)', value: '10749' },
|
||||
{ title: '科幻 (SF)', value: '878' },
|
||||
{ title: '电视电影', value: '10770' },
|
||||
{ title: '惊悚 (Thriller)', value: '53' },
|
||||
{ title: '战争 (War)', value: '10752' },
|
||||
{ title: '西部 (Western)', value: '37' },
|
||||
{ title: '儿童 (Kids)', value: '10762' },
|
||||
{ title: '新闻 (News)', value: '10763' },
|
||||
{ title: '真人秀 (Reality)', value: '10764' },
|
||||
{ title: '科幻/奇幻 (Sci-Fi)', value: '10765' },
|
||||
{ title: '肥皂剧 (Soap)', value: '10766' },
|
||||
{ title: '访谈 (Talk)', value: '10767' },
|
||||
{ title: '战争/政治', value: '10768' },
|
||||
]
|
||||
|
||||
// 语种选项 (original_language)
|
||||
const languageOptions = [
|
||||
{ title: '中文', value: 'zh' },
|
||||
{ title: '中文', value: 'cn' },
|
||||
{ title: '英语 (English)', value: 'en' },
|
||||
{ title: '日语 (Japanese)', value: 'ja' },
|
||||
{ title: '韩语 (Korean)', value: 'ko' },
|
||||
{ title: '法语 (French)', value: 'fr' },
|
||||
{ title: '德语 (German)', value: 'de' },
|
||||
{ title: '西班牙语 (Spanish)', value: 'es' },
|
||||
{ title: '意大利语 (Italian)', value: 'it' },
|
||||
{ title: '葡萄牙语 (Portuguese)', value: 'pt' },
|
||||
{ title: '俄语 (Russian)', value: 'ru' },
|
||||
{ title: '阿拉伯语', value: 'ar' },
|
||||
{ title: '泰语 (Thai)', value: 'th' },
|
||||
{ title: '越南语 (Vietnamese)', value: 'vi' },
|
||||
{ title: '印地语 (Hindi)', value: 'hi' },
|
||||
{ title: '土耳其语 (Turkish)', value: 'tr' },
|
||||
{ title: '荷兰语 (Dutch)', value: 'nl' },
|
||||
{ title: '波兰语 (Polish)', value: 'pl' },
|
||||
{ title: '瑞典语 (Swedish)', value: 'sv' },
|
||||
{ title: '丹麦语 (Danish)', value: 'da' },
|
||||
{ title: '挪威语 (Norwegian)', value: 'nb' },
|
||||
{ title: '芬兰语 (Finnish)', value: 'fi' },
|
||||
{ title: '希腊语 (Greek)', value: 'el' },
|
||||
{ title: '捷克语 (Czech)', value: 'cs' },
|
||||
{ title: '匈牙利语 (Hungarian)', value: 'hu' },
|
||||
{ title: '罗马尼亚语 (Romanian)', value: 'ro' },
|
||||
{ title: '乌克兰语 (Ukrainian)', value: 'uk' },
|
||||
{ title: '印度尼西亚语 (Indonesian)', value: 'id' },
|
||||
{ title: '马来语 (Malay)', value: 'ms' },
|
||||
{ title: '希伯来语 (Hebrew)', value: 'he' },
|
||||
]
|
||||
|
||||
// 国家/地区选项 (origin_country/production_countries)
|
||||
const countryOptions = [
|
||||
{ title: '中国大陆 (CN)', value: 'CN' },
|
||||
{ title: '中国香港 (HK)', value: 'HK' },
|
||||
{ title: '中国台湾 (TW)', value: 'TW' },
|
||||
{ title: '美国 (US)', value: 'US' },
|
||||
{ title: '英国 (GB)', value: 'GB' },
|
||||
{ title: '日本 (JP)', value: 'JP' },
|
||||
{ title: '韩国 (KR)', value: 'KR' },
|
||||
{ title: '法国 (FR)', value: 'FR' },
|
||||
{ title: '德国 (DE)', value: 'DE' },
|
||||
{ title: '意大利 (IT)', value: 'IT' },
|
||||
{ title: '西班牙 (ES)', value: 'ES' },
|
||||
{ title: '加拿大 (CA)', value: 'CA' },
|
||||
{ title: '澳大利亚 (AU)', value: 'AU' },
|
||||
{ title: '俄罗斯 (RU)', value: 'RU' },
|
||||
{ title: '印度 (IN)', value: 'IN' },
|
||||
{ title: '泰国 (TH)', value: 'TH' },
|
||||
{ title: '新加坡 (SG)', value: 'SG' },
|
||||
{ title: '马来西亚 (MY)', value: 'MY' },
|
||||
{ title: '越南 (VN)', value: 'VN' },
|
||||
{ title: '菲律宾 (PH)', value: 'PH' },
|
||||
{ title: '巴西 (BR)', value: 'BR' },
|
||||
{ title: '墨西哥 (MX)', value: 'MX' },
|
||||
{ title: '阿根廷 (AR)', value: 'AR' },
|
||||
{ title: '荷兰 (NL)', value: 'NL' },
|
||||
{ title: '比利时 (BE)', value: 'BE' },
|
||||
{ title: '瑞士 (CH)', value: 'CH' },
|
||||
{ title: '瑞典 (SE)', value: 'SE' },
|
||||
{ title: '挪威 (NO)', value: 'NO' },
|
||||
{ title: '丹麦 (DK)', value: 'DK' },
|
||||
{ title: '波兰 (PL)', value: 'PL' },
|
||||
{ title: '捷克 (CZ)', value: 'CZ' },
|
||||
{ title: '土耳其 (TR)', value: 'TR' },
|
||||
{ title: '以色列 (IL)', value: 'IL' },
|
||||
{ title: '埃及 (EG)', value: 'EG' },
|
||||
{ title: '南非 (ZA)', value: 'ZA' },
|
||||
{ title: '新西兰 (NZ)', value: 'NZ' },
|
||||
]
|
||||
|
||||
const fetchConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await api.get('media/category/config')
|
||||
if (res && res.data) {
|
||||
parseConfig(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseConfig = (data: CategoryConfig) => {
|
||||
// 将对象 { "Name": { ... } } 转换为数组 [ { id: uuid, name: "Name", rule: { ... } } ]
|
||||
movieList.value = []
|
||||
if (data.movie) {
|
||||
for (const [key, value] of Object.entries(data.movie)) {
|
||||
// 为了UI一致性处理 genre_ids 为数组或字符串,但 API 发送的是字符串
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// UI 多选预期为数组,检查输入。实际上 VAutocomplete 多选预期数组。我们需要将字符串分割为数组。
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理制片国家/地区
|
||||
if (rule.production_countries && typeof rule.production_countries === 'string') {
|
||||
// @ts-ignore
|
||||
rule.production_countries = rule.production_countries.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.production_countries = []
|
||||
}
|
||||
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tvList.value = []
|
||||
if (data.tv) {
|
||||
for (const [key, value] of Object.entries(data.tv)) {
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理发行国家/地区
|
||||
if (rule.origin_country && typeof rule.origin_country === 'string') {
|
||||
// @ts-ignore
|
||||
rule.origin_country = rule.origin_country.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.origin_country = []
|
||||
}
|
||||
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addMovieItem = () => {
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeMovieItem = (index: number) => {
|
||||
movieList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const addTvItem = () => {
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeTvItem = (index: number) => {
|
||||
tvList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// 将数组转换回对象
|
||||
const payload: CategoryConfig = {
|
||||
movie: {},
|
||||
tv: {},
|
||||
}
|
||||
|
||||
movieList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
// 将 genre_ids 数组转换回字符串
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 production_countries 数组转换回字符串
|
||||
if (Array.isArray(rule.production_countries) && rule.production_countries.length > 0) {
|
||||
rule.production_countries = rule.production_countries.join(',')
|
||||
} else {
|
||||
rule.production_countries = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.movie[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
tvList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 origin_country 数组转换回字符串
|
||||
if (Array.isArray(rule.origin_country) && rule.origin_country.length > 0) {
|
||||
rule.origin_country = rule.origin_country.join(',')
|
||||
} else {
|
||||
rule.origin_country = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.tv[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
const res: any = await api.post('media/category/config', payload)
|
||||
if (res && res.success) {
|
||||
toast.success(t('setting.category.saveSuccess'))
|
||||
emit('save')
|
||||
emit('close')
|
||||
} else {
|
||||
toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.saveFailed', { message: 'Network or Config Error' }))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="modelValue" max-width="1000" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem class="py-3">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-shape-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('setting.category.title') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ t('setting.category.subtitle') }}
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VTabs v-model="activeTab" show-arrows class="mb-4">
|
||||
<VTab value="movie">
|
||||
<VIcon icon="mdi-movie-outline" class="me-2" />
|
||||
{{ t('setting.category.movie') }}
|
||||
</VTab>
|
||||
<VTab value="tv">
|
||||
<VIcon icon="mdi-television" class="me-2" />
|
||||
{{ t('setting.category.tv') }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
|
||||
<VProgressCircular indeterminate color="primary" size="64" />
|
||||
</div>
|
||||
|
||||
<VWindow v-else v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="movie">
|
||||
<draggable v-model="movieList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeMovieItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.production_countries"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addMovieItem"
|
||||
>
|
||||
{{ t('setting.category.addMovie') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
|
||||
<VWindowItem value="tv">
|
||||
<draggable v-model="tvList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeTvItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.origin_country"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addTvItem"
|
||||
>
|
||||
{{ t('setting.category.addTv') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.category-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.add-category-btn {
|
||||
border-style: dashed !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-category-btn:hover {
|
||||
border-style: solid !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.disable-tab-transition > * {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
237
src/components/dialog/ContentToggleSettingsDialog.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
|
||||
type UnknownRecord = Record<string, any>
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
enabled: Record<string, boolean>
|
||||
elevated?: boolean
|
||||
hint: string
|
||||
items: UnknownRecord[]
|
||||
labelGetter?: (item: UnknownRecord) => string
|
||||
modelValue?: boolean
|
||||
selectAllText?: string
|
||||
selectNoneText?: string
|
||||
showBulkActions?: boolean
|
||||
switchLabel?: string
|
||||
title: string
|
||||
valueGetter?: (item: UnknownRecord) => string
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
elevated: false,
|
||||
labelGetter: undefined,
|
||||
modelValue: true,
|
||||
selectAllText: '',
|
||||
selectNoneText: '',
|
||||
showBulkActions: false,
|
||||
switchLabel: '',
|
||||
valueGetter: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'save', payload: { elevated: boolean; enabled: Record<string, boolean> }): void
|
||||
(event: 'update:elevated', value: boolean): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const localEnabled = ref<Record<string, boolean>>({})
|
||||
const localElevated = ref(props.elevated)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const elevatedValue = computed({
|
||||
get: () => localElevated.value,
|
||||
set: value => {
|
||||
localElevated.value = value
|
||||
emit('update:elevated', value)
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.enabled, props.elevated, props.items],
|
||||
() => {
|
||||
resetLocalSettings()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
// 重置弹窗内部设置副本,避免直接修改父级 props。
|
||||
function resetLocalSettings() {
|
||||
localEnabled.value = { ...props.enabled }
|
||||
localElevated.value = props.elevated
|
||||
}
|
||||
|
||||
// 获取设置项的稳定键值。
|
||||
function getItemValue(item: UnknownRecord) {
|
||||
return props.valueGetter?.(item) ?? String(item.id ?? item.title ?? item.name ?? '')
|
||||
}
|
||||
|
||||
// 获取设置项展示名称。
|
||||
function getItemLabel(item: UnknownRecord) {
|
||||
return props.labelGetter?.(item) ?? String(item.attrs?.title ?? item.name ?? item.title ?? '')
|
||||
}
|
||||
|
||||
// 切换单个设置项的启用状态。
|
||||
function toggleItem(item: UnknownRecord) {
|
||||
const key = getItemValue(item)
|
||||
localEnabled.value[key] = !localEnabled.value[key]
|
||||
}
|
||||
|
||||
// 批量设置所有项目启用状态。
|
||||
function setAllItems(value: boolean) {
|
||||
props.items.forEach(item => {
|
||||
localEnabled.value[getItemValue(item)] = value
|
||||
})
|
||||
}
|
||||
|
||||
// 提交通用内容开关设置。
|
||||
function submitSettings() {
|
||||
emit('save', {
|
||||
elevated: localElevated.value,
|
||||
enabled: { ...localEnabled.value },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="35rem" class="settings-dialog" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="settings-card">
|
||||
<VCardItem class="settings-card-header">
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-tune" size="small" class="me-2" />
|
||||
{{ props.title }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="settings-hint">{{ props.hint }}</p>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
v-for="item in props.items"
|
||||
:key="getItemValue(item)"
|
||||
class="setting-item"
|
||||
:class="{ 'enabled': localEnabled[getItemValue(item)] }"
|
||||
:style="{ '--item-color': props.colors[getItemValue(item)] }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<div class="setting-item-inner">
|
||||
<div class="setting-check">
|
||||
<VIcon
|
||||
:icon="localEnabled[getItemValue(item)] ? 'mdi-check-circle' : 'mdi-circle-outline'"
|
||||
:color="localEnabled[getItemValue(item)] ? 'primary' : undefined"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<span class="setting-label">{{ getItemLabel(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="props.switchLabel" class="mt-3">
|
||||
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
|
||||
{{ props.selectAllText }}
|
||||
</VBtn>
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(false)">
|
||||
{{ props.selectNoneText }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" class="px-5" @click="submitSettings">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-card-header {
|
||||
padding-block: 16px;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
cursor: pointer;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, var(--item-color, rgb(var(--v-theme-primary))) 0%, transparent 100%);
|
||||
content: '';
|
||||
inline-size: 3px;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.4);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.setting-item.enabled::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-check {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
151
src/components/dialog/CustomCssDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
css?: string
|
||||
editorTheme?: string
|
||||
modelValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
css: '',
|
||||
editorTheme: 'monokai',
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'save', css: string): void
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 正在编辑的 CSS 内容
|
||||
const editableCSS = ref(props.css)
|
||||
const editorOptions = {
|
||||
displayIndentGuides: true,
|
||||
fontSize: 14,
|
||||
highlightActiveLine: true,
|
||||
scrollPastEnd: 0.2,
|
||||
showPrintMargin: false,
|
||||
tabSize: 2,
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.css,
|
||||
value => {
|
||||
editableCSS.value = value
|
||||
},
|
||||
)
|
||||
|
||||
/** 提交当前 CSS 内容给调用方保存。 */
|
||||
function submitCustomCSS() {
|
||||
emit('save', editableCSS.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="custom-css-dialog">
|
||||
<VCardItem class="custom-css-header py-3">
|
||||
<template #prepend>
|
||||
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
|
||||
<VIcon icon="mdi-palette" size="22" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('theme.custom') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<div class="custom-css-editor-body">
|
||||
<VAceEditor
|
||||
v-model:value="editableCSS"
|
||||
lang="css"
|
||||
:theme="props.editorTheme"
|
||||
:options="editorOptions"
|
||||
wrap
|
||||
class="custom-css-editor"
|
||||
/>
|
||||
</div>
|
||||
<VCardActions class="custom-css-actions">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-css-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-css-header {
|
||||
flex: 0 0 auto;
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.custom-css-editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.custom-css-editor {
|
||||
overflow: hidden;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
block-size: min(62vh, 34rem);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.custom-css-actions {
|
||||
flex: 0 0 auto;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 0.875rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.custom-css-dialog {
|
||||
block-size: 100dvh;
|
||||
max-block-size: 100dvh;
|
||||
}
|
||||
|
||||
.custom-css-editor-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.custom-css-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
.custom-css-actions {
|
||||
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
209
src/components/dialog/CustomRuleInfoDialog.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<script lang="ts" setup>
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
import type { CustomRule } from '@/api/types'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 单条规则
|
||||
rule: {
|
||||
type: Object as PropType<CustomRule>,
|
||||
required: true,
|
||||
},
|
||||
// 所有规则
|
||||
rules: {
|
||||
type: Array as PropType<CustomRule[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const ruleInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 规则详情
|
||||
const ruleInfo = ref<CustomRule>({
|
||||
id: '',
|
||||
name: '',
|
||||
include: '',
|
||||
exclude: '',
|
||||
size_range: '',
|
||||
seeders: '',
|
||||
publish_time: '',
|
||||
})
|
||||
|
||||
/** 初始化规则编辑表单数据。 */
|
||||
function initializeRuleInfo() {
|
||||
ruleInfo.value = cloneDeep(props.rule)
|
||||
}
|
||||
|
||||
/** 保存规则编辑结果并通知父级刷新。 */
|
||||
function saveRuleInfo() {
|
||||
if (!ruleInfo.value.id || !ruleInfo.value.name) {
|
||||
if (!ruleInfo.value.id && !ruleInfo.value.name) {
|
||||
$toast.error(t('customRule.error.emptyIdName'))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
|
||||
$toast.error(t('customRule.error.idOccupied'))
|
||||
return
|
||||
}
|
||||
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
|
||||
$toast.error(t('customRule.error.nameOccupied'))
|
||||
return
|
||||
}
|
||||
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
|
||||
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
|
||||
return
|
||||
}
|
||||
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
|
||||
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
|
||||
return
|
||||
}
|
||||
ruleInfoDialog.value = false
|
||||
emit('change', ruleInfo.value, props.rule.id)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 规范化规则 ID 输入,只保留英文和数字。 */
|
||||
function validateRuleId() {
|
||||
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeRuleInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.id"
|
||||
:label="t('customRule.field.ruleId')"
|
||||
:placeholder="t('customRule.placeholder.ruleId')"
|
||||
:hint="t('customRule.hint.ruleId')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
@input="validateRuleId"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.name"
|
||||
:label="t('customRule.field.ruleName')"
|
||||
:placeholder="t('customRule.placeholder.ruleName')"
|
||||
:hint="t('customRule.hint.ruleName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.include"
|
||||
:label="t('customRule.field.include')"
|
||||
:placeholder="t('customRule.placeholder.include')"
|
||||
:hint="t('customRule.hint.include')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-plus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.exclude"
|
||||
:label="t('customRule.field.exclude')"
|
||||
:placeholder="t('customRule.placeholder.exclude')"
|
||||
:hint="t('customRule.hint.exclude')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-minus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.size_range"
|
||||
:label="t('customRule.field.sizeRange')"
|
||||
:placeholder="t('customRule.placeholder.sizeRange')"
|
||||
:hint="t('customRule.hint.sizeRange')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-harddisk"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.seeders"
|
||||
:label="t('customRule.field.seeders')"
|
||||
:placeholder="t('customRule.placeholder.seeders')"
|
||||
:hint="t('customRule.hint.seeders')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.publish_time"
|
||||
:label="t('customRule.field.publishTime')"
|
||||
:placeholder="t('customRule.placeholder.publishTime')"
|
||||
: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" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('customRule.action.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
161
src/components/dialog/DiscoverTabOrderDialog.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import type { DiscoverSource } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
modelValue?: boolean
|
||||
tabs: DiscoverSource[]
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'save', tabs: DiscoverSource[]): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const localTabs = ref<DiscoverSource[]>([])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.tabs,
|
||||
() => {
|
||||
resetLocalTabs()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
// 重置弹窗内部排序副本。
|
||||
function resetLocalTabs() {
|
||||
localTabs.value = props.tabs.map(item => ({ ...item }))
|
||||
}
|
||||
|
||||
// 保存当前拖拽后的发现标签顺序。
|
||||
function submitOrder() {
|
||||
emit('save', localTabs.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="35rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
|
||||
{{ t('discover.setTabOrder') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="settings-hint">{{ t('discover.dragToReorder') }}</p>
|
||||
<draggable
|
||||
v-model="localTabs"
|
||||
handle=".cursor-move"
|
||||
item-key="mediaid_prefix"
|
||||
tag="div"
|
||||
:component-data="{ 'class': 'settings-grid' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<VCard
|
||||
variant="text"
|
||||
class="setting-item enabled"
|
||||
:style="{ '--item-color': props.colors[element.mediaid_prefix] }"
|
||||
>
|
||||
<div class="setting-item-inner">
|
||||
<span class="setting-label">{{ element.name }}</span>
|
||||
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn @click="submitOrder">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-hint {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.3);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
cursor: grab;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background-color: var(--item-color, #4caf50);
|
||||
block-size: 100%;
|
||||
content: '';
|
||||
inline-size: 4px;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
529
src/components/dialog/DownloaderInfoDialog.vue
Normal file
@@ -0,0 +1,529 @@
|
||||
<script setup lang="ts">
|
||||
import type { DownloaderConf } from '@/api/types'
|
||||
import { storageAttributes } from '@/api/constants'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
downloader: {
|
||||
type: Object as PropType<DownloaderConf>,
|
||||
required: true,
|
||||
},
|
||||
downloaders: {
|
||||
type: Array as PropType<DownloaderConf[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 表单
|
||||
const downloaderForm = ref()
|
||||
|
||||
// 下载器详情弹窗
|
||||
const downloaderInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 下载器详情
|
||||
const downloaderInfo = ref<DownloaderConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
default: false,
|
||||
enabled: false,
|
||||
config: {},
|
||||
path_mapping: [],
|
||||
})
|
||||
|
||||
// 路径映射行定义
|
||||
interface PathMappingRow {
|
||||
id: string
|
||||
storage: string
|
||||
download: string
|
||||
}
|
||||
|
||||
// 路径映射行数据
|
||||
const pathMappingRows = ref<PathMappingRow[]>([])
|
||||
|
||||
// 路径前缀选项
|
||||
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'),
|
||||
]
|
||||
|
||||
/** 生成路径映射行使用的临时唯一 ID。 */
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
/** 初始化下载器编辑表单数据。 */
|
||||
function initializeDownloaderInfo() {
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||||
id: generateId(),
|
||||
storage: item[0],
|
||||
download: item[1],
|
||||
}))
|
||||
}
|
||||
|
||||
/** 保存下载器编辑结果并通知父级刷新。 */
|
||||
async function saveDownloaderInfo() {
|
||||
const { valid } = (await downloaderForm.value?.validate()) || { valid: true }
|
||||
if (!valid) return
|
||||
|
||||
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
|
||||
|
||||
if (!downloaderInfo.value.name) {
|
||||
$toast.error(t('downloader.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
|
||||
$toast.error(t('downloader.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
if (downloaderInfo.value.default) {
|
||||
props.downloaders.forEach(item => {
|
||||
if (item.default && item !== props.downloader) {
|
||||
item.default = false
|
||||
$toast.info(t('downloader.defaultChanged'))
|
||||
}
|
||||
})
|
||||
}
|
||||
downloaderInfoDialog.value = false
|
||||
emit('change', downloaderInfo.value, props.downloader.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 新增一行路径映射。 */
|
||||
function addPathMapping() {
|
||||
pathMappingRows.value.push({
|
||||
id: generateId(),
|
||||
storage: '',
|
||||
download: '',
|
||||
})
|
||||
}
|
||||
|
||||
/** 移除指定位置的路径映射。 */
|
||||
function removePathMapping(index: number) {
|
||||
pathMappingRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeDownloaderInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 ref="downloaderForm">
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.default"
|
||||
:label="t('downloader.default')"
|
||||
:disabled="!downloaderInfo.enabled"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
|
||||
<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"
|
||||
: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">
|
||||
<VTextField
|
||||
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">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
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">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.category"
|
||||
:label="t('downloader.category')"
|
||||
:hint="t('downloader.category')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.sequentail"
|
||||
:label="t('downloader.sequentail')"
|
||||
:hint="t('downloader.sequentail')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.force_resume"
|
||||
:label="t('downloader.force_resume')"
|
||||
:hint="t('downloader.force_resume')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.first_last_piece"
|
||||
:label="t('downloader.first_last_piece')"
|
||||
:hint="t('downloader.first_last_piece')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'transmission'">
|
||||
<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"
|
||||
:hint="t('downloader.host')"
|
||||
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>
|
||||
<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>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.type"
|
||||
:label="t('downloader.type')"
|
||||
:hint="t('downloader.customTypeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
: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="path-mapping-card 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="path-storage-select-col pe-sm-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>
|
||||
<VRow no-gutters>
|
||||
<VCol cols="12" sm="4" class="d-none d-sm-block" />
|
||||
<VCol cols="12" sm="8">
|
||||
<VTextField
|
||||
v-model="row.download"
|
||||
:placeholder="'/path/to/download'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</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" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.path-mapping-card {
|
||||
border-color: rgba(var(--v-border-color), 0.08) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 599.98px) {
|
||||
.path-storage-select-col {
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
src/components/dialog/FileNewFolderDialog.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'create'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:name', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const folderName = computed({
|
||||
get: () => props.name,
|
||||
set: value => emit('update:name', value),
|
||||
})
|
||||
|
||||
// 关闭新建目录弹窗并通知共享弹窗 Host 回收实例。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-folder-plus-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('file.newFolder') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<div class="flex-grow-1" />
|
||||
<VBtn :disabled="!folderName" prepend-icon="mdi-folder-plus" class="px-5 me-3" @click="emit('create')">
|
||||
{{ t('common.create') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
94
src/components/dialog/FileRenameDialog.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FileItem } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
item: Object as PropType<FileItem>,
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
recursive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'auto-name'): void
|
||||
(event: 'close'): void
|
||||
(event: 'rename'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:name', value: string): void
|
||||
(event: 'update:recursive', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const renameName = computed({
|
||||
get: () => props.name,
|
||||
set: value => emit('update:name', value),
|
||||
})
|
||||
|
||||
const includeSubfolders = computed({
|
||||
get: () => props.recursive,
|
||||
set: value => emit('update:recursive', value),
|
||||
})
|
||||
|
||||
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-pencil" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="renameName"
|
||||
:label="t('file.newName')"
|
||||
:loading="loading"
|
||||
prepend-inner-icon="mdi-format-text"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="item && item.type == 'dir'" cols="12">
|
||||
<VSwitch v-model="includeSubfolders" :label="t('file.includeSubfolders')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="success" prepend-icon="mdi-magic" class="px-5 me-3" @click="emit('auto-name')">
|
||||
{{ t('file.autoRecognizeName') }}
|
||||
</VBtn>
|
||||
<VBtn :disabled="!renameName" prepend-icon="mdi-check" class="px-5 me-3" @click="emit('rename')">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
314
src/components/dialog/FilterRuleGroupInfoDialog.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<script lang="ts" setup>
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
|
||||
// 规则组详情弹窗内才需要拖拽和导入代码,避免规则组卡片列表首屏带入重交互依赖。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 单个规则组
|
||||
group: {
|
||||
type: Object as PropType<FilterRuleGroup>,
|
||||
required: true,
|
||||
},
|
||||
// 所有规则组
|
||||
groups: {
|
||||
type: Array as PropType<FilterRuleGroup[]>,
|
||||
required: true,
|
||||
},
|
||||
// 媒体类型字典
|
||||
categories: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
},
|
||||
// 自定义规则列表
|
||||
custom_rules: Array as PropType<CustomRule[]>,
|
||||
})
|
||||
|
||||
// 规则卡片类型
|
||||
interface FilterCard {
|
||||
// 优先级
|
||||
pri: string
|
||||
// 已选规则
|
||||
rules: string[]
|
||||
}
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const groupInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 规则详情
|
||||
const groupInfo = ref<FilterRuleGroup>({
|
||||
name: props.group?.name ?? '',
|
||||
rule_string: props.group?.rule_string ?? '',
|
||||
media_type: props.group?.media_type ?? '',
|
||||
category: props.group?.category ?? '',
|
||||
})
|
||||
|
||||
// 媒体类型字典
|
||||
const mediaTypeItems = [
|
||||
{ title: t('common.all'), value: '' },
|
||||
{ title: t('mediaType.movie'), value: '电影' },
|
||||
{ title: t('mediaType.tv'), value: '电视剧' },
|
||||
]
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: t('common.all'), value: '' }]
|
||||
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
|
||||
return default_value
|
||||
}
|
||||
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
|
||||
})
|
||||
|
||||
// 规则组规则卡片列表
|
||||
const filterRuleCards = ref<FilterCard[]>([])
|
||||
|
||||
|
||||
/** 更新指定优先级规则卡片的选中规则。 */
|
||||
function updateFilterCardValue(pri: string, rules: string[]) {
|
||||
const card = filterRuleCards.value.find(card => card.pri === pri)
|
||||
if (card && Array.isArray(rules)) card.rules = rules
|
||||
}
|
||||
|
||||
/** 移除指定优先级规则卡片并重排优先级。 */
|
||||
function filterCardClose(pri: string) {
|
||||
filterRuleCards.value = filterRuleCards.value
|
||||
.filter(card => card.pri !== pri)
|
||||
.map((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
return card
|
||||
})
|
||||
}
|
||||
|
||||
/** 将当前规则组规则串复制到剪贴板。 */
|
||||
async function shareRules() {
|
||||
if (filterRuleCards.value.length === 0) return
|
||||
|
||||
const value = filterRuleCards.value
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
|
||||
try {
|
||||
let success
|
||||
success = copyToClipboard(value)
|
||||
if (await success) $toast.success(t('filterRule.shareSuccess'))
|
||||
else $toast.error(t('filterRule.shareFailed'))
|
||||
} catch (error) {
|
||||
$toast.error(t('filterRule.shareFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开共享导入弹窗并导入规则串。 */
|
||||
async function importRules(ruleType: string) {
|
||||
openSharedDialog(
|
||||
ImportCodeDialog,
|
||||
{
|
||||
title: t('filterRule.import'),
|
||||
dataType: ruleType,
|
||||
},
|
||||
{
|
||||
save: saveCodeString,
|
||||
},
|
||||
{ closeOn: ['close', 'save'] },
|
||||
)
|
||||
}
|
||||
|
||||
/** 保存导入的规则代码并覆盖当前规则卡片。 */
|
||||
function saveCodeString(type: string, code: any) {
|
||||
try {
|
||||
code = code.value
|
||||
if (type === 'priority') {
|
||||
// 解析值
|
||||
if (!code) return
|
||||
// 首尾增加空格
|
||||
if (!code.startsWith(' ')) code = ` ${code}`
|
||||
if (!code.endsWith(' ')) code = `${code} `
|
||||
const groups = code.split('>')
|
||||
filterRuleCards.value = groups.map((group: string, index: number) => ({
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&').filter(rule => rule),
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(t('filterRule.importFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 新增一个空的规则优先级卡片。 */
|
||||
function addFilterCard() {
|
||||
const pri = (filterRuleCards.value.length + 1).toString()
|
||||
const newCard: FilterCard = { pri, rules: [] }
|
||||
filterRuleCards.value.push(newCard)
|
||||
}
|
||||
|
||||
/** 根据列表的拖动顺序更新优先级。 */
|
||||
function dragOrderEnd() {
|
||||
filterRuleCards.value.forEach((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化规则组编辑数据。 */
|
||||
function opengroupInfoDialog() {
|
||||
groupInfo.value = cloneDeep(props.group)
|
||||
if (props.group.rule_string) {
|
||||
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&').filter(rule => rule),
|
||||
}))
|
||||
}
|
||||
groupInfoDialog.value = true
|
||||
}
|
||||
|
||||
/** 保存规则组编辑结果并通知父级刷新。 */
|
||||
function saveGroupInfo() {
|
||||
if (!groupInfo.value.name.trim()) {
|
||||
$toast.error(t('filterRule.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
|
||||
$toast.error(t('filterRule.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
|
||||
groupInfoDialog.value = false
|
||||
groupInfo.value.rule_string = filterRuleCards.value
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
emit('change', groupInfo.value, props.group.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 关闭规则组编辑弹窗。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
opengroupInfoDialog()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 />
|
||||
<VCardItem class="pt-1">
|
||||
<VRow class="mt-1">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="groupInfo.name"
|
||||
:label="t('filterRule.groupName')"
|
||||
:placeholder="t('filterRule.nameRequired')"
|
||||
:hint="t('filterRule.groupName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<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">
|
||||
<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>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<Draggable
|
||||
v-model="filterRuleCards"
|
||||
handle=".cursor-move"
|
||||
item-key="pri"
|
||||
tag="div"
|
||||
@end="dragOrderEnd"
|
||||
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<FilterRuleCard
|
||||
:pri="element.pri"
|
||||
:maxpri="filterRuleCards.length.toString()"
|
||||
:rules="element.rules"
|
||||
:custom_rules="props.custom_rules"
|
||||
@changed="updateFilterCardValue"
|
||||
@close="filterCardClose(element.pri)"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VBtn color="success" @click="importRules('priority')">
|
||||
<VIcon icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn color="info" @click="shareRules">
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -170,9 +170,8 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<DialogWrapper max-width="40rem" scrollable>
|
||||
<VDialog max-width="40rem" scrollable>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
@@ -285,6 +284,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCol>
|
||||
</VCardText>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +13,10 @@ const { t } = useI18n()
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
workflow: Object as PropType<WorkflowShare>,
|
||||
eventTypes: {
|
||||
type: Array as PropType<Array<{ title: string; value: string }>>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
@@ -32,6 +36,12 @@ const processing = ref(false)
|
||||
// 删除中
|
||||
const deleting = ref(false)
|
||||
|
||||
// 根据事件类型值获取显示文本
|
||||
const getEventTypeText = (eventTypeValue: string) => {
|
||||
const eventType = props.eventTypes.find(item => item.value === eventTypeValue)
|
||||
return eventType ? eventType.title : eventTypeValue
|
||||
}
|
||||
|
||||
// 流程图相关
|
||||
const { nodes, edges } = useVueFlow()
|
||||
|
||||
@@ -146,7 +156,7 @@ async function doDelete() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<DialogWrapper max-width="40rem" scrollable>
|
||||
<VDialog max-width="40rem" scrollable>
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VCol>
|
||||
@@ -190,10 +200,23 @@ async function doDelete() {
|
||||
<span class="text-body-1"> {{ props.workflow?.share_user }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="props.workflow?.timer">
|
||||
<VListItem class="ps-0" v-if="props.workflow?.trigger_type || props.workflow?.timer">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('workflow.timer') }}:</span>
|
||||
<span class="text-body-1"> {{ props.workflow?.timer }}</span>
|
||||
<span class="font-weight-medium">{{ t('workflow.trigger') }}:</span>
|
||||
<span class="text-body-1">
|
||||
<span v-if="props.workflow?.trigger_type === 'timer' || !props.workflow?.trigger_type">
|
||||
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
|
||||
{{ props.workflow?.timer }}
|
||||
</span>
|
||||
<span v-else-if="props.workflow?.trigger_type === 'event'">
|
||||
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
|
||||
{{ getEventTypeText(props.workflow?.event_type || '') }}
|
||||
</span>
|
||||
<span v-else-if="props.workflow?.trigger_type === 'manual'">
|
||||
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
|
||||
{{ t('workflow.manualTrigger') }}
|
||||
</span>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="parsedWorkflow?.actions">
|
||||
@@ -243,7 +266,7 @@ async function doDelete() {
|
||||
</VCardText>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -24,7 +24,7 @@ function handleImport() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogWrapper width="40rem" scrollable max-height="85vh">
|
||||
<VDialog width="40rem" scrollable max-height="85vh">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
@@ -43,5 +43,5 @@ function handleImport() {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
82
src/components/dialog/LlmProviderAuthDialog.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import type { LlmProviderAuthSession } from '@/composables/useLlmProviderDirectory'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
authSession?: LlmProviderAuthSession | null
|
||||
modelValue?: boolean
|
||||
polling?: boolean
|
||||
popupBlocked?: boolean
|
||||
}>(),
|
||||
{
|
||||
authSession: null,
|
||||
modelValue: true,
|
||||
polling: false,
|
||||
popupBlocked: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'openAuthPage'): void
|
||||
(event: 'poll'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 关闭授权弹窗并通知调用方停止轮询。
|
||||
function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="560">
|
||||
<VCard>
|
||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||
<VCardText class="d-flex flex-column ga-4">
|
||||
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
|
||||
{{ props.authSession.instructions }}
|
||||
</VAlert>
|
||||
|
||||
<VAlert v-if="props.popupBlocked" type="warning" variant="tonal">
|
||||
{{ t('setting.system.llmProviderPopupBlocked') }}
|
||||
</VAlert>
|
||||
|
||||
<div v-if="props.authSession?.user_code">
|
||||
<div class="text-caption text-medium-emphasis mb-1">{{ t('setting.system.llmProviderDeviceCode') }}</div>
|
||||
<div class="text-h5 font-weight-bold">{{ props.authSession.user_code }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.authSession?.message" class="text-body-2">
|
||||
{{ props.authSession.message }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="emit('openAuthPage')">
|
||||
{{ t('setting.system.llmProviderOpenAuthPage') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" prepend-icon="mdi-refresh" :loading="props.polling" @click="emit('poll')">
|
||||
{{ t('setting.system.llmProviderCheckAuthStatus') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="closeDialog">
|
||||
{{ t('common.close') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
102
src/components/dialog/LoginMfaDialog.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
errorMessage?: string
|
||||
modelValue?: boolean
|
||||
otpPassword?: string
|
||||
passkeyLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
errorMessage: '',
|
||||
modelValue: true,
|
||||
otpPassword: '',
|
||||
passkeyLoading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'otp'): void
|
||||
(event: 'passkey'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:otpPassword', value: string): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const otpValue = computed({
|
||||
get: () => props.otpPassword,
|
||||
set: value => emit('update:otpPassword', value),
|
||||
})
|
||||
|
||||
// 提交 OTP 登录请求。
|
||||
function submitOtp() {
|
||||
emit('otp')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="400" persistent>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
|
||||
<VCardText class="pt-0">
|
||||
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
|
||||
|
||||
<VCard variant="tonal" class="mb-3">
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="submitOtp">
|
||||
<VTextField
|
||||
v-model="otpValue"
|
||||
:label="t('login.otpCode')"
|
||||
:placeholder="t('login.otpPlaceholder')"
|
||||
type="text"
|
||||
name="otp"
|
||||
id="otp"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
class="mb-2"
|
||||
/>
|
||||
<VBtn block type="submit" color="primary" :disabled="!otpValue">
|
||||
{{ t('login.loginWithOtp') }}
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="tonal">
|
||||
<VCardText>
|
||||
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
|
||||
<VBtn
|
||||
block
|
||||
variant="tonal"
|
||||
color="success"
|
||||
class="passkey-btn"
|
||||
prepend-icon="material-symbols:passkey"
|
||||
:loading="props.passkeyLoading"
|
||||
@click="emit('passkey')"
|
||||
>
|
||||
{{ t('login.verifyWithPasskey') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VAlert v-if="props.errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ props.errorMessage }}
|
||||
</VAlert>
|
||||
|
||||
<VBtn block variant="text" class="mt-4" @click="visible = false">{{ t('common.cancel') }}</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -15,12 +15,12 @@ defineProps({
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
<template>
|
||||
<DialogWrapper max-width="50rem">
|
||||
<VDialog max-width="50rem">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem>
|
||||
<MediaInfoCard :context="context" />
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
601
src/components/dialog/MediaServerInfoDialog.vue
Normal file
@@ -0,0 +1,601 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
mediaserver: {
|
||||
type: Object as PropType<MediaServerConf>,
|
||||
required: true,
|
||||
},
|
||||
mediaservers: {
|
||||
type: Array as PropType<MediaServerConf[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'done', 'change'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 媒体服务器详情弹窗
|
||||
const mediaServerInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 媒体服务器详情
|
||||
const mediaServerInfo = ref<MediaServerConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 同步媒体库选项
|
||||
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
|
||||
{
|
||||
title: t('common.all'),
|
||||
value: 'all',
|
||||
},
|
||||
])
|
||||
|
||||
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' },
|
||||
])
|
||||
|
||||
/** 初始化媒体服务器编辑表单数据。 */
|
||||
function initializeMediaServerInfo() {
|
||||
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
|
||||
}
|
||||
}
|
||||
if (!props.mediaserver.sync_libraries) {
|
||||
mediaServerInfo.value.sync_libraries = ['all']
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存媒体服务器编辑结果并通知父级刷新。 */
|
||||
function saveMediaServerInfo() {
|
||||
if (!mediaServerInfo.value.name) {
|
||||
$toast.error(t('common.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
|
||||
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
|
||||
return
|
||||
}
|
||||
mediaServerInfoDialog.value = false
|
||||
emit('change', mediaServerInfo.value, props.mediaserver.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 调用 API 查询指定媒体服务器的媒体库列表。 */
|
||||
async function loadLibrary(server: string) {
|
||||
try {
|
||||
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
|
||||
if (result && result.length > 0) {
|
||||
librariesOptions.value = result.map(item => ({
|
||||
title: item.name,
|
||||
value: item.id?.toString(),
|
||||
}))
|
||||
} else {
|
||||
librariesOptions.value = []
|
||||
}
|
||||
librariesOptions.value.unshift({
|
||||
title: t('common.all'),
|
||||
value: 'all',
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeMediaServerInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'emby'">
|
||||
<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" md="6">
|
||||
<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')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
:label="t('mediaserver.apiKey')"
|
||||
:hint="t('mediaserver.embyApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</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>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'zspace'">
|
||||
<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')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
persistent-hint
|
||||
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>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
|
||||
<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" md="6">
|
||||
<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.apikey"
|
||||
:label="t('mediaserver.apiKey')"
|
||||
:hint="t('mediaserver.jellyfinApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</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>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'trimemedia'">
|
||||
<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>
|
||||
</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
|
||||
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" md="6">
|
||||
<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.token"
|
||||
:label="t('mediaserver.plexToken')"
|
||||
:hint="t('mediaserver.plexTokenHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</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>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.type"
|
||||
: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
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
1131
src/components/dialog/NotificationChannelInfoDialog.vue
Normal file
157
src/components/dialog/NotificationTemplateEditorDialog.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content?: string
|
||||
editorTheme?: string
|
||||
modelValue?: boolean
|
||||
subtitle?: string
|
||||
templateType?: string
|
||||
}>(),
|
||||
{
|
||||
content: '{}',
|
||||
editorTheme: 'monokai',
|
||||
modelValue: true,
|
||||
subtitle: '',
|
||||
templateType: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'save', value: string): void
|
||||
(event: 'update:content', value: string): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const editableContent = ref(props.content)
|
||||
const editorOptions = {
|
||||
displayIndentGuides: true,
|
||||
fontSize: 14,
|
||||
highlightActiveLine: true,
|
||||
scrollPastEnd: 0.2,
|
||||
showPrintMargin: false,
|
||||
tabSize: 2,
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
value => {
|
||||
editableContent.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(editableContent, value => {
|
||||
emit('update:content', value)
|
||||
})
|
||||
|
||||
// 提交通知模板内容,由调用方负责保存到后端。
|
||||
function submitTemplate() {
|
||||
emit('save', editableContent.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="notification-template-editor-dialog">
|
||||
<VCardItem class="template-editor-header py-3">
|
||||
<template #prepend>
|
||||
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
|
||||
<VIcon icon="mdi-code-json" size="22" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('setting.notification.templateConfigTitle') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ props.subtitle }}
|
||||
</VCardSubtitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<div class="template-editor-body">
|
||||
<VAceEditor
|
||||
:key="`${props.templateType}-jinja2-json`"
|
||||
v-model:value="editableContent"
|
||||
lang="jinja2_json"
|
||||
:theme="props.editorTheme"
|
||||
:options="editorOptions"
|
||||
wrap
|
||||
class="template-ace-editor"
|
||||
/>
|
||||
</div>
|
||||
<VCardActions class="template-editor-actions">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-template-editor-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-editor-header {
|
||||
flex: 0 0 auto;
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.template-editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.template-ace-editor {
|
||||
overflow: hidden;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
block-size: min(62vh, 34rem);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.template-editor-actions {
|
||||
flex: 0 0 auto;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 0.875rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.notification-template-editor-dialog {
|
||||
block-size: 100dvh;
|
||||
max-block-size: 100dvh;
|
||||
}
|
||||
|
||||
.template-editor-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.template-ace-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
.template-editor-actions {
|
||||
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
235
src/components/dialog/OTPAuthDialog.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import QRCode from 'qrcode'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
passkeyList?: PassKey[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
passkeyList: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// otp uri
|
||||
const otpUri = ref('')
|
||||
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
const otpPassword = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
|
||||
// 二维码图片 base64
|
||||
const qrCodeImage = ref('')
|
||||
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
|
||||
// 为当前用户获取Otp Uri
|
||||
async function getOtpUri() {
|
||||
// 如果已经启用OTP,只打开对话框,不生成新的二维码
|
||||
if (props.isOtp) {
|
||||
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
|
||||
qrCodeImage.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 未启用OTP,生成新的二维码
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
|
||||
uri: string
|
||||
secret: string
|
||||
}>
|
||||
if (result.success) {
|
||||
otpUri.value = result.data.uri
|
||||
secret.value = result.data.secret
|
||||
qrCode.value = result.data.uri
|
||||
// 生成二维码图片
|
||||
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
})
|
||||
} else {
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
}
|
||||
|
||||
// 启用Otp
|
||||
async function judgeOtpPassword() {
|
||||
if (!otpPassword.value) {
|
||||
$toast.error(t('profile.otpCodeRequired'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/verify', {
|
||||
uri: otpUri.value,
|
||||
otpPassword: otpPassword.value,
|
||||
})) as ApiResponse
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.otpEnableSuccess'))
|
||||
show.value = false
|
||||
emit('update:isOtp', true)
|
||||
} else {
|
||||
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpEnableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
function disableOtp() {
|
||||
// 如果已绑定PassKey,不允许关闭OTP
|
||||
if (props.passkeyList && props.passkeyList.length > 0 && !allowPasskeyWithoutOtp.value) {
|
||||
$toast.error(t('profile.disableOtpWithPasskeyError'))
|
||||
return
|
||||
}
|
||||
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.disableTwoFactor'),
|
||||
text: t('profile.confirmToDisableOtp'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/disable', {
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
emit('update:isOtp', false)
|
||||
$toast.success(t('profile.otpDisableSuccess'))
|
||||
show.value = false
|
||||
} else {
|
||||
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpDisableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,自动获取 URI
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (val) {
|
||||
getOtpUri()
|
||||
otpPassword.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
qrCodeImage.value = ''
|
||||
qrCode.value = ''
|
||||
otpUri.value = ''
|
||||
secret.value = ''
|
||||
otpPassword.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-cellphone-key" class="me-2" />
|
||||
{{ props.isOtp && !qrCode ? t('profile.authenticatorManagement') : t('profile.setupAuthenticator') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="show = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.authenticatorAppDescription') }}
|
||||
</p>
|
||||
<!-- 如果已启用OTP,显示清除界面 -->
|
||||
<template v-if="props.isOtp && !qrCode">
|
||||
<VAlert type="success" variant="tonal" class="mb-4">
|
||||
{{ t('profile.authenticatorEnabled') }}
|
||||
</VAlert>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.clearAuthenticatorTip') }}
|
||||
</p>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="error" @click="disableOtp">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
{{ t('profile.clearAuthenticator') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 设置新的OTP -->
|
||||
<template v-else>
|
||||
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
|
||||
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
<VForm @submit.prevent="judgeOtpPassword">
|
||||
<VTextField
|
||||
v-model="otpPassword"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
:label="t('profile.enterVerificationCode')"
|
||||
class="mb-8"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn type="submit">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</template>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
175
src/components/dialog/OfflineStatusDialog.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
type?: 'offline' | 'online'
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
type: 'offline',
|
||||
},
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
// 重试连接
|
||||
const retrying = ref(false)
|
||||
|
||||
/** 尝试请求静态资源来触发网络状态重新检测。 */
|
||||
async function handleRetry() {
|
||||
if (retrying.value) return
|
||||
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
await fetch('/favicon.ico?' + new Date().getTime(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
retrying.value = false
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 状态文本
|
||||
const statusText = computed(() => {
|
||||
if (props.type === 'online') {
|
||||
return t('app.onlineMessage')
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
|
||||
// 图标
|
||||
const statusIcon = computed(() => {
|
||||
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
|
||||
})
|
||||
|
||||
// 颜色主题
|
||||
const colorTheme = computed(() => {
|
||||
return props.type === 'online' ? 'success' : 'error'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
|
||||
<VCard class="offline-dialog">
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VCardText class="text-center">
|
||||
<h2 class="offline-title mb-4">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h2>
|
||||
|
||||
<p class="offline-message mb-6">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<div class="action-section mb-6">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="default"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.offline-dialog {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.status-icon-wrapper {
|
||||
padding-block: 24px 0;
|
||||
padding-inline: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
animation: icon-pulse 3s ease-in-out infinite;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status-icon-bg::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: icon-glow 2s ease-in-out infinite alternate;
|
||||
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
|
||||
content: '';
|
||||
inset: -3px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-glow {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
322
src/components/dialog/PasskeyDialog.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<script lang="ts" setup>
|
||||
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
}
|
||||
|
||||
// WebAuthn 相关接口定义
|
||||
interface PublicKeyCredentialDescriptorJSON {
|
||||
id: string
|
||||
type: 'public-key'
|
||||
transports?: AuthenticatorTransport[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPassword'])
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// PassKey列表
|
||||
const passkeyList = ref<PassKey[]>([])
|
||||
|
||||
// PassKey注册loading
|
||||
const passkeyRegistering = ref(false)
|
||||
|
||||
// PassKey名称
|
||||
const passkeyName = ref('')
|
||||
|
||||
// PassKey challenge
|
||||
const passkeyChallenge = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
const canRegisterPasskey = computed(() => props.isOtp || allowPasskeyWithoutOtp.value)
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString(locale.value)
|
||||
}
|
||||
|
||||
// 获取PassKey列表
|
||||
async function fetchPassKeyList() {
|
||||
try {
|
||||
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
|
||||
if (result.success) {
|
||||
passkeyList.value = result.data || []
|
||||
emit('update:passkeyList', passkeyList.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册PassKey
|
||||
async function registerPassKey() {
|
||||
if (!passkeyName.value) {
|
||||
$toast.error(t('profile.passkeyNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查浏览器环境
|
||||
if (!window.PublicKeyCredential) {
|
||||
if (!window.isSecureContext) {
|
||||
$toast.error(t('login.passkeySecureContextRequired'))
|
||||
} else {
|
||||
$toast.error(t('login.passkeyNotSupported'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passkeyRegistering.value = true
|
||||
try {
|
||||
// 1. 开始注册
|
||||
const startResult = (await api.post('mfa/passkey/register/start', {
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse<{ options: string; challenge: string }>
|
||||
|
||||
if (!startResult.success) {
|
||||
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const { options, challenge } = startResult.data
|
||||
const publicKeyOptions = JSON.parse(options)
|
||||
passkeyChallenge.value = challenge
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
const credential = (await navigator.credentials.create({
|
||||
publicKey: {
|
||||
...publicKeyOptions,
|
||||
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
|
||||
user: {
|
||||
...publicKeyOptions.user,
|
||||
id: base64UrlToUint8Array(publicKeyOptions.user.id),
|
||||
},
|
||||
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: PublicKeyCredentialDescriptorJSON) => ({
|
||||
...cred,
|
||||
id: base64UrlToUint8Array(cred.id),
|
||||
})),
|
||||
},
|
||||
})) as PublicKeyCredential
|
||||
|
||||
if (!credential) {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 转换credential为可传输格式
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
const credentialJSON = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
transports: typeof response.getTransports === 'function' ? response.getTransports() : [],
|
||||
},
|
||||
}
|
||||
|
||||
// 4. 完成注册
|
||||
const finishResult = (await api.post('mfa/passkey/register/finish', {
|
||||
credential: credentialJSON,
|
||||
challenge: passkeyChallenge.value,
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse
|
||||
|
||||
if (finishResult.success) {
|
||||
$toast.success(t('profile.passkeyRegisterSuccess'))
|
||||
passkeyName.value = ''
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('PassKey注册失败:', error)
|
||||
if (error.name === 'NotAllowedError') {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
$toast.error(t('login.passkeyNotSupported'))
|
||||
} else if (error.message?.includes('start failed')) {
|
||||
$toast.error(t('login.passkeyLoginStartFailed'))
|
||||
} else if (error.response) {
|
||||
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
|
||||
} else {
|
||||
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} finally {
|
||||
passkeyRegistering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除PassKey
|
||||
async function deletePassKey(passkeyId: number) {
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.deletePasskey'),
|
||||
text: t('profile.confirmToDeletePasskey'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/passkey/delete', {
|
||||
passkey_id: passkeyId,
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.passkeyDeleteSuccess'))
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,自动加载列表
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (val) {
|
||||
fetchPassKeyList()
|
||||
passkeyName.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
passkeyName.value = ''
|
||||
passkeyChallenge.value = ''
|
||||
passkeyList.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="material-symbols:passkey" class="me-2" />
|
||||
{{ t('profile.passkeyManagement') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="show = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.passkeyAppDescription') }}
|
||||
</p>
|
||||
|
||||
<!-- 安全警告 -->
|
||||
<VAlert type="warning" variant="tonal" class="mb-6" icon="mdi-alert">
|
||||
<i18n-t keypath="profile.passkeyDomainWarning" tag="span">
|
||||
<template #domain>
|
||||
<b>{{ t('profile.accessDomain') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 注册新通行密钥 -->
|
||||
<VCard v-if="canRegisterPasskey" variant="tonal" class="mb-6">
|
||||
<VCardText>
|
||||
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
|
||||
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
|
||||
<VForm @submit.prevent="registerPassKey">
|
||||
<VTextField
|
||||
v-model="passkeyName"
|
||||
:label="t('profile.passkeyName')"
|
||||
:placeholder="t('profile.passkeyNamePlaceholder')"
|
||||
class="mb-4"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-form-textbox"
|
||||
/>
|
||||
<VBtn color="primary" type="submit" :loading="passkeyRegistering" prepend-icon="mdi-plus">
|
||||
{{ t('profile.registerPasskey') }}
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- 未启用 OTP 提示 -->
|
||||
<VAlert v-else type="error" variant="tonal" class="mb-6" icon="mdi-shield-lock">
|
||||
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
|
||||
<template #otp>
|
||||
<b>{{ t('profile.otpAuthenticator') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 已注册的通行密钥列表 -->
|
||||
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
|
||||
<div
|
||||
v-for="passkey in passkeyList"
|
||||
:key="passkey.id"
|
||||
class="py-4 d-flex align-center justify-space-between border-b last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-bold mb-1">{{ passkey.name }}</div>
|
||||
<div class="text-caption text-disabled d-flex flex-wrap gap-x-3">
|
||||
<span>{{ t('profile.createdAt') }} {{ formatDate(passkey.created_at) }}</span>
|
||||
<span v-if="passkey.last_used_at">
|
||||
{{ t('profile.lastUsedAt') }} {{ formatDateDifference(passkey.last_used_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<VBtn
|
||||
variant="flat"
|
||||
color="error"
|
||||
size="small"
|
||||
class="rounded delete-btn"
|
||||
@click="deletePassKey(passkey.id)"
|
||||
>
|
||||
<VIcon icon="mdi-trash-can-outline" size="20" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VAlert v-else type="info" variant="tonal" class="mt-6">
|
||||
{{ t('profile.noPasskeys') }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-end px-6 pb-4">
|
||||
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-btn.delete-btn {
|
||||
min-width: 45px;
|
||||
padding: 0;
|
||||
background-color: rgba(var(--v-theme-error), 0.1);
|
||||
color: rgb(var(--v-theme-error));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.v-btn.delete-btn:hover {
|
||||
background-color: rgba(var(--v-theme-error), 0.2);
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
</style>
|
||||