mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-09 22:22:58 +08:00
Compare commits
561 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b53cd0a09 | ||
|
|
3d7a0d9b0d | ||
|
|
114844ad48 | ||
|
|
f83f709080 | ||
|
|
999a6e7c6e | ||
|
|
2fd4c0b2ea | ||
|
|
16d62642f6 | ||
|
|
96a0ce8c5f | ||
|
|
7703d8157c | ||
|
|
87aa4e902c | ||
|
|
c86f32fab5 | ||
|
|
f85ac34753 | ||
|
|
f58d4fcb7e | ||
|
|
675c32cee3 | ||
|
|
de011b35db | ||
|
|
288a7ebc20 | ||
|
|
d7c3167ecd | ||
|
|
3205ae3ebe | ||
|
|
2ba609fb78 | ||
|
|
7e70b1b7ab | ||
|
|
561bdf4137 | ||
|
|
22a2bb65c8 | ||
|
|
c4f6db9f9f | ||
|
|
e5d2140ea3 | ||
|
|
83e57deec3 | ||
|
|
fc357a03e5 | ||
|
|
f031077fbd | ||
|
|
02de63210d | ||
|
|
98610e3e0d | ||
|
|
bb6cfd9d0e | ||
|
|
57c6d7e8f3 | ||
|
|
6d8b850b15 | ||
|
|
db6c3ea36c | ||
|
|
0ddf7ab070 | ||
|
|
93686bd354 | ||
|
|
89e4a68a03 | ||
|
|
204719caf8 | ||
|
|
267f53942b | ||
|
|
7ae9bbc4f0 | ||
|
|
717b460246 | ||
|
|
d52a814d77 | ||
|
|
6e1503334e | ||
|
|
34a33f87b2 | ||
|
|
03d885d391 | ||
|
|
c50b25997d | ||
|
|
3adcc894b7 | ||
|
|
8a73ad63ee | ||
|
|
69b5b2b900 | ||
|
|
97075fc167 | ||
|
|
bde70a2e26 | ||
|
|
2e05c8079b | ||
|
|
357191afcf | ||
|
|
8352ba335b | ||
|
|
7ae3e402cf | ||
|
|
c5e62cc8e4 | ||
|
|
ddb0befa4d | ||
|
|
82c19f3512 | ||
|
|
e95552b47a | ||
|
|
50ffcc6e92 | ||
|
|
43beb1df95 | ||
|
|
3cef928b75 | ||
|
|
6f5d62f1f9 | ||
|
|
e3a385c989 | ||
|
|
12356abf00 | ||
|
|
50e76496a2 | ||
|
|
a98bf08b2d | ||
|
|
697fd57bc7 | ||
|
|
7a691fe4e7 | ||
|
|
3822ab20d5 | ||
|
|
88f261584f | ||
|
|
62db4508da | ||
|
|
122acc7ad3 | ||
|
|
c15927cca0 | ||
|
|
b5b2de30a2 | ||
|
|
aebce53450 | ||
|
|
3c261a2c29 | ||
|
|
6a6a3bd463 | ||
|
|
ae62847ded | ||
|
|
c873787a89 | ||
|
|
410ff78ef5 | ||
|
|
ed53fbae93 | ||
|
|
a3d8aa6a33 | ||
|
|
24d03431c4 | ||
|
|
1d40d4a329 | ||
|
|
564896d99d | ||
|
|
2b2e25202d | ||
|
|
9055b95d00 | ||
|
|
5a8eb5b10e | ||
|
|
3e36cb6e31 | ||
|
|
6b4b44aec6 | ||
|
|
91a10c9d28 | ||
|
|
d7fbbd2d28 | ||
|
|
7b171e2c6f | ||
|
|
90ecaa1891 | ||
|
|
842f7401a0 | ||
|
|
77a6c591ff | ||
|
|
9bd3aebd73 | ||
|
|
b70d03e86b | ||
|
|
7d1ff9876f | ||
|
|
2cd8303191 | ||
|
|
21dbaf6db5 | ||
|
|
f9f45d9e32 | ||
|
|
ef5db9ee4b | ||
|
|
a909cdc21c | ||
|
|
b8e546a584 | ||
|
|
c4f54dcddc | ||
|
|
59b5e4a330 | ||
|
|
f8f7275438 | ||
|
|
6eec2e97f9 | ||
|
|
9020494f65 | ||
|
|
43fbc7abd7 | ||
|
|
d65a4b747d | ||
|
|
849fad8a8a | ||
|
|
f0b2d14502 | ||
|
|
fa169fb785 | ||
|
|
49acf7fba3 | ||
|
|
80d55dae8d | ||
|
|
76aa5407a2 | ||
|
|
d70789934f | ||
|
|
398e8b6afc | ||
|
|
593fede47c | ||
|
|
40c7e9c126 | ||
|
|
54e2f70ee0 | ||
|
|
81f85b9e46 | ||
|
|
60a5476e59 | ||
|
|
4271b63530 | ||
|
|
8aca17f0c6 | ||
|
|
4f238dc1a3 | ||
|
|
d4777fde70 | ||
|
|
b6c823c386 | ||
|
|
b7488214fc | ||
|
|
06b6c3f3cb | ||
|
|
abfaf926c4 | ||
|
|
6eabeb09c9 | ||
|
|
a15afabfa7 | ||
|
|
30276d5022 | ||
|
|
683ddc3fce | ||
|
|
f00f79279b | ||
|
|
7989965b1a | ||
|
|
5b84ce307b | ||
|
|
d13264b10e | ||
|
|
29a1c4ae35 | ||
|
|
9ac15e530a | ||
|
|
d4b446280a | ||
|
|
4593898549 | ||
|
|
c030d1a309 | ||
|
|
fd71e471b2 | ||
|
|
bc245e0a7a | ||
|
|
8236461c37 | ||
|
|
e1e8344764 | ||
|
|
14398e083e | ||
|
|
f36fe075ce | ||
|
|
25cf9d7fce | ||
|
|
9355788221 | ||
|
|
64042b51e9 | ||
|
|
7145af48ad | ||
|
|
ddb5468656 | ||
|
|
793cdd8f4c | ||
|
|
faafbb59c6 | ||
|
|
cd0ea07c2f | ||
|
|
f6e3807a3d | ||
|
|
fc36496aee | ||
|
|
1c8881d7a4 | ||
|
|
f6e8aacd0f | ||
|
|
79ddc39492 | ||
|
|
e63c5fb8e5 | ||
|
|
695f4827fd | ||
|
|
5a8b183c0f | ||
|
|
2845a889ed | ||
|
|
6333103050 | ||
|
|
cb6be91538 | ||
|
|
8cdd4b4af5 | ||
|
|
f4ec2029d9 | ||
|
|
b84b0f229f | ||
|
|
ef6a01a32f | ||
|
|
b451b8066a | ||
|
|
57efd516c5 | ||
|
|
d5979e6bf3 | ||
|
|
d75970cb2a | ||
|
|
ad4bb07cd7 | ||
|
|
9c558c3625 | ||
|
|
b467bb6c56 | ||
|
|
5cd021ea85 | ||
|
|
3d64382c9b | ||
|
|
6d5d4354d9 | ||
|
|
1b43446b5c | ||
|
|
7a9984f392 | ||
|
|
3c6fbfb106 | ||
|
|
bab46964ff | ||
|
|
661919f27a | ||
|
|
f3a03349b4 | ||
|
|
29791bf986 | ||
|
|
a06f0f29c6 | ||
|
|
b426d94180 | ||
|
|
5618d87e58 | ||
|
|
721d4f7685 | ||
|
|
7a025bcd38 | ||
|
|
24a8125621 | ||
|
|
468584a906 | ||
|
|
c056ec9377 | ||
|
|
87239994ae | ||
|
|
da09860a53 | ||
|
|
195ee5b2a6 | ||
|
|
32621ee299 | ||
|
|
40645180a0 | ||
|
|
59d4b1e544 | ||
|
|
8962a2c4ac | ||
|
|
6955f35ad1 | ||
|
|
1f722e7d7f | ||
|
|
5e587dfd88 | ||
|
|
2c687e5648 | ||
|
|
fdb0f63283 | ||
|
|
002e675b47 | ||
|
|
114f2a2dd0 | ||
|
|
c314d49e11 | ||
|
|
f5d0556808 | ||
|
|
27bc2a488f | ||
|
|
3a5999c341 | ||
|
|
a80a099ee7 | ||
|
|
68f458738a | ||
|
|
0f08f69738 | ||
|
|
a664066465 | ||
|
|
e6c11665a5 | ||
|
|
c119384c22 | ||
|
|
787cccb89f | ||
|
|
3df5d75c46 | ||
|
|
de6ad2479e | ||
|
|
632dfbaf10 | ||
|
|
68c14c24b8 | ||
|
|
d343d6d54d | ||
|
|
391a160f97 | ||
|
|
2d95110f75 | ||
|
|
e2ced8d36d | ||
|
|
a2b4511602 | ||
|
|
bdccc71b64 | ||
|
|
d7038a7d18 | ||
|
|
3998e1f685 | ||
|
|
5def9d5f81 | ||
|
|
c62937371e | ||
|
|
52843dcf97 | ||
|
|
ef5680d5ad | ||
|
|
bd3f24c84b | ||
|
|
399f85c52e | ||
|
|
14430e5c89 | ||
|
|
b703757d28 | ||
|
|
b642eabbb3 | ||
|
|
673596d8f9 | ||
|
|
b14e927e6c | ||
|
|
b03ae41ac7 | ||
|
|
92a0a9fe2f | ||
|
|
2511acfea1 | ||
|
|
361a4e0414 | ||
|
|
7e310236fe | ||
|
|
8705606c70 | ||
|
|
1f812a5258 | ||
|
|
e9264fa472 | ||
|
|
9164a1aefc | ||
|
|
30351a02ee | ||
|
|
7f918408a6 | ||
|
|
82f69bcad0 | ||
|
|
83b25eabbb | ||
|
|
47da6db51a | ||
|
|
eee092a7fd | ||
|
|
4c0f65fcbc | ||
|
|
acbd979569 | ||
|
|
52b68c18bf | ||
|
|
c6a74a75da | ||
|
|
e39eb62f52 | ||
|
|
4ecec4865d | ||
|
|
589007a22a | ||
|
|
4d4c9516c6 | ||
|
|
8491f26617 | ||
|
|
fcb3768a76 | ||
|
|
966bb769df | ||
|
|
dc8f7caab0 | ||
|
|
683346d652 | ||
|
|
f5fe39b2d2 | ||
|
|
51beb53f51 | ||
|
|
9d3f03c83a | ||
|
|
3eda1e4ef7 | ||
|
|
7181f83d66 | ||
|
|
fffad6e1b8 | ||
|
|
7f3906e5cb | ||
|
|
f836d175f0 | ||
|
|
f49cafc0cc | ||
|
|
a3ecad3436 | ||
|
|
a019dbd44e | ||
|
|
b316f960a1 | ||
|
|
d049b26825 | ||
|
|
852579c6ee | ||
|
|
5adcfa1877 | ||
|
|
f74458629e | ||
|
|
798f9249f8 | ||
|
|
6b4383643f | ||
|
|
256e8d0452 | ||
|
|
4112214c1f | ||
|
|
c183158ffe | ||
|
|
d523790c0f | ||
|
|
615ce34a72 | ||
|
|
1d59b3566c | ||
|
|
8071b90a2b | ||
|
|
8966584ca0 | ||
|
|
822711a530 | ||
|
|
1fe8aeb9e1 | ||
|
|
f021ba8a98 | ||
|
|
e4af05cd56 | ||
|
|
43d1cdb91c | ||
|
|
ed3f66681f | ||
|
|
c718d57e77 | ||
|
|
ce2e88a532 | ||
|
|
e60015a477 | ||
|
|
761e3ac76d | ||
|
|
2cf5535376 | ||
|
|
1a3d76d7b9 | ||
|
|
942a536289 | ||
|
|
fb1f6abf2e | ||
|
|
61ecb421e6 | ||
|
|
0098f9db2f | ||
|
|
2a348a7f18 | ||
|
|
838dff4758 | ||
|
|
7fb78a86ba | ||
|
|
07c815e943 | ||
|
|
9a4392eceb | ||
|
|
dc25e457eb | ||
|
|
d65ed9725c | ||
|
|
41ce095505 | ||
|
|
0e2290ce8a | ||
|
|
1b8db5b7f1 | ||
|
|
0cb42c1117 | ||
|
|
a289fe3da5 | ||
|
|
f53192cfa2 | ||
|
|
235e014542 | ||
|
|
211b05c643 | ||
|
|
3e1bd687f1 | ||
|
|
072fb01a04 | ||
|
|
81fbf4f5ba | ||
|
|
88c86f49bf | ||
|
|
3023214072 | ||
|
|
6ea6f89ab2 | ||
|
|
43c6672ab1 | ||
|
|
5cb56127d5 | ||
|
|
afa333243f | ||
|
|
047e99e27c | ||
|
|
eef6f37ace | ||
|
|
e8ede6e606 | ||
|
|
bfb4ea4123 | ||
|
|
51b0403f64 | ||
|
|
a5cd396de6 | ||
|
|
754bc3d3c9 | ||
|
|
07a2bcfb97 | ||
|
|
20222201ae | ||
|
|
a2a5ddd66c | ||
|
|
7bfc7602a7 | ||
|
|
b52b2cedad | ||
|
|
e93df6ba2c | ||
|
|
f9f29ccc3c | ||
|
|
3bd63ab7c8 | ||
|
|
301ea445bb | ||
|
|
475bee28c6 | ||
|
|
cd69920b41 | ||
|
|
83aab4e47d | ||
|
|
e12093c966 | ||
|
|
f21d546d18 | ||
|
|
26c8a6ba43 | ||
|
|
827bb8ba69 | ||
|
|
d1d2ef37d2 | ||
|
|
659594898b | ||
|
|
7569401fe0 | ||
|
|
dc9c86273d | ||
|
|
0e816e678a | ||
|
|
ff1c2a890c | ||
|
|
b802ad8a75 | ||
|
|
c11fb54b0b | ||
|
|
856dec3991 | ||
|
|
1d8c71da3f | ||
|
|
4152d0f715 | ||
|
|
0ead8cc052 | ||
|
|
2b5ecf3f8a | ||
|
|
de7aeeaeb3 | ||
|
|
994c52f6aa | ||
|
|
c6eb744257 | ||
|
|
4f462c5cfd | ||
|
|
60850970a8 | ||
|
|
3b2d5e45bb | ||
|
|
a604d3223a | ||
|
|
00bd1c45a1 | ||
|
|
4bc6dc7af7 | ||
|
|
3a8effd01f | ||
|
|
da67088e9c | ||
|
|
bacd4d23a3 | ||
|
|
020f667749 | ||
|
|
84652e8c82 | ||
|
|
565ebd936e | ||
|
|
3af127c66f | ||
|
|
849bb04249 | ||
|
|
09d647877f | ||
|
|
c868afbcbf | ||
|
|
3f033bfdec | ||
|
|
eca2f43e0e | ||
|
|
eeb17040f7 | ||
|
|
11dee1ed62 | ||
|
|
11a6232f83 | ||
|
|
9eded24e0e | ||
|
|
7548882148 | ||
|
|
4ad89955d4 | ||
|
|
a53553d658 | ||
|
|
2602cb0998 | ||
|
|
e402de29d5 | ||
|
|
a4cc1cc615 | ||
|
|
2d900baad1 | ||
|
|
17d6f6db05 | ||
|
|
7f3ba543b7 | ||
|
|
b33cb8a12c | ||
|
|
cfa8b78c2e | ||
|
|
4024daf189 | ||
|
|
77d7c3bb61 | ||
|
|
868ad57e12 | ||
|
|
eaf9724295 | ||
|
|
30e98de38a | ||
|
|
79c606370c | ||
|
|
b70597b5f5 | ||
|
|
a469282730 | ||
|
|
c3708360fa | ||
|
|
80f0560e0f | ||
|
|
84951cdc44 | ||
|
|
a72cb797ab | ||
|
|
6898e6b816 | ||
|
|
adb0b966ff | ||
|
|
81284b8d21 | ||
|
|
1a2b112e64 | ||
|
|
442c484dc9 | ||
|
|
2368c2f25f | ||
|
|
2320c58254 | ||
|
|
c9a4f36414 | ||
|
|
1df9a981b2 | ||
|
|
cbc917b834 | ||
|
|
240a568d16 | ||
|
|
eb1a847faa | ||
|
|
e09e57879b | ||
|
|
ddd2982971 | ||
|
|
621da7e4ef | ||
|
|
420827c389 | ||
|
|
ce9399b894 | ||
|
|
1bdd08c59a | ||
|
|
52d62dda81 | ||
|
|
d69e3cedae | ||
|
|
648bfcdd0d | ||
|
|
e4b8ff0a64 | ||
|
|
4576ef854d | ||
|
|
7323668db5 | ||
|
|
b11d709070 | ||
|
|
e25ac006c2 | ||
|
|
9e85e7edce | ||
|
|
804bcd440c | ||
|
|
6e4c896cb7 | ||
|
|
0cae89f8e3 | ||
|
|
59a7607c07 | ||
|
|
6dca0c157f | ||
|
|
d2aa5a64aa | ||
|
|
2620a55c5a | ||
|
|
14e33215f8 | ||
|
|
6862c2a744 | ||
|
|
fb215e8d87 | ||
|
|
f52ad2151b | ||
|
|
1a47b7d09d | ||
|
|
f292071a34 | ||
|
|
dd616d29e8 | ||
|
|
0509f18d66 | ||
|
|
f59fb119e4 | ||
|
|
46127cac1f | ||
|
|
c1abf76211 | ||
|
|
fe5b45d48d | ||
|
|
10ac1ebf7b | ||
|
|
e5d8144510 | ||
|
|
f9a65fba7a | ||
|
|
9b4138349b | ||
|
|
db9c9db5a9 | ||
|
|
24e992339f | ||
|
|
f26d1babf7 | ||
|
|
de3347cea1 | ||
|
|
e900fac4bd | ||
|
|
396218a467 | ||
|
|
d3a66ffa8c | ||
|
|
1e7ffb4c2e | ||
|
|
3df5d4c690 | ||
|
|
02a8331996 | ||
|
|
a29ad6a091 | ||
|
|
3ef1e65412 | ||
|
|
2deaec1fc6 | ||
|
|
c9b0b23d36 | ||
|
|
f06cca4ead | ||
|
|
a1990ce3e4 | ||
|
|
cbbf023030 | ||
|
|
307aa724eb | ||
|
|
cd6f37d80f | ||
|
|
b903134770 | ||
|
|
11effdd297 | ||
|
|
8873d8372d | ||
|
|
964aa29d12 | ||
|
|
b45a3c6539 | ||
|
|
b72b7ad0fb | ||
|
|
0e3106d8c1 | ||
|
|
71a6626fa9 | ||
|
|
68006bac88 | ||
|
|
34cbcc38a6 | ||
|
|
f4daee85c7 | ||
|
|
dd347039b5 | ||
|
|
0c9367d58a | ||
|
|
af10c4f1c3 | ||
|
|
52fbeda941 | ||
|
|
ace23af363 | ||
|
|
a097d89d68 | ||
|
|
77cb817523 | ||
|
|
c956e271a2 | ||
|
|
6413f30d18 | ||
|
|
789e748df0 | ||
|
|
c89edae375 | ||
|
|
f4dca4922b | ||
|
|
73b9ef5ee7 | ||
|
|
462742961a | ||
|
|
5a647fabfa | ||
|
|
2580ceac20 | ||
|
|
6905391785 | ||
|
|
7406226e68 | ||
|
|
af9ee00ad3 | ||
|
|
01f63a4b6b | ||
|
|
45e48755d3 | ||
|
|
f6c740738f | ||
|
|
29780cd4b7 | ||
|
|
a050b7c7d5 | ||
|
|
1f25387f81 | ||
|
|
36fb7b53ba | ||
|
|
354295ffda | ||
|
|
4f28018f4f | ||
|
|
5d37666bea | ||
|
|
705e81db7f | ||
|
|
57d5859727 | ||
|
|
06387ab33e | ||
|
|
5f0c3b3639 | ||
|
|
414fb8afd1 | ||
|
|
58fbaaa8f4 | ||
|
|
040790a672 | ||
|
|
bf36e39f3b | ||
|
|
a780946915 | ||
|
|
1d537c2799 | ||
|
|
6a3e383f30 | ||
|
|
cb72c6b586 | ||
|
|
384e1a63b3 | ||
|
|
e6357d0a54 | ||
|
|
a0ebb42e1e | ||
|
|
324fec8f94 | ||
|
|
226efc3d85 | ||
|
|
e785997d99 | ||
|
|
7998b51e6b | ||
|
|
e54384fcd7 | ||
|
|
39946cad1b | ||
|
|
6041ae9344 | ||
|
|
dc9fda8d86 | ||
|
|
7dd3877955 | ||
|
|
5386fc54ff | ||
|
|
c3839f092f |
45
.github/ISSUE_TEMPLATE/rfc.yml
vendored
Normal file
45
.github/ISSUE_TEMPLATE/rfc.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: 功能提案
|
||||
description: Request for Comments
|
||||
title: '[RFC]'
|
||||
labels: ['RFC']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
一份提案(RFC)定位为 **「在某功能/重构的具体开发前,用于开发者间 review 技术设计/方案的文档」**,
|
||||
目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论;
|
||||
以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突),
|
||||
因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。
|
||||
|
||||
如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+)
|
||||
- type: textarea
|
||||
id: background
|
||||
attributes:
|
||||
label: 背景 or 问题
|
||||
description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: goal
|
||||
attributes:
|
||||
label: '目标 & 方案简述'
|
||||
description: 简单描述提案此提案实现后,**预期的目标效果**,以及简单大致描述会采取的方案/步骤,可能会/不会产生什么影响。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: design
|
||||
attributes:
|
||||
label: '方案设计 & 实现步骤'
|
||||
description: |
|
||||
详细描述你设计的具体方案,可以考虑拆分列表或要点,一步步描述具体打算如何实现的步骤和相关细节。
|
||||
这部份不需要一次性写完整,即使在创建完此提案 issue 后,依旧可以再次编辑修改。
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: alternative
|
||||
attributes:
|
||||
label: '替代方案 & 对比'
|
||||
description: |
|
||||
[可选] 为来实现目标效果,还考虑过什么其他方案,有什么对比?
|
||||
validations:
|
||||
required: false
|
||||
369
auto-imports.d.ts
vendored
369
auto-imports.d.ts
vendored
@@ -3,9 +3,11 @@
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const computed: typeof import('vue')['computed']
|
||||
@@ -20,13 +22,11 @@ declare global {
|
||||
const createGenericProjection: typeof import('@vueuse/math')['createGenericProjection']
|
||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createLogger: typeof import('vuex')['createLogger']
|
||||
const createNamespacedHelpers: typeof import('vuex')['createNamespacedHelpers']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const createProjection: typeof import('@vueuse/math')['createProjection']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createStore: typeof import('vuex')['createStore']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
@@ -34,9 +34,11 @@ declare global {
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
@@ -52,10 +54,11 @@ declare global {
|
||||
const logicNot: typeof import('@vueuse/math')['logicNot']
|
||||
const logicOr: typeof import('@vueuse/math')['logicOr']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const mapActions: typeof import('vuex')['mapActions']
|
||||
const mapGetters: typeof import('vuex')['mapGetters']
|
||||
const mapMutations: typeof import('vuex')['mapMutations']
|
||||
const mapState: typeof import('vuex')['mapState']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
@@ -66,6 +69,7 @@ declare global {
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
|
||||
const onLongPress: typeof import('@vueuse/core')['onLongPress']
|
||||
@@ -77,6 +81,7 @@ declare global {
|
||||
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
@@ -96,9 +101,12 @@ declare global {
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
||||
@@ -190,6 +198,7 @@ declare global {
|
||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
||||
const useImage: typeof import('@vueuse/core')['useImage']
|
||||
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
|
||||
@@ -209,6 +218,7 @@ declare global {
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useMin: typeof import('@vueuse/math')['useMin']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
const useMouse: typeof import('@vueuse/core')['useMouse']
|
||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
||||
@@ -234,6 +244,7 @@ declare global {
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
|
||||
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
||||
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
|
||||
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
||||
const useProjection: typeof import('@vueuse/math')['useProjection']
|
||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||
@@ -242,6 +253,7 @@ declare global {
|
||||
const useRound: typeof import('@vueuse/math')['useRound']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
|
||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
||||
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
|
||||
@@ -256,11 +268,11 @@ declare global {
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStore: typeof import('vuex')['useStore']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSum: typeof import('@vueuse/math')['useSum']
|
||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
|
||||
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
|
||||
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
|
||||
@@ -313,15 +325,17 @@ declare global {
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
|
||||
// for vue template auto import
|
||||
import { UnwrapRef } from 'vue'
|
||||
declare module 'vue' {
|
||||
interface GlobalComponents {}
|
||||
interface ComponentCustomProperties {
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
@@ -336,13 +350,11 @@ declare module 'vue' {
|
||||
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
|
||||
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
|
||||
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
|
||||
readonly createLogger: UnwrapRef<typeof import('vuex')['createLogger']>
|
||||
readonly createNamespacedHelpers: UnwrapRef<typeof import('vuex')['createNamespacedHelpers']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
|
||||
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
|
||||
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
|
||||
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
|
||||
readonly createStore: UnwrapRef<typeof import('vuex')['createStore']>
|
||||
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
|
||||
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
|
||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||
@@ -350,9 +362,11 @@ declare module 'vue' {
|
||||
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
|
||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
@@ -368,10 +382,11 @@ declare module 'vue' {
|
||||
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
|
||||
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
readonly mapActions: UnwrapRef<typeof import('vuex')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('vuex')['mapGetters']>
|
||||
readonly mapMutations: UnwrapRef<typeof import('vuex')['mapMutations']>
|
||||
readonly mapState: UnwrapRef<typeof import('vuex')['mapState']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
|
||||
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
|
||||
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
|
||||
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||
@@ -382,6 +397,7 @@ declare module 'vue' {
|
||||
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
|
||||
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||
readonly onElementRemoval: UnwrapRef<typeof import('@vueuse/core')['onElementRemoval']>
|
||||
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
|
||||
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
|
||||
@@ -393,6 +409,7 @@ declare module 'vue' {
|
||||
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
|
||||
@@ -412,9 +429,12 @@ declare module 'vue' {
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
|
||||
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
|
||||
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
||||
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
||||
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
|
||||
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
|
||||
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
|
||||
@@ -506,6 +526,7 @@ declare module 'vue' {
|
||||
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
|
||||
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
|
||||
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
|
||||
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
||||
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
|
||||
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
|
||||
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
|
||||
@@ -525,6 +546,7 @@ declare module 'vue' {
|
||||
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
|
||||
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
|
||||
readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']>
|
||||
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
|
||||
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
|
||||
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
||||
@@ -550,6 +572,7 @@ declare module 'vue' {
|
||||
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
|
||||
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
|
||||
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
|
||||
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
|
||||
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
|
||||
readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>
|
||||
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
|
||||
@@ -558,6 +581,7 @@ declare module 'vue' {
|
||||
readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']>
|
||||
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
|
||||
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
|
||||
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
|
||||
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
|
||||
@@ -572,11 +596,11 @@ declare module 'vue' {
|
||||
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
|
||||
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
|
||||
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
|
||||
readonly useStore: UnwrapRef<typeof import('vuex')['useStore']>
|
||||
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
|
||||
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
|
||||
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
|
||||
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
|
||||
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
|
||||
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
|
||||
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
|
||||
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
|
||||
@@ -626,313 +650,4 @@ declare module 'vue' {
|
||||
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
|
||||
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
|
||||
}
|
||||
}
|
||||
declare module '@vue/runtime-core' {
|
||||
interface GlobalComponents {}
|
||||
interface ComponentCustomProperties {
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
|
||||
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
|
||||
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
|
||||
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
|
||||
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
|
||||
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
|
||||
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
|
||||
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
|
||||
readonly createLogger: UnwrapRef<typeof import('vuex')['createLogger']>
|
||||
readonly createNamespacedHelpers: UnwrapRef<typeof import('vuex')['createNamespacedHelpers']>
|
||||
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
|
||||
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
|
||||
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
|
||||
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
|
||||
readonly createStore: UnwrapRef<typeof import('vuex')['createStore']>
|
||||
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
|
||||
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
|
||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||
readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
|
||||
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
|
||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
|
||||
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
|
||||
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
|
||||
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
||||
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly logicAnd: UnwrapRef<typeof import('@vueuse/math')['logicAnd']>
|
||||
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
|
||||
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
readonly mapActions: UnwrapRef<typeof import('vuex')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('vuex')['mapGetters']>
|
||||
readonly mapMutations: UnwrapRef<typeof import('vuex')['mapMutations']>
|
||||
readonly mapState: UnwrapRef<typeof import('vuex')['mapState']>
|
||||
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
|
||||
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
|
||||
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
|
||||
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
|
||||
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
|
||||
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
|
||||
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
|
||||
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
|
||||
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
|
||||
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
|
||||
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
|
||||
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
|
||||
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
|
||||
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
|
||||
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
|
||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||
readonly reactiveComputed: UnwrapRef<typeof import('@vueuse/core')['reactiveComputed']>
|
||||
readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']>
|
||||
readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']>
|
||||
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
||||
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
||||
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
|
||||
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
|
||||
readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>
|
||||
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
|
||||
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
|
||||
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
|
||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
|
||||
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
|
||||
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
|
||||
readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']>
|
||||
readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']>
|
||||
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
||||
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
|
||||
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
|
||||
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
|
||||
readonly tryOnMounted: UnwrapRef<typeof import('@vueuse/core')['tryOnMounted']>
|
||||
readonly tryOnScopeDispose: UnwrapRef<typeof import('@vueuse/core')['tryOnScopeDispose']>
|
||||
readonly tryOnUnmounted: UnwrapRef<typeof import('@vueuse/core')['tryOnUnmounted']>
|
||||
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
|
||||
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
|
||||
readonly useAbs: UnwrapRef<typeof import('@vueuse/math')['useAbs']>
|
||||
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
|
||||
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
|
||||
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
|
||||
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
|
||||
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
|
||||
readonly useArrayFind: UnwrapRef<typeof import('@vueuse/core')['useArrayFind']>
|
||||
readonly useArrayFindIndex: UnwrapRef<typeof import('@vueuse/core')['useArrayFindIndex']>
|
||||
readonly useArrayFindLast: UnwrapRef<typeof import('@vueuse/core')['useArrayFindLast']>
|
||||
readonly useArrayIncludes: UnwrapRef<typeof import('@vueuse/core')['useArrayIncludes']>
|
||||
readonly useArrayJoin: UnwrapRef<typeof import('@vueuse/core')['useArrayJoin']>
|
||||
readonly useArrayMap: UnwrapRef<typeof import('@vueuse/core')['useArrayMap']>
|
||||
readonly useArrayReduce: UnwrapRef<typeof import('@vueuse/core')['useArrayReduce']>
|
||||
readonly useArraySome: UnwrapRef<typeof import('@vueuse/core')['useArraySome']>
|
||||
readonly useArrayUnique: UnwrapRef<typeof import('@vueuse/core')['useArrayUnique']>
|
||||
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
|
||||
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
|
||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||
readonly useAverage: UnwrapRef<typeof import('@vueuse/math')['useAverage']>
|
||||
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
|
||||
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
|
||||
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
|
||||
readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']>
|
||||
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
|
||||
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
|
||||
readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>
|
||||
readonly useCeil: UnwrapRef<typeof import('@vueuse/math')['useCeil']>
|
||||
readonly useClamp: UnwrapRef<typeof import('@vueuse/math')['useClamp']>
|
||||
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
|
||||
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
|
||||
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
|
||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
||||
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
|
||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
|
||||
readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>
|
||||
readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>
|
||||
readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>
|
||||
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
|
||||
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
|
||||
readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>
|
||||
readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>
|
||||
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
|
||||
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
|
||||
readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>
|
||||
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
|
||||
readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>
|
||||
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
|
||||
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>
|
||||
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
|
||||
readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>
|
||||
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
|
||||
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
|
||||
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
|
||||
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
|
||||
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
|
||||
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
|
||||
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
|
||||
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
|
||||
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
|
||||
readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']>
|
||||
readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']>
|
||||
readonly useFloor: UnwrapRef<typeof import('@vueuse/math')['useFloor']>
|
||||
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
|
||||
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
|
||||
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
|
||||
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
|
||||
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
|
||||
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
|
||||
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
|
||||
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
|
||||
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
|
||||
readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']>
|
||||
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
|
||||
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
|
||||
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
|
||||
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
||||
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
|
||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
||||
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
|
||||
readonly useMath: UnwrapRef<typeof import('@vueuse/math')['useMath']>
|
||||
readonly useMax: UnwrapRef<typeof import('@vueuse/math')['useMax']>
|
||||
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
|
||||
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
|
||||
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
|
||||
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
|
||||
readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']>
|
||||
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
|
||||
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
|
||||
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
||||
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
|
||||
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
|
||||
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
|
||||
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
|
||||
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
|
||||
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
|
||||
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
|
||||
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
|
||||
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
|
||||
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
|
||||
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
|
||||
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
|
||||
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
|
||||
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
|
||||
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
|
||||
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
|
||||
readonly usePrecision: UnwrapRef<typeof import('@vueuse/math')['usePrecision']>
|
||||
readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>
|
||||
readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>
|
||||
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
|
||||
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
|
||||
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
|
||||
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
|
||||
readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>
|
||||
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
|
||||
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
|
||||
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
|
||||
readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']>
|
||||
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
|
||||
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
|
||||
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
|
||||
readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>
|
||||
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
|
||||
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
|
||||
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
|
||||
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
|
||||
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
|
||||
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
|
||||
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
|
||||
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
|
||||
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
|
||||
readonly useStore: UnwrapRef<typeof import('vuex')['useStore']>
|
||||
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
|
||||
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
|
||||
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
|
||||
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
|
||||
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
|
||||
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
|
||||
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
|
||||
readonly useTextareaAutosize: UnwrapRef<typeof import('@vueuse/core')['useTextareaAutosize']>
|
||||
readonly useThrottle: UnwrapRef<typeof import('@vueuse/core')['useThrottle']>
|
||||
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
|
||||
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
|
||||
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
|
||||
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
|
||||
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
|
||||
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
|
||||
readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']>
|
||||
readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']>
|
||||
readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']>
|
||||
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
|
||||
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
|
||||
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
|
||||
readonly useTrunc: UnwrapRef<typeof import('@vueuse/math')['useTrunc']>
|
||||
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
|
||||
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
|
||||
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
|
||||
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
|
||||
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
|
||||
readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']>
|
||||
readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']>
|
||||
readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']>
|
||||
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>
|
||||
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
|
||||
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
|
||||
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
|
||||
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
|
||||
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
|
||||
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
|
||||
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
|
||||
readonly watchDebounced: UnwrapRef<typeof import('@vueuse/core')['watchDebounced']>
|
||||
readonly watchDeep: UnwrapRef<typeof import('@vueuse/core')['watchDeep']>
|
||||
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
||||
readonly watchIgnorable: UnwrapRef<typeof import('@vueuse/core')['watchIgnorable']>
|
||||
readonly watchImmediate: UnwrapRef<typeof import('@vueuse/core')['watchImmediate']>
|
||||
readonly watchOnce: UnwrapRef<typeof import('@vueuse/core')['watchOnce']>
|
||||
readonly watchPausable: UnwrapRef<typeof import('@vueuse/core')['watchPausable']>
|
||||
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
||||
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
|
||||
readonly watchThrottled: UnwrapRef<typeof import('@vueuse/core')['watchThrottled']>
|
||||
readonly watchTriggerable: UnwrapRef<typeof import('@vueuse/core')['watchTriggerable']>
|
||||
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
|
||||
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
|
||||
}
|
||||
}
|
||||
}
|
||||
3
components.d.ts
vendored
3
components.d.ts
vendored
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
|
||||
@@ -14,6 +14,7 @@ declare module 'vue' {
|
||||
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ScrollToTopBtn: typeof import('./src/@core/components/ScrollToTopBtn.vue')['default']
|
||||
StatIcon: typeof import('./src/@core/components/StatIcon.vue')['default']
|
||||
ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default']
|
||||
}
|
||||
|
||||
362
index.html
362
index.html
@@ -1,161 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html
|
||||
lang="en"
|
||||
style="
|
||||
overflow: hidden auto;
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
"
|
||||
>
|
||||
<head>
|
||||
<meta http-equiv="pragma" content="no-cache" />
|
||||
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="expires" content="0" />
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
|
||||
<title>MoviePilot</title>
|
||||
<meta name="Robots" content="noindex,nofollow,noarchive" />
|
||||
<meta name="referrer" content="origin" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
|
||||
<meta name="description" content="MoviePilot" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="referrer" content="never" />
|
||||
<meta name="msapplication-TileColor" content="#7D34FD" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
|
||||
<meta name="HandheldFriendly" content="True" />
|
||||
<meta name="MobileOptimized" content="320" />
|
||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||
<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)
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="pragma" content="no-cache">
|
||||
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="expires" content="0">
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
|
||||
<title>MoviePilot</title>
|
||||
<meta name="Robots" content="noindex,nofollow,noarchive" />
|
||||
<meta name="referrer" content="origin" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
|
||||
<meta name="description" content="MoviePilot" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="referrer" content="never" />
|
||||
<meta name="msapplication-TileColor" content="#7D34FD" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#28243D" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
|
||||
<meta name="HandheldFriendly" content="True" />
|
||||
<meta name="MobileOptimized" content="320" />
|
||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<svg width="10rem" height="10rem" 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">
|
||||
<body style="margin: 0">
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<svg
|
||||
width="10rem"
|
||||
height="10rem"
|
||||
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="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)">
|
||||
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="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)" />
|
||||
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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script>
|
||||
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
|
||||
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
|
||||
|
||||
if (loaderColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
|
||||
if (primaryColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
</script>
|
||||
</body>
|
||||
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
14609
package-lock.json
generated
Normal file
14609
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
122
package.json
122
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.0.3",
|
||||
"version": "2.3.9",
|
||||
"private": true,
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
@@ -19,86 +19,96 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.8",
|
||||
"@fullcalendar/daygrid": "^6.1.8",
|
||||
"@fullcalendar/interaction": "^6.1.7",
|
||||
"@fullcalendar/list": "^6.1.7",
|
||||
"@fullcalendar/timegrid": "^6.1.7",
|
||||
"@fullcalendar/vue3": "^6.1.8",
|
||||
"@iconify/utils": "^2.1.22",
|
||||
"@vueuse/core": "^10.1.2",
|
||||
"@vueuse/math": "^10.1.2",
|
||||
"ace-builds": "^1.32.6",
|
||||
"apexcharts-clevision": "^3.28.5",
|
||||
"axios": "1.6.8",
|
||||
"colorthief": "^2.4.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"express": "^4.18.2",
|
||||
"express-http-proxy": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/list": "^6.1.15",
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@fullcalendar/vue3": "^6.1.15",
|
||||
"@iconify/utils": "^2.2.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.2",
|
||||
"@vue-flow/core": "^1.42.1",
|
||||
"@vue-flow/minimap": "^1.5.2",
|
||||
"@vue-flow/node-resizer": "^1.4.0",
|
||||
"@vue-flow/node-toolbar": "^1.1.0",
|
||||
"@vue-js-cron/vuetify": "^5.0.9",
|
||||
"@vueuse/core": "^12.4.0",
|
||||
"@vueuse/math": "^12.4.0",
|
||||
"ace-builds": "^1.37.4",
|
||||
"apexcharts": "^4.0.0",
|
||||
"axios": "^1.7.9",
|
||||
"colorthief": "^2.6.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-http-proxy": "^2.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mousetrap": "^1.6.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
"sass": "^1.59.3",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"unplugin-vue-define-options": "^1.3.5",
|
||||
"vue": "^3.3.2",
|
||||
"vue-router": "^4.2.0",
|
||||
"vue-toast-notification": "^3",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"sass": "^1.83.4",
|
||||
"tailwindcss": "^ 3.4.17",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-toast-notification": "^3.1.3",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
"vue3-apexcharts": "^1.4.1",
|
||||
"vue3-apexcharts": "^1.8.0",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "3.6.8",
|
||||
"vuetify": "3.7.3",
|
||||
"vuetify-use-dialog": "^0.6.11",
|
||||
"vuex": "^4.1.0",
|
||||
"vuex-persistedstate": "^4.1.0",
|
||||
"webfontloader": "^1.6.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config-vue": "^0.43.1",
|
||||
"@iconify-json/mdi": "^1.1.52",
|
||||
"@iconify/tools": "^4.0.4",
|
||||
"@iconify/vue": "4.1.1",
|
||||
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^20.1.4",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^3.0.0",
|
||||
"@vitejs/plugin-vue-jsx": "^4.1.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-promise": "^6.0.1",
|
||||
"eslint-plugin-promise": "^7.2.1",
|
||||
"eslint-plugin-regex": "^1.10.0",
|
||||
"eslint-plugin-sonarjs": "^0.25.1",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"eslint-plugin-sonarjs": "^3.0.1",
|
||||
"eslint-plugin-unicorn": "^56.0.1",
|
||||
"eslint-plugin-vue": "^9.12.0",
|
||||
"postcss": "8",
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-html": "^1.5.0",
|
||||
"stylelint": "16.3.1",
|
||||
"stylelint-config-idiomatic-order": "10.0.0",
|
||||
"stylelint-config-standard-scss": "13.1.0",
|
||||
"stylelint": "^16.13.2",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
"stylelint-use-logical-spec": "5.0.1",
|
||||
"terser": "^5.36.0",
|
||||
"type-fest": "^4.15.0",
|
||||
"typescript": "^5.0.4",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.2.8",
|
||||
"unplugin-auto-import": "^19.0.0",
|
||||
"unplugin-vue-components": "^28.0.0",
|
||||
"unplugin-vue-define-options": "^1.5.3",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-pages": "^0.32.1",
|
||||
"vite-plugin-pwa": "^0.20.0",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vite-plugin-vue-layouts": "^0.11.0",
|
||||
"vite-plugin-vuetify": "2.0.3",
|
||||
"vue-shepherd": "^3.0.0",
|
||||
"vue-tsc": "^2.0.10"
|
||||
"vite-plugin-vuetify": "2.0.4",
|
||||
"vue-shepherd": "^4.1.0",
|
||||
"vue-tsc": "^2.0.10",
|
||||
"workbox-build": "^7.3.0",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.18",
|
||||
"resolutions": {
|
||||
"postcss": "8"
|
||||
}
|
||||
"packageManager": "yarn@1.22.18"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden auto;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
min-block-size: calc(100% + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
display: block;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
block-size: 100vh;
|
||||
|
||||
85
src/@core/components/ScrollToTopBtn.vue
Normal file
85
src/@core/components/ScrollToTopBtn.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
// 控制回到顶部按钮的可见性
|
||||
const showScrollToTop = ref(false)
|
||||
const scrollThreshold = 200 // 滚动多少像素后显示按钮
|
||||
|
||||
// 滚动事件处理函数
|
||||
const handleScroll = () => {
|
||||
showScrollToTop.value = window.scrollY > scrollThreshold
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Add scroll event listener
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
// Initial check for scroll-to-top
|
||||
handleScroll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Remove scroll event listener
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="global-action-buttons d-none d-sm-block">
|
||||
<Transition name="scroll-fade">
|
||||
<button v-show="showScrollToTop" class="global-action-button" @click="scrollToTop">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7 14L12 9L17 14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Global Action Button Styles (FAB) */
|
||||
.global-action-buttons {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
inset-block-end: 30px;
|
||||
inset-inline-end: 30px;
|
||||
}
|
||||
|
||||
.global-action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(10px);
|
||||
background-color: rgba(var(--v-theme-background), 0.8);
|
||||
block-size: 44px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 8%);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
cursor: pointer;
|
||||
inline-size: 44px;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--v-theme-background), 0.95);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
svg {
|
||||
block-size: 20px;
|
||||
inline-size: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@ import type { ThemeSwitcherTheme } from '@layouts/types'
|
||||
import api from '@/api'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { saveLocalTheme } from '../utils/theme'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -18,10 +18,12 @@ const { name: themeName, global: globalTheme } = useTheme()
|
||||
|
||||
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
|
||||
|
||||
const { state: currentThemeName, next: getNextThemeName } = useCycleList(
|
||||
props.themes.map(t => t.name),
|
||||
{ initialValue: savedTheme.value },
|
||||
)
|
||||
const currentThemeName = ref(savedTheme.value)
|
||||
const getNextThemeName = () => {
|
||||
const currentIndex = props.themes.findIndex(t => t.name === currentThemeName.value)
|
||||
const nextIndex = (currentIndex + 1) % props.themes.length
|
||||
return props.themes[nextIndex].name
|
||||
}
|
||||
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -103,8 +105,7 @@ function updateTheme() {
|
||||
savedTheme.value = theme
|
||||
themeTransition()
|
||||
// 保存主题到本地
|
||||
localStorage.setItem('theme', theme)
|
||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||
saveLocalTheme(theme, globalTheme)
|
||||
}
|
||||
|
||||
// 切换主题
|
||||
@@ -115,7 +116,7 @@ function changeTheme(theme: string) {
|
||||
// 保存主题到服务端
|
||||
try {
|
||||
api.post('/user/config/Layout', {
|
||||
theme: nextTheme
|
||||
theme: nextTheme,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('保存主题到服务端失败')
|
||||
@@ -176,7 +177,7 @@ async function saveCustomCSS() {
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) $toast.success('自定义CSS保存成功!')
|
||||
if (result.success) $toast.success('自定义CSS保存成功,请刷新页面生效!')
|
||||
} catch (e) {
|
||||
console.error('保存自定义 CSS 到服务端失败')
|
||||
}
|
||||
@@ -188,31 +189,59 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu v-if="props.themes">
|
||||
<VMenu v-if="props.themes" class="theme-menu" scrim>
|
||||
<template v-slot:activator="{ props }">
|
||||
<IconBtn v-bind="props">
|
||||
<VIcon :icon="getThemeIcon" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem v-for="theme in props.themes" :key="theme.name" @click="changeTheme(theme.name)">
|
||||
<template #prepend>
|
||||
<VIcon :icon="theme.icon" />
|
||||
</template>
|
||||
<VListItemTitle>{{ theme.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="cssDialog = true">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-palette" />
|
||||
</template>
|
||||
<VListItemTitle>自定义</VListItemTitle>
|
||||
</VListItem>
|
||||
<VList class="theme-switcher-list pt-0">
|
||||
<VCardItem class="theme-switcher-header">
|
||||
<VCardTitle class="font-weight-medium text-primary">主题选择</VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<div class="theme-switcher-options px-2">
|
||||
<VListItem
|
||||
v-for="theme in props.themes"
|
||||
:key="theme.name"
|
||||
@click="changeTheme(theme.name)"
|
||||
class="theme-option"
|
||||
:class="{ 'theme-option-active': currentThemeName === theme.name }"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="theme-icon-wrapper">
|
||||
<VIcon :icon="theme.icon" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle>{{ theme.title }}</VListItemTitle>
|
||||
<template #append v-if="currentThemeName === theme.name">
|
||||
<VIcon icon="mdi-check" color="primary" size="small" />
|
||||
</template>
|
||||
</VListItem>
|
||||
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<VListItem @click="cssDialog = true" class="theme-option custom-theme-option">
|
||||
<template #prepend>
|
||||
<div class="theme-icon-wrapper custom-theme-icon">
|
||||
<VIcon icon="mdi-palette" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle>自定义主题</VListItemTitle>
|
||||
</VListItem>
|
||||
</div>
|
||||
</VList>
|
||||
</VMenu>
|
||||
<!-- 自定义 CSS -- -->
|
||||
<VDialog v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard title="自定义主题风格">
|
||||
<DialogCloseBtn @click="cssDialog = false" />
|
||||
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-palette" class="me-2" />
|
||||
自定义主题风格
|
||||
</VCardTitle>
|
||||
<DialogCloseBtn @click="cssDialog = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VAceEditor
|
||||
v-model:value="customCSS"
|
||||
@@ -233,18 +262,73 @@ onMounted(() => {
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="sass">
|
||||
// Theme transition
|
||||
.app-copy
|
||||
position: fixed !important
|
||||
z-index: -1 !important
|
||||
pointer-events: none !important
|
||||
contain: size style !important
|
||||
overflow: clip !important
|
||||
<style lang="scss">
|
||||
.theme-switcher-header {
|
||||
background: linear-gradient(to right, rgba(var(--v-theme-primary), 0.04), rgba(var(--v-theme-primary), 0.01));
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.app-transition
|
||||
--clip-size: 0
|
||||
--clip-pos: 0 0
|
||||
clip-path: circle(var(--clip-size) at var(--clip-pos))
|
||||
transition: clip-path .35s ease-out
|
||||
.theme-switcher-options {
|
||||
max-block-size: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
border-radius: 8px;
|
||||
margin-block: 4px;
|
||||
margin-inline: 0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
&.theme-option-active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
margin-inline-end: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.v-icon {
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-theme-icon {
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.15), rgba(var(--v-theme-info), 0.15));
|
||||
|
||||
.v-icon {
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme transition
|
||||
.app-copy {
|
||||
position: fixed !important;
|
||||
z-index: -1 !important;
|
||||
overflow: clip !important;
|
||||
contain: size style !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.app-transition {
|
||||
--clip-size: 0;
|
||||
--clip-pos: 0 0;
|
||||
|
||||
clip-path: circle(var(--clip-size) at var(--clip-pos));
|
||||
transition: clip-path 0.35s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
@extend %nav;
|
||||
|
||||
|
||||
@at-root {
|
||||
// ℹ️ Add styles for collapsed vertical nav
|
||||
.layout-vertical-nav-collapsed#{$sl-layout-nav-type-vertical}.hovered {
|
||||
@@ -60,9 +61,9 @@
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(
|
||||
rgb(#{variables.$vertical-nav-background-color-rgb}) 5%,
|
||||
rgba(#{variables.$vertical-nav-background-color-rgb}, 75%) 45%,
|
||||
rgba(#{variables.$vertical-nav-background-color-rgb}, 20%) 80%,
|
||||
rgb(var(--v-theme-surface)) 5%,
|
||||
rgba(var(--v-theme-surface), 75%) 45%,
|
||||
rgba(var(--v-theme-surface), 20%) 80%,
|
||||
transparent
|
||||
);
|
||||
block-size: 55px;
|
||||
@@ -85,8 +86,8 @@
|
||||
}
|
||||
|
||||
.ps__rail-y {
|
||||
// ℹ️ Setting z-index: 1 will make perfect scrollbar thumb appear on top of vertical nav items shadow
|
||||
z-index: 1;
|
||||
// ℹ️ Setting z-index: 1 will make perfect scrollbar thumb appear on top of vertical nav items shadow;Settingz-indexSettingz-indexSettingz-indexSettingz-index
|
||||
z-index: 1z-indexz-indexz-index
|
||||
}
|
||||
|
||||
// 👉 Nav section title
|
||||
|
||||
@@ -212,10 +212,6 @@ h6,
|
||||
}
|
||||
}
|
||||
|
||||
.v-data-table-footer {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
// 👉 v-field
|
||||
.v-field:hover .v-field__outline {
|
||||
--v-field-border-opacity: var(--v-medium-emphasis-opacity);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.auth-wrapper {
|
||||
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top));
|
||||
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.auth-footer-mask {
|
||||
|
||||
@@ -176,10 +176,6 @@
|
||||
th {
|
||||
background: rgb(var(--v-table-header-background)) !important;
|
||||
}
|
||||
|
||||
.v-data-table-footer {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Pagination
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
@use "sass:map";
|
||||
@use "vuetify/lib/styles/settings" as vuetify_settings;
|
||||
|
||||
@mixin avatar-font-sizes($map: $avatar-sizes) {
|
||||
@each $sizeName, $multiplier in vuetify_settings.$size-scales {
|
||||
/* stylelint-disable-next-line scss/no-global-function-names */
|
||||
$size: map-get($map, $sizeName);
|
||||
$size: map.get($map, $sizeName);
|
||||
|
||||
&.v-avatar--size-#{$sizeName} {
|
||||
font-size: #{$size}px;
|
||||
|
||||
@@ -92,8 +92,7 @@
|
||||
.fc-header-toolbar {
|
||||
flex-wrap: wrap;
|
||||
margin: 1.25rem;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 1rem;
|
||||
gap: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.fc-toolbar-chunk {
|
||||
@@ -238,7 +237,7 @@
|
||||
inline-size: 1.5625rem;
|
||||
margin-inline-end: 0.25rem;
|
||||
|
||||
@media (max-width: 1264px) {
|
||||
@media (width <= 1264px) {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ export function useDefer(maxFrameCount = 1) {
|
||||
const refreshFrameCount = () => {
|
||||
requestAnimationFrame(() => {
|
||||
frameCount.value++
|
||||
if (frameCount.value < maxFrameCount)
|
||||
refreshFrameCount()
|
||||
if (frameCount.value < maxFrameCount) refreshFrameCount()
|
||||
})
|
||||
}
|
||||
refreshFrameCount()
|
||||
@@ -19,3 +18,9 @@ export function useDefer(maxFrameCount = 1) {
|
||||
return frameCount.value >= showInFrameCount
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureRenderComplete(callback: () => void) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(callback)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -153,3 +153,12 @@ export function formatDateDifference(dateString: string): string {
|
||||
if (!dateString) return ''
|
||||
return dayjs(dateString).fromNow()
|
||||
}
|
||||
|
||||
// 格式化评份,如为10及以下的数按原值显示,否则格式化为xxM、xxK显示
|
||||
export function formatRating(rating: number): string {
|
||||
if (!rating) return ''
|
||||
if (rating <= 10) return rating.toString()
|
||||
if (rating < 1000) return rating.toLocaleString()
|
||||
if (rating < 1000 * 1000) return `${(rating / 1000).toFixed(1)}K`
|
||||
return `${(rating / 1000 / 1000).toFixed(1)}M`
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import copy from 'copy-to-clipboard'
|
||||
|
||||
// 请求和获取剪贴板内容
|
||||
export async function getClipboardContent() {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
@@ -13,20 +15,10 @@ export async function getClipboardContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// 将内容复制到剪贴板,兼容非安全域场景
|
||||
// 将内容复制到剪贴板
|
||||
export async function copyToClipboard(content: string) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(content)
|
||||
} else {
|
||||
const input = document.createElement('textarea')
|
||||
input.value = content
|
||||
document.body.appendChild(input)
|
||||
// 阻止事件冒泡到其他元素,确保 focusin 事件只在 textarea 元素上处理,不会影响其他元素
|
||||
input.addEventListener('focusin', e => e.stopPropagation())
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
const success = copy(content)
|
||||
return success
|
||||
}
|
||||
|
||||
// VAPID公钥转Uint8Array
|
||||
|
||||
6
src/@core/utils/theme.ts
Normal file
6
src/@core/utils/theme.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function saveLocalTheme(name: string, theme: any) {
|
||||
// 存储主题到本地
|
||||
localStorage.setItem('theme', name)
|
||||
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
|
||||
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
|
||||
}
|
||||
122
src/@core/utils/workflow.ts
Normal file
122
src/@core/utils/workflow.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useVueFlow } from '@vue-flow/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
/**
|
||||
* @returns {string} - A unique id.
|
||||
*/
|
||||
function getId() {
|
||||
// 生成以act_开头的唯一id
|
||||
return 'act_' + Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
/**
|
||||
* In a real world scenario you'd want to avoid creating refs in a global scope like this as they might not be cleaned up properly.
|
||||
* @type {{draggedData: Ref<any>, isDragOver: Ref<boolean>, isDragging: Ref<boolean>}}
|
||||
*/
|
||||
const state = {
|
||||
/**
|
||||
* The type of the node being dragged.
|
||||
*/
|
||||
draggedData: ref<any | null>({}),
|
||||
isDragOver: ref(false),
|
||||
isDragging: ref(false),
|
||||
}
|
||||
|
||||
export default function useDragAndDrop() {
|
||||
const { draggedData, isDragOver, isDragging } = state
|
||||
|
||||
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
|
||||
|
||||
watch(isDragging, dragging => {
|
||||
document.body.style.userSelect = dragging ? 'none' : ''
|
||||
})
|
||||
|
||||
function onDragStart(event: any, data: any) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/vueflow', data)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
draggedData.value = data
|
||||
isDragging.value = true
|
||||
|
||||
document.addEventListener('drop', onDragEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the drag over event.
|
||||
*
|
||||
* @param {DragEvent} event
|
||||
*/
|
||||
function onDragOver(event: any) {
|
||||
event.preventDefault()
|
||||
|
||||
if (draggedData.value) {
|
||||
isDragOver.value = true
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging.value = false
|
||||
isDragOver.value = false
|
||||
draggedData.value = null
|
||||
document.removeEventListener('drop', onDragEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the drop event.
|
||||
*
|
||||
* @param {DragEvent} event
|
||||
*/
|
||||
function onDrop(event: any) {
|
||||
const position = screenToFlowCoordinate({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
|
||||
const nodeId = getId()
|
||||
|
||||
const newNode = {
|
||||
id: nodeId,
|
||||
type: draggedData.value?.type,
|
||||
name: draggedData.value?.name,
|
||||
description: draggedData.value?.description,
|
||||
position,
|
||||
data: draggedData.value?.data ? cloneDeep(draggedData.value.data) : {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Align node position after drop, so it's centered to the mouse
|
||||
*
|
||||
* We can hook into events even in a callback, and we can remove the event listener after it's been called.
|
||||
*/
|
||||
const { off } = onNodesInitialized(() => {
|
||||
updateNode(nodeId, node => ({
|
||||
position: { x: node.position.x - node.dimensions.width / 2, y: node.position.y - node.dimensions.height / 2 },
|
||||
}))
|
||||
|
||||
off()
|
||||
})
|
||||
|
||||
addNodes(newNode)
|
||||
}
|
||||
|
||||
return {
|
||||
draggedData,
|
||||
isDragOver,
|
||||
isDragging,
|
||||
onDragStart,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}
|
||||
}
|
||||
1
src/@iconify/tsconfig.tsbuildinfo
Normal file
1
src/@iconify/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./build-icons.ts"],"version":"5.7.3"}
|
||||
@@ -1,92 +1,209 @@
|
||||
<!-- Thanks: https://markus.oberlehner.net/blog/transition-to-height-auto-with-vue/ -->
|
||||
|
||||
<script lang="ts">
|
||||
import { Transition } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TransitionExpand',
|
||||
setup(_, { slots }) {
|
||||
const onEnter = (element: HTMLElement) => {
|
||||
const width = getComputedStyle(element).width
|
||||
setup(props, { slots }) {
|
||||
const isOverlayNavActive = ref(false)
|
||||
const isLayoutOverlayVisible = ref(false)
|
||||
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)
|
||||
|
||||
element.style.width = width
|
||||
element.style.position = 'absolute'
|
||||
element.style.visibility = 'hidden'
|
||||
element.style.height = 'auto'
|
||||
const route = useRoute()
|
||||
const { mdAndDown } = useDisplay()
|
||||
|
||||
const height = getComputedStyle(element).height
|
||||
// ℹ️ This is alternative to below two commented watcher
|
||||
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
|
||||
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
|
||||
|
||||
element.style.width = ''
|
||||
element.style.position = ''
|
||||
element.style.visibility = ''
|
||||
element.style.height = '0px'
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
|
||||
// Force repaint to make sure the
|
||||
// animation is triggered correctly.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
getComputedStyle(element).height
|
||||
|
||||
// Trigger the animation.
|
||||
// We use `requestAnimationFrame` because we need
|
||||
// to make sure the browser has finished
|
||||
// painting after setting the `height`
|
||||
// to `0` in the line above.
|
||||
requestAnimationFrame(() => {
|
||||
element.style.height = height
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const onAfterEnter = (element: HTMLElement) => {
|
||||
element.style.height = 'auto'
|
||||
}
|
||||
return () => {
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
VerticalNav,
|
||||
{ isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive },
|
||||
{
|
||||
'nav-header': () => slots['vertical-nav-header']?.(),
|
||||
'before-nav-items': () => slots['before-vertical-nav-items']?.(),
|
||||
'default': () => slots['vertical-nav-content']?.(),
|
||||
'after-nav-items': () => slots['after-vertical-nav-items']?.(),
|
||||
},
|
||||
)
|
||||
|
||||
const onLeave = (element: HTMLElement) => {
|
||||
const height = getComputedStyle(element).height
|
||||
// 👉 Navbar
|
||||
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
|
||||
h(
|
||||
'div',
|
||||
{ class: 'navbar-content-container' },
|
||||
slots.navbar?.({
|
||||
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
element.style.height = height
|
||||
const main = h(
|
||||
'main',
|
||||
{ class: 'layout-page-content' },
|
||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
|
||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
),
|
||||
)
|
||||
|
||||
// Force repaint to make sure the
|
||||
// animation is triggered correctly.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
getComputedStyle(element).height
|
||||
// 👉 根据路由 meta 决定 footer 高度
|
||||
const shouldShowFooter = !route.meta.hideFooter
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
element.style.height = '0px'
|
||||
// 👉 Footer
|
||||
const footer = h('footer', { class: 'layout-footer' }, [
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: ['footer-content-container', !shouldShowFooter && 'footer-content-container-noheight'],
|
||||
},
|
||||
slots.footer?.(),
|
||||
),
|
||||
])
|
||||
|
||||
// 👉 Overlay
|
||||
const layoutOverlay = h('div', {
|
||||
class: ['layout-overlay', 'touch-none', { visible: isLayoutOverlayVisible.value }],
|
||||
onClick: () => {
|
||||
isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return () => h(
|
||||
h(Transition),
|
||||
{
|
||||
name: 'expand',
|
||||
onEnter,
|
||||
onAfterEnter,
|
||||
onLeave,
|
||||
},
|
||||
() => slots.default?.(),
|
||||
)
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: [
|
||||
'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
|
||||
'layout-navbar-fixed',
|
||||
mdAndDown.value && 'layout-overlay-nav',
|
||||
route.meta.layoutWrapperClasses,
|
||||
scrollDistance.value && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
overflow: hidden;
|
||||
transition: block-size var(--expand-transition-duration, 0.25s) ease;
|
||||
<style lang="scss">
|
||||
@use '@configured-variables' as variables;
|
||||
@use '@layouts/styles/placeholders';
|
||||
@use '@layouts/styles/mixins';
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
block-size: 100%;
|
||||
|
||||
.layout-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
min-block-size: calc(var(--vh, 1vh) * 100);
|
||||
transition: padding-inline-start 0.2s ease-in-out;
|
||||
will-change: padding-inline-start;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
width: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
|
||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||
inset-block-start: 0;
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
.layout-navbar {
|
||||
@if variables.$layout-vertical-nav-navbar-is-contained {
|
||||
@include mixins.boxed-content;
|
||||
} @else {
|
||||
.navbar-content-container {
|
||||
@include mixins.boxed-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-navbar-fixed .layout-navbar {
|
||||
@extend %layout-navbar-fixed;
|
||||
}
|
||||
|
||||
&.layout-navbar-hidden .layout-navbar {
|
||||
@extend %layout-navbar-hidden;
|
||||
}
|
||||
|
||||
// 👉 Footer
|
||||
.layout-footer {
|
||||
@include mixins.boxed-content;
|
||||
}
|
||||
|
||||
// 👉 Layout overlay
|
||||
.layout-overlay {
|
||||
position: fixed;
|
||||
z-index: variables.$layout-overlay-z-index;
|
||||
background-color: rgb(0 0 0 / 60%);
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease-in-out;
|
||||
will-change: transform;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.layout-overlay-nav) .layout-content-wrapper {
|
||||
padding-inline-start: variables.$layout-vertical-nav-width;
|
||||
}
|
||||
|
||||
// Adjust right column pl when vertical nav is collapsed
|
||||
&.layout-vertical-nav-collapsed .layout-content-wrapper {
|
||||
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
|
||||
}
|
||||
|
||||
// 👉 Content height fixed
|
||||
&.layout-content-height-fixed {
|
||||
.layout-content-wrapper {
|
||||
max-block-size: calc(var(--vh) * 100);
|
||||
}
|
||||
|
||||
.layout-page-content {
|
||||
// display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.page-content-container {
|
||||
inline-size: 100%;
|
||||
|
||||
> :first-child {
|
||||
max-block-size: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
block-size: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
backface-visibility: hidden;
|
||||
perspective: 62.5rem;
|
||||
transform: translateZ(0);
|
||||
will-change: block-size;
|
||||
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
|
||||
.layout-navbar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,9 +59,6 @@ function handleNavScroll(evt: Event) {
|
||||
</RouterLink>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="before-nav-items">
|
||||
<div class="vertical-nav-items-shadow" />
|
||||
</slot>
|
||||
<slot name="nav-items" :update-is-vertical-nav-scrolled="updateIsVerticalNavScrolled">
|
||||
<PerfectScrollbar
|
||||
tag="ul"
|
||||
|
||||
@@ -51,14 +51,23 @@ export default defineComponent({
|
||||
const main = h(
|
||||
'main',
|
||||
{ class: 'layout-page-content' },
|
||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true },
|
||||
() => h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
|
||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
),
|
||||
)
|
||||
|
||||
// 👉 根据路由 meta 决定 footer 高度
|
||||
const shouldShowFooter = !route.meta.hideFooter
|
||||
|
||||
// 👉 Footer
|
||||
const footer = h('footer', { class: 'layout-footer' }, [
|
||||
h('div', { class: 'footer-content-container' }, slots.footer?.()),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: ['footer-content-container', !shouldShowFooter && 'footer-content-container-noheight'],
|
||||
},
|
||||
slots.footer?.(),
|
||||
),
|
||||
])
|
||||
|
||||
// 👉 Overlay
|
||||
@@ -80,11 +89,7 @@ export default defineComponent({
|
||||
scrollDistance.value && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[
|
||||
verticalNav,
|
||||
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
|
||||
layoutOverlay,
|
||||
],
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -92,9 +97,16 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@configured-variables" as variables;
|
||||
@use "@layouts/styles/placeholders";
|
||||
@use "@layouts/styles/mixins";
|
||||
@use '@configured-variables' as variables;
|
||||
@use '@layouts/styles/placeholders';
|
||||
@use '@layouts/styles/mixins';
|
||||
|
||||
.layout-page-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-block-start: 0;
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
@@ -111,14 +123,12 @@ export default defineComponent({
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
|
||||
inset-block-start: 0;
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(
|
||||
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height
|
||||
);
|
||||
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
@@ -168,7 +178,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
&:not(.layout-overlay-nav) .layout-content-wrapper {
|
||||
padding-inline-start: variables.$layout-vertical-nav-width;
|
||||
padding-inline-start: calc(variables.$layout-vertical-nav-width + 0.5rem);
|
||||
}
|
||||
|
||||
// Adjust right column pl when vertical nav is collapsed
|
||||
@@ -200,7 +210,9 @@ export default defineComponent({
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
|
||||
.layout-navbar {
|
||||
width: 100%;
|
||||
inline-size: calc(100% - 0.5rem);
|
||||
margin-inline-start: 0.5rem;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,19 +7,9 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="nav-link"
|
||||
:class="{ disabled: item.disable }"
|
||||
>
|
||||
<Component
|
||||
:is="item.to ? 'RouterLink' : 'a'"
|
||||
:to="item.to"
|
||||
:href="item.href"
|
||||
>
|
||||
<VIcon
|
||||
:icon="item.icon"
|
||||
class="nav-item-icon"
|
||||
/>
|
||||
<li class="nav-link" :class="{ disabled: item.disable }">
|
||||
<Component :is="item.to ? 'RouterLink' : 'a'" :to="item.to" :href="item.href">
|
||||
<VIcon :icon="item.icon as string" class="nav-item-icon" />
|
||||
<!-- 👉 Title -->
|
||||
<span class="nav-item-title">
|
||||
{{ item.title }}
|
||||
|
||||
@@ -10,10 +10,7 @@ defineProps<{
|
||||
<li class="nav-section-title">
|
||||
<div class="title-wrapper">
|
||||
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
|
||||
<span
|
||||
class="title-text"
|
||||
v-text="item.heading"
|
||||
/>
|
||||
<span class="title-text" v-text="item.heading" />
|
||||
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -5,58 +5,60 @@
|
||||
@use "@configured-variables" as variables;
|
||||
|
||||
html {
|
||||
min-height: calc(100% + env(safe-area-inset-top));
|
||||
background: rgb(var(--v-theme-background));
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
overflow-y: overlay;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior-y: contain;
|
||||
--webkit-overflow-scrolling: touch;
|
||||
background: rgb(var(--v-theme-background));
|
||||
overscroll-behavior-y: contain;
|
||||
|
||||
--webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body,
|
||||
#app,
|
||||
.v-application {
|
||||
min-height: 100%;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
.layout-vertical-nav {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.navbar-content-container {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-block-start: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.layout-page-content {
|
||||
@include mixins.boxed-content(true);
|
||||
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
// TODO: Use grid gutter variable here
|
||||
flex-grow: 1;
|
||||
|
||||
// TODO: Use grid gutter variable here;
|
||||
padding-block: 1.5rem;
|
||||
padding-top: calc(env(safe-area-inset-top) + 4.25rem);
|
||||
// display: flex;
|
||||
padding-block-start: calc(env(safe-area-inset-top) + 4.25rem);
|
||||
|
||||
// display: flex;display
|
||||
|
||||
|
||||
.page-content-container {
|
||||
// flex: 1;
|
||||
// flex: 1;flex
|
||||
display: flex;
|
||||
|
||||
& > div:first-child {
|
||||
flex: auto;
|
||||
position: relative;
|
||||
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||
flex: auto;
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1280px){
|
||||
@media screen and (width <= 1280px){
|
||||
.page-content-container > div:first-child {
|
||||
width: calc(100vw - 1rem) !important;
|
||||
inline-size: calc(100vw - 1rem) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +67,10 @@ body,
|
||||
block-size: variables.$layout-vertical-nav-footer-height;
|
||||
}
|
||||
|
||||
.footer-content-container-noheight {
|
||||
block-size: 0 !important;
|
||||
}
|
||||
|
||||
.layout-footer-sticky & {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
min-height: calc(100% + env(safe-area-inset-top))
|
||||
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom))
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ $layout-horizontal-nav-layout-navbar-z-index: 11 !default;
|
||||
$layout-boxed-content-width: 90rem !default;
|
||||
|
||||
// 👉Footer
|
||||
$layout-vertical-nav-footer-height: 0rem !default;
|
||||
$layout-vertical-nav-footer-height: 3.5rem !default;
|
||||
|
||||
// 👉 Layout overlay
|
||||
$layout-overlay-z-index: 11 !default;
|
||||
|
||||
33
src/App.vue
33
src/App.vue
@@ -1,15 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { useTheme } from 'vuetify'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
|
||||
const { global: globalTheme } = useTheme()
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
|
||||
// 生效主题
|
||||
async function setTheme() {
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
}
|
||||
const { global: globalTheme } = useTheme()
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
// 显示状态
|
||||
const show = ref(false)
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
@@ -41,14 +42,24 @@ if (window.Apex) {
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时,加载当前用户数据
|
||||
onBeforeMount(async () => {
|
||||
setTheme()
|
||||
onMounted(() => {
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
// 移除加载动画
|
||||
removeEl('#loading-bg')
|
||||
// 将background属性从html的style中移除
|
||||
document.documentElement.style.removeProperty('background')
|
||||
// 显示页面
|
||||
show.value = true
|
||||
}, 1500)
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VApp>
|
||||
<VApp v-show="show">
|
||||
<RouterView />
|
||||
</VApp>
|
||||
</template>
|
||||
|
||||
@@ -2,18 +2,32 @@ export const storageOptions = [
|
||||
{
|
||||
title: '本地',
|
||||
value: 'local',
|
||||
icon: 'mdi-folder-multiple-outline',
|
||||
remote: false,
|
||||
},
|
||||
{
|
||||
title: '阿里云盘',
|
||||
value: 'alipan',
|
||||
icon: 'mdi-cloud-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
title: '115网盘',
|
||||
value: 'u115',
|
||||
icon: 'mdi-cloud-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
title: 'Rclone网盘',
|
||||
title: 'RClone',
|
||||
value: 'rclone',
|
||||
icon: 'mdi-cloud-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
title: 'AList',
|
||||
value: 'alist',
|
||||
icon: 'mdi-cloud-outline',
|
||||
remote: true,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -62,3 +76,10 @@ export const storageDict = storageOptions.reduce((dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
export const transferTypeOptions = [
|
||||
{ title: '复制', value: 'copy' },
|
||||
{ title: '移动', value: 'move' },
|
||||
{ title: '硬链接', value: 'link' },
|
||||
{ title: '软链接', value: 'softlink' },
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import router from '@/router'
|
||||
import store from '@/store'
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
@@ -9,10 +9,12 @@ const api = axios.create({
|
||||
|
||||
// 添加请求拦截器
|
||||
api.interceptors.request.use(config => {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 在请求头中添加token
|
||||
const token = store.state.auth.token
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -26,8 +28,10 @@ api.interceptors.response.use(
|
||||
// 请求超时
|
||||
return Promise.reject(new Error(error))
|
||||
} else if (error.response.status === 403) {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 清除登录状态信息
|
||||
store.dispatch('auth/logout')
|
||||
authStore.logout()
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
176
src/api/types.ts
176
src/api/types.ts
@@ -14,6 +14,10 @@ export interface Subscribe {
|
||||
tmdbid: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: string
|
||||
// 其它媒体ID
|
||||
mediaid?: string
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
@@ -44,7 +48,7 @@ export interface Subscribe {
|
||||
lack_episode?: number
|
||||
// 附加信息
|
||||
note?: string
|
||||
// 状态:N-新建, R-订阅中
|
||||
// 状态:N-新建 R-订阅中 P-待定 S-暂停
|
||||
state: string
|
||||
// 最后更新时间
|
||||
last_update: string
|
||||
@@ -72,6 +76,10 @@ export interface Subscribe {
|
||||
media_category?: string
|
||||
// 过滤规则组
|
||||
filter_groups?: string[]
|
||||
// 下载器
|
||||
downloader?: string
|
||||
// 自定义剧集组
|
||||
episode_group?: string
|
||||
}
|
||||
|
||||
// 订阅分享
|
||||
@@ -86,6 +94,8 @@ export interface SubscribeShare {
|
||||
share_comment?: string
|
||||
// 分享人
|
||||
share_user?: string
|
||||
// 分享人唯一ID
|
||||
share_uid?: string
|
||||
// 订阅名称
|
||||
name?: string
|
||||
// 订阅年份
|
||||
@@ -130,6 +140,8 @@ export interface SubscribeShare {
|
||||
media_category?: string
|
||||
// 复用次数
|
||||
count?: number
|
||||
// 自定义剧集组
|
||||
episode_group?: string
|
||||
}
|
||||
|
||||
// 历史记录
|
||||
@@ -176,13 +188,15 @@ export interface TransferHistory {
|
||||
errmsg?: string
|
||||
// 日期
|
||||
date?: string
|
||||
// 源文件项
|
||||
src_fileitem?: FileItem
|
||||
}
|
||||
|
||||
// 媒体信息
|
||||
export interface MediaInfo {
|
||||
// 来源:themoviedb、douban、bangumi
|
||||
source?: string
|
||||
// 类型 电影、电视剧
|
||||
// 类型 电影、电视剧、合集
|
||||
type?: string
|
||||
// 媒体标题
|
||||
title?: string
|
||||
@@ -202,6 +216,12 @@ export interface MediaInfo {
|
||||
douban_id?: string
|
||||
// Bangumi ID
|
||||
bangumi_id?: string
|
||||
// 合集ID
|
||||
collection_id?: number
|
||||
// 其它媒体ID前缀
|
||||
mediaid_prefix?: string
|
||||
// 其它媒体ID值
|
||||
media_id?: string
|
||||
// 媒体原语种
|
||||
original_language?: string
|
||||
// 媒体原发行标题
|
||||
@@ -272,6 +292,26 @@ export interface MediaInfo {
|
||||
next_episode_to_air?: object
|
||||
// 别名
|
||||
names?: string[]
|
||||
// 剧集组
|
||||
episode_group?: string
|
||||
}
|
||||
|
||||
// 季信息
|
||||
export interface MediaSeason {
|
||||
// 上映日期
|
||||
air_date?: string
|
||||
// 总集数
|
||||
episode_count?: number
|
||||
// 季名称
|
||||
name?: string
|
||||
// 描述
|
||||
overview?: string
|
||||
// 海报
|
||||
poster_path?: string
|
||||
// 季号
|
||||
season_number?: number
|
||||
// 评分
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
// TMDB季信息
|
||||
@@ -387,6 +427,8 @@ export interface Site {
|
||||
pri?: number
|
||||
// RSS地址
|
||||
rss?: string
|
||||
// 下载器
|
||||
downloader: string
|
||||
// Cookie
|
||||
cookie?: string
|
||||
// ApiKey
|
||||
@@ -605,6 +647,8 @@ export interface TorrentInfo {
|
||||
site_proxy: boolean
|
||||
// 站点优先级
|
||||
site_order: number
|
||||
// 站点下载器
|
||||
site_downloader?: string
|
||||
// 种子名称
|
||||
title?: string
|
||||
// 种子副标题
|
||||
@@ -759,6 +803,8 @@ export interface User {
|
||||
permissions: { [key: string]: any }
|
||||
// 用户个性化设置 json
|
||||
settings: { [key: string]: string | null }
|
||||
// 昵称
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 存储空间
|
||||
@@ -1045,7 +1091,7 @@ export interface TransferDirectoryConf {
|
||||
// 监控模式 fast/compatibility
|
||||
monitor_mode?: string
|
||||
// 整理方式 move/copy/link/softlink
|
||||
transfer_type?: string
|
||||
transfer_type: string
|
||||
// 文件覆盖模式 always/size/never/latest
|
||||
overwrite_mode?: string
|
||||
// 整理到媒体库目录
|
||||
@@ -1094,6 +1140,7 @@ export interface FilterRuleGroup {
|
||||
category?: string
|
||||
}
|
||||
|
||||
// 订阅下载文件详情
|
||||
export interface SubscribeDownloadFileInfo {
|
||||
// 种子名称
|
||||
torrent_title?: string
|
||||
@@ -1107,6 +1154,7 @@ export interface SubscribeDownloadFileInfo {
|
||||
file_path?: string
|
||||
}
|
||||
|
||||
// 订阅媒体库文件详情
|
||||
export interface SubscribeLibraryFileInfo {
|
||||
// 存储
|
||||
storage?: string
|
||||
@@ -1114,6 +1162,7 @@ export interface SubscribeLibraryFileInfo {
|
||||
file_path?: string
|
||||
}
|
||||
|
||||
// 订阅集详情
|
||||
export interface SubscribeEpisodeInfo {
|
||||
// 标题
|
||||
title?: string
|
||||
@@ -1127,9 +1176,130 @@ export interface SubscribeEpisodeInfo {
|
||||
library?: SubscribeLibraryFileInfo[]
|
||||
}
|
||||
|
||||
// 订阅详情
|
||||
export interface SubscrbieInfo {
|
||||
// 订阅信息
|
||||
subscribe: Subscribe
|
||||
// 集信息 {集号: {download: 文件路径,library: 文件路径, backdrop: url, title: 标题, description: 描述}}
|
||||
episodes: Record<number, SubscribeEpisodeInfo>
|
||||
}
|
||||
|
||||
// 整理表单
|
||||
export interface TransferForm {
|
||||
// 文件项
|
||||
fileitem: FileItem
|
||||
// 历史ID
|
||||
logid: number
|
||||
// 目标存储
|
||||
target_storage: string
|
||||
// 目标路径
|
||||
target_path: string
|
||||
// TMDB ID
|
||||
tmdbid?: number
|
||||
// 豆瓣 ID
|
||||
doubanid?: string
|
||||
// 季号
|
||||
season?: number
|
||||
// 类型
|
||||
type_name?: string
|
||||
// 整理方式
|
||||
transfer_type: string
|
||||
// 自定义格式
|
||||
episode_format?: string
|
||||
// 指定集数
|
||||
episode_detail?: string
|
||||
// 指定PART
|
||||
episode_part?: string
|
||||
// 集数偏移
|
||||
episode_offset?: string
|
||||
// 最小文件大小
|
||||
min_filesize: number
|
||||
// 刮削
|
||||
scrape: boolean
|
||||
// 复用历史识别信息
|
||||
from_history: boolean
|
||||
// 媒体库类型子目录
|
||||
library_type_folder?: boolean
|
||||
// 媒体库类别子目录
|
||||
library_category_folder?: boolean
|
||||
// 剧集组编号
|
||||
episode_group?: string
|
||||
}
|
||||
|
||||
// 整理队列
|
||||
export interface TransferQueue {
|
||||
// 媒体信息
|
||||
media: MediaInfo
|
||||
// 季
|
||||
season?: number
|
||||
// 任务列表
|
||||
tasks: {
|
||||
// 文件项
|
||||
fileitem: FileItem
|
||||
// 元数据
|
||||
meta: MetaInfo
|
||||
// 状态
|
||||
state: string
|
||||
}[]
|
||||
}
|
||||
|
||||
// 探索的数据源
|
||||
export interface DiscoverSource {
|
||||
// 数据源名称
|
||||
name: string
|
||||
// 媒体ID的前缀,不含:
|
||||
mediaid_prefix: string
|
||||
// 媒体数据源API地址
|
||||
api_path: string
|
||||
// 过滤参数
|
||||
filter_params: { [key: string]: any }
|
||||
// 过滤参数UI配置
|
||||
filter_ui: RenderProps[]
|
||||
// UI依赖关系字典
|
||||
depends?: { [key: string]: string[] }
|
||||
}
|
||||
|
||||
// 推荐的数据源
|
||||
export interface RecommendSource {
|
||||
// 数据源名称
|
||||
name: string
|
||||
// 媒体数据源API地址
|
||||
api_path: string
|
||||
// 类型
|
||||
type: string
|
||||
}
|
||||
|
||||
// 站点资源分类
|
||||
export interface SiteCategory {
|
||||
id: number
|
||||
cat: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
// 工作流
|
||||
export interface Workflow {
|
||||
// 工作流ID
|
||||
id?: string
|
||||
// 工作流名称
|
||||
name?: string
|
||||
// 工作流描述
|
||||
description?: string
|
||||
// 定时器
|
||||
timer?: string
|
||||
// 状态
|
||||
state?: string
|
||||
// 当前执行动作
|
||||
current_action?: string
|
||||
// 任务执行结果
|
||||
result?: string
|
||||
// 已执行次数
|
||||
run_count?: number
|
||||
// 动作列表
|
||||
actions?: any[]
|
||||
// 动作流
|
||||
flows?: any[]
|
||||
// 创建时间
|
||||
add_time?: string
|
||||
// 最后执行时间
|
||||
last_time?: string
|
||||
}
|
||||
|
||||
BIN
src/assets/images/logos/bangumi_title.png
Normal file
BIN
src/assets/images/logos/bangumi_title.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
BIN
src/assets/images/logos/douban_title.png
Normal file
BIN
src/assets/images/logos/douban_title.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
1
src/assets/images/logos/tmdb_title.svg
Normal file
1
src/assets/images/logos/tmdb_title.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 273.42 35.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.76" x2="273.42" y2="17.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 3</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M191.85,35.37h63.9A17.67,17.67,0,0,0,273.42,17.7h0A17.67,17.67,0,0,0,255.75,0h-63.9A17.67,17.67,0,0,0,174.18,17.7h0A17.67,17.67,0,0,0,191.85,35.37ZM10.1,35.42h7.8V6.92H28V0H0v6.9H10.1Zm28.1,0H46V8.25h.1L55.05,35.4h6L70.3,8.25h.1V35.4h7.8V0H66.45l-8.2,23.1h-.1L50,0H38.2ZM89.14.12h11.7a33.56,33.56,0,0,1,8.08,1,18.52,18.52,0,0,1,6.67,3.08,15.09,15.09,0,0,1,4.53,5.52,18.5,18.5,0,0,1,1.67,8.25,16.91,16.91,0,0,1-1.62,7.58,16.3,16.3,0,0,1-4.38,5.5,19.24,19.24,0,0,1-6.35,3.37,24.53,24.53,0,0,1-7.55,1.15H89.14Zm7.8,28.2h4a21.66,21.66,0,0,0,5-.55A10.58,10.58,0,0,0,110,26a8.73,8.73,0,0,0,2.68-3.35,11.9,11.9,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9.17,9.17,0,0,0-2.63-3.18A11.61,11.61,0,0,0,106.22,8a17.06,17.06,0,0,0-4.68-.63h-4.6ZM133.09.12h13.2a32.87,32.87,0,0,1,4.63.33,12.66,12.66,0,0,1,4.17,1.3,7.94,7.94,0,0,1,3,2.72,8.34,8.34,0,0,1,1.15,4.65,7.48,7.48,0,0,1-1.67,5,9.13,9.13,0,0,1-4.43,2.82V17a10.28,10.28,0,0,1,3.18,1,8.51,8.51,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.32,9.32,0,0,1-3.1,3A13.38,13.38,0,0,1,152.32,35a22.5,22.5,0,0,1-4.73.5h-14.5Zm7.8,14.15h5.65a7.65,7.65,0,0,0,1.78-.2,4.78,4.78,0,0,0,1.57-.65,3.43,3.43,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8A3.3,3.3,0,0,0,151,8.6a3.42,3.42,0,0,0-1.23-1.13A6.07,6.07,0,0,0,148,6.9a9.9,9.9,0,0,0-1.85-.18h-5.3Zm0,14.65h7a8.27,8.27,0,0,0,1.83-.2,4.67,4.67,0,0,0,1.67-.7,3.93,3.93,0,0,0,1.23-1.3,3.8,3.8,0,0,0,.47-1.95,3.16,3.16,0,0,0-.62-2,4,4,0,0,0-1.58-1.18,8.23,8.23,0,0,0-2-.55,15.12,15.12,0,0,0-2.05-.15h-5.9Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/assets/images/logos/trimemedia.png
Normal file
BIN
src/assets/images/logos/trimemedia.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
10
src/assets/images/misc/alist.svg
Normal file
10
src/assets/images/misc/alist.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="1252" height="1252" xmlns="http://www.w3.org/2000/svg" version="1.1">
|
||||
<g>
|
||||
<g id="#70c6beff">
|
||||
<path id="svg_2" d="m634.37,138.38c11.88,-1.36 24.25,1.3 34.18,8.09c14.96,9.66 25.55,24.41 34.49,39.51c40.59,68.03 81.45,135.91 122.02,203.96c54.02,90.99 108.06,181.97 161.94,273.06c37.28,63 74.65,125.96 112.18,188.82c24.72,41.99 50.21,83.54 73.84,126.16c10.18,17.84 15.77,38.44 14.93,59.03c-0.59,15.92 -3.48,32.28 -11.84,46.08c-11.73,19.46 -31.39,33.2 -52.71,40.36c-11.37,4.09 -23.3,6.87 -35.43,6.89c-132.32,-0.05 -264.64,0.04 -396.95,0.03c-11.38,-0.29 -22.95,-1.6 -33.63,-5.72c-7.81,-3.33 -15.5,-7.43 -21.61,-13.42c-10.43,-10.32 -17.19,-24.96 -15.38,-39.83c0.94,-10.39 3.48,-20.64 7.76,-30.16c4.15,-9.77 9.99,-18.67 15.06,-27.97c22.13,-39.47 45.31,-78.35 69.42,-116.65c7.72,-12.05 14.44,-25.07 25.12,-34.87c11.35,-10.39 25.6,-18.54 41.21,-19.6c12.55,-0.52 24.89,3.82 35.35,10.55c11.8,6.92 21.09,18.44 24.2,31.88c4.49,17.01 -0.34,34.88 -7.55,50.42c-8.09,17.65 -19.62,33.67 -25.81,52.18c-1.13,4.21 -2.66,9.52 0.48,13.23c3.19,3 7.62,4.18 11.77,5.22c12,2.67 24.38,1.98 36.59,2.06c45,-0.01 90,0 135,0c8.91,-0.15 17.83,0.3 26.74,-0.22c6.43,-0.74 13.44,-1.79 18.44,-6.28c3.3,-2.92 3.71,-7.85 2.46,-11.85c-2.74,-8.86 -7.46,-16.93 -12.12,-24.89c-119.99,-204.91 -239.31,-410.22 -360.56,-614.4c-3.96,-6.56 -7.36,-13.68 -13.03,-18.98c-2.8,-2.69 -6.95,-4.22 -10.77,-3.11c-3.25,1.17 -5.45,4.03 -7.61,6.57c-5.34,6.81 -10.12,14.06 -14.51,21.52c-20.89,33.95 -40.88,68.44 -61.35,102.64c-117.9,198.43 -235.82,396.85 -353.71,595.29c-7.31,13.46 -15.09,26.67 -23.57,39.43c-7.45,10.96 -16.49,21.23 -28.14,27.83c-13.73,7.94 -30.69,11.09 -46.08,6.54c-11.23,-3.47 -22.09,-9.12 -30.13,-17.84c-10.18,-10.08 -14.69,-24.83 -14.17,-38.94c0.52,-14.86 5.49,-29.34 12.98,-42.1c71.58,-121.59 143.62,-242.92 215.93,-364.09c37.2,-62.8 74.23,-125.69 111.64,-188.36c37.84,-63.5 75.77,-126.94 113.44,-190.54c21.02,-35.82 42.19,-71.56 64.28,-106.74c6.79,-11.15 15.58,-21.15 26.16,-28.85c8.68,-5.92 18.42,-11 29.05,-11.94z" fill="#70c6be"/>
|
||||
</g>
|
||||
<g id="#1ba0d8ff">
|
||||
<path id="svg_3" d="m628.35,608.38c17.83,-2.87 36.72,1.39 51.5,11.78c11.22,8.66 19.01,21.64 21.26,35.65c1.53,10.68 0.49,21.75 -3.44,31.84c-3.02,8.73 -7.35,16.94 -12.17,24.81c-68.76,115.58 -137.5,231.17 -206.27,346.75c-8.8,14.47 -16.82,29.47 -26.96,43.07c-7.37,9.11 -16.58,16.85 -27.21,21.89c-22.47,11.97 -51.79,4.67 -68.88,-13.33c-8.66,-8.69 -13.74,-20.63 -14.4,-32.84c-0.98,-12.64 1.81,-25.42 7.53,-36.69c5.03,-10.96 10.98,-21.45 17.19,-31.77c30.22,-50.84 60.17,-101.84 90.3,-152.73c41.24,-69.98 83.16,-139.55 124.66,-209.37c4.41,-7.94 9.91,-15.26 16.09,-21.9c8.33,-8.46 18.9,-15.3 30.8,-17.16z" fill="#1ba0d8"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -2,7 +2,10 @@
|
||||
import type { Axios } from 'axios'
|
||||
import FileList from './filebrowser/FileList.vue'
|
||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { storageOptions } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -27,28 +30,11 @@ const props = defineProps({
|
||||
// 对外事件
|
||||
const emit = defineEmits(['pathchanged'])
|
||||
|
||||
const availableStorages = [
|
||||
{
|
||||
name: '本地',
|
||||
code: 'local',
|
||||
icon: 'mdi-folder-multiple-outline',
|
||||
},
|
||||
{
|
||||
name: '阿里云盘',
|
||||
code: 'alipan',
|
||||
icon: 'mdi-cloud-outline',
|
||||
},
|
||||
{
|
||||
name: '115网盘',
|
||||
code: 'u115',
|
||||
icon: 'mdi-cloud-outline',
|
||||
},
|
||||
{
|
||||
name: 'Rclone网盘',
|
||||
code: 'rclone',
|
||||
icon: 'mdi-cloud-outline',
|
||||
},
|
||||
]
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
const fileIcons = {
|
||||
// 压缩包
|
||||
@@ -148,10 +134,13 @@ const refreshPending = ref(false)
|
||||
// 排序
|
||||
const sort = ref('name')
|
||||
|
||||
// 是否显示目录树
|
||||
const showDirTree = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const storagesArray = computed(() => {
|
||||
const storageCodes = props.storages?.map(item => item.type)
|
||||
return availableStorages.filter(item => storageCodes?.includes(item.code))
|
||||
return storageOptions.filter(item => storageCodes?.includes(item.value))
|
||||
})
|
||||
|
||||
// 方法
|
||||
@@ -176,10 +165,37 @@ function sortChanged(s: string) {
|
||||
sort.value = s
|
||||
refreshPending.value = true
|
||||
}
|
||||
|
||||
// 切换目录树
|
||||
function switchDirTree(state: boolean) {
|
||||
showDirTree.value = state
|
||||
}
|
||||
|
||||
// 文件列表
|
||||
const fileListItems = ref<FileItem[]>([])
|
||||
|
||||
// 文件列表数据更新
|
||||
function fileListUpdated(items: FileItem[]) {
|
||||
fileListItems.value = items
|
||||
}
|
||||
|
||||
// 外层DIV大小控制
|
||||
const scrollStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 10rem - env(safe-area-inset-bottom) - 6rem)'
|
||||
: 'height: calc(100vh - 10rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
// 文件列表大小限制
|
||||
const fileListStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 14rem - env(safe-area-inset-bottom) - 6rem)'
|
||||
: 'height: calc(100vh - 14rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="mx-auto" :loading="loading > 0">
|
||||
<div class="mx-auto" :loading="loading > 0">
|
||||
<div v-if="activeStorage && item">
|
||||
<FileToolbar
|
||||
:item="item"
|
||||
@@ -193,20 +209,36 @@ function sortChanged(s: string) {
|
||||
@foldercreated="refreshPending = true"
|
||||
@sortchanged="sortChanged"
|
||||
/>
|
||||
<FileList
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@filedeleted="refreshPending = true"
|
||||
@renamed="refreshPending = true"
|
||||
/>
|
||||
<div class="flex" :style="scrollStyle">
|
||||
<FileNavigator
|
||||
v-if="showDirTree"
|
||||
:storage="activeStorage"
|
||||
:currentPath="item.path"
|
||||
:items="fileListItems"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
@navigate="pathChanged"
|
||||
/>
|
||||
<FileList
|
||||
class="flex-grow"
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
:listStyle="fileListStyle"
|
||||
:showTree="showDirTree"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@filedeleted="refreshPending = true"
|
||||
@renamed="refreshPending = true"
|
||||
@items-updated="fileListUpdated"
|
||||
@switch-tree="switchDirTree"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,31 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import image from '@images/no-data.svg'
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
interface Props {
|
||||
errorCode?: string
|
||||
errorTitle?: string
|
||||
errorDescription?: string
|
||||
icon?: string
|
||||
iconColor?: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VEmptyState :image="image" size="250">
|
||||
<template #title>
|
||||
<div class="mt-8 text-2xl">
|
||||
{{ props.errorTitle }}
|
||||
<div class="no-data-container">
|
||||
<!-- 图标容器 -->
|
||||
<div class="icon-wrapper">
|
||||
<div class="icon-glow"></div>
|
||||
<div class="icon-container">
|
||||
<VIcon
|
||||
:icon="props.icon || 'mdi-file-search-outline'"
|
||||
:color="props.iconColor || 'white'"
|
||||
size="48"
|
||||
class="main-icon"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="pulse-ring"></div>
|
||||
</div>
|
||||
|
||||
<template #text>
|
||||
<div class="text-subtitle mt-3">
|
||||
{{ props.errorDescription }}
|
||||
</div>
|
||||
</template>
|
||||
<!-- 标题 -->
|
||||
<div class="error-title">
|
||||
{{ props.errorTitle || '暂无数据' }}
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<!-- 描述 -->
|
||||
<div class="error-description">
|
||||
{{ props.errorDescription || '没有找到相关内容' }}
|
||||
</div>
|
||||
|
||||
<!-- 按钮插槽 -->
|
||||
<div class="actions-container">
|
||||
<slot name="button" />
|
||||
</template>
|
||||
</VEmptyState>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-data-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 100%;
|
||||
min-block-size: 300px;
|
||||
padding-block: 3rem;
|
||||
padding-inline: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 图标样式 */
|
||||
.icon-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
margin-block: 0 2rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.icon-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: pulse 3s infinite ease-in-out;
|
||||
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.8) 0%, rgba(var(--v-theme-primary), 0) 70%);
|
||||
block-size: 80px;
|
||||
filter: blur(15px);
|
||||
inline-size: 80px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.9), rgba(var(--v-theme-secondary), 0.8));
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
animation: slight-bounce 3s infinite ease-in-out;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 30%));
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border: 2px solid rgba(var(--v-theme-primary), 0.5);
|
||||
border-radius: 50%;
|
||||
animation: ripple 2s infinite ease-out;
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.pulse-ring::before {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(var(--v-theme-primary), 0.3);
|
||||
border-radius: 50%;
|
||||
animation: ripple 2s infinite 0.5s ease-out;
|
||||
block-size: 85px;
|
||||
content: '';
|
||||
inline-size: 85px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slight-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文字样式 */
|
||||
.error-title {
|
||||
position: relative;
|
||||
color: rgba(var(--v-theme-on-surface), 0.95);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-block-end: 0.75rem;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
|
||||
}
|
||||
|
||||
.error-title::after {
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-primary), 0.8), rgba(var(--v-theme-primary), 0.2));
|
||||
block-size: 3px;
|
||||
content: '';
|
||||
inline-size: 40px;
|
||||
margin-block: 0.5rem 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
color: rgba(var(--v-theme-on-surface), 0.75);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-block-end: 1.5rem;
|
||||
margin-inline: auto;
|
||||
max-inline-size: 80%;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
margin-block-start: 1.5rem;
|
||||
}
|
||||
|
||||
.actions-container :deep(.v-btn) {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.actions-container :deep(.v-btn:hover) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (width <= 600px) {
|
||||
.no-data-container {
|
||||
padding-block: 2rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block-end: 1.5rem;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
block-size: 70px;
|
||||
inline-size: 70px;
|
||||
}
|
||||
|
||||
.icon-glow {
|
||||
block-size: 70px;
|
||||
inline-size: 70px;
|
||||
}
|
||||
|
||||
.pulse-ring,
|
||||
.pulse-ring::before {
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 0.95rem;
|
||||
max-inline-size: 90%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,7 @@ const getImgUrl = computed(() => {
|
||||
:width="props.width"
|
||||
class="ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import filter_svg from '@images/svg/filter.svg'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
|
||||
// 输入参数
|
||||
@@ -98,13 +98,13 @@ function onClose() {
|
||||
<DialogCloseBtn @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.id }}</h5>
|
||||
<div class="text-body-1 mb-3">{{ props.rule.name }}</div>
|
||||
<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>
|
||||
<VDialog v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.rule.id} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="ruleInfoDialog" />
|
||||
<VDivider />
|
||||
@@ -174,9 +174,9 @@ function onClose() {
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.publish_time"
|
||||
placeholder="0"
|
||||
placeholder="0/1-10"
|
||||
label="发布时间(分钟)"
|
||||
hint="距离资源发布的最小时间间隔"
|
||||
hint="距离资源发布的最小时间间隔或时间区间"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TransferDirectoryConf } from '@/api/types'
|
||||
import { VDivider, VSpacer, VTextField } from 'vuetify/lib/components/index.mjs'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import { nextTick } from 'vue'
|
||||
import { storageOptions } from '@/api/constants'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -20,12 +19,6 @@ const props = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 下载路径
|
||||
const downloadPath = ref<string>('')
|
||||
|
||||
// 媒体库路径
|
||||
const libraryPath = ref<string>('')
|
||||
|
||||
// 卡版是否折叠状态
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
@@ -36,19 +29,17 @@ const typeItems = [
|
||||
{ title: '电视剧', value: '电视剧' },
|
||||
]
|
||||
|
||||
// 存储下拉字典
|
||||
const storageItems = [
|
||||
{ title: '本地', value: 'local' },
|
||||
{ title: '阿里云盘', value: 'alipan' },
|
||||
{ title: '115网盘', value: 'u115' },
|
||||
{ title: 'Rclone网盘', value: 'rclone' },
|
||||
]
|
||||
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
|
||||
const resourceStorageOptions = computed(() => {
|
||||
return storageOptions.filter(item => !item.remote || props.directory.monitor_type !== 'downloader')
|
||||
})
|
||||
|
||||
// 自动整理方式下拉字典
|
||||
const transferSourceItems = [
|
||||
{ title: '不自动整理', value: '' },
|
||||
{ title: '不整理', value: '' },
|
||||
{ title: '下载器监控', value: 'downloader' },
|
||||
{ title: '目录监控', value: 'monitor' },
|
||||
{ title: '手动整理', value: 'manual' },
|
||||
]
|
||||
|
||||
// 监控模式下拉字典
|
||||
@@ -112,13 +103,13 @@ async function loadTransferTypeItems() {
|
||||
// 整理方式无数据提示
|
||||
const computedNoDataText = computed(() => {
|
||||
if (!props.directory.library_storage && !props.directory.storage) {
|
||||
return '无可用整理方式!请先选择下载器储存与媒体库储存!'
|
||||
return '请选择储存'
|
||||
} else if (!props.directory.library_storage) {
|
||||
return '无可用整理方式!请先选择媒体库储存!'
|
||||
return '请选择媒体库储存'
|
||||
} else if (!props.directory.storage) {
|
||||
return '无可用整理方式!请先选择下载器储存!'
|
||||
return '请选择下载器储存'
|
||||
} else {
|
||||
return '选择的存储没有支持的整理方法!'
|
||||
return '选择的存储类型没有支持的整理方式'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -138,24 +129,6 @@ function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 下载路径更新
|
||||
function updateDownloadPath(value: string) {
|
||||
downloadPath.value = value
|
||||
emit('update:modelValue', {
|
||||
download: downloadPath.value,
|
||||
library: libraryPath.value,
|
||||
})
|
||||
}
|
||||
|
||||
// 媒体库路径更新
|
||||
function updateLibraryPath(value: string) {
|
||||
libraryPath.value = value
|
||||
emit('update:modelValue', {
|
||||
download: downloadPath.value,
|
||||
library: libraryPath.value,
|
||||
})
|
||||
}
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: '全部', value: '' }]
|
||||
@@ -163,7 +136,7 @@ const getCategories = computed(() => {
|
||||
return default_value.concat(props.categories[props.directory.media_type ?? ''])
|
||||
})
|
||||
|
||||
// 监听 下载储存与媒体库储存 变化,重新加载整理方式下拉字典
|
||||
// 监听 资源存储与媒体库储存 变化,重新加载整理方式下拉字典
|
||||
watch(
|
||||
[() => props.directory.library_storage, () => props.directory.storage],
|
||||
([newLibraryStorage, newStorage], [oldLibraryStorage, oldStorage]) => {
|
||||
@@ -188,6 +161,16 @@ watch(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 监听monitor_type变化,如果为downloader则设置为本地
|
||||
watch(
|
||||
() => props.directory.monitor_type,
|
||||
newMonitorType => {
|
||||
if (newMonitorType === 'downloader') {
|
||||
props.directory.storage = 'local'
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -227,19 +210,20 @@ watch(
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
<VSelect v-model="props.directory.storage" variant="underlined" :items="storageItems" label="下载存储" />
|
||||
<VSelect
|
||||
v-model="props.directory.storage"
|
||||
variant="underlined"
|
||||
:items="resourceStorageOptions"
|
||||
label="资源存储"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
<VPathField @update:modelValue="updateDownloadPath" :storage="props.directory.storage">
|
||||
<template #activator="{ menuprops }">
|
||||
<VTextField
|
||||
v-model="props.directory.download_path"
|
||||
v-bind="menuprops"
|
||||
variant="underlined"
|
||||
label="下载目录"
|
||||
/>
|
||||
</template>
|
||||
</VPathField>
|
||||
<VPathField
|
||||
v-model="props.directory.download_path"
|
||||
:storage="props.directory.storage"
|
||||
variant="underlined"
|
||||
label="资源目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
|
||||
<VSwitch v-model="props.directory.download_type_folder" label="按类型分类"></VSwitch>
|
||||
@@ -272,21 +256,17 @@ watch(
|
||||
<VSelect
|
||||
v-model="props.directory.library_storage"
|
||||
variant="underlined"
|
||||
:items="storageItems"
|
||||
:items="storageOptions"
|
||||
label="媒体库存储"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
<VPathField @update:modelValue="updateLibraryPath" :storage="props.directory.library_storage">
|
||||
<template #activator="{ menuprops }">
|
||||
<VTextField
|
||||
v-model="props.directory.library_path"
|
||||
v-bind="menuprops"
|
||||
variant="underlined"
|
||||
label="媒体库目录"
|
||||
/>
|
||||
</template>
|
||||
</VPathField>
|
||||
<VPathField
|
||||
v-model="props.directory.library_path"
|
||||
:storage="props.directory.library_storage"
|
||||
variant="underlined"
|
||||
label="媒体库目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
<VSelect
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useToast } from 'vue-toast-notification'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import qbittorrent_image from '@images/logos/qbittorrent.png'
|
||||
import transmission_image from '@images/logos/transmission.png'
|
||||
import {cloneDeep} from "lodash";
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -104,7 +104,7 @@ function saveDownloaderInfo() {
|
||||
props.downloaders.forEach(item => {
|
||||
if (item.default && item !== props.downloader) {
|
||||
item.default = false
|
||||
$toast.info(`【${item.name}】存在默认下载器,已替换成【${downloaderInfo.value.name}】`)
|
||||
$toast.info(`存在默认下载器【${item.name}】,已替换成【${downloaderInfo.value.name}】`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -143,36 +143,43 @@ onUnmounted(() => {
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openDownloaderInfoDialog">
|
||||
<DialogCloseBtn @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>
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
variant="tonal"
|
||||
@click="openDownloaderInfoDialog"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<DialogCloseBtn @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 class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
|
||||
<span class="me-2">{{ `↑ ${formatFileSize(upload_rate, 1)}/s ` }}</span>
|
||||
<span>{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
|
||||
<span class="me-2">{{ `↑ ${formatFileSize(upload_rate, 1)}/s ` }}</span>
|
||||
<span>{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VHover>
|
||||
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
@@ -211,7 +218,6 @@ onUnmounted(() => {
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
label="用户名"
|
||||
placeholder="admin"
|
||||
hint="登录使用的用户名"
|
||||
persistent-hint
|
||||
active
|
||||
@@ -289,7 +295,6 @@ onUnmounted(() => {
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
label="用户名"
|
||||
placeholder="admin"
|
||||
hint="登录使用的用户名"
|
||||
persistent-hint
|
||||
active
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
|
||||
@@ -6,7 +6,7 @@ import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import filter_group_svg from '@images/svg/filter-group.svg'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -48,10 +48,10 @@ 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,
|
||||
name: props.group?.name ?? '',
|
||||
rule_string: props.group?.rule_string ?? '',
|
||||
media_type: props.group?.media_type ?? '',
|
||||
category: props.group?.category ?? '',
|
||||
})
|
||||
|
||||
// 媒体类型字典
|
||||
@@ -64,24 +64,30 @@ const mediaTypeItems = [
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: '全部', value: '' }]
|
||||
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type ?? ''])
|
||||
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 ?? ''])
|
||||
}
|
||||
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
|
||||
})
|
||||
|
||||
// 规则组规则卡片列表
|
||||
const filterRuleCards = ref<FilterCard[]>([])
|
||||
// 规则组类型,仅用于导入判断
|
||||
const filterRuleCardsType = ref<FilterCard>({
|
||||
pri: '',
|
||||
rules: [],
|
||||
})
|
||||
|
||||
// 导入代码弹窗
|
||||
const importCodeDialog = ref(false)
|
||||
|
||||
// 导入的代码
|
||||
const importCodeString = ref('')
|
||||
// 导入代码类型
|
||||
const importCodeType = ref('')
|
||||
|
||||
// 更新规则卡片的值
|
||||
function updateFilterCardValue(pri: string, rules: string[]) {
|
||||
const card = filterRuleCards.value.find(card => card.pri === pri)
|
||||
if (card) card.rules = rules
|
||||
if (card && Array.isArray(rules)) card.rules = rules
|
||||
}
|
||||
|
||||
// 移除卡片
|
||||
@@ -95,99 +101,93 @@ function filterCardClose(pri: string) {
|
||||
}
|
||||
|
||||
// 分享规则
|
||||
function shareRules() {
|
||||
// 有值才处理
|
||||
async function shareRules() {
|
||||
if (filterRuleCards.value.length === 0) return
|
||||
|
||||
// 将卡片规则接装为字符串
|
||||
const value = filterRuleCards.value
|
||||
.filter(card => card.rules.length > 0)
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
|
||||
// 复制到剪贴板
|
||||
try {
|
||||
copyToClipboard(value)
|
||||
$toast.success('优先级规则已复制到剪贴板')
|
||||
let success
|
||||
success = copyToClipboard(value)
|
||||
if (await success) $toast.success('优先级规则已复制到剪贴板!')
|
||||
else $toast.error('优先级规则复制失败:可能是浏览器不支持或被用户阻止!')
|
||||
} catch (error) {
|
||||
$toast.error('优先级规则复制失败!')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入规则
|
||||
async function importRules() {
|
||||
importCodeString.value = ''
|
||||
async function importRules(ruleType: string) {
|
||||
importCodeType.value = ruleType
|
||||
importCodeDialog.value = true
|
||||
}
|
||||
|
||||
// 监听导入代码变化
|
||||
watchEffect(() => {
|
||||
if (!importCodeString.value) return
|
||||
// 导入代码需要以空格开头和结束,没有则拼接
|
||||
if (!importCodeString.value.startsWith(' ')) importCodeString.value = ` ${importCodeString.value}`
|
||||
if (!importCodeString.value.endsWith(' ')) importCodeString.value = `${importCodeString.value} `
|
||||
// 将导入的代码转换为规则卡片
|
||||
const groups = importCodeString.value.split('>')
|
||||
filterRuleCards.value = groups.map((group: string, index: number) => {
|
||||
return {
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&'),
|
||||
// 保存导入的代码,直接覆盖原有值
|
||||
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('导入失败!')
|
||||
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.map((card, index) => {
|
||||
filterRuleCards.value.forEach((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
return card
|
||||
})
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
function opengroupInfoDialog() {
|
||||
// 深复制
|
||||
groupInfo.value = cloneDeep(props.group)
|
||||
if (props.group.rule_string) {
|
||||
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => {
|
||||
return {
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&'),
|
||||
}
|
||||
})
|
||||
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) {
|
||||
if (!groupInfo.value.name.trim()) {
|
||||
$toast.error('规则组名称不能为空')
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
|
||||
$toast.error(`规则组名称【${groupInfo.value.name}】已存在,请替换`)
|
||||
return
|
||||
}
|
||||
// 保存
|
||||
|
||||
groupInfoDialog.value = false
|
||||
// 更新到 groupInfo的rule_string
|
||||
groupInfo.value.rule_string = filterRuleCards.value
|
||||
.filter(card => card.rules.length > 0)
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
emit('change', groupInfo.value, props.group.name)
|
||||
@@ -220,12 +220,12 @@ function onClose() {
|
||||
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
|
||||
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
|
||||
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="groupInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCardItem class="pt-1">
|
||||
<VRow class="mt-1">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="groupInfo.name"
|
||||
@@ -257,7 +257,7 @@ function onClose() {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
v-model="filterRuleCards"
|
||||
@@ -284,7 +284,7 @@ function onClose() {
|
||||
<VBtn color="primary" variant="tonal" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VBtn color="success" variant="tonal" @click="importRules">
|
||||
<VBtn color="success" variant="tonal" @click="importRules('priority')">
|
||||
<VIcon icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn color="info" variant="tonal" @click="shareRules">
|
||||
@@ -295,8 +295,13 @@ function onClose() {
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<VDialog v-model="importCodeDialog" width="60rem" scrollable>
|
||||
<ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
|
||||
</VDialog>
|
||||
<ImportCodeDialog
|
||||
v-if="importCodeDialog"
|
||||
v-model="importCodeDialog"
|
||||
title="导入规则优先级"
|
||||
:dataType="importCodeType"
|
||||
@close="importCodeDialog = false"
|
||||
@save="saveCodeString"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -38,6 +39,7 @@ 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
|
||||
else return plex
|
||||
}
|
||||
|
||||
@@ -91,7 +93,17 @@ async function drawImages(imageList: string[]) {
|
||||
const img = new Image()
|
||||
img.setAttribute('crossorigin', 'anonymous')
|
||||
img.src = imgSrc
|
||||
await new Promise(resolve => (img.onload = resolve))
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve()
|
||||
img.onerror = () => reject(new Error(`Failed to load image: ${imgSrc}`))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ctx.fillStyle = '#e5e7eb'
|
||||
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), MARGIN_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT)
|
||||
return
|
||||
}
|
||||
|
||||
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
|
||||
const y = MARGIN_HEIGHT
|
||||
@@ -146,7 +158,7 @@ onMounted(async () => {
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType, Ref } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
|
||||
import router from '@/router'
|
||||
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 api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
|
||||
import router, { registerAbortController } from '@/router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -22,7 +24,8 @@ const props = defineProps({
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
const store = useStore()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -33,18 +36,12 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// TMDB识别标志
|
||||
const tmdbFlag = ref(true)
|
||||
|
||||
// 当前订阅状态
|
||||
const isSubscribed = ref(false)
|
||||
|
||||
// 本地存在状态
|
||||
const isExists = ref(false)
|
||||
|
||||
// 各季缺失状态:0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
|
||||
const seasonsNotExisted = ref<{ [key: number]: number }>({})
|
||||
|
||||
// 订阅季弹窗
|
||||
const subscribeSeasonDialog = ref(false)
|
||||
|
||||
@@ -54,11 +51,8 @@ const subscribeEditDialog = ref(false)
|
||||
// 订阅ID
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
// 季详情
|
||||
const seasonInfos = ref<TmdbSeason[]>([])
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<TmdbSeason[]>([])
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
@@ -67,19 +61,55 @@ const sourceIconDict: { [key: string]: any } = {
|
||||
bangumi: bangumiImage,
|
||||
}
|
||||
|
||||
// 绑定MediaCard元素
|
||||
const mediaCardRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 创建Intersection Observer实例
|
||||
const observer = ref<IntersectionObserver | null>(null)
|
||||
|
||||
// 所有站点
|
||||
const allSites = ref<Site[]>([])
|
||||
|
||||
// 选中的站点
|
||||
const selectedSites = ref<number[]>([])
|
||||
|
||||
// 搜索菜单显示状态
|
||||
const searchMenuShow = ref(false)
|
||||
|
||||
// 选择站点对话框
|
||||
const chooseSiteDialog = ref(false)
|
||||
|
||||
// 选择的剧集组
|
||||
const episodeGroup = ref('')
|
||||
|
||||
// 查询所有站点
|
||||
async function querySites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/')
|
||||
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
allSites.value = data.filter(item => item.is_active)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询用户选中的站点
|
||||
async function querySelectedSites() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
||||
selectedSites.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||
else return `bangumi:${props.media?.bangumi_id}`
|
||||
}
|
||||
|
||||
// 订阅弹窗选择的多季
|
||||
function subscribeSeasons() {
|
||||
subscribeSeasonDialog.value = false
|
||||
seasonsSelected.value.forEach(season => {
|
||||
addSubscribe(season.season_number)
|
||||
})
|
||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
@@ -90,33 +120,11 @@ function getChipColor(type: string) {
|
||||
}
|
||||
|
||||
// 添加订阅处理
|
||||
|
||||
async function handleAddSubscribe() {
|
||||
if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
|
||||
// TMDB电视剧
|
||||
// 查询TMDB所有季信息
|
||||
await getMediaSeasons()
|
||||
if (!seasonInfos.value) {
|
||||
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
|
||||
return
|
||||
}
|
||||
// 检查各季的缺失状态
|
||||
await checkSeasonsNotExists()
|
||||
if (!tmdbFlag.value) return
|
||||
|
||||
if (seasonInfos.value.length === 1) {
|
||||
// 添加订阅
|
||||
addSubscribe(1)
|
||||
} else {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
subscribeSeasonDialog.value = true
|
||||
}
|
||||
} else if (props.media?.type === '电视剧') {
|
||||
// 豆瓣电视剧,只会有一季
|
||||
const season = props.media?.season ?? 1
|
||||
// 添加订阅
|
||||
addSubscribe(season)
|
||||
if (props.media?.type === '电视剧') {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
subscribeSeasonDialog.value = true
|
||||
} else {
|
||||
// 电影
|
||||
addSubscribe()
|
||||
@@ -124,15 +132,12 @@ async function handleAddSubscribe() {
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season = 0) {
|
||||
async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
// 是否洗版
|
||||
let best_version = isExists.value ? 1 : 0
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonsNotExisted.value[season] ? 1 : 0
|
||||
if (!best_version && props.media?.type == '电影') best_version = isExists.value ? 1 : 0
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: props.media?.title,
|
||||
@@ -141,8 +146,10 @@ async function addSubscribe(season = 0) {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season,
|
||||
best_version,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
|
||||
// 订阅状态
|
||||
@@ -219,6 +226,9 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
registerAbortController(abortController)
|
||||
const { signal } = abortController
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
@@ -227,6 +237,7 @@ async function handleCheckExists() {
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
@@ -238,13 +249,16 @@ async function handleCheckExists() {
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season = 0) {
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
registerAbortController(abortController)
|
||||
const { signal } = abortController
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season,
|
||||
title: props.media?.title,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
@@ -255,51 +269,15 @@ async function checkSubscribe(season = 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查所有季的缺失状态(数据库)
|
||||
async function checkSeasonsNotExists() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
|
||||
if (result) {
|
||||
result.forEach(item => {
|
||||
// 0-已入库 1-部分缺失 2-全部缺失
|
||||
let state = 0
|
||||
if (item.episodes.length === 0) state = 2
|
||||
else if (item.episodes.length < item.total_episode) state = 1
|
||||
|
||||
seasonsNotExisted.value[item.season] = state
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(`${props.media?.title}无法识别TMDB媒体信息!`)
|
||||
tmdbFlag.value = false
|
||||
} finally {
|
||||
// 处理完成
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 查询TMDB的所有季信息
|
||||
async function getMediaSeasons() {
|
||||
try {
|
||||
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
// 非管理员不显示
|
||||
if (!store.state.auth.superUser) return false
|
||||
if (!userStore.superUser) return false
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
|
||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -313,39 +291,55 @@ function handleSubscribe() {
|
||||
else handleAddSubscribe()
|
||||
}
|
||||
|
||||
// 计算存在状态的颜色
|
||||
function getExistColor(season: number) {
|
||||
const state = seasonsNotExisted.value[season]
|
||||
if (!state) return 'success'
|
||||
|
||||
if (state === 1) return 'warning'
|
||||
else if (state === 2) return 'error'
|
||||
else return 'success'
|
||||
}
|
||||
|
||||
// 计算存在状态的文本
|
||||
function getExistText(season: number) {
|
||||
const state = seasonsNotExisted.value[season]
|
||||
if (!state) return '已入库'
|
||||
|
||||
if (state === 1) return '部分缺失'
|
||||
else if (state === 2) return '缺失'
|
||||
else return '已入库'
|
||||
// 订阅多季
|
||||
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
|
||||
subscribeSeasonDialog.value = false
|
||||
episodeGroup.value = groudId
|
||||
seasonsSelected.value = seasons || []
|
||||
seasonsSelected.value.forEach(season => {
|
||||
let best_version = 0
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
|
||||
addSubscribe(season.season_number, best_version)
|
||||
})
|
||||
}
|
||||
|
||||
// 打开详情页
|
||||
function goMediaDetail(isHovering = false) {
|
||||
if (isHovering) {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: getMediaId(),
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
if (props.media?.collection_id) {
|
||||
// 跳转到合集列表
|
||||
router.push({
|
||||
path: `/browse/tmdb/collection/${props.media?.collection_id}`,
|
||||
query: {
|
||||
title: props.media?.title,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 跳转到媒体详情页
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 点击搜索
|
||||
async function clickSearch() {
|
||||
if (allSites.value?.length == 0) {
|
||||
querySites()
|
||||
querySelectedSites()
|
||||
}
|
||||
chooseSiteDialog.value = true
|
||||
}
|
||||
|
||||
// 开始搜索
|
||||
function handleSearch() {
|
||||
router.push({
|
||||
@@ -354,15 +348,58 @@ function handleSearch() {
|
||||
keyword: getMediaId(),
|
||||
type: props.media?.type,
|
||||
area: 'title',
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 装载时检查是否已订阅
|
||||
onBeforeMount(() => {
|
||||
// 搜索多站点
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 懒加载检查
|
||||
function handleCheckLazy() {
|
||||
if (props.media?.collection_id) {
|
||||
return
|
||||
}
|
||||
handleCheckSubscribe()
|
||||
handleCheckExists()
|
||||
}
|
||||
|
||||
// 在元素进入视窗时触发懒加载函数
|
||||
function setupIntersectionObserver() {
|
||||
if (mediaCardRef.value) {
|
||||
observer.value = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
// 只要MediaCard进入视窗,就调用懒加载的操作
|
||||
handleCheckLazy()
|
||||
// 加载后销毁观察者实例
|
||||
observer.value?.disconnect()
|
||||
observer.value = null
|
||||
}
|
||||
})
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.value.observe(mediaCardRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer.value?.disconnect()
|
||||
observer.value = null
|
||||
})
|
||||
|
||||
// 计算图片地址
|
||||
@@ -378,26 +415,6 @@ const getImgUrl: Ref<string> = computed(() => {
|
||||
return url
|
||||
})
|
||||
|
||||
// 拼装季图片地址
|
||||
function getSeasonPoster(posterPath: string) {
|
||||
if (!posterPath) return ''
|
||||
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
||||
function formatAirDate(airDate: string) {
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
// 从yyyy-mm-dd中提取年份
|
||||
function getYear(airDate: string) {
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return date.getFullYear()
|
||||
}
|
||||
|
||||
// 移除订阅
|
||||
function onRemoveSubscribe() {
|
||||
subscribeEditDialog.value = false
|
||||
@@ -407,140 +424,96 @@ function onRemoveSubscribe() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none shadow ring-gray-500 rounded-lg"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
:class="hover.isHovering ? 'on-hover' : ''"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
<div ref="mediaCardRef">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none shadow ring-gray-500 media-card"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError || searchMenuShow"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
>
|
||||
<span class="font-bold">{{ props.media?.year }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.overview }}
|
||||
</p>
|
||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||
<div v-else class="flex align-center justify-between">
|
||||
<IconBtn icon="mdi-magnify" color="white" @click.stop="clickSearch" />
|
||||
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists && !hover.isHovering" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.vote_average }}
|
||||
</VChip>
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<span class="font-bold">{{ props.media?.year }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.overview }}
|
||||
</p>
|
||||
<div class="flex align-center justify-between">
|
||||
<IconBtn icon="mdi-magnify" color="white" @click.stop="handleSearch" />
|
||||
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
|
||||
</div>
|
||||
</VCardText>
|
||||
<VAvatar
|
||||
size="24"
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
</VCard>
|
||||
</VCardText>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists && !hover.isHovering" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ formatRating(props.media?.vote_average) }}
|
||||
</VChip>
|
||||
<!--来源图标-->
|
||||
<VAvatar
|
||||
size="24"
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅季弹窗 -->
|
||||
<VBottomSheet v-if="subscribeSeasonDialog" v-model="subscribeSeasonDialog" inset scrollable>
|
||||
<VCard class="rounded-t">
|
||||
<DialogCloseBtn @click="subscribeSeasonDialog = false" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
|
||||
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="90"
|
||||
width="60"
|
||||
:src="getSeasonPoster(item.poster_path || '')"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded shadow ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle> 第 {{ item.season_number }} 季 </VListItemTitle>
|
||||
<VListItemSubtitle class="mt-1 me-2">
|
||||
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
|
||||
<VIcon icon="mdi-star" /> {{ item.vote_average }}
|
||||
</VChip>
|
||||
{{ getYear(item.air_date || '') }} • {{ item.episode_count }} 集
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
《{{ media?.title }}》第 {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播。
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
<VChip v-if="seasonsNotExisted" class="mt-2" size="small" :color="getExistColor(item.season_number || 0)">
|
||||
{{ getExistText(item.season_number || 0) }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
<template #append="{ isSelected }">
|
||||
<VListItemAction start>
|
||||
<VSwitch :model-value="isSelected" />
|
||||
</VListItemAction>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<div class="my-2 text-center">
|
||||
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
|
||||
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</VBottomSheet>
|
||||
<subscribeSeasonDialog
|
||||
v-if="subscribeSeasonDialog"
|
||||
v-model="subscribeSeasonDialog"
|
||||
:media="media"
|
||||
@subscribe="subscribeSeasons"
|
||||
@close="subscribeSeasonDialog = false"
|
||||
/>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
@@ -550,10 +523,13 @@ function onRemoveSubscribe() {
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="onRemoveSubscribe"
|
||||
/>
|
||||
<!-- 站点选择对话框 -->
|
||||
<SearchSiteDialog
|
||||
v-if="chooseSiteDialog"
|
||||
v-model="chooseSiteDialog"
|
||||
:sites="allSites"
|
||||
:selected="selectedSites"
|
||||
@search="searchSites"
|
||||
@close="chooseSiteDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.on-hover img {
|
||||
@apply brightness-50;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Context } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
context: Object as PropType<Context>,
|
||||
})
|
||||
|
||||
@@ -45,8 +45,7 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem class="pb-1">
|
||||
<VCardTitle class="text-center text-md-left">
|
||||
{{ context?.media_info?.title || context?.meta_info?.name }}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useToast } from 'vue-toast-notification'
|
||||
import emby_image from '@images/logos/emby.png'
|
||||
import jellyfin_image from '@images/logos/jellyfin.png'
|
||||
import plex_image from '@images/logos/plex.png'
|
||||
import trimemedia_image from '@images/logos/trimemedia.png'
|
||||
import api from '@/api'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -101,6 +102,8 @@ const getIcon = computed(() => {
|
||||
return emby_image
|
||||
case 'jellyfin':
|
||||
return jellyfin_image
|
||||
case 'trimemedia':
|
||||
return trimemedia_image
|
||||
default:
|
||||
return plex_image
|
||||
}
|
||||
@@ -185,7 +188,7 @@ onMounted(() => {
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="mediaServerInfoDialog" />
|
||||
<VDivider />
|
||||
@@ -278,6 +281,53 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'trimemedia'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
label="名称"
|
||||
placeholder="必填;不可与其他名称重名"
|
||||
hint="媒体服务器的别名"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
label="地址"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="跳转播放页面使用的地址,格式:http(s)://domain:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
label="用户名"
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
label="密码"
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'plex'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
|
||||
@@ -45,24 +46,31 @@ function replaceNewLine(value: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink">
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
|
||||
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
|
||||
<VImg
|
||||
:src="props.message?.image"
|
||||
aspect-ratio="4/3"
|
||||
aspect-ratio="3/2"
|
||||
cover
|
||||
position="top"
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.message?.title && !props.message?.image && !props.message?.note"
|
||||
v-if="
|
||||
props.message?.title &&
|
||||
!props.message?.text &&
|
||||
!props.message?.image &&
|
||||
isNullOrEmptyObject(props.message?.note) &&
|
||||
props.message?.action === 0
|
||||
"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
>
|
||||
<p class="mb-0">{{ props.message?.title }}</p>
|
||||
</div>
|
||||
<VCardTitle v-else-if="props.message?.title">
|
||||
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
|
||||
{{ props.message?.title }}
|
||||
</VCardTitle>
|
||||
<div
|
||||
@@ -72,13 +80,13 @@ function replaceNewLine(value: string) {
|
||||
<p class="mb-0">{{ props.message?.text }}</p>
|
||||
</div>
|
||||
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
|
||||
<VCardText v-if="props.message?.note">
|
||||
<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">
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold whitespace-break-spaces">
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
|
||||
@@ -7,7 +7,7 @@ import synologychat_image from '@images/logos/synologychat.png'
|
||||
import slack_image from '@images/logos/slack.webp'
|
||||
import chrome_image from '@images/logos/chrome.png'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { cloneDeep } from "lodash"
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -132,7 +132,7 @@ function onClose() {
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.notification.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="notificationInfoDialog" />
|
||||
<VDivider />
|
||||
|
||||
@@ -77,9 +77,8 @@ function goPersonDetail() {
|
||||
v-bind="hover.props"
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
class="rounded-lg"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
@@ -116,7 +115,7 @@ function goPersonDetail() {
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.person-card {
|
||||
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
|
||||
}
|
||||
|
||||
@@ -43,11 +43,8 @@ const imageLoadError = ref(false)
|
||||
// 更新日志弹窗
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
// 计算插件标签
|
||||
const pluginLabels = computed(() => {
|
||||
if (!props.plugin?.plugin_label) return []
|
||||
return props.plugin.plugin_label.split(',')
|
||||
})
|
||||
// 插件详情弹窗
|
||||
const detailDialog = ref(false)
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
@@ -76,7 +73,7 @@ async function installPlugin() {
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
|
||||
|
||||
detailDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('install')
|
||||
} else {
|
||||
@@ -149,80 +146,150 @@ const dropdownItems = ref([
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
|
||||
<div class="me-n3 absolute bottom-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
|
||||
></div>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="text-white px-2 py-1 text-shadow line-clamp-3">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="detailDialog = true"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
|
||||
></div>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle
|
||||
class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="text-white text-sm px-2 py-0 text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span>
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
<div class="me-n3 absolute bottom-1 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem v-for="(item, i) in dropdownItems" v-show="item.show" :key="i" @click="item.props.click">
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 安装插件进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
<!-- 更新日志 -->
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
|
||||
<DialogCloseBtn @click="releaseDialog = false" />
|
||||
<VDivider />
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 插件详情-->
|
||||
<VDialog v-if="detailDialog" v-model="detailDialog" max-width="30rem">
|
||||
<VCard>
|
||||
<DialogCloseBtn @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
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@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">版本:</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">作者:</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"> 安装到本地 </VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />共 {{ props.count?.toLocaleString() }} 次下载
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
</VCardText>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span>
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 安装插件进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
<!-- 更新日志 -->
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
|
||||
<DialogCloseBtn @click="releaseDialog = false" />
|
||||
<VDivider />
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { VIcon } from 'vuetify/lib/components/index.mjs'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import FormRender from '@/components/render/FormRender.vue'
|
||||
import PageRender from '@/components/render/PageRender.vue'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import { isNullOrEmptyObject } from '@core/utils'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
|
||||
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -46,15 +41,12 @@ const isVisible = ref(true)
|
||||
// 插件配置页面
|
||||
const pluginConfigDialog = ref(false)
|
||||
|
||||
// 插件配置表单数据
|
||||
const pluginConfigForm = ref({})
|
||||
// 菜单显示状态
|
||||
const menuVisible = ref(false)
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 插件表单配置项
|
||||
let pluginFormItems = reactive([])
|
||||
|
||||
// 插件数据页面
|
||||
const pluginInfoDialog = ref(false)
|
||||
|
||||
@@ -64,9 +56,6 @@ const progressText = ref('正在更新插件...')
|
||||
// 用户头像是否加载完成
|
||||
const isAvatarLoaded = ref(false)
|
||||
|
||||
// 插件数据页面配置项
|
||||
let pluginPageItems = ref([])
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
@@ -134,75 +123,14 @@ async function uninstallPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API读取表单页面
|
||||
async function loadPluginForm() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
|
||||
if (result) {
|
||||
pluginFormItems = result.conf
|
||||
if (result.model) pluginConfigForm.value = result.model
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API读取数据页面
|
||||
async function loadPluginPage() {
|
||||
try {
|
||||
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
|
||||
if (result) pluginPageItems.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API读取配置数据
|
||||
async function loadPluginConf() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
|
||||
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API保存配置数据
|
||||
async function savePluginConf() {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
|
||||
if (result.success) {
|
||||
progressDialog.value = false
|
||||
pluginConfigDialog.value = false
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
progressDialog.value = false
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示插件数据
|
||||
async function showPluginInfo() {
|
||||
// 加载数据
|
||||
await loadPluginPage()
|
||||
pluginConfigDialog.value = false
|
||||
pluginInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 显示插件配置
|
||||
async function showPluginConfig() {
|
||||
// 加载表单
|
||||
await loadPluginForm()
|
||||
// 加载配置
|
||||
await loadPluginConf()
|
||||
// 显示对话框
|
||||
pluginInfoDialog.value = false
|
||||
pluginConfigDialog.value = true
|
||||
@@ -299,6 +227,12 @@ function openPluginDetail() {
|
||||
else showPluginConfig()
|
||||
}
|
||||
|
||||
// 配置完成
|
||||
function configDone() {
|
||||
pluginConfigDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -390,130 +324,138 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 插件卡片 -->
|
||||
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
|
||||
<div class="me-n3 absolute bottom-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
|
||||
/>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<VBadge v-if="props.plugin?.state" dot inline color="success" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="px-2 py-1 text-white text-shadow line-clamp-3">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span class="author-info">
|
||||
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
||||
<VIcon v-if="!isAvatarLoaded" icon="mdi-github" class="me-1" />
|
||||
</VImg>
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
</VCardText>
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
<div>
|
||||
<!-- 插件卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="isVisible"
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="openPluginDetail"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
|
||||
/>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<VBadge v-if="props.plugin?.state" dot inline color="success" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
<VCardText class="px-2 py-0 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span class="author-info">
|
||||
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
||||
<VIcon v-if="!isAvatarLoaded" icon="mdi-github" class="me-1" />
|
||||
</VImg>
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
<div class="me-n3 absolute bottom-1 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<div v-if="hover.isHovering" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon class="cursor-move text-white">mdi-drag</VIcon>
|
||||
</div>
|
||||
<div v-else-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<!-- 插件配置页面 -->
|
||||
<VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="pluginConfigDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
|
||||
查看数据
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 插件配置页面 -->
|
||||
<PluginConfigDialog
|
||||
v-if="pluginConfigDialog"
|
||||
v-model="pluginConfigDialog"
|
||||
:plugin="props.plugin"
|
||||
@save="configDone"
|
||||
@close="pluginConfigDialog = false"
|
||||
@switch="showPluginInfo"
|
||||
/>
|
||||
|
||||
<!-- 插件数据页面 -->
|
||||
<VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="pluginInfoDialog" />
|
||||
<VCardText class="min-h-40">
|
||||
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
<VFab icon="mdi-cog" location="bottom" size="x-large" fixed app appear @click="showPluginConfig" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 插件数据页面 -->
|
||||
<PluginDataDialog
|
||||
v-if="pluginInfoDialog"
|
||||
v-model="pluginInfoDialog"
|
||||
:plugin="props.plugin"
|
||||
@close="pluginInfoDialog = false"
|
||||
@switch="showPluginConfig"
|
||||
/>
|
||||
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
|
||||
<!-- 更新日志 -->
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
|
||||
<DialogCloseBtn @click="releaseDialog = false" />
|
||||
<VDivider />
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VBtn @click="updatePlugin" block>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
更新到最新版本
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 更新日志 -->
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
|
||||
<DialogCloseBtn @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>
|
||||
更新到最新版本
|
||||
</VBtn>
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -31,7 +31,7 @@ const getImgUrl = computed(() => {
|
||||
})
|
||||
|
||||
// 跳转播放
|
||||
function goPlay(isHovering = false) {
|
||||
function goPlay(isHovering: boolean | null = false) {
|
||||
if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
|
||||
}
|
||||
</script>
|
||||
@@ -43,18 +43,16 @@ function goPlay(isHovering = false) {
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none shadow ring-gray-500 rounded-lg"
|
||||
class="outline-none shadow ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
:class="hover.isHovering ? 'on-hover' : ''"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
@@ -78,7 +76,9 @@ function goPlay(isHovering = false) {
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<span class="font-bold">{{ props.media?.subtitle }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
@@ -89,9 +89,3 @@ function goPlay(isHovering = false) {
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.on-hover img {
|
||||
@apply brightness-50;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,8 +8,8 @@ import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
|
||||
import api from '@/api'
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { VCardActions, VExpandTransition, VProgressLinear, VSpacer } from 'vuetify/lib/components/index.mjs'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
|
||||
// 输入参数
|
||||
const cardProps = defineProps({
|
||||
@@ -20,6 +20,9 @@ const cardProps = defineProps({
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update', 'remove'])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 图标
|
||||
const siteIcon = ref<string>('')
|
||||
|
||||
@@ -27,7 +30,7 @@ const siteIcon = ref<string>('')
|
||||
const $toast = useToast()
|
||||
|
||||
// 测试按钮文字
|
||||
const testButtonText = ref('测试')
|
||||
const testButtonText = ref('测试连通性')
|
||||
|
||||
// 测试按钮可用性
|
||||
const testButtonDisable = ref(false)
|
||||
@@ -44,9 +47,6 @@ const resourceDialog = ref(false)
|
||||
// 用户数据弹窗
|
||||
const siteUserDataDialog = ref(false)
|
||||
|
||||
// 站点操作显示
|
||||
const siteActionShow = ref(false)
|
||||
|
||||
// 站点使用统计
|
||||
const siteStats = ref<SiteStatistic>({})
|
||||
|
||||
@@ -69,7 +69,7 @@ async function testSite() {
|
||||
if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
|
||||
else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
|
||||
|
||||
testButtonText.value = '测试'
|
||||
testButtonText.value = '测试连通性'
|
||||
testButtonDisable.value = false
|
||||
|
||||
getSiteStats()
|
||||
@@ -107,6 +107,25 @@ function openSitePage() {
|
||||
window.open(cardProps.site?.url, '_blank')
|
||||
}
|
||||
|
||||
// 调用API删除站点信息
|
||||
async function deleteSiteInfo() {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认删除站点?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`)
|
||||
if (result.success) emit('remove')
|
||||
else $toast.error(`${cardProps.site?.name} 删除失败:${result.message}`)
|
||||
} catch (error) {
|
||||
$toast.error(`${cardProps.site?.name} 删除失败!`)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据站点状态显示不同的状态图标
|
||||
const statColor = computed(() => {
|
||||
if (isNullOrEmptyObject(siteStats.value)) {
|
||||
@@ -121,10 +140,28 @@ const statColor = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 计算上传量和下载量的百分比
|
||||
const getPercentage = computed(() => {
|
||||
if (cardProps.data?.upload === 0) return 100
|
||||
return ((cardProps.data?.download ?? 0) / ((cardProps.data?.download ?? 0) + (cardProps.data?.upload ?? 0))) * 100
|
||||
// 数据百分比计算
|
||||
const getMaxDataValue = computed(() => {
|
||||
// 获取站点数据中的最大值作为基准
|
||||
const upload = cardProps.data?.upload || 0
|
||||
const download = cardProps.data?.download || 0
|
||||
|
||||
// 避免两者都为0的情况
|
||||
if (upload === 0 && download === 0) return 1
|
||||
|
||||
return Math.max(upload, download)
|
||||
})
|
||||
|
||||
// 上传百分比
|
||||
const getUploadPercent = computed(() => {
|
||||
const upload = cardProps.data?.upload || 0
|
||||
return Math.min(100, Math.max(3, (upload / getMaxDataValue.value) * 100))
|
||||
})
|
||||
|
||||
// 下载百分比
|
||||
const getDownloadPercent = computed(() => {
|
||||
const download = cardProps.data?.download || 0
|
||||
return Math.min(100, Math.max(3, (download / getMaxDataValue.value) * 100))
|
||||
})
|
||||
|
||||
// 保存站点
|
||||
@@ -155,93 +192,146 @@ onMounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
|
||||
class="overflow-hidden"
|
||||
@click="siteEditDialog = true"
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'inactive',
|
||||
{
|
||||
'status-error': statColor === 'error',
|
||||
'status-warning': statColor === 'warning',
|
||||
'status-success': statColor === 'success',
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
@click="handleResourceBrowse"
|
||||
>
|
||||
<template #image>
|
||||
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardItem style="padding-block-end: 0">
|
||||
<VCardTitle class="font-bold">
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText class="py-1">
|
||||
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.filter" text="过滤">
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-filter-cog-outline" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn
|
||||
:icon="siteActionShow ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click.stop="siteActionShow = !siteActionShow"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
↑ {{ formatFileSize(cardProps.data?.upload || 0) }} / ↓ {{ formatFileSize(cardProps.data?.download || 0) }}
|
||||
</span>
|
||||
<VSpacer />
|
||||
</VCardActions>
|
||||
<VDivider class="mb-1" v-if="siteActionShow" />
|
||||
<VExpandTransition>
|
||||
<div v-show="siteActionShow" class="py-1 pe-12">
|
||||
<VBtn v-if="!cardProps.site?.public" @click.stop="handleSiteUpdate" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" />
|
||||
</template>
|
||||
更新
|
||||
</VBtn>
|
||||
<VBtn :disabled="testButtonDisable" @click.stop="testSite" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-link" />
|
||||
</template>
|
||||
{{ testButtonText }}
|
||||
</VBtn>
|
||||
<VBtn @click.stop="handleResourceBrowse" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-web" />
|
||||
</template>
|
||||
浏览
|
||||
</VBtn>
|
||||
<VBtn @click.stop="handleSiteUserData" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-chart-bell-curve" />
|
||||
</template>
|
||||
数据
|
||||
</VBtn>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
|
||||
<!-- 主体部分 -->
|
||||
<div class="site-card-content relative flex-1 flex flex-col">
|
||||
<!-- 顶部:图标和站点名称 -->
|
||||
<div class="flex items-center mb-1">
|
||||
<!-- 站点图标 -->
|
||||
<div class="site-icon-container mr-2.5" @click.stop="siteEditDialog = true">
|
||||
<img :src="siteIcon" class="site-icon" :alt="cardProps.site?.name" />
|
||||
<div class="site-icon-edit-overlay">
|
||||
<VIcon icon="mdi-pencil" color="white" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖动图标 -->
|
||||
<VIcon icon="mdi-drag" size="20" class="drag-handle cursor-move opacity-40 mr-1.5 z-10" />
|
||||
|
||||
<!-- 站点名称和特性图标 -->
|
||||
<div class="flex-1 min-w-0 flex items-center">
|
||||
<h3 class="site-title truncate">{{ cardProps.site?.name }}</h3>
|
||||
|
||||
<!-- 站点特性图标 -->
|
||||
<div class="site-features flex items-center gap-1 ml-auto">
|
||||
<div v-if="cardProps.site?.limit_interval" class="feature-icon-wrapper">
|
||||
<VIcon icon="mdi-speedometer" size="16" class="site-feature-icon" />
|
||||
</div>
|
||||
<div v-if="cardProps.site?.proxy === 1" class="feature-icon-wrapper">
|
||||
<VIcon icon="mdi-network-outline" size="16" class="site-feature-icon" />
|
||||
</div>
|
||||
<div v-if="cardProps.site?.render === 1" class="feature-icon-wrapper">
|
||||
<VIcon icon="mdi-apple-safari" size="16" class="site-feature-icon" />
|
||||
</div>
|
||||
<div v-if="cardProps.site?.filter" class="feature-icon-wrapper">
|
||||
<VIcon icon="mdi-filter-cog-outline" size="16" class="site-feature-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
|
||||
<span class="absolute top-1 right-8">
|
||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||
</span>
|
||||
<div class="w-full absolute bottom-0" v-if="(cardProps.data?.upload || cardProps.data?.download || 0) > 0">
|
||||
<VProgressLinear :model-value="getPercentage" bg-color="success" color="warning" bg-opacity="0.5" height="3" />
|
||||
|
||||
<!-- 中间部分:网址 -->
|
||||
<div class="site-meta mb-1.5">
|
||||
<div class="site-url truncate" @click.stop="openSitePage">
|
||||
{{ cardProps.site?.url }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:数据统计 -->
|
||||
<div class="site-stats flex-1 flex flex-col justify-end">
|
||||
<!-- 更直观的上传下载数据条 -->
|
||||
<div class="data-transfer-stats">
|
||||
<!-- 上传数据 -->
|
||||
<div class="data-row upload-row">
|
||||
<div class="data-label">
|
||||
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
|
||||
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
|
||||
</div>
|
||||
<div class="data-progress-bar">
|
||||
<div class="progress-filled upload-filled" :style="`width: ${getUploadPercent}%`">
|
||||
<div class="progress-glow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下载数据 -->
|
||||
<div class="data-row download-row">
|
||||
<div class="data-label">
|
||||
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
|
||||
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
|
||||
</div>
|
||||
<div class="data-progress-bar">
|
||||
<div class="progress-filled download-filled" :style="`width: ${getDownloadPercent}%`">
|
||||
<div class="progress-glow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作按钮区 -->
|
||||
<div class="site-card-actions">
|
||||
<IconBtn
|
||||
elevation="0"
|
||||
class="site-action-btn test-btn"
|
||||
@click.stop="testSite"
|
||||
:class="{ 'testing': testButtonDisable }"
|
||||
>
|
||||
<div class="test-btn-content">
|
||||
<div class="pulse-dot" :class="statColor"></div>
|
||||
</div>
|
||||
<div v-if="testButtonDisable" class="loading-overlay">
|
||||
<div class="loading-spinner">
|
||||
<div class="spinner-circle"></div>
|
||||
<div class="spinner-circle-dot"></div>
|
||||
</div>
|
||||
<span class="loading-text">测试中</span>
|
||||
</div>
|
||||
</IconBtn>
|
||||
<IconBtn elevation="0" class="site-action-btn" @click.stop="handleSiteUserData">
|
||||
<VIcon icon="mdi-chart-bell-curve" size="18" />
|
||||
</IconBtn>
|
||||
<IconBtn elevation="0" class="site-action-btn" @click.stop="handleSiteUpdate">
|
||||
<VIcon icon="mdi-refresh" size="18" />
|
||||
</IconBtn>
|
||||
<IconBtn elevation="0" class="site-action-btn more-btn">
|
||||
<VIcon icon="mdi-dots-vertical" size="18" />
|
||||
<VMenu activator="parent" close-on-content-click location="left">
|
||||
<VList density="compact" nav class="dropdown-menu">
|
||||
<VListItem @click="siteEditDialog = true" base-color="info">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-file-edit-outline" size="small" />
|
||||
</template>
|
||||
<VListItemTitle>编辑站点</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="deleteSiteInfo">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete-outline" size="small" color="error" />
|
||||
</template>
|
||||
<VListItemTitle class="text-error">删除站点</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
<!-- 更新站点Cookie & UA弹窗 -->
|
||||
|
||||
<!-- 对话框组件 -->
|
||||
<SiteCookieUpdateDialog
|
||||
v-if="siteCookieDialog"
|
||||
v-model="siteCookieDialog"
|
||||
@@ -249,7 +339,6 @@ onMounted(() => {
|
||||
@close="siteCookieDialog = false"
|
||||
@done="onSiteCookieUpdated"
|
||||
/>
|
||||
<!-- 站点编辑弹窗 -->
|
||||
<SiteAddEditDialog
|
||||
v-if="siteEditDialog"
|
||||
v-model="siteEditDialog"
|
||||
@@ -258,14 +347,12 @@ onMounted(() => {
|
||||
@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"
|
||||
@@ -274,3 +361,647 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.site-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.09);
|
||||
border-radius: 10px;
|
||||
background: rgba(var(--v-theme-surface), 0.95);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.site-card:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.2);
|
||||
box-shadow: 0 3px 12px -6px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.inactive {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.site-card-content {
|
||||
z-index: 1;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
/* 站点状态指示器 - 更精致的渐变指示 */
|
||||
.site-status-indicator {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
block-size: 2px;
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
opacity: 0.5;
|
||||
transition: block-size 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.site-status-indicator.error {
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-error), 0.7), transparent);
|
||||
box-shadow: 0 0 8px rgba(var(--v-theme-error), 0.3);
|
||||
}
|
||||
|
||||
.site-status-indicator.warning {
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-warning), 0.7), transparent);
|
||||
box-shadow: 0 0 8px rgba(var(--v-theme-warning), 0.3);
|
||||
}
|
||||
|
||||
.site-status-indicator.success {
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-success), 0.7), transparent);
|
||||
box-shadow: 0 0 8px rgba(var(--v-theme-success), 0.3);
|
||||
}
|
||||
|
||||
.site-status-indicator.secondary {
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-secondary), 0.7), transparent);
|
||||
box-shadow: 0 0 8px rgba(var(--v-theme-secondary), 0.3);
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:hover .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 拖动手柄 */
|
||||
.drag-handle {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 数据显示相关样式 */
|
||||
.data-transfer-stats {
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
margin-block-start: 6px;
|
||||
padding-block-start: 6px;
|
||||
}
|
||||
|
||||
.data-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-block-end: 6px;
|
||||
}
|
||||
|
||||
.data-row:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.data-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.8rem;
|
||||
min-inline-size: 70px;
|
||||
}
|
||||
|
||||
.data-progress-bar {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
block-size: 4px;
|
||||
}
|
||||
|
||||
.progress-filled {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
block-size: 100%;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
min-inline-size: 3px;
|
||||
transition: inline-size 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.upload-filled {
|
||||
animation: pulse-width 2s infinite;
|
||||
background: linear-gradient(90deg, #4d79ff, #07f);
|
||||
box-shadow: 0 0 4px rgba(0, 119, 255, 50%);
|
||||
}
|
||||
|
||||
.download-filled {
|
||||
animation: pulse-width 2s infinite;
|
||||
background: linear-gradient(90deg, #42d392, #00b77e);
|
||||
box-shadow: 0 0 4px rgba(0, 183, 126, 50%);
|
||||
}
|
||||
|
||||
.progress-glow {
|
||||
position: absolute;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 50%), transparent);
|
||||
background-size: 200% 100%;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse-width {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 速度等级样式 */
|
||||
.speed-idle {
|
||||
animation: none !important;
|
||||
inline-size: 5% !important;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.speed-low {
|
||||
animation-duration: 6s !important;
|
||||
inline-size: 30% !important;
|
||||
}
|
||||
|
||||
.speed-medium {
|
||||
animation-duration: 4s !important;
|
||||
inline-size: 50% !important;
|
||||
}
|
||||
|
||||
.speed-high {
|
||||
animation-duration: 2s !important;
|
||||
inline-size: 70% !important;
|
||||
}
|
||||
|
||||
@keyframes pulse-width {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleX(0.95);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleX(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 站点图标 */
|
||||
.site-icon-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
block-size: 38px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 6%);
|
||||
cursor: pointer;
|
||||
inline-size: 38px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.site-icon-container:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.site-icon {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.site-icon-edit-overlay {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 50%);
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.site-icon-container:hover .site-icon-edit-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 站点标题 */
|
||||
.site-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 站点网址 */
|
||||
.site-url {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.site-url:hover {
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
}
|
||||
|
||||
/* 站点特性图标 */
|
||||
.site-feature-icon {
|
||||
color: rgba(var(--v-theme-primary), 0.95);
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 5%));
|
||||
margin-block: 0;
|
||||
margin-inline: 1px;
|
||||
opacity: 0.85;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.site-feature-icon:hover {
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 10%));
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 特性标签 */
|
||||
.site-features {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
/* 数据统计 */
|
||||
.site-stats {
|
||||
margin-block-start: auto;
|
||||
padding-block-start: 1rem;
|
||||
}
|
||||
|
||||
.site-data-values {
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.site-data-bar {
|
||||
overflow: hidden;
|
||||
border-radius: 1.5px;
|
||||
block-size: 3px;
|
||||
}
|
||||
|
||||
.site-data-bar-bg {
|
||||
position: absolute;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.05);
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.site-data-bar-upload {
|
||||
background-color: rgba(var(--v-theme-info), 0.4);
|
||||
}
|
||||
|
||||
.site-data-bar-download {
|
||||
background-color: rgba(var(--v-theme-success), 0.4);
|
||||
}
|
||||
|
||||
/* 状态样式 */
|
||||
.status-error {
|
||||
border-color: rgba(var(--v-theme-error), 0.2);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
border-color: rgba(var(--v-theme-warning), 0.2);
|
||||
}
|
||||
|
||||
.status-success {
|
||||
border-color: rgba(var(--v-theme-success), 0.2);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.site-card-actions {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(var(--v-theme-surface), 0.97);
|
||||
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.06);
|
||||
inset-block: 0;
|
||||
inset-inline-end: 0;
|
||||
padding-block: 8px;
|
||||
padding-inline: 4px;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* 测试按钮特殊样式 */
|
||||
.test-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 50% !important;
|
||||
block-size: 40px !important;
|
||||
inline-size: 40px !important;
|
||||
margin-block-end: 12px;
|
||||
min-inline-size: 40px;
|
||||
}
|
||||
|
||||
.test-btn-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
animation: fade-in 0.2s ease;
|
||||
background: rgba(var(--v-theme-surface), 0.95);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 10%);
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
block-size: 24px;
|
||||
inline-size: 24px;
|
||||
}
|
||||
|
||||
.spinner-circle {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(var(--v-theme-primary), 0.2);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
block-size: 100%;
|
||||
border-block-start-color: rgba(var(--v-theme-primary), 1);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.spinner-circle-dot {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite reverse;
|
||||
background-color: rgba(var(--v-theme-primary), 1);
|
||||
block-size: 4px;
|
||||
inline-size: 4px;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 50%;
|
||||
margin-block-start: -2px;
|
||||
margin-inline-start: -2px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
position: absolute;
|
||||
color: rgba(var(--v-theme-primary), 1);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
inset-block-end: -20px;
|
||||
margin-block-start: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
block-size: 22px;
|
||||
box-shadow: inset 0 0 0 2px rgba(var(--v-theme-on-surface), 0.1);
|
||||
inline-size: 22px;
|
||||
}
|
||||
|
||||
.pulse-dot::before {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border-radius: 50%;
|
||||
block-size: 70%;
|
||||
content: '';
|
||||
inline-size: 70%;
|
||||
inset-block-start: 15%;
|
||||
inset-inline-start: 15%;
|
||||
}
|
||||
|
||||
.pulse-dot::after {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
border-radius: 50%;
|
||||
block-size: 100%;
|
||||
content: '';
|
||||
inline-size: 100%;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
.pulse-dot.error::before {
|
||||
background-color: rgba(var(--v-theme-error), 1);
|
||||
box-shadow: 0 0 10px rgba(var(--v-theme-error), 0.8);
|
||||
}
|
||||
|
||||
.pulse-dot.error::after {
|
||||
animation: pulse-animation-error 2s infinite;
|
||||
box-shadow: 0 0 0 2px rgba(var(--v-theme-error), 0.3);
|
||||
}
|
||||
|
||||
.pulse-dot.warning::before {
|
||||
background-color: rgba(var(--v-theme-warning), 1);
|
||||
box-shadow: 0 0 10px rgba(var(--v-theme-warning), 0.8);
|
||||
}
|
||||
|
||||
.pulse-dot.warning::after {
|
||||
animation: pulse-animation-warning 2s infinite;
|
||||
box-shadow: 0 0 0 2px rgba(var(--v-theme-warning), 0.3);
|
||||
}
|
||||
|
||||
.pulse-dot.success::before {
|
||||
background-color: rgba(var(--v-theme-success), 1);
|
||||
box-shadow: 0 0 10px rgba(var(--v-theme-success), 0.8);
|
||||
}
|
||||
|
||||
.pulse-dot.success::after {
|
||||
animation: pulse-animation-success 2s infinite;
|
||||
box-shadow: 0 0 0 2px rgba(var(--v-theme-success), 0.3);
|
||||
}
|
||||
|
||||
.pulse-dot.secondary::before {
|
||||
background-color: rgba(var(--v-theme-secondary), 1);
|
||||
box-shadow: 0 0 10px rgba(var(--v-theme-secondary), 0.8);
|
||||
}
|
||||
|
||||
.pulse-dot.secondary::after {
|
||||
animation: pulse-animation-secondary 2s infinite;
|
||||
box-shadow: 0 0 0 2px rgba(var(--v-theme-secondary), 0.3);
|
||||
}
|
||||
|
||||
@keyframes pulse-animation-error {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0.6);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(var(--v-theme-error), 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-animation-warning {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0.6);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(var(--v-theme-warning), 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-animation-success {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0.6);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(var(--v-theme-success), 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-animation-secondary {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0.6);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(var(--v-theme-secondary), 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0);
|
||||
}
|
||||
}
|
||||
|
||||
.site-card:hover .site-card-actions {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.site-action-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface), 1);
|
||||
block-size: 32px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 5%);
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
margin-block-end: 4px;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.site-action-btn::before {
|
||||
position: absolute;
|
||||
background: radial-gradient(circle at center, rgba(var(--v-theme-primary), 0.1), transparent 70%);
|
||||
content: '';
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.site-action-btn:hover {
|
||||
background-color: white;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 10%);
|
||||
color: rgba(var(--v-theme-primary), 1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.site-action-btn:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.site-action-btn.animate-pulse {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-primary), 0.4);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(var(--v-theme-primary), 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-primary), 0);
|
||||
}
|
||||
}
|
||||
|
||||
.site-action-btn.more-btn {
|
||||
margin-block: auto 0;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.feature-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
block-size: 24px;
|
||||
inline-size: 24px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.feature-icon-wrapper:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,11 +5,14 @@ import storage_png from '@images/misc/storage.png'
|
||||
import alipan_png from '@images/misc/alipan.webp'
|
||||
import u115_png from '@images/misc/u115.png'
|
||||
import rclone_png from '@images/misc/rclone.png'
|
||||
import alist_png from '@images/misc/alist.svg'
|
||||
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 { useToast } from 'vue-toast-notification'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -42,6 +45,8 @@ const aliyunAuthDialog = ref(false)
|
||||
const u115AuthDialog = ref(false)
|
||||
// Rclone配置对话框
|
||||
const rcloneConfigDialog = ref(false)
|
||||
// AList配置对话框
|
||||
const aListConfigDialog = ref(false)
|
||||
|
||||
// 打开存储对话框
|
||||
function openStorageDialog() {
|
||||
@@ -55,6 +60,9 @@ function openStorageDialog() {
|
||||
case 'rclone':
|
||||
rcloneConfigDialog.value = true
|
||||
break
|
||||
case 'alist':
|
||||
aListConfigDialog.value = true
|
||||
break
|
||||
default:
|
||||
$toast.info('此存储类型无需配置参数,请直接配置目录!')
|
||||
break
|
||||
@@ -72,6 +80,8 @@ const getIcon = computed(() => {
|
||||
return u115_png
|
||||
case 'rclone':
|
||||
return rclone_png
|
||||
case 'alist':
|
||||
return alist_png
|
||||
default:
|
||||
return storage_png
|
||||
}
|
||||
@@ -109,6 +119,7 @@ function handleDone() {
|
||||
aliyunAuthDialog.value = false
|
||||
u115AuthDialog.value = false
|
||||
rcloneConfigDialog.value = false
|
||||
aListConfigDialog.value = false
|
||||
emit('done')
|
||||
}
|
||||
|
||||
@@ -122,7 +133,7 @@ onMounted(() => {
|
||||
<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>未配置</div>
|
||||
<div v-else-if="isNullOrEmptyObject(storage.config)">未配置</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-5" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
@@ -133,10 +144,17 @@ onMounted(() => {
|
||||
<AliyunAuthDialog
|
||||
v-if="aliyunAuthDialog"
|
||||
v-model="aliyunAuthDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="aliyunAuthDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<U115AuthDialog v-if="u115AuthDialog" v-model="u115AuthDialog" @close="u115AuthDialog = 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"
|
||||
@@ -144,4 +162,11 @@ onMounted(() => {
|
||||
@close="rcloneConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<AlistConfigDialog
|
||||
v-if="aListConfigDialog"
|
||||
v-model="aListConfigDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="aListConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -38,8 +38,11 @@ const subscribeFilesDialog = ref(false)
|
||||
// 分享订阅弹窗
|
||||
const subscribeShareDialog = ref(false)
|
||||
|
||||
// 当前的订阅状态
|
||||
const subscribeState = ref<string>(props.media?.state ?? 'P')
|
||||
|
||||
// 上一次更新时间
|
||||
const lastUpdateText = ref(props.media && props.media.last_update ? formatDateDifference(props.media.last_update) : '')
|
||||
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
@@ -81,13 +84,39 @@ async function searchSubscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换订阅状态
|
||||
async function toggleSubscribeStatus(state: 'R' | 'S') {
|
||||
try {
|
||||
// 根据传入的 state 判断对应的操作文字
|
||||
const action = state === 'S' ? '暂停' : '启用'
|
||||
// 弹出确认框
|
||||
const isConfirmed = await createConfirm({
|
||||
title: `确认${action}`,
|
||||
content: `是否${action}订阅 ${props.media?.name}?`,
|
||||
})
|
||||
if (!isConfirmed) return
|
||||
// 调用 API 更新订阅状态
|
||||
const result: { [key: string]: any } = await api.put(`subscribe/status/${props.media?.id}?state=${state}`)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
$toast.success(`${props.media?.name} 已${action}!`)
|
||||
subscribeState.value = state
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`${action}失败:${result.message}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置订阅
|
||||
async function resetSubscribe() {
|
||||
// 确认
|
||||
try {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `重置后 ${props.media?.name} 已下载记录将被清除,未入库的剧集将会重新下载,是否确认?`,
|
||||
content: `重置后 ${props.media?.name} 将恢复初始状态,已下载记录将被清除,未入库的内容将会重新下载,是否确认?`,
|
||||
})
|
||||
if (!isConfirmed) return
|
||||
// 重置
|
||||
@@ -95,6 +124,7 @@ async function resetSubscribe() {
|
||||
// 提示
|
||||
if (result.success) {
|
||||
$toast.success(`${props.media?.name} 重置成功!`)
|
||||
subscribeState.value = 'R'
|
||||
emit('save')
|
||||
} else $toast.error(`${props.media?.name} 重置失败:${result.message}`)
|
||||
} catch (e) {
|
||||
@@ -112,12 +142,22 @@ async function editSubscribeDialog() {
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||
else return props.media?.mediaid
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.name,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
@@ -129,7 +169,7 @@ async function viewSubscribeFiles() {
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
const dropdownItems = computed(() => [
|
||||
{
|
||||
title: '编辑',
|
||||
value: 1,
|
||||
@@ -163,18 +203,26 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '重置',
|
||||
title: subscribeState.value === 'S' ? '启用' : '暂停',
|
||||
value: 5,
|
||||
props: {
|
||||
prependIcon: subscribeState.value === 'S' ? 'mdi-play' : 'mdi-pause',
|
||||
click: () => toggleSubscribeStatus(subscribeState.value === 'S' ? 'R' : 'S'),
|
||||
color: subscribeState.value === 'S' ? 'success' : 'info',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '重置',
|
||||
value: 6,
|
||||
props: {
|
||||
prependIcon: 'mdi-restore-alert',
|
||||
click: resetSubscribe,
|
||||
color: 'warning',
|
||||
},
|
||||
show: props.media?.type === '电视剧',
|
||||
},
|
||||
{
|
||||
title: '分享',
|
||||
value: 6,
|
||||
value: 7,
|
||||
props: {
|
||||
prependIcon: 'mdi-share',
|
||||
click: shareSubscribe,
|
||||
@@ -184,7 +232,7 @@ const dropdownItems = ref([
|
||||
},
|
||||
{
|
||||
title: '取消订阅',
|
||||
value: 7,
|
||||
value: 8,
|
||||
props: {
|
||||
prependIcon: 'mdi-trash-can-outline',
|
||||
color: 'error',
|
||||
@@ -201,6 +249,14 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 监听订阅状态
|
||||
watch(
|
||||
() => props.media?.state,
|
||||
newState => {
|
||||
subscribeState.value = newState ?? 'P'
|
||||
},
|
||||
)
|
||||
|
||||
// 计算backdrop图片地址
|
||||
const backdropUrl = computed(() => {
|
||||
const url = props.media?.backdrop || props.media?.poster
|
||||
@@ -233,131 +289,134 @@ function onSubscribeEditRemove() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col rounded-lg"
|
||||
:class="{
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
min-height="170"
|
||||
@click="editSubscribeDialog"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-2">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem
|
||||
v-if="item.show !== false"
|
||||
variant="plain"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</VImg>
|
||||
</template>
|
||||
<div>
|
||||
<VCardText class="flex items-center">
|
||||
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap">
|
||||
<div class="flex align-center">
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<div v-if="props.media?.season" class="text-subtitle-2 me-4 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" color="white" class="me-1" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear
|
||||
v-if="getPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
'opacity-70': subscribeState === 'S',
|
||||
}"
|
||||
min-height="170"
|
||||
@click="editSubscribeDialog"
|
||||
:ripple="false"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-2">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="props.media?.id"
|
||||
@remove="onSubscribeEditRemove"
|
||||
@save="onSubscribeEditSave"
|
||||
@close="subscribeEditDialog = false"
|
||||
/>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</VImg>
|
||||
<div v-if="subscribeState === 'P'" class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none" />
|
||||
</template>
|
||||
<div>
|
||||
<VCardText class="flex items-center">
|
||||
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap">
|
||||
<div class="flex align-center">
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<div v-if="props.media?.season" class="text-subtitle-2 me-4 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" color="white" class="me-1" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear
|
||||
v-if="getPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="hover.isHovering" class="me-n3 absolute top-1 right-10">
|
||||
<IconBtn><VIcon class="cursor-move text-white">mdi-drag</VIcon></IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</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"
|
||||
/>
|
||||
<!-- 订阅文件信息弹窗 -->
|
||||
<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">
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<SubscribeShare>,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
// 定义删除事件
|
||||
const emit = defineEmits(['delete'])
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
@@ -28,6 +22,9 @@ const imageLoaded = ref(false)
|
||||
// 订阅编辑弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 复用订阅弹窗
|
||||
const forkSubscribeDialog = ref(false)
|
||||
|
||||
// 订阅ID
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
@@ -57,123 +54,130 @@ const posterUrl = computed(() => {
|
||||
return url
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.name,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 复用订阅
|
||||
async function forkSubscribe() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
// 确认
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认添加来自 ${props.media?.share_user} 分享的订阅:${props.media?.share_title}?`,
|
||||
})
|
||||
if (!isConfirmed) return
|
||||
function showForkSubscribe() {
|
||||
forkSubscribeDialog.value = true
|
||||
}
|
||||
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
|
||||
// 完成复用订阅
|
||||
function finishForkSubscribe(subid: number) {
|
||||
subscribeId.value = subid
|
||||
forkSubscribeDialog.value = false
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
|
||||
// 弹出订阅编辑弹窗
|
||||
subscribeId.value = result.data.id
|
||||
subscribeEditDialog.value = true
|
||||
} else {
|
||||
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}!`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
// 删除订阅分享时处理
|
||||
function doDelete() {
|
||||
forkSubscribeDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('delete')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col rounded-lg"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
min-height="170"
|
||||
@click="forkSubscribe"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
min-height="170"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pb-1 grow">
|
||||
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</VImg>
|
||||
</template>
|
||||
<div>
|
||||
<VCardText class="flex items-center pb-1">
|
||||
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center pl-2 xl:pl-4">
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.share_title }}
|
||||
<div class="flex flex-col justify-center pl-2 xl:pl-4">
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.share_title }}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.share_comment }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.share_comment }}
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap">
|
||||
<div class="flex align-center">
|
||||
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
|
||||
<div class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.share_user }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
|
||||
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.count.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap">
|
||||
<div class="flex align-center">
|
||||
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
|
||||
<div class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.share_user }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="error" class="me-1" />
|
||||
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.count.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-calcdar" class="me-1" />
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="subscribeEditDialog = false"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-calcdar" class="me-1" />
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</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">
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const meta = ref(props.torrent?.meta_info)
|
||||
const downloadItem = ref(props.torrent)
|
||||
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
const siteIcons = ref<Record<number, string>>({})
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
@@ -51,9 +51,10 @@ function addDownloadError(error: string) {
|
||||
}
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
async function getSiteIcon(site: number | undefined) {
|
||||
if (!site) return
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
|
||||
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -78,141 +79,191 @@ async function downloadTorrentFile() {
|
||||
window.open(torrent.value?.enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
else return 'text-white bg-gray-500'
|
||||
// 获取优惠类型样式
|
||||
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||
if (!downloadVolumeFactor) return 'free-discount'
|
||||
if (downloadVolumeFactor === 0) return 'free-discount'
|
||||
else if (downloadVolumeFactor < 1) return 'percent-discount'
|
||||
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 打开更多来源对话框
|
||||
async function openMoreTorrentsDialog() {
|
||||
props.more?.forEach(t => {
|
||||
return getSiteIcon(t.torrent_info?.site)
|
||||
})
|
||||
showMoreTorrents.value = true
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
getSiteIcon(props.torrent?.torrent_info?.site)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
|
||||
:width="props.width || '100%'"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="torrent-card h-full"
|
||||
:class="{ 'downloaded-card': downloaded.includes(torrent?.enclosure || '') }"
|
||||
>
|
||||
<template v-if="!showMoreTorrents" #image>
|
||||
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardItem class="py-1">
|
||||
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
|
||||
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
</VCardTitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail()">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="text-subtitle-2">
|
||||
{{ torrent?.title }}
|
||||
</VCardText>
|
||||
<VCardText>【{{ torrent?.site_name }}】{{ torrent?.description }}</VCardText>
|
||||
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
|
||||
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ torrent?.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in torrent?.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
||||
{{ meta?.video_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
||||
{{ formatFileSize(torrent?.size) }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ meta?.resource_team }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</VChip>
|
||||
</VCardItem>
|
||||
<VCardActions>
|
||||
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
|
||||
<template #append>
|
||||
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
|
||||
</template>
|
||||
更多来源
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
<VExpandTransition>
|
||||
<div v-show="showMoreTorrents">
|
||||
<VDivider />
|
||||
<VChipGroup class="p-3" column>
|
||||
<VChip v-for="(item, index) in props.more" :key="index" @click.stop="handleAddDownload(item)">
|
||||
<template #append>
|
||||
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
|
||||
<VBadge
|
||||
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||
:content="item.torrent_info?.volume_factor"
|
||||
inline
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
{{ item.torrent_info.site_name }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
<!-- 优惠标签 -->
|
||||
<div
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
class="discount-banner"
|
||||
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</div>
|
||||
|
||||
<!-- 媒体标题 -->
|
||||
<div class="card-header">
|
||||
<div class="media-title-wrapper flex flex-row flex-wrap justify-start">
|
||||
<span class="media-title me-2">
|
||||
{{ media?.title ?? meta?.name }}
|
||||
</span>
|
||||
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
|
||||
<!-- 站点信息条 -->
|
||||
<div class="site-info">
|
||||
<div class="d-flex align-center">
|
||||
<img
|
||||
:alt="torrent?.site_name"
|
||||
v-if="siteIcons[torrent?.site || 0]"
|
||||
:src="siteIcons[torrent?.site || 0]"
|
||||
class="site-icon"
|
||||
/>
|
||||
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
|
||||
<span class="site-name">{{ torrent?.site_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="seeder-peers">
|
||||
<span v-if="torrent?.seeders" class="seed-info">
|
||||
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
|
||||
</span>
|
||||
<span v-if="torrent?.peers" class="peer-info">
|
||||
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 种子内容 -->
|
||||
<div class="card-content">
|
||||
<!-- 种子标题 -->
|
||||
<div class="torrent-title" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<!-- 种子描述 -->
|
||||
<div
|
||||
v-if="meta?.subtitle || torrent?.description"
|
||||
class="torrent-desc grow"
|
||||
:title="meta?.subtitle || torrent?.description"
|
||||
>
|
||||
{{ meta?.subtitle || torrent?.description }}
|
||||
</div>
|
||||
|
||||
<!-- 资源标签区 -->
|
||||
<div class="tags-container">
|
||||
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
|
||||
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
|
||||
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
|
||||
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
|
||||
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
|
||||
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
|
||||
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片底部信息 -->
|
||||
<div class="card-footer">
|
||||
<div class="more-sources-wrapper" v-if="props.more && props.more.length > 0">
|
||||
<div class="more-sources-toggle" @click.stop="openMoreTorrentsDialog">
|
||||
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" size="small" class="me-1"></VIcon>
|
||||
<span>更多来源 ({{ props.more.length }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- 体积和详情按钮并排 -->
|
||||
<div class="card-actions">
|
||||
<div v-if="torrent?.size" class="size-badge">
|
||||
{{ formatFileSize(torrent.size) }}
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
size="small"
|
||||
icon="mdi-information-outline"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="detail-btn"
|
||||
@click.stop="openTorrentDetail"
|
||||
></VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 更多来源对话框 - 改为独立对话框 -->
|
||||
<VDialog v-model="showMoreTorrents" max-width="380px" location="center">
|
||||
<VCard>
|
||||
<VCardTitle class="py-2 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">
|
||||
<div
|
||||
v-for="(item, index) in props.more"
|
||||
:key="index"
|
||||
@click.stop="handleAddDownload(item)"
|
||||
class="more-source-item cursor-pointer"
|
||||
>
|
||||
<div class="source-site-info">
|
||||
<img
|
||||
:alt="item.torrent_info?.site_name"
|
||||
v-if="siteIcons[item.torrent_info?.site || 0]"
|
||||
:src="siteIcons[item.torrent_info?.site || 0]"
|
||||
class="source-site-icon"
|
||||
/>
|
||||
<span v-else class="source-site-fallback">{{ item.torrent_info?.site_name?.substring(0, 1) }}</span>
|
||||
<span class="source-site-name">{{ item.torrent_info.site_name }}</span>
|
||||
<span v-if="item.meta_info?.season_episode" class="season-tag source-season-tag">
|
||||
{{ item.meta_info.season_episode }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||
class="source-discount"
|
||||
:class="
|
||||
getPromotionClass(item.torrent_info?.downloadvolumefactor, item.torrent_info?.uploadvolumefactor)
|
||||
"
|
||||
>
|
||||
{{ item.torrent_info?.volume_factor }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="source-stats">
|
||||
<span class="source-size">{{ formatFileSize(item.torrent_info?.size) }}</span>
|
||||
<span class="source-seeders">
|
||||
<VIcon size="x-small" color="success" icon="mdi-arrow-up"></VIcon>
|
||||
{{ item.torrent_info?.seeders }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
@@ -227,3 +278,383 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.torrent-card {
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.torrent-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
.discount-banner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 0 0 0 12px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.free-discount {
|
||||
background-color: #4caf50;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.percent-discount {
|
||||
background-color: #ff5722;
|
||||
}
|
||||
|
||||
.upload-bonus {
|
||||
background-color: #9c27b0;
|
||||
}
|
||||
|
||||
.size-badge {
|
||||
background-color: rgba(var(--v-theme-primary), 0.9);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 4px;
|
||||
margin-right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.media-title-wrapper {
|
||||
margin-bottom: 8px;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.media-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||
}
|
||||
|
||||
.season-tag {
|
||||
font-size: 0.875rem;
|
||||
background-color: #5c6bc0;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.site-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.site-fallback {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
font-weight: 700;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-surface), 0.85);
|
||||
}
|
||||
|
||||
.seeder-peers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.seed-info {
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.peer-info {
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.torrent-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.torrent-desc {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.resource-tag {
|
||||
font-size: 0.8rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.edition {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
.resolution {
|
||||
background-color: #e91e63;
|
||||
}
|
||||
|
||||
.codec {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
|
||||
.team {
|
||||
background-color: #03a9f4;
|
||||
}
|
||||
|
||||
.expire {
|
||||
background-color: #9c27b0;
|
||||
}
|
||||
|
||||
.label {
|
||||
background-color: #3f51b5;
|
||||
}
|
||||
|
||||
.hr {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.more-sources-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.more-sources-toggle {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.more-sources-toggle:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.more-sources-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.more-source-item {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.more-source-item:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.source-site-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.source-site-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.source-site-fallback {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.source-site-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-season-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 1px 4px;
|
||||
margin-left: 4px;
|
||||
background-color: #5c6bc0;
|
||||
}
|
||||
|
||||
.source-discount {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.source-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-size {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.source-seeders {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-btn {
|
||||
border-radius: 50%;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.downloaded-card {
|
||||
border: 2px solid #4caf50 !important;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.resource-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-text {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.menu-activator {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.break-words {
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overflow-visible {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.whitespace-break-spaces {
|
||||
white-space: normal !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,9 +10,6 @@ const props = defineProps({
|
||||
torrent: Object as PropType<Context>,
|
||||
})
|
||||
|
||||
// 更多来源界面
|
||||
const showMoreTorrents = ref(false)
|
||||
|
||||
// 种子信息
|
||||
const torrent = ref(props.torrent?.torrent_info)
|
||||
|
||||
@@ -25,6 +22,10 @@ const meta = ref(props.torrent?.meta_info)
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 站点图标加载状态
|
||||
const iconLoading = ref(false)
|
||||
const iconError = ref(false)
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
|
||||
@@ -33,11 +34,35 @@ const addDownloadDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (!torrent?.value?.site || iconLoading.value) {
|
||||
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
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
iconError.value = true
|
||||
} finally {
|
||||
iconLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取优惠类型样式
|
||||
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||
if (!downloadVolumeFactor) return 'free-discount'
|
||||
if (downloadVolumeFactor === 0) return 'free-discount'
|
||||
else if (downloadVolumeFactor < 1) return 'percent-discount'
|
||||
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 询问并添加下载
|
||||
@@ -63,19 +88,6 @@ function openTorrentDetail() {
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile() {
|
||||
window.open(torrent.value?.enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
else return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
@@ -83,96 +95,89 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="list-item-wrapper">
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="torrent-item rounded"
|
||||
:class="{ 'downloaded-item': downloaded.includes(torrent?.enclosure || '') }"
|
||||
@click="handleAddDownload"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
>
|
||||
<template v-if="!showMoreTorrents" #prepend>
|
||||
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
<template v-slot:prepend>
|
||||
<div class="site-wrapper">
|
||||
<img :alt="torrent?.site_name" v-if="siteIcon" :src="siteIcon" class="site-icon" />
|
||||
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
|
||||
<div class="site-name d-none d-sm-block">{{ torrent?.site_name }}</div>
|
||||
<span
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
class="free-tag"
|
||||
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
|
||||
{{ torrent?.title }}
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
|
||||
<VListItemTitle class="item-content">
|
||||
<div class="item-header">
|
||||
<div class="media-info flex flex-row flex-wrap justify-start">
|
||||
<span class="media-title me-2">{{ media?.title ?? meta?.name }}</span>
|
||||
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="torrent-title" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<div class="torrent-description" :title="meta?.subtitle || torrent?.description || '暂无描述'">
|
||||
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
|
||||
</div>
|
||||
|
||||
<div class="tags-container">
|
||||
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
|
||||
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
|
||||
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
|
||||
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
|
||||
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
|
||||
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
|
||||
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
|
||||
</div>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle> 【{{ torrent?.site_name }}】{{ torrent?.description }} </VListItemSubtitle>
|
||||
<div v-if="torrent?.labels" class="pt-2">
|
||||
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ torrent?.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in torrent?.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
||||
{{ meta?.video_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
||||
{{ formatFileSize(torrent?.size) }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ meta?.resource_team }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail()">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="item-actions">
|
||||
<div class="torrent-stats">
|
||||
<span v-if="torrent?.seeders" class="seed-info">
|
||||
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
|
||||
</span>
|
||||
<span v-if="torrent?.peers" class="peer-info">
|
||||
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<div v-if="torrent?.size" class="size-badge">
|
||||
{{ formatFileSize(torrent.size) }}
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
density="comfortable"
|
||||
variant="text"
|
||||
color="primary"
|
||||
icon="mdi-information-outline"
|
||||
size="small"
|
||||
class="detail-btn"
|
||||
@click.stop="openTorrentDetail"
|
||||
></VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
:title="`${media?.title_year || meta?.name} ${meta?.season_episode}`"
|
||||
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
|
||||
:media="media"
|
||||
:torrent="torrent"
|
||||
@done="addDownloadSuccess"
|
||||
@@ -181,3 +186,295 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-item-wrapper {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
padding: 12px;
|
||||
box-shadow: none;
|
||||
margin-block-end: 8px;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.torrent-item:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.site-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
min-inline-size: 100px;
|
||||
}
|
||||
|
||||
.site-icon {
|
||||
border-radius: 4px;
|
||||
block-size: 32px;
|
||||
inline-size: 32px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.site-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
block-size: 24px;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
inline-size: 24px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.season-tag {
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background-color: #5c6bc0;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-inline-end: 8px;
|
||||
padding-block: 2px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.free-tag {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
padding-block: 2px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.free-discount {
|
||||
background-color: #4caf50;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.percent-discount {
|
||||
background-color: #ff5722;
|
||||
}
|
||||
|
||||
.upload-bonus {
|
||||
background-color: #9c27b0;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-title {
|
||||
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.torrent-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.seed-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.peer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.size-badge {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-inline-end: 6px;
|
||||
padding-block: 2px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.torrent-title {
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||
font-size: 0.9rem;
|
||||
margin-block-end: 6px;
|
||||
max-inline-size: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.torrent-description {
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), 0.65);
|
||||
font-size: 0.8rem;
|
||||
inline-size: 100%;
|
||||
margin-block-end: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.resource-tag {
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
padding-block: 3px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.edition {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
.resolution {
|
||||
background-color: #e91e63;
|
||||
}
|
||||
|
||||
.codec {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
|
||||
.team {
|
||||
background-color: #03a9f4;
|
||||
}
|
||||
|
||||
.expire {
|
||||
background-color: #9c27b0;
|
||||
}
|
||||
|
||||
.label {
|
||||
background-color: #3f51b5;
|
||||
}
|
||||
|
||||
.hr {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.detail-btn {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.downloaded-item {
|
||||
border-inline-start: 4px solid #4caf50;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.break-words {
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.overflow-visible {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.whitespace-break-spaces {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.torrent-item {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.site-icon,
|
||||
.site-fallback {
|
||||
block-size: 24px;
|
||||
inline-size: 24px;
|
||||
}
|
||||
|
||||
.site-wrapper {
|
||||
flex-wrap: wrap;
|
||||
margin-inline-end: 10px;
|
||||
min-inline-size: 24px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 0.8rem;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
.size-badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.resource-tag {
|
||||
font-size: 0.75rem;
|
||||
padding-block: 2px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.torrent-description {
|
||||
max-inline-size: calc(100vw - 150px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { Subscribe, User } from '@/api/types'
|
||||
import store from '@/store'
|
||||
import { useUserStore } from '@/stores'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
||||
|
||||
// 扩展User类型以包含昵称字段
|
||||
interface ExtendedUser extends User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 定义输入变量
|
||||
const props = defineProps({
|
||||
// 用户信息
|
||||
user: {
|
||||
type: Object as PropType<User>,
|
||||
type: Object as PropType<ExtendedUser>,
|
||||
required: true,
|
||||
},
|
||||
// 所有用户
|
||||
@@ -22,10 +27,10 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// 当前用户的ID
|
||||
const currentLoginUserId = computed(() => store.state.auth.userID)
|
||||
const currentLoginUserId = computed(() => useUserStore().userID)
|
||||
|
||||
// 当前用户是否是管理员
|
||||
const currentUserIsSuperuser = computed(() => store.state.auth.superUser)
|
||||
const currentUserIsSuperuser = computed(() => useUserStore().superUser)
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save'])
|
||||
@@ -45,6 +50,29 @@ const movieSubscriptions = ref(0)
|
||||
// 用户电视剧订阅数量
|
||||
const tvShowSubscriptions = ref(0)
|
||||
|
||||
// 是否显示更多操作菜单
|
||||
const showMenu = ref(false)
|
||||
|
||||
// 鼠标悬停状态
|
||||
const isHovered = ref(false)
|
||||
|
||||
// 是否为移动设备
|
||||
const isMobile = ref(window.innerWidth < 600)
|
||||
|
||||
// 显示名称 - 如果有昵称则优先显示昵称
|
||||
const displayName = computed(() => {
|
||||
const settingsNickname = props.user.settings?.nickname as string | undefined
|
||||
const nickname = props.user.nickname || settingsNickname
|
||||
return nickname || props.user.name
|
||||
})
|
||||
|
||||
// 计算用户卡片状态类
|
||||
const cardStatusClass = computed(() => {
|
||||
if (!props.user.is_active) return 'user-card-inactive'
|
||||
if (props.user.is_superuser) return 'user-card-admin'
|
||||
return ''
|
||||
})
|
||||
|
||||
// 按用户查询订阅数量
|
||||
async function fetchSubscriptions() {
|
||||
try {
|
||||
@@ -87,98 +115,155 @@ function editUser() {
|
||||
userEditDialog.value = true
|
||||
}
|
||||
|
||||
// 用户重新完成时
|
||||
// 用户更新完成时
|
||||
function onUserUpdate() {
|
||||
userEditDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// 更新窗口大小监听
|
||||
function handleResize() {
|
||||
isMobile.value = window.innerWidth < 600
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSubscriptions()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VCard>
|
||||
<VCardText class="text-center pt-10 pb-3">
|
||||
<VAvatar variant="flat" size="100" rounded>
|
||||
<VImg :src="user.avatar || avatar1" alt="avatar" />
|
||||
</VAvatar>
|
||||
<h5 class="text-h5 mt-3">{{ user.name }}</h5>
|
||||
<VChip size="small" class="mt-3" :class="{ 'text-error': user.is_superuser }">
|
||||
{{ user.is_superuser ? '管理员' : '普通用户' }}
|
||||
</VChip>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-center gap-6 pb-5">
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
|
||||
<VIcon size="24" icon="mdi-movie-open-outline"></VIcon>
|
||||
<VCard
|
||||
class="user-card"
|
||||
:class="[{ 'user-card-hover': isHovered }, cardStatusClass, { 'mobile-card': isMobile }]"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<!-- 管理员卡片装饰 -->
|
||||
<div v-if="user.is_superuser" class="admin-decoration">
|
||||
<div class="decoration-line"></div>
|
||||
<div class="decoration-circle"><VIcon icon="mdi-shield-star" size="x-small" color="warning" /></div>
|
||||
<div class="decoration-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<div class="user-card-header" :class="{ 'admin-header': user.is_superuser }">
|
||||
<div class="user-avatar-container">
|
||||
<VAvatar
|
||||
:size="isMobile ? 50 : 74"
|
||||
rounded="lg"
|
||||
class="user-avatar"
|
||||
:class="{ 'admin-avatar': user.is_superuser, 'inactive-avatar': !user.is_active }"
|
||||
>
|
||||
<VImg :src="user.avatar || avatar1" :alt="user.name" />
|
||||
<div v-if="!user.is_active" class="avatar-overlay">
|
||||
<VIcon icon="mdi-account-lock" color="white" size="small" />
|
||||
</div>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h6">{{ movieSubscriptions }}</div>
|
||||
<div class="text-sm text-no-wrap">电影订阅</div>
|
||||
<div v-if="user.is_superuser" class="admin-crown">
|
||||
<VIcon icon="mdi-crown" color="warning" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
|
||||
<VIcon size="24" icon="mdi-television"></VIcon>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h6">{{ tvShowSubscriptions }}</div>
|
||||
<div class="text-sm text-no-wrap">电视剧订阅</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="pb-6">
|
||||
<VDivider class="my-2">
|
||||
<h5 class="text-h6">详情</h5>
|
||||
</VDivider>
|
||||
<VList lines="one">
|
||||
<VListItem>
|
||||
<VListItemTitle class="text-sm">
|
||||
<span class="font-weight-medium">邮箱:</span><span class="text-body-1"> {{ user.email }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VListItemTitle class="text-sm">
|
||||
<span class="font-weight-medium">状态:</span
|
||||
><span class="text-body-1">
|
||||
<VChip size="small" :class="{ 'text-success': user.is_active }" variant="tonal">
|
||||
|
||||
<div class="user-info">
|
||||
<div class="user-name-section">
|
||||
<div class="name-and-badges">
|
||||
<h3 class="user-name" :class="{ 'admin-name': user.is_superuser, 'inactive-name': !user.is_active }">
|
||||
{{ displayName }}
|
||||
<VIcon
|
||||
v-if="user.nickname || user.settings?.nickname"
|
||||
icon="mdi-format-quote-close"
|
||||
size="x-small"
|
||||
color="info"
|
||||
class="nickname-icon"
|
||||
/>
|
||||
</h3>
|
||||
<div class="user-badges">
|
||||
<VChip v-if="user.is_superuser" size="x-small" color="error" class="user-badge admin-badge">管理员</VChip>
|
||||
<VChip v-else size="x-small" color="default" class="user-badge">普通用户</VChip>
|
||||
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" class="user-badge">
|
||||
{{ user.is_active ? '激活' : '已停用' }}
|
||||
</VChip>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VListItemTitle class="text-sm">
|
||||
<span class="font-weight-medium">双重认证:</span
|
||||
><span class="text-body-1">
|
||||
<VChip size="small" :class="{ 'text-success': user.is_otp }" variant="tonal">
|
||||
{{ user.is_otp ? '已启用' : '未启用' }}
|
||||
</VChip>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<VCardText class="flex flex-row justify-center">
|
||||
<VBtn
|
||||
v-if="currentUserIsSuperuser"
|
||||
color="primary"
|
||||
class="me-4"
|
||||
@click="editUser"
|
||||
>
|
||||
编辑
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="currentUserIsSuperuser && props.user.id != currentLoginUserId"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
@click="removeUser"
|
||||
>
|
||||
删除
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" class="user-badge"> 2FA </VChip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端订阅数据信息 -->
|
||||
<div v-if="isMobile" class="mobile-stats">
|
||||
<div class="mobile-stat-item">
|
||||
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" />
|
||||
<span>{{ movieSubscriptions }}</span>
|
||||
</div>
|
||||
<div class="mobile-stat-item">
|
||||
<VIcon size="x-small" icon="mdi-television-classic" color="primary" />
|
||||
<span>{{ tvShowSubscriptions }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 头部操作按钮 -->
|
||||
<div class="user-actions" :class="{ 'mobile-actions': isMobile }">
|
||||
<VBtn
|
||||
icon
|
||||
size="small"
|
||||
:color="user.is_superuser ? 'warning' : 'primary'"
|
||||
variant="text"
|
||||
@click="editUser"
|
||||
class="action-btn"
|
||||
>
|
||||
<VIcon icon="mdi-pencil" />
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
|
||||
icon
|
||||
size="small"
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="removeUser"
|
||||
class="action-btn"
|
||||
>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 独立的邮箱显示 -->
|
||||
<div class="email-container" :class="{ 'admin-email': user.is_superuser, 'inactive-email': !user.is_active }">
|
||||
<VIcon icon="mdi-email-outline" size="small" color="primary" class="email-icon" />
|
||||
<span class="email-text">{{ user.email || '未设置邮箱' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- PC端显示订阅统计信息 -->
|
||||
<div v-if="!isMobile" class="user-card-body">
|
||||
<div class="user-stats-container">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon-container" :class="{ 'admin-stat': user.is_superuser }">
|
||||
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ movieSubscriptions }}</div>
|
||||
<div class="stat-label">电影订阅</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon-container" :class="{ 'admin-stat': user.is_superuser }">
|
||||
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" size="20" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ tvShowSubscriptions }}</div>
|
||||
<div class="stat-label">剧集订阅</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 用户编辑弹窗 -->
|
||||
<UserAddEditDialog
|
||||
v-if="userEditDialog"
|
||||
@@ -190,3 +275,510 @@ onMounted(() => {
|
||||
@close="userEditDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-card-hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.user-card-admin {
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box, border-box;
|
||||
background-image: linear-gradient(rgb(var(--v-theme-surface)), rgb(var(--v-theme-surface))),
|
||||
linear-gradient(120deg, rgba(var(--v-theme-warning), 0.5), rgba(var(--v-theme-error), 0.5));
|
||||
background-origin: border-box;
|
||||
}
|
||||
|
||||
.user-card-inactive {
|
||||
position: relative;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
background-color: rgba(var(--v-theme-surface), 0.95);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.user-card-inactive::before {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
backdrop-filter: grayscale(30%);
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-decoration {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
padding-block: 4px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.decoration-line {
|
||||
flex: 1;
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.1), rgba(var(--v-theme-warning), 0.7));
|
||||
block-size: 1px;
|
||||
}
|
||||
|
||||
.decoration-line:last-child {
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.7), rgba(var(--v-theme-warning), 0.1));
|
||||
}
|
||||
|
||||
.decoration-circle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-warning), 0.5);
|
||||
border-radius: 50%;
|
||||
block-size: 18px;
|
||||
inline-size: 18px;
|
||||
margin-block: 0;
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
.user-card-header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
padding-block: 20px 12px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(to bottom, rgba(var(--v-theme-warning), 0.05), transparent);
|
||||
}
|
||||
|
||||
.user-avatar-container {
|
||||
position: relative;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
border: 4px solid rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 4px 8px rgba(var(--v-theme-on-surface), 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.admin-avatar {
|
||||
border: 4px solid rgba(var(--v-theme-warning), 0.1);
|
||||
box-shadow: 0 5px 15px rgba(var(--v-theme-warning), 0.2);
|
||||
}
|
||||
|
||||
.admin-avatar::after {
|
||||
position: absolute;
|
||||
border: 1px solid rgba(var(--v-theme-warning), 0.3);
|
||||
border-radius: 12px;
|
||||
animation: pulse 2.5s infinite;
|
||||
content: '';
|
||||
inset: -5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
70% {
|
||||
opacity: 0.2;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.inactive-avatar {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.1);
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.avatar-overlay {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(1px);
|
||||
background: rgba(var(--v-theme-on-surface), 0.2);
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.otp-badge {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: glow 2s infinite alternate;
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.otp-badge .v-icon {
|
||||
color: #4caf50 !important;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 40%));
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
from {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-otp {
|
||||
inset-block-end: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-otp .v-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.admin-crown {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 40%));
|
||||
inset-block-start: -10px;
|
||||
inset-inline-start: -6px;
|
||||
transform: rotate(-25deg);
|
||||
}
|
||||
|
||||
.admin-crown .v-icon {
|
||||
color: #ffc107 !important;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: rotate(-25deg) translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-25deg) translateY(-3px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(-25deg) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.nickname-icon {
|
||||
animation: pulse-nickname 2s ease infinite;
|
||||
filter: brightness(1.1);
|
||||
margin-inline-start: 4px;
|
||||
opacity: 0.9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes pulse-nickname {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
margin-inline-end: 6px;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.user-card:hover .drag-handle {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.user-name-section {
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
.name-and-badges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-block-end: 4px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-block: 0 4px;
|
||||
margin-inline: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-name {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
font-weight: 700;
|
||||
text-shadow: 0 1px 2px rgba(var(--v-theme-warning), 0.1);
|
||||
}
|
||||
|
||||
.inactive-name {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
}
|
||||
|
||||
.user-badges {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
margin-block-end: 4px;
|
||||
-ms-overflow-style: none;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.user-badges::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
border: 1px solid rgba(var(--v-theme-error), 0.3);
|
||||
}
|
||||
|
||||
.user-account,
|
||||
.user-email {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.8rem;
|
||||
inline-size: 100%;
|
||||
inset-block-start: 100%;
|
||||
inset-inline-start: 0;
|
||||
margin-block-start: 4px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.5);
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
.account-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
margin-inline-end: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.email-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
inset-block-start: 10px;
|
||||
inset-inline-end: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.mobile-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.mobile-stats {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 20px;
|
||||
margin-block-start: 8px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.mobile-stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.95rem;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mobile-stat-item .v-icon {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.mobile-stat-item span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-card-body {
|
||||
padding-block: 0 16px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.user-stats-container {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.02);
|
||||
margin-block-start: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
block-size: 40px;
|
||||
box-shadow: 0 2px 6px rgba(var(--v-theme-on-surface), 0.05);
|
||||
inline-size: 40px;
|
||||
}
|
||||
|
||||
.admin-stat {
|
||||
background-color: rgba(var(--v-theme-warning), 0.1);
|
||||
box-shadow: 0 2px 6px rgba(var(--v-theme-warning), 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
|
||||
.email-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-email {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.inactive-email {
|
||||
background-color: transparent;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.email-container .email-icon {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.email-container .email-text {
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.9rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-card .email-container {
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.mobile-card .email-container .email-text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.mobile-card .user-avatar-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-card .otp-badge {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset-block-end: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
317
src/components/cards/WorkflowTaskCard.vue
Normal file
317
src/components/cards/WorkflowTaskCard.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<script lang="ts" setup>
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
||||
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
workflow: {
|
||||
required: true,
|
||||
type: Object as PropType<Workflow>,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 编辑对话框
|
||||
const editDialog = ref(false)
|
||||
|
||||
// 流程对话框
|
||||
const flowDialog = ref(false)
|
||||
|
||||
// 加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 编辑任务
|
||||
function handleEdit(item: Workflow) {
|
||||
editDialog.value = true
|
||||
}
|
||||
|
||||
// 编辑流程
|
||||
function handleFlow(item: Workflow) {
|
||||
flowDialog.value = true
|
||||
}
|
||||
|
||||
// 计算已完成的动作数
|
||||
function resolveDoneActions(item: Workflow) {
|
||||
return item.current_action?.split(',').length || 0
|
||||
}
|
||||
|
||||
// 编辑完成
|
||||
function editDone() {
|
||||
editDialog.value = false
|
||||
flowDialog.value = false
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
async function handleDelete(item: Workflow) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认删除任务 ${item.name} ?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.delete(`workflow/${item.id}`)
|
||||
if (result.success) {
|
||||
$toast.success('删除任务成功!')
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(`删除任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始任务
|
||||
async function handleEnable(item: Workflow) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/start`)
|
||||
if (result.success) {
|
||||
$toast.success('启用任务成功!')
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(`启用任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 停用任务
|
||||
async function handlePause(item: Workflow) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/pause`)
|
||||
if (result.success) {
|
||||
$toast.success('停用任务成功!')
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(`停用任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 立即执行任务
|
||||
async function handleRun(item: Workflow, from_begin: boolean) {
|
||||
loading.value = true
|
||||
try {
|
||||
setTimeout(() => {
|
||||
emit('refresh')
|
||||
}, 500)
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/run?from_begin=${from_begin}`, {
|
||||
from_begin,
|
||||
})
|
||||
if (result.success) {
|
||||
$toast.success('任务执行完成!')
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(`任务执行失败:${result.message}`)
|
||||
emit('refresh')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 重置任务
|
||||
async function handleReset(item: Workflow) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认重置任务 ${item.name} ?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/reset`)
|
||||
if (result.success) {
|
||||
$toast.success('重置任务成功!')
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(`重置任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算状态颜色
|
||||
const resolveStatusVariant = (status: string | undefined) => {
|
||||
if (status === 'S') return { color: 'success', text: '成功' }
|
||||
else if (status === 'R') return { color: 'primary', text: '运行中' }
|
||||
else if (status === 'F') return { color: 'error', text: '失败' }
|
||||
else if (status === 'P') return { color: 'secondary', text: '暂停' }
|
||||
else return { color: 'info', text: '等待' }
|
||||
}
|
||||
|
||||
// 计算当前动作占比
|
||||
const resolveProgress = (item: Workflow) => {
|
||||
const current_action_length = item.current_action?.split(',').length || 0
|
||||
return item.actions?.length ? Math.round((current_action_length / (item.actions.length || 1)) * 100) : 0
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
class="mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<VCardItem class="py-3" :class="`bg-${resolveStatusVariant(workflow?.state).color}`">
|
||||
<template #prepend>
|
||||
<VAvatar variant="text" class="me-2">
|
||||
<VIcon
|
||||
v-if="workflow?.state === 'P'"
|
||||
color="success"
|
||||
size="x-large"
|
||||
icon="mdi-play"
|
||||
@click.stop="handleEnable(workflow)"
|
||||
/>
|
||||
<VIcon v-else color="warning" icon="mdi-pause" size="x-large" @click.stop="handlePause(workflow)" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle class="text-white">
|
||||
{{ workflow?.name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle class="text-white">{{ workflow?.description }}</VCardSubtitle>
|
||||
<template #append>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-vector-polyline-edit" @click.stop="handleFlow(workflow)" />
|
||||
</IconBtn>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem base-color="primary" @click="handleEdit(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-note-edit" />
|
||||
</template>
|
||||
<VListItemTitle>编辑任务</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, false)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-play-speed" />
|
||||
</template>
|
||||
<VListItemTitle>继续执行</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, true)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-replay" />
|
||||
</template>
|
||||
<VListItemTitle>重新执行</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-else base-color="info" @click="handleRun(workflow, true)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-run" />
|
||||
</template>
|
||||
<VListItemTitle>立即执行</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem base-color="warning" @click="handleReset(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-restore-alert" />
|
||||
</template>
|
||||
<VListItemTitle>重置任务</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem base-color="error" @click="handleDelete(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
<VListItemTitle>删除任务</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="d-flex flex-column gap-y-4">
|
||||
<div class="d-flex flex-wrap gap-x-6">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">定时</div>
|
||||
<h5 class="text-h6">{{ workflow?.timer }}</h5>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">状态</div>
|
||||
<h5 class="text-h6" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
|
||||
{{ resolveStatusVariant(workflow?.state).text }}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-x-6">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">动作数</div>
|
||||
<div>
|
||||
<VAvatar size="32" color="primary" variant="tonal">
|
||||
<span class="text-sm">{{ workflow?.actions?.length }}</span>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">已执行次数</div>
|
||||
<h5 class="text-h6">{{ workflow?.run_count }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-x-6">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">进度</div>
|
||||
<div class="d-flex align-center gap-5">
|
||||
<div class="flex-grow-1">
|
||||
<VProgressLinear color="info" rounded :model-value="resolveProgress(workflow)" />
|
||||
</div>
|
||||
<span> {{ resolveProgress(workflow) }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-x-6" v-if="workflow?.result">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">错误信息</div>
|
||||
<div class="text-error">{{ workflow?.result }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,7 +4,7 @@ import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { VCardTitle } from 'vuetify/lib/components/index.mjs'
|
||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -13,6 +13,9 @@ const props = defineProps({
|
||||
torrent: Object as PropType<TorrentInfo>,
|
||||
})
|
||||
|
||||
// 定义成功和失败事件
|
||||
const emit = defineEmits(['done', 'error', 'close'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -22,8 +25,8 @@ const selectedDownloader = ref<string | null>(null)
|
||||
// 选择的保存目录
|
||||
const selectedDirectory = ref<string | null>(null)
|
||||
|
||||
// 定义成功和失败事件
|
||||
const emit = defineEmits(['done', 'error', 'close'])
|
||||
// 下载器
|
||||
const downloaders = ref<DownloaderConf[]>([])
|
||||
|
||||
// 所有目录设置
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
@@ -53,14 +56,10 @@ const targetDirectories = computed(() => {
|
||||
return [...new Set(downloadDirectories)]
|
||||
})
|
||||
|
||||
// 下载器
|
||||
const downloaders = ref<DownloaderConf[]>([])
|
||||
|
||||
// 调用API查询下载器设置
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Downloaders')
|
||||
downloaders.value = result.data?.value ?? []
|
||||
downloaders.value = await api.get('download/clients')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -119,51 +118,73 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="40rem" scrollable>
|
||||
<VDialog max-width="35rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle v-if="title">下载 - {{ title }}</VCardTitle>
|
||||
<VCardTitle v-else>确认下载</VCardTitle>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VCardTitle class="py-3 me-12">
|
||||
<VIcon icon="mdi-download" class="me-2" />
|
||||
<span v-if="title">{{ torrent?.site_name }} - {{ title }}</span>
|
||||
<span v-else>确认下载</span>
|
||||
</VCardTitle>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" class="text-lg text-high-emphasis pb-0">
|
||||
<div><strong>站点:</strong>{{ props.torrent?.site_name }}</div>
|
||||
<div><strong>标题:</strong>{{ props.torrent?.title }}</div>
|
||||
<div><strong>描述:</strong>{{ props.torrent?.description }}</div>
|
||||
<div><strong>大小:</strong>{{ formatFileSize(props.torrent?.size || 0) }}</div>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="selectedDownloader"
|
||||
:items="downloaderOptions"
|
||||
label="下载器"
|
||||
variant="underlined"
|
||||
placeholder="默认"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="8">
|
||||
<VCombobox
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
label="保存目录"
|
||||
placeholder="自动"
|
||||
variant="underlined"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VList lines="one">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-web"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="whitespace-break-spaces me-2">{{ torrent?.title }}</span>
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="torrent?.description">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-subtitles-outline"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="text-body-2 whitespace-break-spaces">{{ torrent?.description }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="torrent?.size">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-database"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="text-body-2">
|
||||
<VChip variant="tonal" label>
|
||||
{{ formatFileSize(torrent?.size || 0) }}
|
||||
</VChip>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VRow class="px-7">
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="selectedDownloader"
|
||||
:items="downloaderOptions"
|
||||
size="small"
|
||||
label="下载器(默认)"
|
||||
variant="underlined"
|
||||
placeholder="留空默认"
|
||||
density="compact"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="8">
|
||||
<VCombobox
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
label="保存目录(自动)"
|
||||
size="small"
|
||||
placeholder="留空自动匹配"
|
||||
variant="underlined"
|
||||
density="compact"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VCardText class="text-center">
|
||||
<VBtn
|
||||
variant="elevated"
|
||||
:disabled="loading"
|
||||
@click="addDownload"
|
||||
:prepend-icon="icon"
|
||||
class="px-5"
|
||||
size="large"
|
||||
>
|
||||
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
|
||||
{{ buttonText }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
|
||||
60
src/components/dialog/AlistConfigDialog.vue
Normal file
60
src/components/dialog/AlistConfigDialog.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
conf: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
await savaAlistConfig()
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 保存rclone设置
|
||||
async function savaAlistConfig() {
|
||||
try {
|
||||
await api.post(`storage/save/alist`, props.conf)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog width="50rem" scrollable max-height="85vh">
|
||||
<VCard title="AList配置" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="props.conf.url" hint="AList服务地址" label="地址" persistent-hint />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="props.conf.username" hint="AList登录用户名" label="用户名" persistent-hint />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="props.conf.password"
|
||||
hint="AList登录密码"
|
||||
label="密码"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -1,18 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 定义输入
|
||||
defineProps({
|
||||
conf: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
// 二维码内容
|
||||
const qrCodeContent = ref('')
|
||||
|
||||
// ck参数
|
||||
const ck = ref('')
|
||||
|
||||
// t参数
|
||||
const t = ref('')
|
||||
const qrCodeUrl = ref('')
|
||||
|
||||
// 下方的提示信息
|
||||
const text = ref('请用阿里云盘 App 扫码')
|
||||
@@ -25,6 +26,7 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
clearTimeout(timeoutTimer)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
@@ -33,9 +35,8 @@ async function getQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')
|
||||
if (result.success && result.data) {
|
||||
qrCodeContent.value = result.data.codeContent
|
||||
ck.value = result.data.ck
|
||||
t.value = result.data.t
|
||||
qrCodeUrl.value = result.data.codeUrl
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
text.value = result.message
|
||||
}
|
||||
@@ -47,23 +48,21 @@ async function getQrcode() {
|
||||
// 调用/aliyun/check api验证二维码
|
||||
async function checkQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/check/alipan', {
|
||||
params: { ck: ck.value, t: t.value },
|
||||
})
|
||||
const result: { [key: string]: any } = await api.get('/storage/check/alipan')
|
||||
if (result.success && result.data) {
|
||||
const qrCodeStatus = result.data.qrCodeStatus
|
||||
const qrCodeStatus = result.data.status
|
||||
text.value = result.data.tip
|
||||
if (qrCodeStatus == 'CONFIRMED') {
|
||||
// 已确认完成
|
||||
if (qrCodeStatus == 'LoginSuccess') {
|
||||
// 登录成功
|
||||
alertType.value = 'success'
|
||||
handleDone()
|
||||
} else if (qrCodeStatus == 'NEW' || qrCodeStatus == 'SCANED') {
|
||||
} else if (qrCodeStatus == 'WaitLogin' || qrCodeStatus == 'ScanSuccess') {
|
||||
// 等待登录扫码成功
|
||||
alertType.value = 'info'
|
||||
// 新建、待扫码
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
// 过期或者已取消
|
||||
// 二维码过期
|
||||
alertType.value = 'error'
|
||||
}
|
||||
} else {
|
||||
@@ -77,7 +76,6 @@ async function checkQrcode() {
|
||||
|
||||
onMounted(async () => {
|
||||
await getQrcode()
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -91,7 +89,13 @@ onUnmounted(() => {
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText class="pt-2 flex flex-col items-center">
|
||||
<div class="my-6 shadow-lg rounded text-center p-3 border">
|
||||
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
|
||||
<VImg class="mx-auto" :src="qrCodeUrl" 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 variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||
<template #prepend />
|
||||
|
||||
281
src/components/dialog/ForkSubscribeDialog.vue
Normal file
281
src/components/dialog/ForkSubscribeDialog.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { VBtn } from 'vuetify/lib/components/index.mjs'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<SubscribeShare>,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['fork', 'delete', 'close'])
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 处理中
|
||||
const processing = ref(false)
|
||||
|
||||
// 删除中
|
||||
const deleting = ref(false)
|
||||
|
||||
// 是否折叠
|
||||
const isExpanded = ref(false)
|
||||
|
||||
// follow用户列表
|
||||
const followUsers = ref<string[]>([])
|
||||
|
||||
// 当前用户是否已follow
|
||||
const isFollowed = computed(() => followUsers.value.includes(props.media?.share_uid || ''))
|
||||
|
||||
// 折叠展开
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
// 加载follow用户列表
|
||||
async function queryFollowUsers() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')
|
||||
followUsers.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// follow用户
|
||||
async function followUser() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`subscribe/follow?share_uid=${props.media?.share_uid}`)
|
||||
if (result.success) {
|
||||
queryFollowUsers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// unfollow用户
|
||||
async function unfollowUser() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete('subscribe/follow', {
|
||||
params: {
|
||||
share_uid: props.media?.share_uid,
|
||||
},
|
||||
})
|
||||
if (result.success) {
|
||||
queryFollowUsers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.name,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 复用订阅
|
||||
async function doFork() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
processing.value = true
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
|
||||
// 完成
|
||||
emit('fork', result.data.id)
|
||||
} else {
|
||||
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}!`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
processing.value = false
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除订阅分享
|
||||
async function doDelete() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
deleting.value = true
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/share/${props.media?.id}`, {
|
||||
params: {
|
||||
share_uid: globalSettings.USER_UNIQUE_ID,
|
||||
},
|
||||
})
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
$toast.success(`${props.media?.share_title} 取消分享成功!`)
|
||||
// 完成
|
||||
emit('delete', result.data.id)
|
||||
} else {
|
||||
$toast.error(`${props.media?.share_title} 取消分享失败:${result.message}!`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryFollowUsers()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="40rem" scrollable>
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="ma-auto">
|
||||
<VImg
|
||||
width="10rem"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500"
|
||||
:src="posterUrl"
|
||||
@click="viewMediaDetail"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<VCardTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.media?.share_title }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.media?.share_comment }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">分享人:</span>
|
||||
<span class="text-body-1"> {{ media?.share_user }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="media?.keyword">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">搜索词:</span>
|
||||
<span class="text-body-1"> {{ media?.keyword }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="media?.custom_words" @click.stop="toggleExpand">
|
||||
<VListItemTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces"
|
||||
:class="{
|
||||
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
|
||||
}"
|
||||
>
|
||||
<span class="font-weight-medium">识别词:</span>
|
||||
<span class="text-body-1"> {{ media?.custom_words }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<div class="text-center text-md-left">
|
||||
<div>
|
||||
<VBtn
|
||||
color="primary"
|
||||
:disabled="processing"
|
||||
@click="doFork"
|
||||
prepend-icon="mdi-heart"
|
||||
:loading="processing"
|
||||
class="mb-2 me-2"
|
||||
>
|
||||
订阅
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="isFollowed && props.media?.share_uid"
|
||||
color="warning"
|
||||
@click="unfollowUser"
|
||||
prepend-icon="mdi-account-remove"
|
||||
class="mb-2 me-2"
|
||||
>
|
||||
取消关注
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else-if="props.media?.share_uid"
|
||||
@click="followUser"
|
||||
color="info"
|
||||
prepend-icon="mdi-account-plus"
|
||||
class="mb-2 me-2"
|
||||
>
|
||||
关注
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="
|
||||
(props.media?.share_uid && props.media?.share_uid === globalSettings.USER_UNIQUE_ID) ||
|
||||
globalSettings.SUBSCRIBE_SHARE_MANAGE
|
||||
"
|
||||
color="error"
|
||||
:disabled="deleting"
|
||||
@click="doDelete"
|
||||
prepend-icon="mdi-delete"
|
||||
:loading="deleting"
|
||||
class="mb-2 me-2"
|
||||
>
|
||||
取消分享
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="text-xs mt-2" v-if="props.media?.count">
|
||||
<VIcon icon="mdi-fire" />共 {{ props.media?.count?.toLocaleString() }} 次复用
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -2,23 +2,24 @@
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
dataType: String,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 代码
|
||||
const codeString = ref('')
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
// 导入
|
||||
function handleImport() {
|
||||
emit('update:modelValue', codeString.value)
|
||||
emit('save', props.dataType, codeString)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog width="40rem" scrollable max-height="85vh">
|
||||
<VDialog width="40rem" scrollable max-height="85vh" persistent>
|
||||
<VCard :title="props.title" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText class="pt-2">
|
||||
|
||||
22
src/components/dialog/MediaInfoDialog.vue
Normal file
22
src/components/dialog/MediaInfoDialog.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { Context } from '@/api/types'
|
||||
import MediaInfoCard from '../cards/MediaInfoCard.vue'
|
||||
|
||||
// 输入参数
|
||||
defineProps({
|
||||
context: Object as PropType<Context>,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="50rem">
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem>
|
||||
<MediaInfoCard :context="context" />
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
109
src/components/dialog/PluginConfigDialog.vue
Normal file
109
src/components/dialog/PluginConfigDialog.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import FormRender from '../render/FormRender.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save', 'switch'])
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 插件配置表单数据
|
||||
const pluginConfigForm = ref({})
|
||||
|
||||
// 插件表单配置项
|
||||
let pluginFormItems = reactive([])
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度文字
|
||||
const progressText = ref('')
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 调用API读取表单页面
|
||||
async function loadPluginForm() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
|
||||
if (result) {
|
||||
pluginFormItems = result.conf
|
||||
if (result.model) pluginConfigForm.value = result.model
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
// 调用API读取配置数据
|
||||
async function loadPluginConf() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
|
||||
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
// 调用API保存配置数据
|
||||
async function savePluginConf() {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
|
||||
if (result.success) {
|
||||
progressDialog.value = false
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
progressDialog.value = false
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await loadPluginForm()
|
||||
await loadPluginConf()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText v-if="isRefreshed">
|
||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info"> 查看数据 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
</VDialog>
|
||||
</template>
|
||||
63
src/components/dialog/PluginDataDialog.vue
Normal file
63
src/components/dialog/PluginDataDialog.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PageRender from '@/components/render/PageRender.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save', 'switch'])
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 插件数据页面配置项
|
||||
let pluginPageItems = ref([])
|
||||
|
||||
// 调用API读取数据页面
|
||||
async function loadPluginPage() {
|
||||
try {
|
||||
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
|
||||
if (result) pluginPageItems.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPluginPage()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<VCardText v-else class="min-h-40">
|
||||
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
<VFab
|
||||
icon="mdi-cog"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="emit('switch')"
|
||||
:class="{ 'mb-10': appMode }"
|
||||
/>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -2,11 +2,6 @@
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
})
|
||||
|
||||
const $toast = useToast()
|
||||
|
||||
// 插件仓库设置字符串
|
||||
@@ -34,7 +29,7 @@ async function saveHandle() {
|
||||
if (result.success) {
|
||||
$toast.success('插件仓库保存成功')
|
||||
emit('save')
|
||||
} else $toast.error('插件仓库保存失败!')
|
||||
} else $toast.error(`插件仓库保存失败:${result?.message}!`)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -47,8 +42,14 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<VDialog width="50rem" scrollable max-height="85vh">
|
||||
<VCard title="插件仓库设置" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCard class="rounded-t">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-store-cog" class="me-2" />
|
||||
插件仓库设置
|
||||
</VCardTitle>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VCardText class="pt-2">
|
||||
<VTextarea
|
||||
v-model="repoString"
|
||||
|
||||
@@ -7,7 +7,7 @@ const props = defineProps({
|
||||
<template>
|
||||
<!-- 手动整理进度框 -->
|
||||
<VDialog :scrim="false" width="25rem">
|
||||
<VCard color="primary">
|
||||
<VCard color="primary" rounded="md">
|
||||
<VCardText class="text-center">
|
||||
{{ props.text }}
|
||||
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
|
||||
|
||||
@@ -38,7 +38,7 @@ async function savaRcloneConfig() {
|
||||
|
||||
<template>
|
||||
<VDialog width="50rem" scrollable max-height="85vh">
|
||||
<VCard title="Rclone网盘配置" class="rounded-t">
|
||||
<VCard title="RClone配置" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import api from '@/api'
|
||||
import { storageOptions } from '@/api/constants'
|
||||
import { storageOptions, transferTypeOptions } from '@/api/constants'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
import { FileItem, TransferDirectoryConf } from '@/api/types'
|
||||
import { FileItem, TransferDirectoryConf, TransferForm } from '@/api/types'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -49,7 +49,7 @@ const progressEventSource = ref<EventSource>()
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 整理进度文本
|
||||
const progressText = ref('请稍候 ...')
|
||||
const progressText = ref('正在处理 ...')
|
||||
|
||||
// 整理进度
|
||||
const progressValue = ref(0)
|
||||
@@ -65,21 +65,21 @@ const dialogTitle = computed(() => {
|
||||
return '手动整理'
|
||||
})
|
||||
|
||||
// 禁用指定集数
|
||||
const disableEpisodeDetail = computed(() => {
|
||||
if (props.items) {
|
||||
if (transferForm.episode_format) return false
|
||||
return !(props.items.length === 1 && props.items[0].type !== 'dir')
|
||||
}
|
||||
})
|
||||
|
||||
// 表单
|
||||
const transferForm = reactive({
|
||||
fileitem: {},
|
||||
const transferForm = reactive<TransferForm>({
|
||||
fileitem: {} as FileItem,
|
||||
logid: 0,
|
||||
target_storage: props.target_storage ?? 'local',
|
||||
target_path: props.target_path ?? null,
|
||||
tmdbid: null,
|
||||
doubanid: null,
|
||||
season: null,
|
||||
type_name: '',
|
||||
transfer_type: '',
|
||||
episode_format: '',
|
||||
episode_detail: '',
|
||||
episode_part: '',
|
||||
episode_offset: null,
|
||||
target_path: '',
|
||||
min_filesize: 0,
|
||||
scrape: false,
|
||||
from_history: false,
|
||||
@@ -104,16 +104,58 @@ const targetDirectories = computed(() => {
|
||||
return [...new Set(libraryDirectories)]
|
||||
})
|
||||
|
||||
// 监听目的路径变化,自动查询目录削配置
|
||||
watch(transferForm, async () => {
|
||||
if (transferForm.target_path) {
|
||||
const directory = directories.value.find(item => item.library_path === transferForm.target_path)
|
||||
if (directory) {
|
||||
transferForm.scrape = directory.scraping ?? false
|
||||
transferForm.transfer_type = directory.transfer_type ?? ''
|
||||
// 监听目的路径变化,配置默认值
|
||||
watch(
|
||||
() => transferForm.target_path,
|
||||
async newPath => {
|
||||
if (newPath) {
|
||||
const directory = directories.value.find(item => item.library_path === newPath)
|
||||
if (directory) {
|
||||
transferForm.target_storage = directory.library_storage ?? 'local'
|
||||
transferForm.transfer_type = transferForm.transfer_type || directory.transfer_type
|
||||
transferForm.scrape = directory.scraping ?? false
|
||||
transferForm.library_category_folder = directory.library_category_folder ?? false
|
||||
transferForm.library_type_folder = directory.library_type_folder ?? false
|
||||
} else {
|
||||
transferForm.transfer_type = transferForm.transfer_type || 'copy'
|
||||
transferForm.scrape = false
|
||||
transferForm.library_category_folder = false
|
||||
transferForm.library_type_folder = false
|
||||
}
|
||||
} else {
|
||||
// 路径为空时, 恢复到`自动`条件
|
||||
transferForm.transfer_type = ''
|
||||
transferForm.library_type_folder = undefined
|
||||
transferForm.library_category_folder = undefined
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 整理文件
|
||||
async function handleTransfer(item: FileItem, background: boolean = false) {
|
||||
transferForm.fileitem = item
|
||||
transferForm.logid = 0
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
|
||||
if (!result.success) $toast.error(result.message)
|
||||
else if (background) $toast.success(`文件 ${item.name} 已加入整理队列!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 整理日志
|
||||
async function handleTransferLog(logid: number, background: boolean = false) {
|
||||
transferForm.logid = logid
|
||||
transferForm.fileitem = {} as FileItem
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
|
||||
if (!result.success) $toast.error(result.message)
|
||||
else if (background) $toast.success(`历史记录 ${logid} 已加入整理队列!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
@@ -134,67 +176,53 @@ function stopLoadingProgress() {
|
||||
}
|
||||
|
||||
// 整理文件
|
||||
async function transfer() {
|
||||
async function transfer(background: boolean = false) {
|
||||
if (!props.logids && !props.items) return
|
||||
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
// 开始监听进度
|
||||
startLoadingProgress()
|
||||
|
||||
if (!background) {
|
||||
// 开始监听进度
|
||||
startLoadingProgress()
|
||||
}
|
||||
|
||||
// 文件整理
|
||||
if (props.items) {
|
||||
for (const item of props.items) {
|
||||
await handleTransfer(item)
|
||||
await handleTransfer(item, background)
|
||||
}
|
||||
}
|
||||
|
||||
// 日志整理
|
||||
if (props.logids) {
|
||||
for (const logid of props.logids) {
|
||||
await handleTransferLog(logid)
|
||||
await handleTransferLog(logid, background)
|
||||
}
|
||||
}
|
||||
|
||||
// 停止监听进度
|
||||
stopLoadingProgress()
|
||||
if (!background) {
|
||||
// 停止监听进度
|
||||
stopLoadingProgress()
|
||||
}
|
||||
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
// 重新加载
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 整理文件
|
||||
async function handleTransfer(item: FileItem) {
|
||||
transferForm.fileitem = item
|
||||
transferForm.logid = 0
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('transfer/manual', transferForm)
|
||||
if (!result.success) $toast.error(`文件 ${item.path} 整理失败:${result.message}!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 整理日志
|
||||
async function handleTransferLog(logid: number) {
|
||||
transferForm.logid = logid
|
||||
transferForm.fileitem = {}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('transfer/manual', transferForm)
|
||||
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDirectories()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopLoadingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="dialogTitle" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
@@ -215,18 +243,16 @@ onMounted(() => {
|
||||
<VSelect
|
||||
v-model="transferForm.transfer_type"
|
||||
label="整理方式"
|
||||
:items="[
|
||||
{ title: '默认', value: '' },
|
||||
{ title: '移动', value: 'move' },
|
||||
{ title: '复制', value: 'copy' },
|
||||
{ title: '硬链接', value: 'link' },
|
||||
{ title: '软链接', value: 'softlink' },
|
||||
]"
|
||||
:items="transferTypeOptions"
|
||||
hint="文件操作整理方式"
|
||||
persistent-hint
|
||||
/>
|
||||
>
|
||||
<template v-slot:selection="{ item }">
|
||||
{{ transferForm.transfer_type === '' ? '自动' : item.title }}
|
||||
</template>
|
||||
</VSelect>
|
||||
</VCol>
|
||||
<VCol cols="12" md="12">
|
||||
<VCol cols="12">
|
||||
<VCombobox
|
||||
v-model="transferForm.target_path"
|
||||
:items="targetDirectories"
|
||||
@@ -238,7 +264,7 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="transferForm.type_name"
|
||||
label="类型"
|
||||
@@ -251,7 +277,7 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="transferForm.tmdbid"
|
||||
@@ -277,19 +303,37 @@ onMounted(() => {
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-show="transferForm.type_name === '电视剧'"
|
||||
v-model.number="transferForm.season"
|
||||
label="季"
|
||||
:items="seasonItems"
|
||||
hint="指定季数"
|
||||
</VRow>
|
||||
<VRow v-show="transferForm.type_name === '电视剧'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_group"
|
||||
label="剧集组编号"
|
||||
placeholder="手动查询剧集组"
|
||||
hint="指定剧集组"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VCol cols="12" md="3">
|
||||
<VSelect
|
||||
v-model.number="transferForm.season"
|
||||
label="季"
|
||||
:items="seasonItems"
|
||||
hint="第几季"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_detail"
|
||||
:disabled="disableEpisodeDetail"
|
||||
label="集"
|
||||
placeholder="起始集,终止集"
|
||||
hint="集数或范围,如1或1,2"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_format"
|
||||
label="集数定位"
|
||||
@@ -298,25 +342,7 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_detail"
|
||||
label="指定集数"
|
||||
placeholder="起始集,终止集,如1或1,2"
|
||||
hint="指定集数或范围,如1或1,2"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_part"
|
||||
label="指定Part"
|
||||
placeholder="如part1"
|
||||
hint="指定Part,如part1"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_offset"
|
||||
label="集数偏移"
|
||||
@@ -325,7 +351,18 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_part"
|
||||
label="指定Part"
|
||||
placeholder="如part1"
|
||||
hint="指定Part,如part1"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="transferForm.min_filesize"
|
||||
label="最小文件大小(MB)"
|
||||
@@ -337,6 +374,22 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6" v-if="transferForm.target_path">
|
||||
<VSwitch
|
||||
v-model="transferForm.library_type_folder"
|
||||
label="按类型分类"
|
||||
hint="整理时目的路径下按媒体类型添加子目录"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="transferForm.target_path">
|
||||
<VSwitch
|
||||
v-model="transferForm.library_category_folder"
|
||||
label="按类别分类"
|
||||
hint="整理时在目的路径下按媒体类别添加子目录"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="transferForm.scrape"
|
||||
@@ -349,7 +402,7 @@ onMounted(() => {
|
||||
<VSwitch
|
||||
v-model="transferForm.from_history"
|
||||
label="复用历史识别信息"
|
||||
hint="使用历史记录中已识别的媒体信息"
|
||||
hint="使用历史整理记录中已识别的媒体信息"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
@@ -358,7 +411,12 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="transfer" prepend-icon="mdi-arrow-right-bold" class="px-5"> 开始整理 </VBtn>
|
||||
<VBtn variant="elevated" color="success" @click="transfer(true)" prepend-icon="mdi-plus" class="px-5">
|
||||
加入整理队列
|
||||
</VBtn>
|
||||
<VBtn variant="elevated" @click="transfer(false)" prepend-icon="mdi-arrow-right-bold" class="px-5">
|
||||
立即整理
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- 手动整理进度框 -->
|
||||
|
||||
855
src/components/dialog/SearchBarDialog.vue
Normal file
855
src/components/dialog/SearchBarDialog.vue
Normal file
@@ -0,0 +1,855 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { Site, Plugin, Subscribe } from '@/api/types'
|
||||
import { SystemNavMenus, SettingTabs } from '@/router/menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useUserStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
|
||||
// 定义props,接收modelValue
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 超级用户
|
||||
const superUser = userStore.superUser
|
||||
|
||||
// 当前用户名
|
||||
const userName = userStore.userName
|
||||
|
||||
// 所有订阅数据
|
||||
const SubscribeItems = ref<Subscribe[]>([])
|
||||
|
||||
// 站点选择对话框
|
||||
const chooseSiteDialog = ref(false)
|
||||
const selectedSites = ref<number[]>([])
|
||||
const allSites = ref<Site[]>([])
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'update:modelValue'])
|
||||
|
||||
// 对话框状态的本地计算属性
|
||||
const dialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: val => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
// 搜索词
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
// ref
|
||||
const searchWordInput = ref<HTMLElement | null>(null)
|
||||
|
||||
// 近期搜索词条
|
||||
const recentSearches = ref<string[]>([])
|
||||
|
||||
// 保存近期搜索到本地
|
||||
function saveRecentSearches(keyword: string) {
|
||||
if (!keyword) return
|
||||
if (recentSearches.value.includes(keyword)) return
|
||||
recentSearches.value.unshift(keyword)
|
||||
localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))
|
||||
}
|
||||
|
||||
// 从本地加载近期搜索
|
||||
function loadRecentSearches() {
|
||||
const recentSearchesStr = localStorage.getItem('MP_RecentSearches')
|
||||
if (recentSearchesStr) {
|
||||
recentSearches.value = JSON.parse(recentSearchesStr)
|
||||
// 只保留最近的 5 条
|
||||
if (recentSearches.value.length > 5) {
|
||||
recentSearches.value = recentSearches.value.slice(0, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有菜单功能
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
SystemNavMenus.forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
title: item.full_title ?? item.title,
|
||||
icon: item.icon,
|
||||
to: item.to,
|
||||
header: item.header,
|
||||
admin: item.admin,
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
SettingTabs.forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
title: '设定 -> ' + item.title,
|
||||
icon: item.icon,
|
||||
to: `/setting?tab=${item.tab}`,
|
||||
header: '',
|
||||
admin: true,
|
||||
description: item.description,
|
||||
}),
|
||||
)
|
||||
|
||||
return menus
|
||||
}
|
||||
|
||||
// 匹配的菜单列表
|
||||
const matchedMenuItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
if (!superUser) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
const menuItems = getMenus()
|
||||
if (menuItems)
|
||||
return menuItems.filter(
|
||||
item =>
|
||||
item.title.toLowerCase().includes(lowerWord) ||
|
||||
(item.description && item.description.toLowerCase().includes(lowerWord)),
|
||||
)
|
||||
return []
|
||||
})
|
||||
|
||||
// 所有插件(已安装)
|
||||
const pluginItems = ref<Plugin[]>([])
|
||||
|
||||
// 获取插件列表数据
|
||||
async function fetchInstalledPlugins() {
|
||||
try {
|
||||
pluginItems.value = await api.get('plugin/', {
|
||||
params: {
|
||||
state: 'installed',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 区配的插件列表
|
||||
const matchedPluginItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
if (!superUser) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return pluginItems.value.filter((item: Plugin) => {
|
||||
if (!item.plugin_name && !item.plugin_desc) return false
|
||||
return item.plugin_name?.toLowerCase().includes(lowerWord) || item.plugin_desc?.toLowerCase().includes(lowerWord)
|
||||
})
|
||||
})
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchSubscribes() {
|
||||
try {
|
||||
SubscribeItems.value = await api.get('subscribe/')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 从接口加载用户站点偏好设置
|
||||
const loadUserSitePreferences = async () => {
|
||||
try {
|
||||
const result = await api.get('system/setting/IndexerSites')
|
||||
if (result && result.data && result.data.value) {
|
||||
selectedSites.value = result.data.value
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有站点
|
||||
async function queryAllSites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/')
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
allSites.value = data.filter(item => item.is_active)
|
||||
// 如果没有选择任何站点并且有可用站点,才默认选择全部
|
||||
if (selectedSites.value.length === 0 && allSites.value.length > 0) {
|
||||
selectedSites.value = allSites.value.map((site: Site) => site.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开站点选择对话框
|
||||
const openSiteDialog = () => {
|
||||
chooseSiteDialog.value = true
|
||||
}
|
||||
|
||||
// 匹配的订阅列表
|
||||
const matchedSubscribeItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return SubscribeItems.value.filter((item: Subscribe) => {
|
||||
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
|
||||
})
|
||||
})
|
||||
|
||||
// 搜索多站点
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
searchTorrent()
|
||||
}
|
||||
|
||||
// 搜索资源
|
||||
function searchTorrent() {
|
||||
if (!searchWord.value) return
|
||||
// 记录搜索词
|
||||
saveRecentSearches(searchWord.value)
|
||||
// 跳转到搜索页面
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword: searchWord.value,
|
||||
area: 'title',
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
// 关闭搜索对话框
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转媒体搜索页面
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/browse/media/search',
|
||||
query: {
|
||||
title: searchWord.value,
|
||||
type: searchType,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转到历史记录页面
|
||||
function searchHistory() {
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/history',
|
||||
query: {
|
||||
search: searchWord.value,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转插件页面
|
||||
function showPlugin(pluginId: string) {
|
||||
router.push({
|
||||
path: `/plugins/`,
|
||||
query: {
|
||||
tab: 'installed',
|
||||
id: pluginId,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转菜单页面
|
||||
function goPage(to: string) {
|
||||
router.push(to)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转订阅页面
|
||||
function goSubscribe(subscribe: Subscribe) {
|
||||
if (subscribe.type === '电影') {
|
||||
router.push({
|
||||
path: '/subscribe/movie',
|
||||
query: {
|
||||
id: subscribe.id,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
router.push({
|
||||
path: '/subscribe/tv',
|
||||
query: {
|
||||
id: subscribe.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
searchWordInput.value?.focus()
|
||||
}, 500)
|
||||
fetchInstalledPlugins()
|
||||
fetchSubscribes()
|
||||
loadRecentSearches()
|
||||
loadUserSitePreferences()
|
||||
if (superUser) queryAllSites()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog v-model="dialog" max-width="42rem" scrollable>
|
||||
<VCard class="search-dialog">
|
||||
<!-- 搜索输入框 -->
|
||||
<VCardItem class="pa-4 pa-sm-5 search-box-container">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-magnify" color="primary" size="x-large" />
|
||||
</template>
|
||||
<VCombobox
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
class="search-input"
|
||||
placeholder="输入关键词搜索..."
|
||||
@keydown.enter="searchMedia('media')"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
<template #append>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-close" color="primary" @click="emit('close')" size="x-large" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<!-- 主搜索结果区域 -->
|
||||
<VCardText class="search-results-container pa-0">
|
||||
<!-- 有搜索词时显示结果 -->
|
||||
<VList lines="two" v-if="searchWord" class="search-list py-2">
|
||||
<!-- 搜索结果分组标题 -->
|
||||
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 媒体 </VListSubheader>
|
||||
|
||||
<!-- 媒体搜索选项 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="searchMedia('media')"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon
|
||||
icon="mdi-movie-search"
|
||||
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium"> 电影、电视剧 </VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的影视作品
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="searchMedia('collection')"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon
|
||||
icon="mdi-movie-filter"
|
||||
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium"> 系列合集 </VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的系列作品
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="searchMedia('person')"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon
|
||||
icon="mdi-account-search"
|
||||
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium"> 演职人员 </VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的演员、导演等
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<VHover v-if="superUser">
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="searchHistory"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon icon="mdi-history" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium"> 整理记录 </VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的历史记录
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<!-- 其他搜索结果 -->
|
||||
<template v-if="matchedSubscribeItems.length > 0">
|
||||
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
|
||||
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 订阅 </VListSubheader>
|
||||
|
||||
<VHover v-for="subscribe in matchedSubscribeItems" :key="subscribe.id">
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="goSubscribe(subscribe)"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon
|
||||
:icon="subscribe.type === '电影' ? 'mdi-movie-roll' : 'mdi-television-classic'"
|
||||
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium">
|
||||
{{ subscribe.name
|
||||
}}<span v-if="subscribe.season" class="text-body-2"> 第 {{ subscribe.season }} 季</span>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ subscribe.type }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<template v-if="matchedMenuItems.length > 0">
|
||||
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
|
||||
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 功能 </VListSubheader>
|
||||
|
||||
<VHover v-for="menu in matchedMenuItems" :key="menu.title">
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="goPage(menu.to as string)"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon
|
||||
:icon="menu.icon as string"
|
||||
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium">
|
||||
{{ menu.title }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="menu.description" class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ menu.description }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<template v-if="matchedPluginItems.length > 0">
|
||||
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
|
||||
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 插件 </VListSubheader>
|
||||
|
||||
<VHover v-for="plugin in matchedPluginItems" :key="plugin.id">
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="showPlugin(plugin.id ?? '')"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon icon="mdi-puzzle" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium">
|
||||
{{ plugin.plugin_name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ plugin.plugin_desc }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<!-- 将站点资源搜索移到最底部 -->
|
||||
<template v-if="searchWord">
|
||||
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
|
||||
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 站点资源 </VListSubheader>
|
||||
|
||||
<VCard class="mx-3 mx-sm-6 mb-4 mt-2 site-search-card">
|
||||
<VCardText class="pa-3 pa-sm-4">
|
||||
<div class="d-flex flex-column">
|
||||
<div class="d-flex align-center">
|
||||
<div class="search-icon-wrapper mr-3">
|
||||
<VIcon icon="mdi-file-search" color="primary" size="small" />
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-weight-medium text-body-1">在站点中搜索种子资源</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关资源
|
||||
</div>
|
||||
</div>
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="searchTorrent"
|
||||
prepend-icon="mdi-magnify"
|
||||
size="small"
|
||||
variant="flat"
|
||||
class="search-btn"
|
||||
>
|
||||
搜索
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="superUser"
|
||||
class="d-flex align-center flex-wrap site-chips-container mt-4 py-2 px-2 px-sm-3"
|
||||
>
|
||||
<div class="d-flex align-center flex-wrap flex-grow-1">
|
||||
<VChip
|
||||
v-if="selectedSites.length > 0"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="flat"
|
||||
class="mr-2 mb-1 font-weight-medium"
|
||||
>
|
||||
{{ selectedSites.length }}/{{ allSites.length }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(site, index) in allSites.filter(s => selectedSites.includes(s.id)).slice(0, 5)"
|
||||
:key="site.id"
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
class="mr-1 mb-1 site-chip"
|
||||
>
|
||||
{{ site.name }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="selectedSites.length > 5"
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
class="mr-1 mb-1 site-chip text-medium-emphasis"
|
||||
>
|
||||
+{{ selectedSites.length - 5 }}
|
||||
</VChip>
|
||||
</div>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="openSiteDialog"
|
||||
class="ml-auto site-select-btn"
|
||||
rounded="pill"
|
||||
>
|
||||
选择站点
|
||||
<VIcon size="small" class="ml-1">mdi-cog-outline</VIcon>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</VList>
|
||||
|
||||
<!-- 无搜索词时显示最近搜索和提示 -->
|
||||
<div v-else class="recent-searches py-6 px-4 px-sm-6">
|
||||
<div v-if="recentSearches.length > 0" class="mb-6">
|
||||
<div class="text-h6 font-weight-medium mb-3">最近搜索</div>
|
||||
<div class="d-flex flex-wrap">
|
||||
<VChip
|
||||
v-for="(word, index) in recentSearches"
|
||||
:key="index"
|
||||
class="me-2 mb-2"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="small"
|
||||
@click="searchWord = word"
|
||||
>
|
||||
<VIcon start size="x-small">mdi-history</VIcon>
|
||||
{{ word }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-6 py-6 empty-search-state">
|
||||
<div class="search-icon-wrapper mx-auto mb-4">
|
||||
<VIcon icon="mdi-magnify" size="large" color="primary" />
|
||||
</div>
|
||||
<div class="text-h6 font-weight-medium mb-2">输入关键词开始搜索</div>
|
||||
<div class="text-body-2 text-medium-emphasis">可搜索电影、电视剧、演员、资源等</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 站点选择对话框 -->
|
||||
<SearchSiteDialog
|
||||
v-if="chooseSiteDialog"
|
||||
v-model="chooseSiteDialog"
|
||||
:sites="allSites"
|
||||
:selected="selectedSites"
|
||||
@search="searchSites"
|
||||
@close="chooseSiteDialog = false"
|
||||
@reload="queryAllSites"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-dialog {
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 8%);
|
||||
}
|
||||
|
||||
.site-dialog {
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 8%);
|
||||
}
|
||||
|
||||
.search-divider {
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
inset-block-start: 1.4rem;
|
||||
inset-inline-end: 1.2rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: rgba(var(--v-theme-error), 0.1);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.option-icon-wrapper {
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.12);
|
||||
block-size: 32px;
|
||||
inline-size: 32px;
|
||||
margin-inline-end: 12px;
|
||||
}
|
||||
|
||||
.search-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
}
|
||||
|
||||
.search-icon-wrapper.warning {
|
||||
background-color: rgba(var(--v-theme-warning), 0.08);
|
||||
}
|
||||
|
||||
.primary-text {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.search-option {
|
||||
border: 1px solid transparent;
|
||||
margin-block-end: 2px;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-option:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.recent-searches {
|
||||
min-block-size: 200px;
|
||||
}
|
||||
|
||||
.site-search-card {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.site-chip {
|
||||
font-weight: normal;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.site-chip:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
min-inline-size: 70px;
|
||||
}
|
||||
|
||||
.empty-search-state,
|
||||
.empty-site-state {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clear-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.site-select-btn {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
min-block-size: 32px;
|
||||
padding-block: 0;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.site-chips-container {
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.06);
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.search-box-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
block-size: 32px;
|
||||
inline-size: 32px;
|
||||
inset-block-start: 1rem;
|
||||
inset-inline-end: 0.8rem;
|
||||
}
|
||||
|
||||
.site-chips-container {
|
||||
padding-block: 6px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.site-select-btn {
|
||||
font-size: 11px;
|
||||
min-block-size: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
213
src/components/dialog/SearchSiteDialog.vue
Normal file
213
src/components/dialog/SearchSiteDialog.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import type { Site, Plugin, Subscribe } from '@/api/types'
|
||||
import { popScopeId, PropType } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
sites: {
|
||||
type: Array as PropType<Site[]>,
|
||||
required: true,
|
||||
},
|
||||
selected: Array as PropType<Number[]>,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'search', 'reload'])
|
||||
|
||||
// 过滤词
|
||||
const siteFilter = ref('')
|
||||
|
||||
// 已选择站点
|
||||
const selectedSites = ref<any[]>(props.selected || [])
|
||||
|
||||
watch(
|
||||
() => props.selected,
|
||||
value => {
|
||||
if (selectedSites.value.length == 0 && value) {
|
||||
selectedSites.value = value
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 全选/全不选按钮文字
|
||||
const checkAllText = computed(() => {
|
||||
return selectedSites.value.length < props.sites?.length ? '选择全部' : '取消全选'
|
||||
})
|
||||
|
||||
// 全选/全不选
|
||||
const checkAllSitesorNot = () => {
|
||||
if (selectedSites.value.length < props.sites?.length) {
|
||||
selectedSites.value = props.sites?.map((item: Site) => item.id)
|
||||
} else {
|
||||
selectedSites.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 根据筛选条件过滤站点
|
||||
const filteredSites = computed(() => {
|
||||
if (!siteFilter.value) return props.sites
|
||||
const filter = siteFilter.value.toLowerCase()
|
||||
return props.sites?.filter((site: Site) => site.name.toLowerCase().includes(filter))
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<!-- 手动整理进度框 -->
|
||||
<VDialog max-width="40rem" fullscreen-mobile>
|
||||
<VCard class="site-dialog">
|
||||
<VCardTitle class="d-flex align-center pa-4">
|
||||
<span class="text-h6 font-weight-medium">选择搜索站点</span>
|
||||
<VSpacer />
|
||||
<VTextField
|
||||
v-model="siteFilter"
|
||||
placeholder="过滤站点..."
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="ml-4"
|
||||
style="max-width: 200px"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
/>
|
||||
</VCardTitle>
|
||||
<VDivider class="search-divider" />
|
||||
|
||||
<VCardText style="max-height: 420px" class="overflow-y-auto px-4 py-4">
|
||||
<!-- 站点列表 -->
|
||||
<div v-if="filteredSites.length > 0">
|
||||
<!-- 选择操作 -->
|
||||
<div class="d-flex align-center mb-4">
|
||||
<VBtn
|
||||
size="small"
|
||||
:color="selectedSites.length < sites.length ? 'primary' : 'error'"
|
||||
@click="checkAllSitesorNot"
|
||||
class="me-2"
|
||||
rounded="pill"
|
||||
variant="flat"
|
||||
>
|
||||
<VIcon start size="small">
|
||||
{{ selectedSites.length < sites.length ? 'mdi-check-all' : 'mdi-close-circle-outline' }}
|
||||
</VIcon>
|
||||
{{ checkAllText }}
|
||||
</VBtn>
|
||||
<div
|
||||
class="text-body-2 font-weight-medium"
|
||||
:class="selectedSites.length > 0 ? 'text-primary' : 'text-medium-emphasis'"
|
||||
>
|
||||
已选择 {{ selectedSites.length }}/{{ sites.length }} 个站点
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 站点选择器 -->
|
||||
<VRow dense>
|
||||
<VCol v-for="site in filteredSites" :key="site.id" cols="6" sm="6" md="4">
|
||||
<VHover v-slot="{ isHovering, props }">
|
||||
<div
|
||||
v-bind="props"
|
||||
:class="[
|
||||
'site-checkbox-wrapper pa-2 pa-sm-3 rounded-lg d-flex align-center',
|
||||
{
|
||||
'site-selected': selectedSites.includes(site.id),
|
||||
'site-hover': isHovering && !selectedSites.includes(site.id),
|
||||
},
|
||||
]"
|
||||
@click="
|
||||
() => {
|
||||
const index = selectedSites.indexOf(site.id)
|
||||
if (index === -1) {
|
||||
selectedSites.push(site.id)
|
||||
} else {
|
||||
selectedSites.splice(index, 1)
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<VIcon
|
||||
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
|
||||
:color="selectedSites.includes(site.id) ? 'primary' : 'medium-emphasis'"
|
||||
class="me-2"
|
||||
size="small"
|
||||
/>
|
||||
<span :class="['text-body-2 site-name', { 'font-weight-medium': selectedSites.includes(site.id) }]">
|
||||
{{ site.name }}
|
||||
</span>
|
||||
</div>
|
||||
</VHover>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 empty-site-state">
|
||||
<div class="search-icon-wrapper mb-4 mx-auto warning">
|
||||
<VIcon icon="mdi-alert-circle-outline" size="large" color="warning" />
|
||||
</div>
|
||||
<div class="text-h6 font-weight-medium mb-2">没有找到匹配的站点</div>
|
||||
<div class="text-subtitle-1 text-medium-emphasis mb-4">
|
||||
{{ siteFilter ? '请尝试修改过滤条件' : '站点数据加载失败,请刷新页面重试' }}
|
||||
</div>
|
||||
<VBtn
|
||||
v-if="siteFilter"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
class="mt-3"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="siteFilter = ''"
|
||||
>
|
||||
重置
|
||||
</VBtn>
|
||||
<VBtn v-else color="primary" variant="flat" class="mt-3" prepend-icon="mdi-refresh" @click="emit('reload')">
|
||||
重新加载站点
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VDivider class="search-divider" />
|
||||
|
||||
<VCardActions class="pa-4">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="grey-darken-1"
|
||||
variant="text"
|
||||
@click="emit('close')"
|
||||
class="mr-2 d-flex align-center justify-center"
|
||||
>
|
||||
取消
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="selectedSites.length === 0"
|
||||
@click="emit('search', selectedSites)"
|
||||
prepend-icon="mdi-magnify"
|
||||
class="d-flex align-center justify-center px-5"
|
||||
>
|
||||
搜索
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.site-checkbox-wrapper {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.site-checkbox-wrapper:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.site-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.site-selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
border-color: rgba(var(--v-theme-primary), 0.2);
|
||||
}
|
||||
|
||||
.site-hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import type { Site } from '@/api/types'
|
||||
import type { DownloaderConf, Site } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { numberValidator, requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
siteid: Number,
|
||||
@@ -35,6 +31,7 @@ const siteForm = ref<Site>({
|
||||
limit_seconds: 0,
|
||||
name: '',
|
||||
domain: '',
|
||||
downloader: '',
|
||||
})
|
||||
|
||||
// 提示框
|
||||
@@ -60,6 +57,24 @@ const priorityItems = ref(
|
||||
})),
|
||||
)
|
||||
|
||||
// 下载器选项
|
||||
const downloaderOptions = ref<{ title: string; value: string }[]>([])
|
||||
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
const downloaders: DownloaderConf[] = await api.get('download/clients')
|
||||
downloaderOptions.value = [
|
||||
{ title: '默认', value: '' },
|
||||
...downloaders.map((item: { name: any }) => ({
|
||||
title: item.name,
|
||||
value: item.name,
|
||||
})),
|
||||
]
|
||||
} catch (error) {
|
||||
console.error('加载下载器设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询站点信息
|
||||
async function fetchSiteInfo() {
|
||||
try {
|
||||
@@ -89,25 +104,6 @@ async function addSite() {
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 调用API删除站点信息
|
||||
async function deleteSiteInfo() {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认删除站点?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`site/${siteForm.value?.id}`)
|
||||
if (result.success) emit('remove')
|
||||
else $toast.error(`${siteForm.value?.name} 删除失败:${result.message}`)
|
||||
} catch (error) {
|
||||
$toast.error(`${siteForm.value?.name} 删除失败!`)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API更新站点信息
|
||||
async function updateSiteInfo() {
|
||||
startNProgress()
|
||||
@@ -142,11 +138,12 @@ onMounted(async () => {
|
||||
isLimit.value = true
|
||||
if (siteForm.value.apikey) siteType.value = 'api'
|
||||
}
|
||||
await loadDownloaderSetting()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable :close-on-back="false" eager max-width="45rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
|
||||
class="rounded-t"
|
||||
@@ -186,7 +183,7 @@ onMounted(async () => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="9">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="siteForm.rss"
|
||||
label="RSS地址"
|
||||
@@ -195,7 +192,21 @@ onMounted(async () => {
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
|
||||
<VTextField
|
||||
v-model="siteForm.timeout"
|
||||
label="超时时间(秒)"
|
||||
hint="站点请求超时时间,为0时不限制"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VSelect
|
||||
v-model="siteForm.downloader"
|
||||
label="下载器"
|
||||
:items="downloaderOptions"
|
||||
hint="此站点使用的下载器"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
|
||||
@@ -304,9 +315,6 @@ onMounted(async () => {
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.oper !== 'add'" color="error" @click="deleteSiteInfo" variant="outlined" class="me-3">
|
||||
删除
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="props.oper === 'add'"
|
||||
|
||||
@@ -67,17 +67,18 @@ async function updateSiteCookie() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="50rem">
|
||||
<VDialog max-width="30rem">
|
||||
<!-- Dialog Content -->
|
||||
<VCard title="更新站点Cookie & UA">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="userPwForm.password"
|
||||
label="密码"
|
||||
@@ -88,19 +89,19 @@ async function updateSiteCookie() {
|
||||
@keydown.enter="updateSiteCookie"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="userPwForm.code" label="两步验证" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VCardActions class="mx-auto">
|
||||
<VBtn
|
||||
size="large"
|
||||
variant="elevated"
|
||||
@click="updateSiteCookie"
|
||||
:disabled="updateButtonDisable"
|
||||
:loading="updateButtonDisable"
|
||||
prepend-icon="mdi-refresh"
|
||||
class="px-5"
|
||||
>
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Site } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo } from '@/api/types'
|
||||
import type { TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
})
|
||||
|
||||
// 关键字
|
||||
const keyword = ref<string>()
|
||||
|
||||
// 选择分类
|
||||
const selectCategory = ref<number[]>([])
|
||||
|
||||
// 全部分类
|
||||
const siteCategoryList = ref<SiteCategory[]>()
|
||||
|
||||
// 分类选项
|
||||
const categoryOptions = computed(() => {
|
||||
return siteCategoryList.value?.map(item => {
|
||||
return { title: item.desc, value: item.id }
|
||||
})
|
||||
})
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
@@ -55,17 +67,6 @@ async function downloadTorrentFile(enclosure: string) {
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`)
|
||||
resourceLoading.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
@@ -93,17 +94,75 @@ function addDownloadError(error: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
|
||||
params: {
|
||||
keyword: keyword.value,
|
||||
cat: selectCategory.value?.join(','),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
resourceLoading.value = false
|
||||
}
|
||||
|
||||
// 加载站点分类
|
||||
async function getSiteCategoryList() {
|
||||
try {
|
||||
siteCategoryList.value = await api.get(`site/category/${props.site?.id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteCategoryList()
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="80rem" scrollable z-index="1010" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`浏览 - ${props.site?.name}`">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText class="pt-2">
|
||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
||||
<VCard>
|
||||
<!-- Toolbar -->
|
||||
<div>
|
||||
<VToolbar color="primary">
|
||||
<VToolbarTitle>{{ `浏览 - ${props.site?.name}` }}</VToolbarTitle>
|
||||
<VSpacer />
|
||||
<VToolbarItems>
|
||||
<VBtn icon @click="emit('close')" class="me-3">
|
||||
<VIcon size="large" color="white" icon="ri-close-line" />
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<VRow>
|
||||
<VCol cols="6" md="5">
|
||||
<VTextField v-model="keyword" size="small" density="compact" label="搜索关键字" clearable />
|
||||
</VCol>
|
||||
<VCol cols="6" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
label="资源分类"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2" class="text-center">
|
||||
<VBtn block prepend-icon="mdi-magnify" @click="getResourceList">搜索</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
<VCardText class="px-0 py-0 my-0">
|
||||
<VDataTable
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
@@ -119,6 +178,7 @@ onMounted(() => {
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
class="h-full"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
@@ -178,17 +238,13 @@ onMounted(() => {
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail(item.page_url || '')">
|
||||
<VListItem @click="openTorrentDetail(item.page_url || '')">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
<VListItem v-if="item.enclosure?.startsWith('http')" @click="downloadTorrentFile(item.enclosure)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import type { Site, SiteUserData } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { VAvatar, VCardText, VIcon } from 'vuetify/lib/components/index.mjs'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
@@ -31,9 +29,8 @@ const currentTheme = controlledComputed(
|
||||
// 站点数据列表
|
||||
const siteDatas = ref<SiteUserData[]>([])
|
||||
|
||||
// 最新一天的数据,按时间倒序排序后取第一条记录
|
||||
const siteData = computed(() => siteDatas.value[0])
|
||||
|
||||
// 最新一天的数据
|
||||
const siteData = computed(() => siteDatas.value[siteDatas.value.length - 1])
|
||||
|
||||
// 站点数据列表中的上传量、下载量数据生成图形使用的数据
|
||||
const historySeries = computed(() => {
|
||||
@@ -57,6 +54,8 @@ const historyChartOptions = computed(() => {
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true },
|
||||
background: currentTheme.value.surface, // 新增背景色同步
|
||||
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
},
|
||||
@@ -64,6 +63,9 @@ const historyChartOptions = computed(() => {
|
||||
autoScaleYaxis: true,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
tooltip: {
|
||||
@@ -71,6 +73,10 @@ const historyChartOptions = computed(() => {
|
||||
format: 'dd MMM yyyy',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
background: currentTheme.value.background, // 提示框背景色同步
|
||||
color: currentTheme.value.onBackground, // 文字颜色同步
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
@@ -143,10 +149,15 @@ const seedingChartOptions = computed(() => {
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true },
|
||||
background: currentTheme.value.surface, // 新增背景色同步
|
||||
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
x: {
|
||||
@@ -154,6 +165,10 @@ const seedingChartOptions = computed(() => {
|
||||
return '数量:' + val.toLocaleString()
|
||||
},
|
||||
},
|
||||
style: {
|
||||
background: currentTheme.value.background, // 提示框背景色同步
|
||||
color: currentTheme.value.onBackground, // 文字颜色同步
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
@@ -243,13 +258,12 @@ async function fetchSiteUserData() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 刷新站点数据
|
||||
async function refreshSiteData(){
|
||||
async function refreshSiteData() {
|
||||
progressDialog.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`site/userdata/${props.site?.id}`)
|
||||
if (result.success){
|
||||
if (result.success) {
|
||||
await fetchSiteUserData()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -267,19 +281,21 @@ onBeforeMount(async () => {
|
||||
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="rounded-t">
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ `数据 - ${props.site?.name}` }}
|
||||
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh"</VIcon></IconBtn>
|
||||
<VCardTitle
|
||||
>{{ `数据 - ${props.site?.name}` }}
|
||||
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh" /></IconBtn>
|
||||
</VCardTitle>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VDivider />
|
||||
<VCardText class="pt-5">
|
||||
<VRow class="match-height">
|
||||
<!-- 用户信息 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">用户等级</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.user_level || '无' }}
|
||||
@@ -297,7 +313,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">积分</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.bonus?.toLocaleString() }}
|
||||
@@ -339,7 +355,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">总上传量</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ formatFileSize(siteData?.upload || 0) }}
|
||||
@@ -360,7 +376,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">总下载量</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ formatFileSize(siteData?.download || 0) }}
|
||||
@@ -381,7 +397,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">总做种数</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.seeding?.toLocaleString() }}
|
||||
@@ -402,7 +418,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">总做种体积</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ formatFileSize(siteData?.seeding_size || 0) }}
|
||||
@@ -423,7 +439,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">加入时间</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.join_at?.split(' ')[0] }}
|
||||
@@ -441,7 +457,7 @@ onBeforeMount(async () => {
|
||||
<VCol>
|
||||
<VCard title="历史流量">
|
||||
<VCardText>
|
||||
<VueApexCharts type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
|
||||
<VApexChart type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
@@ -450,7 +466,7 @@ onBeforeMount(async () => {
|
||||
<VCol>
|
||||
<VCard title="做种分布">
|
||||
<VCardText>
|
||||
<VueApexCharts type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
|
||||
<VApexChart type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
|
||||
import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { VTextarea, VTextField } from 'vuetify/lib/components/index.mjs'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -50,13 +49,74 @@ const subscribeForm = ref<Subscribe>({
|
||||
sites: [],
|
||||
best_version: undefined,
|
||||
current_priority: 0,
|
||||
downloader: '',
|
||||
date: '',
|
||||
show_edit_dialog: false,
|
||||
episode_group: '',
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 下载器选项
|
||||
const downloaderOptions = ref<{ title: string; value: string }[]>([])
|
||||
|
||||
// 所有剧集组
|
||||
const episodeGroups = ref<{ [key: string]: any }[]>([])
|
||||
|
||||
// 剧集组选项
|
||||
const episodeGroupOptions = computed(() => {
|
||||
return (episodeGroups.value as { id: number; name: string; group_count: number; episode_count: number }[]).map(
|
||||
item => {
|
||||
return {
|
||||
title: item.name,
|
||||
subtitle: `${item.group_count} 季 • ${item.episode_count} 集`,
|
||||
value: item.id,
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// 生成1到100季的下拉框选项
|
||||
const seasonItems = ref(
|
||||
Array.from({ length: 101 }, (_, i) => i).map(item => ({
|
||||
title: `第 ${item} 季`,
|
||||
value: item,
|
||||
})),
|
||||
)
|
||||
|
||||
// 剧集组选项属性
|
||||
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
return {
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有剧集组
|
||||
async function getEpisodeGroups() {
|
||||
try {
|
||||
episodeGroups.value = await api.get(`media/groups/${subscribeForm.value.tmdbid}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
const downloaders: DownloaderConf[] = await api.get('download/clients')
|
||||
downloaderOptions.value = [
|
||||
{ title: '默认', value: '' },
|
||||
...downloaders.map((item: { name: any }) => ({
|
||||
title: item.name,
|
||||
value: item.name,
|
||||
})),
|
||||
]
|
||||
} catch (error) {
|
||||
console.error('加载下载器设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载规则组
|
||||
async function queryFilterRuleGroups() {
|
||||
try {
|
||||
@@ -159,6 +219,8 @@ async function getSubscribeInfo() {
|
||||
subscribeForm.value = result
|
||||
subscribeForm.value.best_version = subscribeForm.value.best_version === 1
|
||||
subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1
|
||||
// 加载剧集组
|
||||
if (subscribeForm.value.type == '电视剧') getEpisodeGroups()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -188,7 +250,7 @@ async function removeSubscribe() {
|
||||
// 查询下载目录
|
||||
async function loadDownloadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
if (result.success && result.data?.value) {
|
||||
downloadDirectories.value = result.data.value
|
||||
}
|
||||
@@ -200,8 +262,7 @@ async function loadDownloadDirectories() {
|
||||
// 保存目录下拉框
|
||||
const targetDirectories = computed(() => {
|
||||
// 去重后的下载目录
|
||||
const directories = downloadDirectories.value.map(item => item.download_path)
|
||||
return [...new Set(directories)]
|
||||
return downloadDirectories.value.map(item => item.download_path)
|
||||
})
|
||||
|
||||
// 质量选择框数据
|
||||
@@ -292,13 +353,14 @@ onMounted(() => {
|
||||
queryFilterRuleGroups()
|
||||
loadDownloadDirectories()
|
||||
getSiteList()
|
||||
loadDownloaderSetting()
|
||||
if (props.subid) getSubscribeInfo()
|
||||
if (props.default) queryDefaultSubscribeConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${
|
||||
props.default
|
||||
@@ -392,6 +454,26 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="subscribeForm.downloader"
|
||||
:items="downloaderOptions"
|
||||
label="下载器"
|
||||
hint="指定该订阅使用的下载器"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VCombobox
|
||||
v-model="subscribeForm.save_path"
|
||||
:items="targetDirectories"
|
||||
label="保存路径"
|
||||
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
@@ -441,7 +523,7 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="subscribeForm.filter_groups"
|
||||
:items="filterRuleGroupOptions"
|
||||
@@ -453,7 +535,26 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="!props.default">
|
||||
<VCol v-if="!props.default && subscribeForm.type === '电视剧'" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="subscribeForm.episode_group"
|
||||
:items="episodeGroupOptions"
|
||||
:item-props="episodeGroupItemProps"
|
||||
label="指定剧集组"
|
||||
hint="按特定剧集组识别和刮削"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="!props.default && subscribeForm.type === '电视剧'" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="subscribeForm.season"
|
||||
:items="seasonItems"
|
||||
label="指定季"
|
||||
hint="指定任意季订阅"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" v-if="!props.default">
|
||||
<VTextField
|
||||
v-model="subscribeForm.media_category"
|
||||
label="自定义类别"
|
||||
@@ -461,15 +562,6 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VCombobox
|
||||
v-model="subscribeForm.save_path"
|
||||
:items="targetDirectories"
|
||||
label="自定义保存路径"
|
||||
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="!props.default">
|
||||
<VCol cols="12">
|
||||
|
||||
@@ -132,59 +132,63 @@ onBeforeMount(() => {
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="download">
|
||||
<transition name="fade-slide" appear>
|
||||
<VDataTable
|
||||
items-per-page="50"
|
||||
:headers="downloadHeaders"
|
||||
:items="downloadInfos"
|
||||
:items-length="totalCount"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
hover
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
>
|
||||
<template #item.episode_number="{ item }">
|
||||
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
|
||||
</template>
|
||||
<template #item.torrent_title="{ item }">
|
||||
<div class="text-xs" v-for="file in item.download">
|
||||
【{{ file.site_name }}】{{ file.torrent_title }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.file_path="{ item }">
|
||||
<div class="text-xs" v-for="file in item.download">{{ file.file_path }}</div>
|
||||
</template>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTable>
|
||||
<div>
|
||||
<VDataTable
|
||||
items-per-page="50"
|
||||
:headers="downloadHeaders"
|
||||
:items="downloadInfos"
|
||||
:items-length="totalCount"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
hover
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
>
|
||||
<template #item.episode_number="{ item }">
|
||||
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
|
||||
</template>
|
||||
<template #item.torrent_title="{ item }">
|
||||
<div class="text-xs" v-for="file in item.download">
|
||||
【{{ file.site_name }}】{{ file.torrent_title }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.file_path="{ item }">
|
||||
<div class="text-xs" v-for="file in item.download">{{ file.file_path }}</div>
|
||||
</template>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTable>
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="library">
|
||||
<transition name="fade-slide" appear>
|
||||
<VDataTable
|
||||
items-per-page="50"
|
||||
:headers="libraryHeaders"
|
||||
:items="libraryInfos"
|
||||
:items-length="totalCount"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
hover
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
>
|
||||
<template #item.episode_number="{ item }">
|
||||
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
|
||||
</template>
|
||||
<template #item.file_path="{ item }">
|
||||
<div class="text-xs" v-for="file in item.library">{{ file.file_path }}</div>
|
||||
</template>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTable>
|
||||
<div>
|
||||
<VDataTable
|
||||
items-per-page="50"
|
||||
:headers="libraryHeaders"
|
||||
:items="libraryInfos"
|
||||
:items-length="totalCount"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
hover
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
>
|
||||
<template #item.episode_number="{ item }">
|
||||
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
|
||||
</template>
|
||||
<template #item.file_path="{ item }">
|
||||
<div class="text-xs" v-for="file in item.library">{{ file.file_path }}</div>
|
||||
</template>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTable>
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
@@ -194,7 +198,7 @@ onBeforeMount(() => {
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.vue-media-back {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
|
||||
@@ -86,7 +86,7 @@ async function reSubscribe(item: Subscribe) {
|
||||
else progressText.value = `正在重新订阅 ${item.name} 第 ${item.season} 季 ...`
|
||||
progressDialog.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('subscribe', item)
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', item)
|
||||
if (result.success) {
|
||||
emit('save')
|
||||
}
|
||||
@@ -138,14 +138,7 @@ const dropdownItems = ref([
|
||||
<VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<DialogCloseBtn
|
||||
@click="
|
||||
() => {
|
||||
emit('close')
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- <VList lines="two" v-if="historyList.length > 0"> -->
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VList lines="two">
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-hidden" @load="loadHistory">
|
||||
<template #loading>
|
||||
@@ -188,7 +181,6 @@ const dropdownItems = ref([
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
@@ -207,7 +199,7 @@ const dropdownItems = ref([
|
||||
</template>
|
||||
</VInfiniteScroll>
|
||||
</VList>
|
||||
<VCardText v-if="historyList.length === 0" class="text-center"> 没有已完成的订阅 </VCardText>
|
||||
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center"> 没有已完成的订阅 </VCardText>
|
||||
</VCard>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
|
||||
262
src/components/dialog/SubscribeSeasonDialog.vue
Normal file
262
src/components/dialog/SubscribeSeasonDialog.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
||||
import { PropType } from 'vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['subscribe', 'close'])
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaInfo>,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
// 季详情
|
||||
const seasonInfos = ref<MediaSeason[]>([])
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
|
||||
// 各季缺失状态:0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
|
||||
const seasonsNotExisted = ref<{ [key: number]: number }>({})
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 所有剧集组
|
||||
const episodeGroups = ref<{ [key: string]: any }[]>([])
|
||||
|
||||
// 当前选择剧集组
|
||||
const episodeGroup = ref('')
|
||||
|
||||
// 剧集组选项属性
|
||||
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
return {
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
}
|
||||
}
|
||||
|
||||
// 剧集组选项
|
||||
const episodeGroupOptions = computed(() => {
|
||||
let options = (episodeGroups.value as { id: string; name: string; group_count: number; episode_count: number }[]).map(
|
||||
item => {
|
||||
return {
|
||||
title: item.name,
|
||||
subtitle: `${item.group_count} 季 • ${item.episode_count} 集`,
|
||||
value: item.id,
|
||||
}
|
||||
},
|
||||
)
|
||||
// 添加不使用选项
|
||||
options.unshift({
|
||||
title: '默认',
|
||||
subtitle: `${seasonInfos.value.length} 季`,
|
||||
value: '',
|
||||
})
|
||||
return options
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
// 查询所有剧集组
|
||||
async function getEpisodeGroups() {
|
||||
try {
|
||||
episodeGroups.value = await api.get(`media/groups/${props.media?.tmdb_id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询TMDB的所有季信息
|
||||
async function getMediaSeasons() {
|
||||
try {
|
||||
seasonInfos.value = await api.get('media/seasons', {
|
||||
params: {
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
},
|
||||
})
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询剧集组的剧集
|
||||
async function getGroupSeasons() {
|
||||
if (!episodeGroup.value) return
|
||||
isRefreshed.value = false
|
||||
try {
|
||||
seasonInfos.value = await api.get(`media/group/seasons/${episodeGroup.value}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
// 检查所有季的缺失状态(数据库)
|
||||
async function checkSeasonsNotExists() {
|
||||
// 开始处理
|
||||
try {
|
||||
let tmpMedia = props.media ?? { episode_group: '' }
|
||||
if (episodeGroup.value) tmpMedia.episode_group = episodeGroup.value
|
||||
else tmpMedia.episode_group = ''
|
||||
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', tmpMedia)
|
||||
if (result) {
|
||||
result.forEach(item => {
|
||||
// 0-已入库 1-部分缺失 2-全部缺失
|
||||
let state = 0
|
||||
if (item.episodes.length === 0) state = 2
|
||||
else if (item.episodes.length < item.total_episode) state = 1
|
||||
seasonsNotExisted.value[item.season] = state
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算存在状态的颜色
|
||||
function getExistColor(season: number) {
|
||||
const state = seasonsNotExisted.value[season]
|
||||
if (!state) return 'success'
|
||||
|
||||
if (state === 1) return 'warning'
|
||||
else if (state === 2) return 'error'
|
||||
else return 'success'
|
||||
}
|
||||
|
||||
// 计算存在状态的文本
|
||||
function getExistText(season: number) {
|
||||
const state = seasonsNotExisted.value[season]
|
||||
if (!state) return '已入库'
|
||||
|
||||
if (state === 1) return '部分缺失'
|
||||
else if (state === 2) return '缺失'
|
||||
else return '已入库'
|
||||
}
|
||||
|
||||
// 拼装季图片地址
|
||||
function getSeasonPoster(posterPath: string) {
|
||||
if (!posterPath) return props.media?.poster_path
|
||||
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
||||
function formatAirDate(airDate: string) {
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
// 从yyyy-mm-dd中提取年份
|
||||
function getYear(airDate: string) {
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return date.getFullYear()
|
||||
}
|
||||
|
||||
function subscribeSeasons() {
|
||||
emit('subscribe', seasonsSelected.value, seasonsNotExisted.value, episodeGroup.value)
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (episodeGroup.value) getGroupSeasons()
|
||||
else getMediaSeasons()
|
||||
checkSeasonsNotExists()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
getMediaSeasons()
|
||||
getEpisodeGroups()
|
||||
checkSeasonsNotExists()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VBottomSheet inset scrollable>
|
||||
<VCard class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VSelect
|
||||
v-model="episodeGroup"
|
||||
:items="episodeGroupOptions"
|
||||
:item-props="episodeGroupItemProps"
|
||||
label="选择剧集组"
|
||||
persistent-hint
|
||||
/>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<div v-else-if="seasonInfos.length > 0">
|
||||
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
|
||||
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="90"
|
||||
width="60"
|
||||
:src="getSeasonPoster(item.poster_path || '')"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded shadow ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle> 第 {{ item.season_number }} 季 </VListItemTitle>
|
||||
<VListItemSubtitle class="mt-1 me-2">
|
||||
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
|
||||
<VIcon icon="mdi-star" /> {{ item.vote_average }}
|
||||
</VChip>
|
||||
{{ getYear(item.air_date || '') }} • {{ item.episode_count }} 集
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
《{{ media?.title }}》第 {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播。
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
<VChip
|
||||
v-if="seasonsNotExisted"
|
||||
class="mt-2"
|
||||
size="small"
|
||||
:color="getExistColor(item.season_number || 0)"
|
||||
>
|
||||
{{ getExistText(item.season_number || 0) }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
<template #append="{ isSelected }">
|
||||
<VListItemAction start>
|
||||
<VSwitch :model-value="isSelected" />
|
||||
</VListItemAction>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
<NoDataFound v-else errorTitle="出错啦!" :errorDescription="`${props.media?.title} 未查询到季集信息`" />
|
||||
</VCardText>
|
||||
<div class="my-2 text-center">
|
||||
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
|
||||
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</VBottomSheet>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { Subscribe, SubscribeShare } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -16,16 +17,22 @@ const props = defineProps({
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 分享处理状态
|
||||
const shareDoing = ref(false)
|
||||
|
||||
// 订阅编辑表单
|
||||
const shareForm = ref<SubscribeShare>({
|
||||
subscribe_id: props.sub?.id ?? 0,
|
||||
share_title: `${props.sub?.name} ${formatSeason(props.sub?.season ? props.sub?.season.toString() : '')}`,
|
||||
})
|
||||
|
||||
// 分享订阅
|
||||
async function doShare() {
|
||||
if (!shareForm.value.share_title || !shareForm.value.share_comment || !shareForm.value.share_user) return
|
||||
try {
|
||||
shareDoing.value = true
|
||||
const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value)
|
||||
shareDoing.value = false
|
||||
// 提示
|
||||
if (result.success) {
|
||||
$toast.success(`${props.sub?.name} 分享成功!`)
|
||||
@@ -56,8 +63,8 @@ const $toast = useToast()
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="shareForm.share_title"
|
||||
readonly
|
||||
label="标题"
|
||||
hint="给分享取一个便于识别的名称"
|
||||
:rules="[requiredValidator]"
|
||||
persistent-hint
|
||||
/>
|
||||
@@ -67,7 +74,7 @@ const $toast = useToast()
|
||||
v-model="shareForm.share_comment"
|
||||
label="说明"
|
||||
:rules="[requiredValidator]"
|
||||
hint="关于该订阅的说明"
|
||||
hint="填写关于该订阅的说明,订阅中的搜索词、识别词等将会默认包含在分享中"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
@@ -85,7 +92,16 @@ const $toast = useToast()
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="doShare" prepend-icon="mdi-share" class="px-5"> 确认分享 </VBtn>
|
||||
<VBtn
|
||||
variant="elevated"
|
||||
:disabled="shareDoing"
|
||||
@click="doShare"
|
||||
prepend-icon="mdi-share"
|
||||
class="px-5"
|
||||
:loading="shareDoing"
|
||||
>
|
||||
确认分享
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
187
src/components/dialog/TransferQueueDialog.vue
Normal file
187
src/components/dialog/TransferQueueDialog.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { FileItem, TransferQueue } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<TransferQueue[]>([])
|
||||
|
||||
// 加载进度SSE
|
||||
const progressEventSource = ref<EventSource>()
|
||||
|
||||
// 整理进度文本
|
||||
const progressText = ref('请稍候 ...')
|
||||
|
||||
// 整理进度
|
||||
const progressValue = ref(0)
|
||||
|
||||
// 数据可刷新标志
|
||||
const refreshFlag = ref(false)
|
||||
|
||||
// 活动标签
|
||||
const activeTab = ref('')
|
||||
|
||||
// 状态标签
|
||||
const stateDict: { [key: string]: string } = {
|
||||
'waiting': '等待中',
|
||||
'running': '正在整理',
|
||||
'completed': '完成',
|
||||
'failed': '失败',
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
function getStateColor(state: string) {
|
||||
if (state === 'waiting') return 'gray'
|
||||
else if (state === 'running') return 'primary'
|
||||
else if (state === 'completed') return 'success'
|
||||
else return 'error'
|
||||
}
|
||||
|
||||
// 从dataList中提取所有的媒体信息
|
||||
const mediaList = computed(() => {
|
||||
return dataList.value.map(item => item.media)
|
||||
})
|
||||
|
||||
// 按media计算总数和完成数,返回 x/x
|
||||
function getMediaCount(title_year: string) {
|
||||
// 按title_year查询出所有media列表
|
||||
const medias = dataList.value.filter(item => item.media.title_year === title_year)
|
||||
// 计算media下任务的总数
|
||||
const total = medias.reduce((acc, cur) => acc + cur.tasks.length, 0)
|
||||
// 计算media下任务的完成数
|
||||
const completed = medias.reduce((acc, cur) => acc + cur.tasks.filter(task => task.state === 'completed').length, 0)
|
||||
return `${completed} / ${total}`
|
||||
}
|
||||
|
||||
// 根据媒体信息获取对应的整理任务
|
||||
const activeTasks = computed(() => {
|
||||
return dataList.value.find(item => item.media.title_year === activeTab.value)?.tasks
|
||||
})
|
||||
|
||||
// 调用API获取队列信息
|
||||
async function get_transfer_queue() {
|
||||
try {
|
||||
dataList.value = await api.get('transfer/queue')
|
||||
if (dataList.value.length > 0) {
|
||||
if (!activeTab.value || activeTasks.value?.length == 0) activeTab.value = dataList.value[0].media.title_year || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除队列任务
|
||||
async function remove_queue_task(fileitem: FileItem) {
|
||||
try {
|
||||
await api.delete(`transfer/queue`, { data: fileitem })
|
||||
get_transfer_queue()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
progressText.value = '请稍候 ...'
|
||||
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
|
||||
progressEventSource.value.onmessage = event => {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
if (!progress.enable) {
|
||||
progressText.value = '请稍候 ...'
|
||||
progressValue.value = 0
|
||||
if (refreshFlag.value) {
|
||||
refreshFlag.value = false
|
||||
get_transfer_queue()
|
||||
}
|
||||
return
|
||||
}
|
||||
progressText.value = progress.text
|
||||
progressValue.value = progress.value
|
||||
if (progress.value >= 100 && refreshFlag.value) {
|
||||
refreshFlag.value = false
|
||||
get_transfer_queue()
|
||||
} else {
|
||||
if (progress.value > 0 && refreshFlag.value && progress.text?.includes('整理完成')) {
|
||||
refreshFlag.value = false
|
||||
get_transfer_queue()
|
||||
} else {
|
||||
refreshFlag.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停止监听加载进度
|
||||
function stopLoadingProgress() {
|
||||
progressEventSource.value?.close()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
get_transfer_queue()
|
||||
startLoadingProgress()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopLoadingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="mx-auto" width="100%">
|
||||
<VCardItem>
|
||||
<VCardTitle>整理队列</VCardTitle>
|
||||
</VCardItem>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VProgressLinear
|
||||
v-if="dataList.length > 0 && progressValue > 0"
|
||||
:value="progressValue"
|
||||
color="primary"
|
||||
indeterminate
|
||||
/>
|
||||
<VCardItem v-if="dataList.length > 0 && progressValue > 0" class="text-center pt-2">
|
||||
<span class="text-sm">{{ progressText }}</span>
|
||||
</VCardItem>
|
||||
<VCardText v-if="dataList.length === 0" class="text-center"> 没有正在整理的任务 </VCardText>
|
||||
<VCardText>
|
||||
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill" stacked>
|
||||
<VTab
|
||||
v-for="media in mediaList"
|
||||
:value="media.title_year"
|
||||
selected-class="v-slide-group-item--active v-tab--selected"
|
||||
>
|
||||
<div class="font-bold text-lg">{{ media.title }}</div>
|
||||
<div>({{ getMediaCount(media.title_year || '') }})</div>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindowItem v-for="media in mediaList" :value="media.title_year">
|
||||
<VList>
|
||||
<VListItem v-for="task in activeTasks">
|
||||
<VListItemTitle>{{ task.fileitem.name }}</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
大小:{{ formatFileSize(task.fileitem.size || 0) }}
|
||||
<VChip size="small" :color="getStateColor(task.state)" class="ms-2">
|
||||
{{ stateDict[task.state] }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<IconBtn size="small" icon="mdi-cancel" @click="remove_queue_task(task.fileitem)" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -1,6 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import api from '@/api'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
conf: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
@@ -19,6 +27,7 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
clearTimeout(timeoutTimer)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
@@ -28,6 +37,7 @@ async function getQrcode() {
|
||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
|
||||
if (result.success && result.data) {
|
||||
qrCodeContent.value = result.data.codeContent
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
text.value = result.message
|
||||
}
|
||||
@@ -43,15 +53,21 @@ async function checkQrcode() {
|
||||
if (result.success && result.data) {
|
||||
const status = result.data.status
|
||||
text.value = result.data.tip
|
||||
if (status == 1) {
|
||||
// 已确认完成
|
||||
alertType.value = 'success'
|
||||
handleDone()
|
||||
} else if (status == 0) {
|
||||
if (status == 0) {
|
||||
alertType.value = 'info'
|
||||
// 新建、待扫码
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else if (status == 1) {
|
||||
// 已扫码
|
||||
alertType.value = 'info'
|
||||
text.value = '已扫码,请确认登录'
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else if (status == 2) {
|
||||
// 已确认完成
|
||||
alertType.value = 'success'
|
||||
handleDone()
|
||||
} else {
|
||||
// 过期或者已取消
|
||||
alertType.value = 'error'
|
||||
@@ -67,7 +83,6 @@ async function checkQrcode() {
|
||||
|
||||
onMounted(async () => {
|
||||
await getQrcode()
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -80,10 +95,8 @@ onUnmounted(() => {
|
||||
<VCard title="115网盘登录" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText class="pt-2 flex flex-col items-center">
|
||||
<div class="my-6 shadow-lg rounded border">
|
||||
<VImg class="mx-auto" :src="qrCodeContent" style="block-size: 200px; inline-size: 200px">
|
||||
<VSkeletonLoader v-if="!qrCodeContent" class="w-full h-full" />
|
||||
</VImg>
|
||||
<div class="my-6 shadow-lg rounded text-center p-3 border">
|
||||
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
|
||||
</div>
|
||||
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||
<template #prepend />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import store from '@/store'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -23,8 +23,11 @@ const props = defineProps({
|
||||
oper: String,
|
||||
})
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 当前登录用户名称
|
||||
const currentLoginUser = store.state.auth.userName
|
||||
const currentLoginUser = userStore.userName
|
||||
|
||||
// 用户名
|
||||
const userName = ref('')
|
||||
@@ -53,8 +56,13 @@ const statusItems = [
|
||||
{ title: '已停用', value: 0 },
|
||||
]
|
||||
|
||||
// 扩展User类型以包含note字段
|
||||
interface ExtendedUser extends User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 用户编辑表单数据
|
||||
const userForm = ref<User>({
|
||||
const userForm = ref<ExtendedUser>({
|
||||
id: 0,
|
||||
name: props.username ?? '',
|
||||
password: '',
|
||||
@@ -71,6 +79,7 @@ const userForm = ref<User>({
|
||||
vocechat_userid: null,
|
||||
synologychat_userid: null,
|
||||
},
|
||||
nickname: '', // 昵称字段
|
||||
})
|
||||
|
||||
// 更新头像
|
||||
@@ -187,6 +196,15 @@ async function updateUser() {
|
||||
}
|
||||
userForm.value.password = newPassword.value
|
||||
}
|
||||
|
||||
// 将nickname保存到settings中,后端可以直接处理JSON对象
|
||||
if (userForm.value.nickname) {
|
||||
if (!userForm.value.settings) {
|
||||
userForm.value.settings = {}
|
||||
}
|
||||
userForm.value.settings.nickname = userForm.value.nickname
|
||||
}
|
||||
|
||||
const oldUserName = userForm.value.name
|
||||
userForm.value.name = currentUserName.value
|
||||
const oldAvatar = userForm.value.avatar
|
||||
@@ -194,18 +212,24 @@ async function updateUser() {
|
||||
isUpdating.value = true
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put('user/', userForm.value)
|
||||
// 确保昵称保存,使用一个临时变量存储完整数据
|
||||
const userData = { ...userForm.value }
|
||||
|
||||
const result: { [key: string]: any } = await api.put('user/', userData)
|
||||
|
||||
if (result.success) {
|
||||
if (oldUserName !== currentUserName.value) {
|
||||
$toast.success(`【${oldUserName}】更名【${currentUserName.value}】, 更新成功!`)
|
||||
// 如果是当前登录用户,更新当前用户名称显示
|
||||
if (isCurrentUser.value) store.commit('auth/setUserName', currentUserName.value)
|
||||
if (isCurrentUser.value) {
|
||||
userStore.setUserName(currentUserName.value)
|
||||
}
|
||||
} else {
|
||||
$toast.success(`【${userForm.value?.name}】更新成功!`)
|
||||
}
|
||||
// 更新本地头像显示
|
||||
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
|
||||
store.commit('auth/setAvatar', currentAvatar.value)
|
||||
userStore.setAvatar(currentAvatar.value)
|
||||
}
|
||||
emit('save')
|
||||
} else {
|
||||
@@ -224,7 +248,7 @@ async function updateUser() {
|
||||
userForm.value.password = ''
|
||||
} catch (error) {
|
||||
$toast.error(`【${userForm.value?.name}】更新失败!`)
|
||||
console.error(error)
|
||||
console.error('更新失败:', error)
|
||||
}
|
||||
doneNProgress()
|
||||
isUpdating.value = false
|
||||
@@ -262,48 +286,55 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable :close-on-back="false" eager max-width="40rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${props.oper === 'add' ? '新增' : '编辑'}用户${props.oper !== 'add' ? ` - ${userName}` : ''}`"
|
||||
class="rounded-t"
|
||||
>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText class="d-flex">
|
||||
<VCardItem>
|
||||
<!-- 👉 Avatar -->
|
||||
<VAvatar rounded="lg" size="100" class="me-6" :image="currentAvatar" />
|
||||
<div class="flex flex-row">
|
||||
<VAvatar rounded="lg" size="100" class="me-5" :image="currentAvatar" />
|
||||
<!-- 👉 Upload Photo -->
|
||||
<div class="flex flex-col justify-center gap-5">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<VBtn color="primary" @click="refInputEl?.click()">
|
||||
<VIcon icon="mdi-cloud-upload-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
|
||||
</VBtn>
|
||||
|
||||
<!-- 👉 Upload Photo -->
|
||||
<form class="d-flex flex-column justify-center gap-5">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<VBtn color="primary" @click="refInputEl?.click()">
|
||||
<VIcon icon="mdi-cloud-upload-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
|
||||
</VBtn>
|
||||
<input
|
||||
ref="refInputEl"
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".jpeg,.png,.jpg,GIF"
|
||||
hidden
|
||||
@input="changeAvatar"
|
||||
/>
|
||||
|
||||
<input ref="refInputEl" type="file" name="file" accept=".jpeg,.png,.jpg,GIF" hidden @input="changeAvatar" />
|
||||
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
|
||||
</VBtn>
|
||||
|
||||
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
type="reset"
|
||||
:color="props.oper === 'add' ? 'info' : 'error'"
|
||||
variant="tonal"
|
||||
@click="resetDefaultAvatar"
|
||||
>
|
||||
<VIcon icon="mdi-image-sync-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">默认</span>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
type="reset"
|
||||
:color="props.oper === 'add' ? 'info' : 'error'"
|
||||
variant="tonal"
|
||||
@click="resetDefaultAvatar"
|
||||
>
|
||||
<VIcon icon="mdi-image-sync-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">默认</span>
|
||||
</VBtn>
|
||||
</div>
|
||||
<p class="text-body-1 mb-0">允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。</p>
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-0">允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。</p>
|
||||
</form>
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}" class="mt-3">
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VDivider class="my-10">
|
||||
<span>用户基础设置</span>
|
||||
</VDivider>
|
||||
@@ -343,6 +374,15 @@ onMounted(() => {
|
||||
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.nickname"
|
||||
density="comfortable"
|
||||
clearable
|
||||
label="昵称"
|
||||
placeholder="显示昵称,优先于用户名显示"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="canControl">
|
||||
<VSelect
|
||||
v-model="userStatus"
|
||||
@@ -355,7 +395,7 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VDivider class="my-10">
|
||||
<span>消息账号绑定</span>
|
||||
<span>账号绑定</span>
|
||||
</VDivider>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -388,6 +428,9 @@ onMounted(() => {
|
||||
label="SynologyChat用户"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="userForm.settings.douban_userid" density="comfortable" clearable label="豆瓣用户" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
|
||||
177
src/components/dialog/UserAuthDialog.vue
Normal file
177
src/components/dialog/UserAuthDialog.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts" setup>
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 用户认证表单
|
||||
const authForm = ref<any>({
|
||||
site: null,
|
||||
params: {},
|
||||
})
|
||||
|
||||
// 所有认证站点
|
||||
const authSites = ref<{
|
||||
[key: string]: {
|
||||
name: string
|
||||
icon: string
|
||||
params: { [key: string]: any }
|
||||
}
|
||||
}>({})
|
||||
|
||||
// 生成站点拉选项
|
||||
const dropdownItems = computed(() => {
|
||||
return Object.keys(authSites.value).map(key => {
|
||||
return {
|
||||
key,
|
||||
name: authSites.value[key].name,
|
||||
prependAvatar: authSites.value[key].icon,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 读取authSites.params,生成表单配置列表
|
||||
const formFields = computed(() => {
|
||||
const site = authSites.value[authForm.value.site]
|
||||
return Object.keys(site?.params || {})
|
||||
.filter(item => {
|
||||
return site.params[item].name && site.params[item].type
|
||||
})
|
||||
.map(key => {
|
||||
return {
|
||||
key,
|
||||
site: authForm.value.site,
|
||||
name: site.params[key].name,
|
||||
type: site.params[key].type,
|
||||
placeholder: site.params[key].placeholder,
|
||||
tooltip: site.params[key].tooltip,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 查询之前使用的认证参数
|
||||
async function loadLastAuthParams() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`system/setting/UserSiteAuthParams`)
|
||||
if (result.success) {
|
||||
const ret = result.data?.value
|
||||
if (ret && !isNullOrEmptyObject(ret.params)) {
|
||||
authForm.value = ret
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载认证站点配置
|
||||
async function loadAuthSites() {
|
||||
try {
|
||||
authSites.value = (await api.get(`site/auth`)) || {}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
await checkUser()
|
||||
}
|
||||
|
||||
// 认证处理
|
||||
async function checkUser() {
|
||||
if (!authForm.value.site) {
|
||||
$toast.error('请选择认证站点!')
|
||||
return
|
||||
}
|
||||
if (!authSites.value[authForm.value.site]) {
|
||||
$toast.error('站点配置不存在!')
|
||||
return
|
||||
}
|
||||
if (formFields.value.length > 0) {
|
||||
for (const field of formFields.value) {
|
||||
if (!authForm.value.params[field.site.toUpperCase() + '_' + field.key.toUpperCase()]) {
|
||||
$toast.error(`请输入${field.name}!`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`site/auth`, authForm.value)
|
||||
if (result.success) {
|
||||
$toast.success('用户认证成功,请重新登录!')
|
||||
// 1秒后刷新页面
|
||||
setTimeout(() => {
|
||||
emit('done')
|
||||
}, 1000)
|
||||
} else {
|
||||
$toast.error(`认证失败:${result.message}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAuthSites()
|
||||
loadLastAuthParams()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog width="40rem" max-height="85vh">
|
||||
<VCard title="用户认证" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="authForm.site"
|
||||
:items="dropdownItems"
|
||||
item-value="key"
|
||||
item-title="name"
|
||||
label="选择认证站点"
|
||||
item-props
|
||||
>
|
||||
</VSelect>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol v-for="param in formFields" :key="param.key">
|
||||
<VTextField
|
||||
v-model="authForm.params[param.site.toUpperCase() + '_' + param.key.toUpperCase()]"
|
||||
:type="param.type"
|
||||
:label="param.name"
|
||||
:placeholder="param.placeholder"
|
||||
:hint="param.tooltip"
|
||||
clearable
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardText class="text-center">
|
||||
<VBtn
|
||||
variant="elevated"
|
||||
@click="handleDone"
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5"
|
||||
size="large"
|
||||
:disabled="loading"
|
||||
>
|
||||
开始认证
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
350
src/components/dialog/WorkflowActionsDialog.vue
Normal file
350
src/components/dialog/WorkflowActionsDialog.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { VueFlow, useVueFlow, type Connection, type GraphNode } from '@vue-flow/core'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import useDragAndDrop from '@core/utils/workflow'
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
|
||||
const { onConnect, addEdges, nodes, edges, addNodes, screenToFlowCoordinate } = useVueFlow()
|
||||
|
||||
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
|
||||
|
||||
// 连接事件
|
||||
onConnect((connection: Connection) => {
|
||||
// 双重校验
|
||||
if (!isValidConnection(connection)) {
|
||||
$toast.warning('非法连接:不能连接自身或同类型端口!')
|
||||
return
|
||||
}
|
||||
addEdges(connection)
|
||||
})
|
||||
|
||||
// 获取指定节点端口的类型(输入/输出)
|
||||
const getPortType = (node: GraphNode, handleId: string) => {
|
||||
// 检查是否是输入端口(对应 handleBounds.target)
|
||||
const isInput = node.handleBounds?.target?.some(h => h.id === handleId)
|
||||
if (isInput) return 'input'
|
||||
|
||||
// 检查是否是输出端口(对应 handleBounds.source)
|
||||
const isOutput = node.handleBounds?.source?.some(h => h.id === handleId)
|
||||
return isOutput ? 'output' : null
|
||||
}
|
||||
|
||||
// 校验连接是否合法
|
||||
const isValidConnection = (connection: Connection) => {
|
||||
// 获取连接的源节点和目标节点
|
||||
const sourceNode = nodes.value.find(n => n.id === connection.source)
|
||||
const targetNode = nodes.value.find(n => n.id === connection.target)
|
||||
|
||||
if (!sourceNode || !targetNode) return false
|
||||
|
||||
// 获取端口类型
|
||||
const sourcePortType = getPortType(sourceNode, connection.sourceHandle!)
|
||||
const targetPortType = getPortType(targetNode, connection.targetHandle!)
|
||||
|
||||
/* 同时满足三个条件,才允许连接:
|
||||
* 1. 源端口是输出类型(output)
|
||||
* 2. 目标端口是输入类型(input)
|
||||
* 3. 不是同一节点的连接
|
||||
*/
|
||||
return sourcePortType === 'output' && targetPortType === 'input' && connection.source !== connection.target
|
||||
}
|
||||
|
||||
// 自定义节点类型
|
||||
const nodeTypes: Record<string, any> = ref({})
|
||||
|
||||
// 自动扫描目录下所有的 .vue 文件
|
||||
const components = import.meta.glob('../workflow/*Action.vue')
|
||||
|
||||
// 动态加载某个组件
|
||||
const loadComponent = async (componentName: string) => {
|
||||
const component = components[`../workflow/${componentName}.vue`]
|
||||
if (component) {
|
||||
return ((await component()) as any).default
|
||||
}
|
||||
throw new Error(`组件 ${componentName} 未找到`)
|
||||
}
|
||||
|
||||
// 将所有components中的组件加载到nodeTypes中
|
||||
for (const path in components) {
|
||||
const componentName = path.match(/\.\/workflow\/(.*).vue$/)?.[1]
|
||||
if (!componentName) {
|
||||
continue
|
||||
}
|
||||
loadComponent(componentName).then(component => {
|
||||
nodeTypes.value[componentName] = markRaw(component)
|
||||
})
|
||||
}
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
workflow: Object as PropType<Workflow>,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
// 站点编辑表单数据
|
||||
const workflowForm = ref<any>(props.workflow || {})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 导入代码对话框
|
||||
const importCodeDialog = ref(false)
|
||||
|
||||
// 为移动端生成节点ID
|
||||
function getId() {
|
||||
return 'act_' + Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
// 处理移动端组件点击事件
|
||||
function handleComponentClick(action: any) {
|
||||
// 计算当前视图中心点
|
||||
const centerX = window.innerWidth / 2
|
||||
const centerY = window.innerHeight / 3
|
||||
|
||||
// 转换为画布坐标
|
||||
const position = screenToFlowCoordinate({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
})
|
||||
|
||||
// 生成一个新节点ID
|
||||
const nodeId = getId()
|
||||
|
||||
// 创建新节点
|
||||
const newNode = {
|
||||
id: nodeId,
|
||||
type: action.type,
|
||||
name: action.name,
|
||||
description: action.desc || '',
|
||||
position,
|
||||
data: {},
|
||||
}
|
||||
|
||||
// 添加节点到画布
|
||||
addNodes(newNode)
|
||||
|
||||
// 显示提示
|
||||
$toast.success('已添加组件到画布')
|
||||
}
|
||||
|
||||
// 调用API 编辑任务
|
||||
async function updateWorkflow() {
|
||||
// 更新节点和流程
|
||||
workflowForm.value.actions = nodes
|
||||
workflowForm.value.flows = edges
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`保存任务流程成功!`)
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`保存任务流程失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存导入的代码,直接覆盖原有值
|
||||
function saveCodeString(type: string, code: any) {
|
||||
try {
|
||||
if (code) {
|
||||
const codeObject = JSON.parse(code.value)
|
||||
if (type === 'workflow') {
|
||||
nodes.value = codeObject.actions || []
|
||||
edges.value = codeObject.flows || []
|
||||
}
|
||||
importCodeDialog.value = false
|
||||
$toast.success('导入成功!')
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error('导入失败!')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分享工作流程
|
||||
function shareWorkflow() {
|
||||
const codeString = JSON.stringify({ actions: nodes.value, flows: edges.value })
|
||||
navigator.clipboard.writeText(codeString)
|
||||
$toast.success('任务流程代码已复制到剪贴板!')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.workflow) {
|
||||
nodes.value = props.workflow.actions ?? []
|
||||
edges.value = props.workflow.flows ?? []
|
||||
}
|
||||
})
|
||||
|
||||
// 判断是不是MACOS
|
||||
const isMacOS = computed(() => {
|
||||
return /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
||||
<VCard class="workflow-dialog">
|
||||
<!-- Toolbar -->
|
||||
<VToolbar color="primary">
|
||||
<VToolbarItems>
|
||||
<VBtn icon @click="emit('close')" class="ms-3">
|
||||
<VIcon size="large" color="white" icon="mdi-close" />
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
<VToolbarTitle> 编辑流程 - {{ workflow?.name }} </VToolbarTitle>
|
||||
<VSpacer></VSpacer>
|
||||
<VToolbarItems>
|
||||
<VBtn icon variant="text" @click="importCodeDialog = true" class="ms-2">
|
||||
<VIcon size="24" color="white" icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn icon variant="text" @click="shareWorkflow" class="ms-2">
|
||||
<VIcon size="24" color="white" icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VBtn icon variant="text" @click="updateWorkflow" class="ms-2 me-3">
|
||||
<VIcon size="24" color="white" icon="mdi-content-save" />
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
|
||||
<VCardText class="workflow-content pa-0">
|
||||
<div class="workflow-canvas" @drop="onDrop">
|
||||
<VueFlow
|
||||
:nodes="nodes"
|
||||
:edges="edges"
|
||||
:nodeTypes="nodeTypes"
|
||||
:is-valid-connection="isValidConnection"
|
||||
:default-edge-options="{ type: 'animation', animated: true }"
|
||||
:edge-updater-radius="10"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
:delete-key-code="isMacOS ? 'Backspace' : 'Delete'"
|
||||
auto-connect
|
||||
>
|
||||
<MiniMap />
|
||||
<DropzoneBackground
|
||||
:style="{
|
||||
backgroundColor: isDragOver ? '#e7f3ff' : 'transparent',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}"
|
||||
>
|
||||
</DropzoneBackground>
|
||||
</VueFlow>
|
||||
<WorkflowSidebar @component-click="handleComponentClick" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<ImportCodeDialog
|
||||
v-if="importCodeDialog"
|
||||
v-model="importCodeDialog"
|
||||
title="导入任务流程"
|
||||
dataType="workflow"
|
||||
@close="importCodeDialog = false"
|
||||
@save="saveCodeString"
|
||||
/>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@vue-flow/core/dist/style.css';
|
||||
@import '@vue-flow/core/dist/theme-default.css';
|
||||
@import '@vue-flow/controls/dist/style.css';
|
||||
@import '@vue-flow/minimap/dist/style.css';
|
||||
@import '@vue-flow/node-resizer/dist/style.css';
|
||||
|
||||
.workflow-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.workflow-content {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workflow-canvas {
|
||||
position: relative;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.vue-flow__minimap {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface), 0.8);
|
||||
box-shadow: 0 4px 15px rgba(var(--v-shadow-key-umbra-color), 0.1);
|
||||
inset-block-end: 20px;
|
||||
inset-inline-end: 20px;
|
||||
transform: scale(75%);
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.vue-flow__handle {
|
||||
border-radius: 4px;
|
||||
block-size: 24px;
|
||||
inline-size: 8px;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path,
|
||||
.vue-flow__connection-path {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.vue-flow__handle-left {
|
||||
background-color: rgb(var(--v-theme-info));
|
||||
}
|
||||
|
||||
.vue-flow__handle-right {
|
||||
background-color: rgb(var(--v-theme-error));
|
||||
}
|
||||
|
||||
// 自定义节点样式
|
||||
.vue-flow__node {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 12px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 8px 16px rgba(var(--v-shadow-key-umbra-color), 0.15) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义动作连线样式
|
||||
.vue-flow__edge.animation {
|
||||
.vue-flow__edge-path {
|
||||
stroke: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
&.selected {
|
||||
.vue-flow__edge-path {
|
||||
stroke: rgb(var(--v-theme-primary));
|
||||
stroke-width: 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
.vue-flow__minimap {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
133
src/components/dialog/WorkflowAddEditDialog.vue
Normal file
133
src/components/dialog/WorkflowAddEditDialog.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import type { Workflow } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 任务信息
|
||||
workflow: Object as PropType<Workflow>,
|
||||
})
|
||||
|
||||
// 新增或修改字样
|
||||
const title = computed(() => (props.workflow ? '编辑' : '创建'))
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['save', 'remove', 'close'])
|
||||
|
||||
// 站点编辑表单数据
|
||||
const workflowForm = ref<Workflow>(
|
||||
props.workflow || {
|
||||
name: undefined,
|
||||
timer: undefined,
|
||||
description: undefined,
|
||||
state: 'P',
|
||||
run_count: 0,
|
||||
},
|
||||
)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 调用API 新增任务
|
||||
async function addWorkflow() {
|
||||
if (!workflowForm.value.name || !workflowForm.value.timer) {
|
||||
$toast.error('请填写完整信息!')
|
||||
return
|
||||
}
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post('workflow/', workflowForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`创建任务成功,请编辑流程!`)
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`创建任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 调用API 编辑任务
|
||||
async function editWorkflow() {
|
||||
if (!workflowForm.value.name || !workflowForm.value.timer) {
|
||||
$toast.error('请填写完整信息!')
|
||||
return
|
||||
}
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`修改任务成功!`)
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`修改任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable :close-on-back="false" eager max-width="30rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${title}任务`" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="workflowForm.name"
|
||||
label="别名"
|
||||
:rules="[requiredValidator]"
|
||||
persistent-hint
|
||||
hint="任务名称"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCronField
|
||||
v-model="workflowForm.timer"
|
||||
label="定时"
|
||||
:rules="[requiredValidator]"
|
||||
placeholder="5位cron表达式"
|
||||
persistent-hint
|
||||
hint="任务执行周期"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea v-model="workflowForm.description" label="任务描述" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="workflow"
|
||||
block
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
@click="editWorkflow"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
保存
|
||||
</VBtn>
|
||||
<VBtn v-else block color="primary" variant="elevated" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
|
||||
创建
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
46
src/components/field/CronField.vue
Normal file
46
src/components/field/CronField.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import CronInput from '@/components/input/CronInput.vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '* * * * *',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const innerValue = ref(props.modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => {
|
||||
innerValue.value = value
|
||||
},
|
||||
)
|
||||
|
||||
const propsWithoutModelValue = computed(() => {
|
||||
const { modelValue, ...rest } = props
|
||||
return { ...rest, ...attrs }
|
||||
})
|
||||
|
||||
function updateModelValue(value: string) {
|
||||
innerValue.value = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CronInput v-model="innerValue" @update:modelValue="updateModelValue">
|
||||
<template #activator="{ menuprops }">
|
||||
<VTextField
|
||||
:modelValue="innerValue"
|
||||
@update:modelValue="updateModelValue"
|
||||
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
</CronInput>
|
||||
</template>
|
||||
49
src/components/field/PathField.vue
Normal file
49
src/components/field/PathField.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import PathInput from '@/components/input/PathInput.vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '/',
|
||||
},
|
||||
storage: {
|
||||
type: String,
|
||||
default: 'local',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const innerValue = ref(props.modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => {
|
||||
innerValue.value = value
|
||||
},
|
||||
)
|
||||
|
||||
const propsWithoutModelValue = computed(() => {
|
||||
const { modelValue, ...rest } = props
|
||||
return { ...rest, ...attrs }
|
||||
})
|
||||
|
||||
function updateModelValue(value: string) {
|
||||
innerValue.value = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PathInput v-model="innerValue" :storage="props.storage" @update:modelValue="updateModelValue">
|
||||
<template #activator="{ menuprops }">
|
||||
<VTextField
|
||||
:modelValue="innerValue"
|
||||
@update:modelValue="updateModelValue"
|
||||
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
|
||||
/>
|
||||
</template>
|
||||
</PathInput>
|
||||
</template>
|
||||
@@ -7,16 +7,13 @@ import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
import type { Context, EndPoints, FileItem } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
icons: Object,
|
||||
@@ -32,10 +29,20 @@ const inProps = defineProps({
|
||||
required: true,
|
||||
},
|
||||
sort: String,
|
||||
listStyle: String,
|
||||
showTree: Boolean,
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
|
||||
const emit = defineEmits([
|
||||
'loading',
|
||||
'pathchanged',
|
||||
'refreshed',
|
||||
'filedeleted',
|
||||
'renamed',
|
||||
'items-updated',
|
||||
'switch-tree',
|
||||
])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
@@ -106,22 +113,12 @@ const files = computed(() => items.value.filter(item => item.type === 'file' &&
|
||||
// 是否文件
|
||||
const isFile = computed(() => inProps.item.type == 'file')
|
||||
|
||||
// 是否目录
|
||||
const isDir = computed(() => !isFile.value)
|
||||
|
||||
// 需要整理的文件项
|
||||
const transferItems = ref<FileItem[]>([])
|
||||
|
||||
// 当前图片地址
|
||||
const currentImgLink = ref('')
|
||||
|
||||
// 大小控制
|
||||
const scrollStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
|
||||
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
const ext = inProps.item.path?.split('.').pop()?.toLowerCase()
|
||||
@@ -152,6 +149,9 @@ async function list_files() {
|
||||
items.value = (await inProps.axios.request(config)) ?? []
|
||||
emit('loading', false)
|
||||
loading.value = false
|
||||
|
||||
// 通知父组件文件列表更新
|
||||
emit('items-updated', items.value)
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
@@ -378,6 +378,11 @@ function formatTime(timestape: number) {
|
||||
return new Date(timestape * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
// 切换文件树显示
|
||||
function switchFileTree(state: boolean) {
|
||||
emit('switch-tree', state)
|
||||
}
|
||||
|
||||
// 监听refreshPending变化
|
||||
watch(
|
||||
() => inProps.refreshpending,
|
||||
@@ -542,8 +547,12 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="d-flex flex-column">
|
||||
<VCard class="d-flex flex-column w-full h-full rounded-t-0" :class="{ 'rounded-s-0': showTree }">
|
||||
<VToolbar v-if="!loading" density="compact" flat color="gray">
|
||||
<IconBtn v-if="display.mdAndUp.value">
|
||||
<VIcon v-if="showTree" icon="mdi-file-tree" @click="switchFileTree(false)" />
|
||||
<VIcon v-else icon="mdi-file-tree-outline" @click="switchFileTree(true)" />
|
||||
</IconBtn>
|
||||
<VTextField
|
||||
v-if="!isFile"
|
||||
v-model="filter"
|
||||
@@ -554,7 +563,7 @@ onMounted(() => {
|
||||
placeholder="搜索 ..."
|
||||
prepend-inner-icon="mdi-filter-outline"
|
||||
class="me-2"
|
||||
rounded="0"
|
||||
rounded
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||
@@ -608,7 +617,7 @@ onMounted(() => {
|
||||
<!-- 目录和文件列表 -->
|
||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||
<VList subheader>
|
||||
<VVirtualScroll :items="[...dirs, ...files]" :style="scrollStyle">
|
||||
<VVirtualScroll :items="[...dirs, ...files]" :style="listStyle">
|
||||
<template #default="{ item }">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
@@ -622,7 +631,8 @@ onMounted(() => {
|
||||
v-if="inProps.icons && item.extension"
|
||||
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
|
||||
/>
|
||||
<VIcon v-else icon="mdi-folder-outline" />
|
||||
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder" />
|
||||
<VIcon v-else icon="mdi-file-outline" />
|
||||
</template>
|
||||
</template>
|
||||
<VListItemTitle v-text="item.name" />
|
||||
@@ -635,12 +645,7 @@ onMounted(() => {
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(menu, i) in dropdownItems" :key="i">
|
||||
<VListItem
|
||||
v-if="menu.show"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<VListItem v-if="menu.show" :base-color="menu.props.color" @click="menu.props.click(item)">
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
@@ -651,41 +656,21 @@ onMounted(() => {
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
|
||||
<VTooltip text="识别">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="刮削">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" @click.stop="scrape(item)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="重命名">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="整理">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="删除">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<IconBtn @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="scrape(item)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
@@ -737,14 +722,12 @@ onMounted(() => {
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||||
<!-- 识别结果对话框 -->
|
||||
<VDialog v-if="nameTestDialog" v-model="nameTestDialog" width="50rem">
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="nameTestDialog = false" />
|
||||
<VCardItem>
|
||||
<MediaInfoCard :context="nameTestResult" />
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<MediaInfoDialog
|
||||
v-if="nameTestDialog"
|
||||
v-model="nameTestDialog"
|
||||
:context="nameTestResult"
|
||||
@close="nameTestDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
476
src/components/filebrowser/FileNavigator.vue
Normal file
476
src/components/filebrowser/FileNavigator.vue
Normal file
@@ -0,0 +1,476 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import type { FileItem } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Axios, AxiosRequestConfig } from 'axios'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
storage: {
|
||||
type: String,
|
||||
default: 'local',
|
||||
},
|
||||
currentPath: {
|
||||
type: String,
|
||||
default: '/',
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<FileItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
endpoints: Object,
|
||||
axios: {
|
||||
type: Object as PropType<Axios>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['navigate'])
|
||||
|
||||
// 树形节点缓存
|
||||
const treeCache = ref<{ [key: string]: FileItem[] }>({})
|
||||
|
||||
// 展开的文件夹
|
||||
const expandedFolders = ref<string[]>([])
|
||||
|
||||
// 是否正在加载
|
||||
const loading = ref<{ [key: string]: boolean }>({})
|
||||
|
||||
// 点击目录
|
||||
function handleFolderClick(item: FileItem) {
|
||||
emit('navigate', item)
|
||||
}
|
||||
|
||||
// 切换文件夹展开状态
|
||||
async function toggleFolder(path: string) {
|
||||
const index = expandedFolders.value.indexOf(path)
|
||||
if (index >= 0) {
|
||||
// 折叠文件夹
|
||||
expandedFolders.value.splice(index, 1)
|
||||
} else {
|
||||
// 展开文件夹
|
||||
expandedFolders.value.push(path)
|
||||
// 如果缓存中没有此目录内容,加载它
|
||||
if (!treeCache.value[path]) {
|
||||
await loadSubdirectories(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判断文件夹是否展开
|
||||
function isFolderExpanded(path: string) {
|
||||
return expandedFolders.value.includes(path)
|
||||
}
|
||||
|
||||
// 渲染文件夹图标
|
||||
function renderFolderIcon(isExpanded: boolean) {
|
||||
if (isExpanded) {
|
||||
return 'mdi-folder-open'
|
||||
}
|
||||
return 'mdi-folder'
|
||||
}
|
||||
|
||||
// 加载子目录
|
||||
async function loadSubdirectories(path: string) {
|
||||
// 如果已经在加载中或已有缓存,跳过
|
||||
if (loading.value[path] || treeCache.value[path]) return
|
||||
|
||||
// 标记为加载中
|
||||
loading.value[path] = true
|
||||
|
||||
try {
|
||||
// 构建假的文件项以加载目录内容
|
||||
const fakeItem: FileItem = {
|
||||
storage: props.storage,
|
||||
type: 'dir',
|
||||
name: path.split('/').pop() || '/',
|
||||
path: path,
|
||||
}
|
||||
|
||||
// 调用API加载目录内容
|
||||
const url = props.endpoints?.list.url.replace(/{sort}/g, 'name')
|
||||
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
method: props.endpoints?.list.method || 'get',
|
||||
data: fakeItem,
|
||||
}
|
||||
|
||||
const result = await props.axios?.request(config)
|
||||
if (result && Array.isArray(result)) {
|
||||
// 过滤出目录项
|
||||
const dirs = result.filter(item => item.type === 'dir')
|
||||
|
||||
// 缓存目录内容
|
||||
treeCache.value[path] = dirs
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载目录失败:', path, error)
|
||||
} finally {
|
||||
// 取消加载状态
|
||||
loading.value[path] = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载根目录
|
||||
async function loadRootDirectories() {
|
||||
await loadSubdirectories('/')
|
||||
}
|
||||
|
||||
// 获取目录层级深度
|
||||
function getDirectoryDepth(path: string) {
|
||||
return path.split('/').filter(p => p).length
|
||||
}
|
||||
|
||||
// 检索所有目录节点
|
||||
function getAllDirectories() {
|
||||
const allDirs: { dir: FileItem; level: number; parentPath: string }[] = []
|
||||
|
||||
// 添加根目录的子目录
|
||||
if (treeCache.value['/']) {
|
||||
treeCache.value['/'].forEach(dir => {
|
||||
allDirs.push({ dir, level: 0, parentPath: '/' })
|
||||
addSubdirectories(dir.path || '', 1, allDirs)
|
||||
})
|
||||
}
|
||||
|
||||
return allDirs
|
||||
}
|
||||
|
||||
// 递归添加子目录
|
||||
function addSubdirectories(
|
||||
parentPath: string,
|
||||
level: number,
|
||||
result: { dir: FileItem; level: number; parentPath: string }[],
|
||||
) {
|
||||
if (treeCache.value[parentPath]) {
|
||||
treeCache.value[parentPath].forEach(dir => {
|
||||
result.push({ dir, level, parentPath })
|
||||
if (isFolderExpanded(dir.path || '')) {
|
||||
addSubdirectories(dir.path || '', level + 1, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 监听当前路径变化,自动展开当前路径
|
||||
watch(
|
||||
() => props.currentPath,
|
||||
async newPath => {
|
||||
if (!newPath) return
|
||||
|
||||
// 如果当前路径不是根目录,自动展开父目录
|
||||
if (newPath !== '/') {
|
||||
const parts = newPath.split('/').filter(p => p)
|
||||
let currentPath = ''
|
||||
|
||||
// 展开到当前路径的每一层
|
||||
for (const part of parts) {
|
||||
currentPath += '/' + part
|
||||
|
||||
// 如果该路径未展开,则展开它
|
||||
if (!expandedFolders.value.includes(currentPath)) {
|
||||
expandedFolders.value.push(currentPath)
|
||||
|
||||
// 确保子目录已加载
|
||||
if (!treeCache.value[currentPath]) {
|
||||
await loadSubdirectories(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有上一级目录,确保它已加载
|
||||
const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/'
|
||||
if (!treeCache.value[parentPath]) {
|
||||
await loadSubdirectories(parentPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听目录变化,缓存当前目录的内容
|
||||
watch(
|
||||
() => props.items,
|
||||
newItems => {
|
||||
if (newItems && newItems.length > 0) {
|
||||
// 过滤出目录项
|
||||
const dirs = newItems.filter(item => item.type === 'dir')
|
||||
|
||||
// 缓存当前目录内容
|
||||
treeCache.value[props.currentPath || '/'] = dirs
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 是否为移动端
|
||||
const isMobile = computed(() => {
|
||||
return display.smAndDown.value
|
||||
})
|
||||
|
||||
// 可用的根目录列表
|
||||
const rootDirectories = computed(() => {
|
||||
return treeCache.value['/'] || []
|
||||
})
|
||||
|
||||
// 扁平化的目录树
|
||||
const flattenedDirectories = computed(() => {
|
||||
return getAllDirectories()
|
||||
})
|
||||
|
||||
// 组件挂载时初始加载
|
||||
onMounted(async () => {
|
||||
await loadRootDirectories()
|
||||
})
|
||||
|
||||
// 检查路径是否为指定目录的子目录或后代
|
||||
function isChildOrDescendant(path: string, ancestorPath: string) {
|
||||
if (!path || !ancestorPath) return false
|
||||
if (ancestorPath === '/') return true
|
||||
|
||||
// 确保路径以斜杠结尾,便于比较
|
||||
const normalizedPath = path.endsWith('/') ? path : path + '/'
|
||||
const normalizedAncestorPath = ancestorPath.endsWith('/') ? ancestorPath : ancestorPath + '/'
|
||||
|
||||
// 检查路径是否以祖先路径开头,但不是祖先路径本身
|
||||
return normalizedPath.startsWith(normalizedAncestorPath) && normalizedPath !== normalizedAncestorPath
|
||||
}
|
||||
|
||||
// 计算目录相对于其祖先的缩进级别
|
||||
function getIndentLevel(path: string, ancestorPath: string) {
|
||||
if (!path || !ancestorPath) return 0
|
||||
|
||||
// 根目录特殊处理
|
||||
if (ancestorPath === '/') {
|
||||
return path.split('/').filter(p => p).length - 1
|
||||
}
|
||||
|
||||
// 计算路径中斜杠的数量差异
|
||||
const pathParts = path.split('/').filter(p => p).length
|
||||
const ancestorParts = ancestorPath.split('/').filter(p => p).length
|
||||
|
||||
return pathParts - ancestorParts
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile">
|
||||
<div class="tree-container">
|
||||
<!-- 根目录项 -->
|
||||
<div
|
||||
class="tree-item root-item"
|
||||
:class="{ 'active': currentPath === '/' }"
|
||||
@click="
|
||||
handleFolderClick({
|
||||
storage: storage,
|
||||
type: 'dir',
|
||||
name: '/',
|
||||
path: '/',
|
||||
})
|
||||
"
|
||||
>
|
||||
<div class="folder-content">
|
||||
<VIcon icon="mdi-home" class="me-2" color="primary" />
|
||||
<span>根目录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载根目录 -->
|
||||
<div v-if="loading['/']" class="tree-loading">
|
||||
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />
|
||||
<span>加载目录结构...</span>
|
||||
</div>
|
||||
|
||||
<!-- 目录树结构 -->
|
||||
<template v-else>
|
||||
<!-- 一级目录(根目录下的目录) -->
|
||||
<div v-for="directory in rootDirectories" :key="directory.path" class="tree-item-container">
|
||||
<!-- 目录项 -->
|
||||
<div class="tree-item" :class="{ 'active': currentPath === directory.path }">
|
||||
<div class="folder-toggle" @click.stop="toggleFolder(directory.path || '')">
|
||||
<VProgressCircular
|
||||
v-if="loading[directory.path || '']"
|
||||
indeterminate
|
||||
size="14"
|
||||
width="2"
|
||||
color="primary"
|
||||
/>
|
||||
<VIcon
|
||||
v-else
|
||||
size="small"
|
||||
:icon="isFolderExpanded(directory.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||
/>
|
||||
</div>
|
||||
<div class="folder-content" @click.stop="handleFolderClick(directory)">
|
||||
<VIcon
|
||||
size="small"
|
||||
:icon="renderFolderIcon(isFolderExpanded(directory.path || ''))"
|
||||
:color="currentPath === directory.path ? 'primary' : 'amber-darken-1'"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="folder-name">
|
||||
{{ directory.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子目录容器 - 如果该目录被展开,显示其所有子目录 -->
|
||||
<div v-if="isFolderExpanded(directory.path || '')">
|
||||
<!-- 加载中状态 -->
|
||||
<div v-if="loading[directory.path || '']" class="tree-loading pl-8">
|
||||
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
|
||||
<span class="text-caption">加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 所有层级的子目录列表 -->
|
||||
<div v-else>
|
||||
<!-- 遍历所有扁平化的目录列表,查找对应层级的目录 -->
|
||||
<div
|
||||
v-for="item in flattenedDirectories"
|
||||
:key="item.dir.path"
|
||||
v-show="isChildOrDescendant(item.dir.path || '', directory.path || '')"
|
||||
class="tree-item"
|
||||
:class="{ 'active': currentPath === item.dir.path }"
|
||||
:style="{ paddingLeft: 16 + getIndentLevel(item.dir.path || '', directory.path || '') * 12 + 'px' }"
|
||||
>
|
||||
<!-- 展开/折叠按钮 -->
|
||||
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
|
||||
<VProgressCircular
|
||||
v-if="loading[item.dir.path || '']"
|
||||
indeterminate
|
||||
size="14"
|
||||
width="2"
|
||||
color="primary"
|
||||
/>
|
||||
<VIcon
|
||||
v-else
|
||||
size="small"
|
||||
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹图标和名称 -->
|
||||
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
|
||||
<VIcon
|
||||
size="small"
|
||||
:icon="renderFolderIcon(isFolderExpanded(item.dir.path || ''))"
|
||||
:color="currentPath === item.dir.path ? 'primary' : 'amber-darken-1'"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="folder-name">
|
||||
{{ item.dir.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-navigator {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
background: rgb(var(--v-table-header-background));
|
||||
block-size: 100%;
|
||||
border-end-start-radius: 12px;
|
||||
inline-size: 240px;
|
||||
}
|
||||
|
||||
.navigator-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-block-end: 1px solid rgba(0, 0, 0, 8%);
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
overflow: hidden auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tree-item-container {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 100%;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 16px;
|
||||
inline-size: 16px;
|
||||
margin-inline-end: 4px;
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px 0;
|
||||
}
|
||||
|
||||
.folder-content {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-inline-size: 0;
|
||||
padding-block: 6px;
|
||||
padding-inline: 8px 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root-item {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
max-inline-size: 150px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subdirectory-container {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.tree-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
padding-block: 4px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.pl-8 {
|
||||
padding-inline-start: 20px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -63,7 +63,7 @@ const pathSegments = computed(() => {
|
||||
|
||||
// 当前存储
|
||||
const storageObject = computed(() => {
|
||||
return inProps.storages?.find(item => item.code === inProps.storage)
|
||||
return inProps.storages?.find(item => item.value === inProps.storage)
|
||||
})
|
||||
|
||||
// 切换存储
|
||||
@@ -115,11 +115,11 @@ const sortIcon = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VToolbar flat dense>
|
||||
<VToolbar flat dense class="rounded-t-lg border-b overflow-hidden">
|
||||
<VToolbarItems class="overflow-hidden">
|
||||
<VMenu v-if="inProps.storages?.length || 0 > 1" offset-y>
|
||||
<template #activator="{ props }">
|
||||
<VBtn v-bind="props">
|
||||
<VBtn>
|
||||
<VIcon icon="mdi-arrow-down-drop-circle-outline" />
|
||||
</VBtn>
|
||||
</template>
|
||||
@@ -127,19 +127,19 @@ const sortIcon = computed(() => {
|
||||
<VListItem
|
||||
v-for="(item, index) in storages"
|
||||
:key="index"
|
||||
:disabled="item.code === storageObject?.code"
|
||||
@click="changeStorage(item.code)"
|
||||
:disabled="item.value === storageObject?.value"
|
||||
@click="changeStorage(item.value)"
|
||||
>
|
||||
<template #prepend>
|
||||
<Icon :icon="item.icon" />
|
||||
</template>
|
||||
<VListItemTitle>{{ item.name }}</VListItemTitle>
|
||||
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
<VBtn variant="text" :input-value="item.path === '/'" class="px-1" @click="changePath(inProps.itemstack[0])">
|
||||
<VIcon :icon="storageObject?.icon" class="mr-2" />
|
||||
{{ storageObject?.name }}
|
||||
{{ storageObject?.title }}
|
||||
</VBtn>
|
||||
<template v-for="(segment, index) in pathSegments" :key="index">
|
||||
<VBtn
|
||||
@@ -155,28 +155,16 @@ const sortIcon = computed(() => {
|
||||
</template>
|
||||
</VToolbarItems>
|
||||
<div class="flex-grow-1" />
|
||||
<VTooltip text="调整排序">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" @click="changeSort">
|
||||
<VIcon :icon="sortIcon" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="返回上一级" v-if="pathSegments.length > 0">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" @click="goUp">
|
||||
<VIcon icon="mdi-arrow-up-bold-outline" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<IconBtn @click="changeSort">
|
||||
<VIcon :icon="sortIcon" />
|
||||
</IconBtn>
|
||||
<IconBtn @click="goUp">
|
||||
<VIcon icon="mdi-arrow-up-bold-outline" />
|
||||
</IconBtn>
|
||||
<VDialog v-model="newFolderPopper" max-width="50rem">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props">
|
||||
<VTooltip text="新建文件夹">
|
||||
<template #activator="{ props: _props }">
|
||||
<VIcon v-bind="_props" icon="mdi-folder-plus-outline" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<IconBtn>
|
||||
<VIcon v-bind="_props" icon="mdi-folder-plus-outline" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VCard title="新建文件夹">
|
||||
|
||||
38
src/components/input/CronInput.vue
Normal file
38
src/components/input/CronInput.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '* * * * *',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const currentCron = ref(props.modelValue)
|
||||
|
||||
watch(currentCron, newVal => {
|
||||
emit('update:modelValue', newVal)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => {
|
||||
currentCron.value = value
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VMenu :close-on-content-click="false" content-class="cursor-default" persistent>
|
||||
<template v-slot:activator="{ props }">
|
||||
<slot name="activator" :menuprops="props" />
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem>
|
||||
<VCronVuetify v-model="currentCron" locale="zh-CN" :chip-props="{ color: 'success' }" class="mt-1" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,108 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { FileItem } from '@/api/types'
|
||||
import { VTreeview } from 'vuetify/labs/VTreeview'
|
||||
|
||||
// 输入变量为默认路径
|
||||
const props = defineProps({
|
||||
root: {
|
||||
type: String,
|
||||
default: '/',
|
||||
required: true,
|
||||
},
|
||||
storage: {
|
||||
type: String,
|
||||
default: 'local',
|
||||
},
|
||||
})
|
||||
|
||||
// update:modelValue 事件
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 激活的目录
|
||||
const activedDirs = ref<string[]>([])
|
||||
|
||||
// 打开的目录
|
||||
const openedDirs = ref<string[]>([])
|
||||
|
||||
// 目录列表
|
||||
const treeItems = ref<FileItem[]>([
|
||||
{
|
||||
name: '/',
|
||||
path: props.root,
|
||||
children: [],
|
||||
type: 'dir',
|
||||
basename: props.root,
|
||||
storage: props.storage,
|
||||
},
|
||||
])
|
||||
|
||||
// 拉取子目录
|
||||
async function fetchDirs(item: any) {
|
||||
return api
|
||||
.post('/storage/list', item)
|
||||
.then((data: any) => {
|
||||
// 只添加目录到子目录
|
||||
data = data.filter((i: any) => i.type === 'dir')
|
||||
item.children.push(...data)
|
||||
})
|
||||
.catch(err => console.warn(err))
|
||||
}
|
||||
|
||||
// 获取选择的目录路径
|
||||
const selectedPath = computed(() => {
|
||||
if (activedDirs.value.length > 0) {
|
||||
return activedDirs.value[0]
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// 监听目录变化
|
||||
watch(activedDirs, newVal => {
|
||||
if (!newVal.length) return
|
||||
emit('update:modelValue', selectedPath)
|
||||
})
|
||||
|
||||
// 监听存储变化
|
||||
watch(
|
||||
() => props.storage,
|
||||
async newVal => {
|
||||
treeItems.value = [
|
||||
{
|
||||
name: '/',
|
||||
path: props.root,
|
||||
children: [],
|
||||
type: 'dir',
|
||||
basename: props.root,
|
||||
storage: newVal,
|
||||
},
|
||||
]
|
||||
openedDirs.value = []
|
||||
activedDirs.value = []
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu :close-on-content-click="false" content-class="cursor-default">
|
||||
<template v-slot:activator="{ props }">
|
||||
<slot name="activator" :menuprops="props" />
|
||||
</template>
|
||||
<VTreeview
|
||||
v-model:activated="activedDirs"
|
||||
v-model:opened="openedDirs"
|
||||
:items="treeItems"
|
||||
:load-children="fetchDirs"
|
||||
item-key="path"
|
||||
item-title="name"
|
||||
item-value="path"
|
||||
item-type="unknown"
|
||||
activatable
|
||||
return-object
|
||||
max-height="20rem"
|
||||
expand-icon="mdi-folder"
|
||||
collapse-icon="mdi-folder-open"
|
||||
>
|
||||
</VTreeview>
|
||||
</VMenu>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user