mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-08 09:09:57 +08:00
Compare commits
327 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fdbc8104c | ||
|
|
433c14679c | ||
|
|
fcaa4476f0 | ||
|
|
85c5c3058c | ||
|
|
035122a08e | ||
|
|
0a76875f8e | ||
|
|
218eac54ce | ||
|
|
84deeff4f5 | ||
|
|
0c72d026f6 | ||
|
|
aec9ea83c5 | ||
|
|
effd13aedd | ||
|
|
42b43d65d7 | ||
|
|
c501d824dd | ||
|
|
384ac2faf1 | ||
|
|
dd2c4dd24b | ||
|
|
356ffddb1c | ||
|
|
de69be7c4e | ||
|
|
e962f555ae | ||
|
|
1987246585 | ||
|
|
393264f66b | ||
|
|
9b50020b3b | ||
|
|
5e5545fe01 | ||
|
|
0e8da35b0a | ||
|
|
4d2cf73330 | ||
|
|
5df89f2ce4 | ||
|
|
045c0b4c0c | ||
|
|
8b4ffa0795 | ||
|
|
14359a37ae | ||
|
|
a8e4a1c2e0 | ||
|
|
9048d181af | ||
|
|
1cb02994bf | ||
|
|
6fad85e957 | ||
|
|
db9b2ee6b3 | ||
|
|
8efeb77102 | ||
|
|
0215a800e2 | ||
|
|
87d282f98b | ||
|
|
60c392d3d0 | ||
|
|
34c3aa25da | ||
|
|
80690d4cc8 | ||
|
|
18f3dc2d44 | ||
|
|
e8256b4e1a | ||
|
|
4f67bb0250 | ||
|
|
5dd071adf4 | ||
|
|
aaf5e7f49d | ||
|
|
6a5958409a | ||
|
|
e0ff98b1d7 | ||
|
|
a815e07cdd | ||
|
|
aa2fe9740c | ||
|
|
75a358a4d2 | ||
|
|
d5646be6f8 | ||
|
|
cb04ebcd95 | ||
|
|
9889ccfc74 | ||
|
|
f528bd861a | ||
|
|
f793654bd8 | ||
|
|
8d064a2165 | ||
|
|
1240899b08 | ||
|
|
558752b890 | ||
|
|
997548b7d6 | ||
|
|
865d597fe8 | ||
|
|
b0a043b464 | ||
|
|
e003b6f9a7 | ||
|
|
9e9e940dfd | ||
|
|
d6dac704eb | ||
|
|
9aa8dff650 | ||
|
|
14c2503b0d | ||
|
|
cb282c6f9a | ||
|
|
66a5a40482 | ||
|
|
8d211ed20b | ||
|
|
bbf2814285 | ||
|
|
a15e479a3e | ||
|
|
505d6ec010 | ||
|
|
314ac65e23 | ||
|
|
118a9a2c5d | ||
|
|
347f47bbef | ||
|
|
a73c35468d | ||
|
|
f9a1446ed5 | ||
|
|
874ba45034 | ||
|
|
febe08eb9d | ||
|
|
9123b34c82 | ||
|
|
c66d7cafa6 | ||
|
|
73c54992e2 | ||
|
|
be1a44ad61 | ||
|
|
28b307fb98 | ||
|
|
a1dc723445 | ||
|
|
23f4a70693 | ||
|
|
be5b4b39e5 | ||
|
|
cf706e0e30 | ||
|
|
8bc80d2088 | ||
|
|
b94f8c92f0 | ||
|
|
c3be75bed1 | ||
|
|
91c8d8077f | ||
|
|
f598eed149 | ||
|
|
971bae3be0 | ||
|
|
9a6abf4d5a | ||
|
|
d756077a48 | ||
|
|
a1fc87bb1e | ||
|
|
07186d2ae1 | ||
|
|
d2164d9ada | ||
|
|
7eacaf8fc5 | ||
|
|
9aa2de526e | ||
|
|
12dfc5b407 | ||
|
|
1fc964ec16 | ||
|
|
7f2f7b100b | ||
|
|
8292140f1f | ||
|
|
c26e610a23 | ||
|
|
c96cfe81ab | ||
|
|
bb1cc0b60e | ||
|
|
1e74073344 | ||
|
|
d83d1dd888 | ||
|
|
e34573e72f | ||
|
|
9d3f4879ef | ||
|
|
6317277a70 | ||
|
|
a1130ec60b | ||
|
|
a1a3ccf6fb | ||
|
|
aedb8bee9c | ||
|
|
6620d1c8fe | ||
|
|
0ecc7dfead | ||
|
|
9f5859ee93 | ||
|
|
d559e1717c | ||
|
|
e649be58a2 | ||
|
|
157c37c862 | ||
|
|
da910ac670 | ||
|
|
3831363815 | ||
|
|
94a6ea13bd | ||
|
|
06c1ad0f69 | ||
|
|
d6873781e8 | ||
|
|
ab6c9647a7 | ||
|
|
59b0350993 | ||
|
|
df0be4c070 | ||
|
|
87f3ef4353 | ||
|
|
2611bbaea4 | ||
|
|
7c0d8cf792 | ||
|
|
2d17baccd2 | ||
|
|
fe31723726 | ||
|
|
bb10b22421 | ||
|
|
6445f3a634 | ||
|
|
d1f28d9c94 | ||
|
|
1e5366123c | ||
|
|
7feff7c90b | ||
|
|
429b3bc045 | ||
|
|
e76f1b89da | ||
|
|
f25e8595c3 | ||
|
|
6977ce55a3 | ||
|
|
222e0e5ff2 | ||
|
|
6996d9bbe2 | ||
|
|
f70e08adac | ||
|
|
223ecc0e6b | ||
|
|
43f36f556c | ||
|
|
4579e00283 | ||
|
|
b5e9b14048 | ||
|
|
2288e72c5f | ||
|
|
4882cc0417 | ||
|
|
499d3d0424 | ||
|
|
d6b17debb4 | ||
|
|
8f970e0008 | ||
|
|
18d778a1cc | ||
|
|
d667c4e45d | ||
|
|
b7f8ffd56f | ||
|
|
c20f9d527f | ||
|
|
b859d00cb9 | ||
|
|
a2d28ad360 | ||
|
|
c6702fbc18 | ||
|
|
5018f96786 | ||
|
|
f29f408b67 | ||
|
|
a475a3b851 | ||
|
|
9335f79c30 | ||
|
|
9dab691649 | ||
|
|
16abc65f49 | ||
|
|
23ac80886d | ||
|
|
b242e757e0 | ||
|
|
a69965a605 | ||
|
|
3321427eb4 | ||
|
|
3ffe354770 | ||
|
|
52e0d3a4bc | ||
|
|
e865a5ca62 | ||
|
|
528a4ddb03 | ||
|
|
36f3b649c6 | ||
|
|
ce91c0cc30 | ||
|
|
e31e9e3520 | ||
|
|
df313ebe7f | ||
|
|
e1cf36e952 | ||
|
|
493194652c | ||
|
|
5030e75c2c | ||
|
|
3c70eac7ca | ||
|
|
f9b22962a4 | ||
|
|
7ce0c21b0c | ||
|
|
7a7a8c923f | ||
|
|
d5d5e28f7e | ||
|
|
b22ac27075 | ||
|
|
3cb5f4bdfe | ||
|
|
d355e4575d | ||
|
|
bdbb118e55 | ||
|
|
9a174d99db | ||
|
|
9c8725066c | ||
|
|
9f0f3de864 | ||
|
|
ac84ed2d6a | ||
|
|
9d7e15f4df | ||
|
|
c3563f4501 | ||
|
|
a543202edc | ||
|
|
52cf517a91 | ||
|
|
11b649dc8c | ||
|
|
19663bacb1 | ||
|
|
41c276d0e0 | ||
|
|
6bb73add28 | ||
|
|
2c16b6c078 | ||
|
|
5ddc955805 | ||
|
|
6a3afa4240 | ||
|
|
deabd7b83c | ||
|
|
422e5858ef | ||
|
|
3c019d1376 | ||
|
|
f676e8423e | ||
|
|
f687d1de01 | ||
|
|
6fe28bc2ef | ||
|
|
86b5af3423 | ||
|
|
8f3dce058c | ||
|
|
825b8bb4a5 | ||
|
|
05320d1070 | ||
|
|
33d2a396ce | ||
|
|
ae4cce8abf | ||
|
|
b85950e4ca | ||
|
|
aecf52551b | ||
|
|
fc877ed836 | ||
|
|
5580921b7d | ||
|
|
6b7d0a0fe2 | ||
|
|
f55efbe1e2 | ||
|
|
8e6fc3c417 | ||
|
|
7943ab6017 | ||
|
|
81725a58cf | ||
|
|
5cbcf46aaa | ||
|
|
49dd3f726a | ||
|
|
73f9ebc709 | ||
|
|
f6884ba4f9 | ||
|
|
5d39d0e139 | ||
|
|
6a1463ef17 | ||
|
|
5d00f23cb3 | ||
|
|
6ea106b25d | ||
|
|
d501bf7506 | ||
|
|
1408060053 | ||
|
|
0c37c01496 | ||
|
|
d2049f7839 | ||
|
|
33cdf672b3 | ||
|
|
145c89acc3 | ||
|
|
706d7d6dc1 | ||
|
|
2c35d0f897 | ||
|
|
f227ae89ec | ||
|
|
ac43d53884 | ||
|
|
4b70549bcb | ||
|
|
ea601ae404 | ||
|
|
201411841c | ||
|
|
d857acc58e | ||
|
|
d005252f13 | ||
|
|
2065992b17 | ||
|
|
74e96980e6 | ||
|
|
09110d1ef7 | ||
|
|
bcf55e63f1 | ||
|
|
dd22b2580e | ||
|
|
62a0e46698 | ||
|
|
14b68135fb | ||
|
|
d44b62e489 | ||
|
|
b0f5c2a493 | ||
|
|
d6cfbc60a8 | ||
|
|
fe51f5ced4 | ||
|
|
b257b0453e | ||
|
|
a88105a086 | ||
|
|
2dc792690e | ||
|
|
aa146b1cdf | ||
|
|
c44b20bae3 | ||
|
|
cad8964841 | ||
|
|
ec9a989214 | ||
|
|
7f05932fb9 | ||
|
|
d51694e1cb | ||
|
|
3079483e6b | ||
|
|
bee4264a39 | ||
|
|
c949ea2667 | ||
|
|
2bcb28d0c0 | ||
|
|
bd257554cd | ||
|
|
68a27e0b61 | ||
|
|
8b589bdb9c | ||
|
|
1a25710aac | ||
|
|
271d59ca51 | ||
|
|
37e5e57d5b | ||
|
|
f817b20545 | ||
|
|
5f8619805e | ||
|
|
c9d4629bfa | ||
|
|
c9c27c83d4 | ||
|
|
7a6a985c47 | ||
|
|
225df7b1e6 | ||
|
|
97ede69609 | ||
|
|
c5ded86d8a | ||
|
|
b4f049ecda | ||
|
|
56692eb6cb | ||
|
|
7c22c60190 | ||
|
|
2f2c4d4a44 | ||
|
|
c1c71916db | ||
|
|
4b15a7454c | ||
|
|
22e723587d | ||
|
|
969adaf5bb | ||
|
|
c2214e8300 | ||
|
|
10af659227 | ||
|
|
5cd3757f4f | ||
|
|
81f674ea01 | ||
|
|
1846ee0ffe | ||
|
|
14a825093a | ||
|
|
d70f477bc1 | ||
|
|
c9c897ffb5 | ||
|
|
462dea3e05 | ||
|
|
4e7a0084dd | ||
|
|
0268df0e24 | ||
|
|
f926ca66c0 | ||
|
|
16b5898928 | ||
|
|
c1bb66cc9d | ||
|
|
f7502d0d18 | ||
|
|
b4975f649c | ||
|
|
89353c1f7e | ||
|
|
fce10b6dca | ||
|
|
2cf95c6706 | ||
|
|
58ab1599db | ||
|
|
9745c2ea1a | ||
|
|
9db46e2949 | ||
|
|
7949505104 | ||
|
|
db0d5133e8 | ||
|
|
54415377ee | ||
|
|
d7f55477da | ||
|
|
faca586fa7 | ||
|
|
5f3ba7b9c7 | ||
|
|
abace4a58d | ||
|
|
5895cea587 |
@@ -1 +1,2 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:3001/api/v1/
|
VITE_API_BASE_URL=http://localhost:3001/api/v1/
|
||||||
|
VITE_PUBLIC_VAPID_KEY=BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
VITE_API_BASE_URL=api/v1/
|
VITE_API_BASE_URL=api/v1/
|
||||||
|
VITE_PUBLIC_VAPID_KEY=BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
dev-dist
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
/cypress/videos/
|
/cypress/videos/
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||||
<link rel="manifest" href="manifest.json" crossorigin="use-credentials" />
|
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-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-status-bar-style" content="black-translucent" />
|
||||||
@@ -30,13 +29,14 @@
|
|||||||
<meta name="HandheldFriendly" content="True" />
|
<meta name="HandheldFriendly" content="True" />
|
||||||
<meta name="MobileOptimized" content="320" />
|
<meta name="MobileOptimized" content="320" />
|
||||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||||
|
<link rel="preload" href="index.js" as="script">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="loading-bg">
|
<div id="loading-bg">
|
||||||
<div class="loading-logo">
|
<div class="loading-logo">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<svg width="100px" height="100px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
<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">
|
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
|
||||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||||
@@ -159,4 +159,4 @@
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
80
package.json
80
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "1.8.4",
|
"version": "1.9.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -19,55 +19,44 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@casl/ability": "^6.2.0",
|
|
||||||
"@casl/vue": "^2.2.0",
|
|
||||||
"@floating-ui/dom": "1.6.3",
|
|
||||||
"@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",
|
|
||||||
"axios-mock-adapter": "^1.21.4",
|
|
||||||
"chart.js": "^4.1.2",
|
|
||||||
"colorthief": "^2.4.0",
|
|
||||||
"express": "^4.18.2",
|
|
||||||
"express-http-proxy": "^2.0.0",
|
|
||||||
"jwt-decode": "^4.0.0",
|
|
||||||
"nprogress": "^0.2.0",
|
|
||||||
"postcss-purgecss": "^5.0.0",
|
|
||||||
"prismjs": "^1.29.0",
|
|
||||||
"pull-refresh-vue3": "^0.3.1",
|
|
||||||
"qrcode.vue": "^3.4.1",
|
|
||||||
"roboto-fontface": "^0.10.0",
|
|
||||||
"sass": "^1.59.3",
|
|
||||||
"tailwindcss": "^3.3.2",
|
|
||||||
"unplugin-vue-define-options": "^1.3.5",
|
|
||||||
"vite-plugin-pwa": "^0.19.8",
|
|
||||||
"vue": "^3.3.2",
|
|
||||||
"vue-chartjs": "^5.2.0",
|
|
||||||
"vue-flatpickr-component": "11.0.5",
|
|
||||||
"vue-i18n": "^9.2.2",
|
|
||||||
"vue-prism-component": "^2.0.0",
|
|
||||||
"vue-router": "^4.2.0",
|
|
||||||
"vue-toast-notification": "^3",
|
|
||||||
"vue3-ace-editor": "^2.2.4",
|
|
||||||
"vue3-apexcharts": "^1.4.1",
|
|
||||||
"vue3-perfect-scrollbar": "^2.0.0",
|
|
||||||
"vuetify": "3.5.14",
|
|
||||||
"vuetify-use-dialog": "^0.6.0",
|
|
||||||
"vuex": "^4.1.0",
|
|
||||||
"vuex-persistedstate": "^4.1.0",
|
|
||||||
"webfontloader": "^1.6.28"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@antfu/eslint-config-vue": "^0.43.1",
|
|
||||||
"@fullcalendar/core": "^6.1.8",
|
"@fullcalendar/core": "^6.1.8",
|
||||||
"@fullcalendar/daygrid": "^6.1.8",
|
"@fullcalendar/daygrid": "^6.1.8",
|
||||||
"@fullcalendar/interaction": "^6.1.7",
|
"@fullcalendar/interaction": "^6.1.7",
|
||||||
"@fullcalendar/list": "^6.1.7",
|
"@fullcalendar/list": "^6.1.7",
|
||||||
"@fullcalendar/timegrid": "^6.1.7",
|
"@fullcalendar/timegrid": "^6.1.7",
|
||||||
"@fullcalendar/vue3": "^6.1.8",
|
"@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",
|
||||||
|
"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",
|
||||||
|
"vue3-ace-editor": "^2.2.4",
|
||||||
|
"vue3-apexcharts": "^1.4.1",
|
||||||
|
"vue3-perfect-scrollbar": "^2.0.0",
|
||||||
|
"vuedraggable": "^4.1.0",
|
||||||
|
"vuetify": "3.6.8",
|
||||||
|
"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-json/mdi": "^1.1.52",
|
||||||
"@iconify/tools": "^4.0.4",
|
"@iconify/tools": "^4.0.4",
|
||||||
"@iconify/vue": "4.1.1",
|
"@iconify/vue": "4.1.1",
|
||||||
@@ -81,7 +70,6 @@
|
|||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"@vitejs/plugin-vue-jsx": "^3.0.0",
|
"@vitejs/plugin-vue-jsx": "^3.0.0",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"dayjs": "^1.11.10",
|
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
"eslint-import-resolver-typescript": "^3.5.1",
|
"eslint-import-resolver-typescript": "^3.5.1",
|
||||||
@@ -91,7 +79,6 @@
|
|||||||
"eslint-plugin-sonarjs": "^0.25.1",
|
"eslint-plugin-sonarjs": "^0.25.1",
|
||||||
"eslint-plugin-unicorn": "^52.0.0",
|
"eslint-plugin-unicorn": "^52.0.0",
|
||||||
"eslint-plugin-vue": "^9.12.0",
|
"eslint-plugin-vue": "^9.12.0",
|
||||||
"lodash": "^4.17.21",
|
|
||||||
"postcss": "8",
|
"postcss": "8",
|
||||||
"postcss-html": "^1.5.0",
|
"postcss-html": "^1.5.0",
|
||||||
"stylelint": "16.3.1",
|
"stylelint": "16.3.1",
|
||||||
@@ -104,6 +91,7 @@
|
|||||||
"unplugin-vue-components": "^0.26.0",
|
"unplugin-vue-components": "^0.26.0",
|
||||||
"vite": "^5.2.8",
|
"vite": "^5.2.8",
|
||||||
"vite-plugin-pages": "^0.32.1",
|
"vite-plugin-pages": "^0.32.1",
|
||||||
|
"vite-plugin-pwa": "^0.20.0",
|
||||||
"vite-plugin-vue-layouts": "^0.11.0",
|
"vite-plugin-vue-layouts": "^0.11.0",
|
||||||
"vite-plugin-vuetify": "2.0.3",
|
"vite-plugin-vuetify": "2.0.3",
|
||||||
"vue-shepherd": "^3.0.0",
|
"vue-shepherd": "^3.0.0",
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
overflow: hidden auto;
|
||||||
background: var(--initial-loader-bg, #fff);
|
background: var(--initial-loader-bg, #fff);
|
||||||
min-block-size: calc(100% + env(safe-area-inset-top));
|
min-block-size: calc(100% + env(safe-area-inset-top));
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#loading-bg {
|
#loading-bg {
|
||||||
@@ -20,8 +19,8 @@ html {
|
|||||||
|
|
||||||
.loading-logo {
|
.loading-logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-block-start: 40%;
|
inset-block-start: 35%;
|
||||||
inset-inline-start: calc(50% - 50px);
|
inset-inline-start: calc(50% - 5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
@@ -83,4 +82,4 @@ html {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: rotate(1turn);
|
transform: rotate(1turn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "MoviePilot",
|
|
||||||
"short_name": "MoviePilot",
|
|
||||||
"start_url": "./",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "./android-chrome-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "./android-chrome-192x192_maskable.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "./android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "./android-chrome-512x512_maskable.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"theme_color": "#28243D",
|
|
||||||
"background_color": "#28243D",
|
|
||||||
"display": "standalone",
|
|
||||||
"shortcuts": [
|
|
||||||
{
|
|
||||||
"name": "推荐",
|
|
||||||
"url": "./ranking",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "./sparkles-icon-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "电影订阅",
|
|
||||||
"url": "./subscribe-movie",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "./clock-icon-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "电视剧订阅",
|
|
||||||
"url": "./subscribe-tv",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "./clock-icon-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "设置",
|
|
||||||
"url": "./setting",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "./cog-icon-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,13 @@ http {
|
|||||||
|
|
||||||
keepalive_timeout 3600;
|
keepalive_timeout 3600;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
|
|
||||||
include mime.types;
|
include mime.types;
|
||||||
@@ -28,9 +35,16 @@ http {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
# 静态资源
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
root html;
|
||||||
|
}
|
||||||
|
|
||||||
location /assets {
|
location /assets {
|
||||||
# 静态资源
|
# 静态资源
|
||||||
expires 7d;
|
expires 1y;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
root html;
|
root html;
|
||||||
}
|
}
|
||||||
|
|||||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -14,7 +14,10 @@ function onClick() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<IconBtn :class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'" @click.stop="onClick">
|
<IconBtn
|
||||||
|
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'"
|
||||||
|
@click.stop="onClick"
|
||||||
|
>
|
||||||
<VIcon icon="mdi-close" />
|
<VIcon icon="mdi-close" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
// 定义输入参数
|
// 定义输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
progress: Number,
|
progress: Number,
|
||||||
text: String
|
text: String,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center">
|
||||||
class="w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
<VProgressCircular v-if="!props.text || !props.progress" class="mb-3" size="64" indeterminate color="primary" />
|
||||||
>
|
<VProgressCircular v-if="props.progress" class="mb-3" color="primary" :model-value="props.progress" size="64" />
|
||||||
<VProgressCircular
|
|
||||||
v-if="!props.text"
|
|
||||||
size="48"
|
|
||||||
indeterminate
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
<VProgressCircular
|
|
||||||
v-if="props.progress"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
:model-value="props.progress"
|
|
||||||
size="64"
|
|
||||||
/>
|
|
||||||
<span>{{ props.text }}</span>
|
<span>{{ props.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useTheme } from 'vuetify'
|
import { useDisplay, useTheme } from 'vuetify'
|
||||||
import type { ThemeSwitcherTheme } from '@layouts/types'
|
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'
|
||||||
|
|
||||||
|
// 显示器宽度
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
themes: ThemeSwitcherTheme[]
|
themes: ThemeSwitcherTheme[]
|
||||||
@@ -11,63 +18,33 @@ const { name: themeName, global: globalTheme } = useTheme()
|
|||||||
|
|
||||||
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
|
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
|
||||||
|
|
||||||
const {
|
const { state: currentThemeName, next: getNextThemeName } = useCycleList(
|
||||||
state: currentThemeName,
|
|
||||||
next: getNextThemeName,
|
|
||||||
index: currentThemeIndex,
|
|
||||||
} = useCycleList(
|
|
||||||
props.themes.map(t => t.name),
|
props.themes.map(t => t.name),
|
||||||
{ initialValue: savedTheme.value },
|
{ initialValue: savedTheme.value },
|
||||||
)
|
)
|
||||||
|
|
||||||
function updateTheme() {
|
const $toast = useToast()
|
||||||
const autoTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
||||||
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
|
|
||||||
globalTheme.name.value = theme
|
|
||||||
savedTheme.value = theme
|
|
||||||
// 修改载入时背景色
|
|
||||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
|
||||||
themeTransition()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听系统主题变化
|
// 自定义CSS弹窗
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
|
const cssDialog = ref(false)
|
||||||
|
|
||||||
watch(
|
// 自定义 CSS
|
||||||
() => currentThemeName.value,
|
const customCSS = ref('')
|
||||||
() => updateTheme(),
|
|
||||||
)
|
|
||||||
|
|
||||||
function changeTheme() {
|
// 编辑器主题
|
||||||
const nextTheme = getNextThemeName()
|
const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
|
||||||
currentThemeName.value = nextTheme
|
|
||||||
localStorage.setItem('theme', nextTheme)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply saved theme on page load
|
|
||||||
// onMounted(() => {
|
|
||||||
// globalTheme.name.value = savedTheme.value
|
|
||||||
// })
|
|
||||||
|
|
||||||
function hasScrollbar(el?: Element | null) {
|
|
||||||
if (!el || el.nodeType !== Node.ELEMENT_NODE)
|
|
||||||
return false
|
|
||||||
|
|
||||||
const style = window.getComputedStyle(el)
|
|
||||||
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 主题切换动画
|
||||||
function themeTransition() {
|
function themeTransition() {
|
||||||
const x = performance.now()
|
const x = performance.now()
|
||||||
for (let i = 0; i++ < 1e7; (i << 9) & ((9 % 9) * 9 + 9));
|
for (let i = 0; i++ < 1e7; (i << 9) & ((9 % 9) * 9 + 9));
|
||||||
const cost = performance.now() - x
|
const cost = performance.now() - x
|
||||||
if (cost > 10)
|
if (cost > 10) return
|
||||||
return
|
|
||||||
|
|
||||||
const el: HTMLElement = document.querySelector('[data-v-app]')!
|
const el: HTMLElement = document.querySelector('[data-v-app]')!
|
||||||
const children = el.querySelectorAll('*') as NodeListOf<HTMLElement>
|
const children = el.querySelectorAll('*') as NodeListOf<HTMLElement>
|
||||||
|
|
||||||
children.forEach((el) => {
|
children.forEach(el => {
|
||||||
if (hasScrollbar(el)) {
|
if (hasScrollbar(el)) {
|
||||||
el.dataset.scrollX = String(el.scrollLeft)
|
el.dataset.scrollX = String(el.scrollLeft)
|
||||||
el.dataset.scrollY = String(el.scrollTop)
|
el.dataset.scrollY = String(el.scrollTop)
|
||||||
@@ -99,7 +76,7 @@ function themeTransition() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
document.body.append(copy)
|
document.body.append(copy)
|
||||||
; (copy.querySelectorAll('[data-scroll-x], [data-scroll-y]') as NodeListOf<HTMLElement>).forEach((el) => {
|
;(copy.querySelectorAll('[data-scroll-x], [data-scroll-y]') as NodeListOf<HTMLElement>).forEach(el => {
|
||||||
el.scrollLeft = +el.dataset.scrollX!
|
el.scrollLeft = +el.dataset.scrollX!
|
||||||
el.scrollTop = +el.dataset.scrollY!
|
el.scrollTop = +el.dataset.scrollY!
|
||||||
})
|
})
|
||||||
@@ -117,12 +94,145 @@ function themeTransition() {
|
|||||||
el.addEventListener('transitionend', onTransitionend)
|
el.addEventListener('transitionend', onTransitionend)
|
||||||
el.addEventListener('transitioncancel', onTransitionend)
|
el.addEventListener('transitioncancel', onTransitionend)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新主题
|
||||||
|
function updateTheme() {
|
||||||
|
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||||
|
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
|
||||||
|
globalTheme.name.value = theme
|
||||||
|
savedTheme.value = theme
|
||||||
|
themeTransition()
|
||||||
|
// 保存主题到本地
|
||||||
|
localStorage.setItem('theme', theme)
|
||||||
|
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换主题
|
||||||
|
function changeTheme(theme: string) {
|
||||||
|
let nextTheme = theme
|
||||||
|
if (!theme) nextTheme = getNextThemeName()
|
||||||
|
currentThemeName.value = nextTheme
|
||||||
|
// 保存主题到服务端
|
||||||
|
try {
|
||||||
|
api.post('/user/config/theme', nextTheme, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存主题到服务端失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否有滚动条
|
||||||
|
function hasScrollbar(el?: Element | null) {
|
||||||
|
if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(el)
|
||||||
|
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听系统主题变化
|
||||||
|
try {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('当前设备不支持监听系统主题变化')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询当前主题的图标
|
||||||
|
const getThemeIcon = computed(() => {
|
||||||
|
const theme = props.themes.find(t => t.name === currentThemeName.value)
|
||||||
|
return theme?.icon ?? 'mdi-circle'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听设置主题变化
|
||||||
|
watch(
|
||||||
|
() => currentThemeName.value,
|
||||||
|
() => updateTheme(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取自定义 CSS
|
||||||
|
async function getCustomCSS() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('system/setting/UserCustomCSS')
|
||||||
|
if (result && result.success && result.data?.value) {
|
||||||
|
customCSS.value = result.data?.value ?? ''
|
||||||
|
if (customCSS.value) {
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.innerHTML = result.data?.value ?? ''
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存自定义 CSS
|
||||||
|
async function saveCustomCSS() {
|
||||||
|
cssDialog.value = false
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', customCSS.value, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) $toast.success('自定义CSS保存成功!')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存自定义 CSS 到服务端失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getCustomCSS()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<IconBtn @click="changeTheme">
|
<VMenu v-if="props.themes">
|
||||||
<VIcon :icon="props.themes[currentThemeIndex].icon" />
|
<template v-slot:activator="{ props }">
|
||||||
</IconBtn>
|
<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>
|
||||||
|
</VMenu>
|
||||||
|
<!-- 自定义 CSS -- -->
|
||||||
|
<VDialog v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||||
|
<VCard title="自定义主题风格">
|
||||||
|
<DialogCloseBtn @click="cssDialog = false" />
|
||||||
|
<VDivider />
|
||||||
|
<VAceEditor
|
||||||
|
v-model:value="customCSS"
|
||||||
|
lang="css"
|
||||||
|
:theme="editorTheme"
|
||||||
|
style="block-size: 100%; min-block-size: 30rem"
|
||||||
|
/>
|
||||||
|
<VDivider />
|
||||||
|
<VCardText class="text-center">
|
||||||
|
<VBtn @click="saveCustomCSS" class="w-1/2">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-content-save" />
|
||||||
|
</template>
|
||||||
|
保存
|
||||||
|
</VBtn>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="sass">
|
<style lang="sass">
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
/* stylelint-enable */
|
/* stylelint-enable */
|
||||||
background-color: rgb(var(--v-theme-surface), 0.9);
|
background-color: rgb(var(--v-theme-surface), 0.8);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
/**
|
/**
|
||||||
* 修复低版本Safari等浏览器数组不支持at函数的问题
|
* 修复低版本Safari等浏览器数组不支持at函数的问题
|
||||||
*/
|
*/
|
||||||
export function fixArrayAt() {
|
;(function fixArrayAt() {
|
||||||
if (!Array.prototype.at) {
|
if (!Array.prototype.at) {
|
||||||
Array.prototype.at = function(index: number) {
|
Array.prototype.at = function (index: number) {
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
return this[index]
|
return this[index]
|
||||||
} else {
|
} else {
|
||||||
return this[this.length + index]
|
return this[this.length + index]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ dayjs.extend(relativeTime)
|
|||||||
dayjs.locale(ZH_CN)
|
dayjs.locale(ZH_CN)
|
||||||
|
|
||||||
export function avatarText(value: string) {
|
export function avatarText(value: string) {
|
||||||
if (!value)
|
if (!value) return ''
|
||||||
return ''
|
|
||||||
const nameArray = value.split(' ')
|
const nameArray = value.split(' ')
|
||||||
|
|
||||||
return nameArray.map(word => word.charAt(0).toUpperCase()).join('')
|
return nameArray.map(word => word.charAt(0).toUpperCase()).join('')
|
||||||
@@ -19,7 +18,9 @@ export function avatarText(value: string) {
|
|||||||
export function kFormatter(num: number) {
|
export function kFormatter(num: number) {
|
||||||
const regex = /\B(?=(\d{3})+(?!\d))/g
|
const regex = /\B(?=(\d{3})+(?!\d))/g
|
||||||
|
|
||||||
return Math.abs(num) > 9999 ? `${Math.sign(num) * +((Math.abs(num) / 1000).toFixed(1))}k` : Math.abs(num).toFixed(0).replace(regex, ',')
|
return Math.abs(num) > 9999
|
||||||
|
? `${Math.sign(num) * +(Math.abs(num) / 1000).toFixed(1)}k`
|
||||||
|
: Math.abs(num).toFixed(0).replace(regex, ',')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,9 +30,11 @@ export function kFormatter(num: number) {
|
|||||||
* @param {string} value date to format
|
* @param {string} value date to format
|
||||||
* @param {Intl.DateTimeFormatOptions} formatting Intl object to format with
|
* @param {Intl.DateTimeFormatOptions} formatting Intl object to format with
|
||||||
*/
|
*/
|
||||||
export function formatDate(value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) {
|
export function formatDate(
|
||||||
if (!value)
|
value: string,
|
||||||
return value
|
formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' },
|
||||||
|
) {
|
||||||
|
if (!value) return value
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
|
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
|
||||||
}
|
}
|
||||||
@@ -46,21 +49,19 @@ export function formatDateToMonthShort(value: string, toTimeForCurrentDay = true
|
|||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
let formatting: Record<string, string> = { month: 'short', day: 'numeric' }
|
let formatting: Record<string, string> = { month: 'short', day: 'numeric' }
|
||||||
|
|
||||||
if (toTimeForCurrentDay && isToday(date))
|
if (toTimeForCurrentDay && isToday(date)) formatting = { hour: 'numeric', minute: 'numeric' }
|
||||||
formatting = { hour: 'numeric', minute: 'numeric' }
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
|
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const prefixWithPlus = (value: number) => value > 0 ? `+${value}` : value
|
export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : value)
|
||||||
|
|
||||||
// 格式化为Sxx
|
// 格式化为Sxx
|
||||||
export const formatSeason = (value: string) => value ? `S${value.padStart(2, '0')}` : ''
|
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
|
||||||
|
|
||||||
// 格式化为xx[TGMK]B
|
// 格式化为xx[TGMK]B
|
||||||
export function formatFileSize(bytes: number) {
|
export function formatFileSize(bytes: number) {
|
||||||
if (bytes < 0)
|
if (bytes < 0) throw new Error('字节数不能为负数。')
|
||||||
throw new Error('字节数不能为负数。')
|
|
||||||
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
let size = bytes
|
let size = bytes
|
||||||
@@ -82,22 +83,18 @@ export function formatSeconds(seconds: number) {
|
|||||||
|
|
||||||
let formattedTime = ''
|
let formattedTime = ''
|
||||||
|
|
||||||
if (hours > 0)
|
if (hours > 0) formattedTime += `${hours}小时`
|
||||||
formattedTime += `${hours}小时`
|
|
||||||
|
|
||||||
if (minutes > 0)
|
if (minutes > 0) formattedTime += `${minutes}分`
|
||||||
formattedTime += `${minutes}分`
|
|
||||||
|
|
||||||
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0)
|
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0) formattedTime += `${remainingSeconds}秒`
|
||||||
formattedTime += `${remainingSeconds}秒`
|
|
||||||
|
|
||||||
return formattedTime
|
return formattedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// YYYY-MM-DD 转化为Date
|
// YYYY-MM-DD 转化为Date
|
||||||
export function parseDate(dateString: string): Date | null {
|
export function parseDate(dateString: string): Date | null {
|
||||||
if (!dateString)
|
if (!dateString) return null
|
||||||
return null
|
|
||||||
const [year, month, day] = dateString.split('-').map(Number)
|
const [year, month, day] = dateString.split('-').map(Number)
|
||||||
|
|
||||||
return new Date(year, month - 1, day)
|
return new Date(year, month - 1, day)
|
||||||
@@ -105,8 +102,7 @@ export function parseDate(dateString: string): Date | null {
|
|||||||
|
|
||||||
// 文件大小格式化
|
// 文件大小格式化
|
||||||
export function formatBytes(bytes: number, decimals = 2) {
|
export function formatBytes(bytes: number, decimals = 2) {
|
||||||
if (bytes === 0)
|
if (bytes === 0) return '0 bytes'
|
||||||
return '0 bytes'
|
|
||||||
|
|
||||||
const k = 1024
|
const k = 1024
|
||||||
const dm = decimals < 0 ? 0 : decimals
|
const dm = decimals < 0 ? 0 : decimals
|
||||||
@@ -119,11 +115,9 @@ export function formatBytes(bytes: number, decimals = 2) {
|
|||||||
|
|
||||||
// 格式化剧集列表
|
// 格式化剧集列表
|
||||||
export function formatEp(nums: number[]): string {
|
export function formatEp(nums: number[]): string {
|
||||||
if (!nums.length)
|
if (!nums.length) return ''
|
||||||
return ''
|
|
||||||
|
|
||||||
if (nums.length === 1)
|
if (nums.length === 1) return nums[0].toString()
|
||||||
return nums[0].toString()
|
|
||||||
|
|
||||||
// 将数组升序排序
|
// 将数组升序排序
|
||||||
nums.sort((a, b) => a - b)
|
nums.sort((a, b) => a - b)
|
||||||
@@ -134,44 +128,22 @@ export function formatEp(nums: number[]): string {
|
|||||||
for (let i = 1; i < nums.length; i++) {
|
for (let i = 1; i < nums.length; i++) {
|
||||||
if (nums[i] === end + 1) {
|
if (nums[i] === end + 1) {
|
||||||
end = nums[i]
|
end = nums[i]
|
||||||
}
|
} else {
|
||||||
else {
|
if (start === end) formattedRanges.push(start.toString())
|
||||||
if (start === end)
|
else formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
||||||
formattedRanges.push(start.toString())
|
|
||||||
|
|
||||||
else
|
|
||||||
formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
|
||||||
|
|
||||||
start = end = nums[i]
|
start = end = nums[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start === end)
|
if (start === end) formattedRanges.push(start.toString())
|
||||||
formattedRanges.push(start.toString())
|
else formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
||||||
|
|
||||||
else
|
|
||||||
formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
|
||||||
|
|
||||||
return formattedRanges.join('、')
|
return formattedRanges.join('、')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将yyyy-mm-dd hh:mm:ss转换为时间差,如:1小时前,1天前
|
// 将yyyy-mm-dd hh:mm:ss转换为时间差,如:1小时前,1天前
|
||||||
export function formatDateDifference(dateString: string): string {
|
export function formatDateDifference(dateString: string): string {
|
||||||
// const timeDifference = dayjs().millisecond() - dayjs(dateString).millisecond()
|
if (!dateString) return ''
|
||||||
// const secondsDifference = Math.floor(timeDifference / 1000)
|
|
||||||
// const minutesDifference = Math.floor(secondsDifference / 60)
|
|
||||||
// const hoursDifference = Math.floor(minutesDifference / 60)
|
|
||||||
// const daysDifference = Math.floor(hoursDifference / 24)
|
|
||||||
|
|
||||||
// if (daysDifference > 0)
|
|
||||||
// return `${daysDifference}天前`
|
|
||||||
// else if (hoursDifference > 0)
|
|
||||||
// return `${hoursDifference}小时前`
|
|
||||||
// else if (minutesDifference > 0)
|
|
||||||
// return `${minutesDifference}分钟前`
|
|
||||||
// else
|
|
||||||
// return '刚刚'
|
|
||||||
if (!dateString)
|
|
||||||
return ''
|
|
||||||
return dayjs(dateString).fromNow()
|
return dayjs(dateString).fromNow()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// 👉 IsEmpty
|
// 👉 IsEmpty
|
||||||
export function isEmpty(value: unknown): boolean {
|
export function isEmpty(value: unknown): boolean {
|
||||||
if (value === null || value === undefined || value === '')
|
if (value === null || value === undefined || value === '') return true
|
||||||
return true
|
|
||||||
|
|
||||||
return !!(Array.isArray(value) && value.length === 0)
|
return !!(Array.isArray(value) && value.length === 0)
|
||||||
}
|
}
|
||||||
@@ -33,73 +32,6 @@ export function isToday(date: Date) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算时间差,返回xx天/xx小时/xx分钟/xx秒
|
|
||||||
*
|
|
||||||
* @deprecated 建议使用:@core/utils/formatters.ts formatDateDifference
|
|
||||||
*/
|
|
||||||
export function calculateTimeDifference(inputTime: string): string {
|
|
||||||
if (!inputTime)
|
|
||||||
return ''
|
|
||||||
|
|
||||||
const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
|
|
||||||
const currentDate = new Date()
|
|
||||||
|
|
||||||
const timeDifference = currentDate.getTime() - inputDate.getTime()
|
|
||||||
const secondsDifference = Math.floor(timeDifference / 1000)
|
|
||||||
|
|
||||||
if (secondsDifference < 60) {
|
|
||||||
return `${secondsDifference}秒`
|
|
||||||
}
|
|
||||||
else if (secondsDifference < 3600) {
|
|
||||||
const minutes = Math.floor(secondsDifference / 60)
|
|
||||||
|
|
||||||
return `${minutes}分钟`
|
|
||||||
}
|
|
||||||
else if (secondsDifference < 86400) {
|
|
||||||
const hours = Math.floor(secondsDifference / 3600)
|
|
||||||
|
|
||||||
return `${hours}小时`
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const days = Math.floor(secondsDifference / 86400)
|
|
||||||
|
|
||||||
return `${days}天`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算时间差,返回xx天xx小时xx分钟
|
|
||||||
export function calculateTimeDiff(inputTime: string): string {
|
|
||||||
if (!inputTime)
|
|
||||||
return ''
|
|
||||||
|
|
||||||
// 使用当前时区
|
|
||||||
const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
|
|
||||||
const currentDate = new Date()
|
|
||||||
|
|
||||||
const timeDifference = currentDate.getTime() - inputDate.getTime()
|
|
||||||
const secondsDifference = Math.floor(timeDifference / 1000)
|
|
||||||
|
|
||||||
const days = Math.floor(secondsDifference / 86400)
|
|
||||||
const hours = Math.floor(secondsDifference % 86400 / 3600)
|
|
||||||
const minutes = Math.floor(secondsDifference % 86400 % 3600 / 60)
|
|
||||||
const secones = Math.floor(secondsDifference % 60)
|
|
||||||
|
|
||||||
if (days > 0)
|
|
||||||
return `${days}天${hours}小时${minutes}分钟`
|
|
||||||
|
|
||||||
else if (hours > 0)
|
|
||||||
return `${hours}小时${minutes}分钟`
|
|
||||||
|
|
||||||
else if (minutes > 0)
|
|
||||||
return `${minutes}分钟`
|
|
||||||
|
|
||||||
else if (secones > 0)
|
|
||||||
return `${secones}秒`
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断一个数组subArray是不是在另一个数组mainArray中
|
// 判断一个数组subArray是不是在另一个数组mainArray中
|
||||||
export function isContained(subArray: any[], mainArray: any[]): boolean {
|
export function isContained(subArray: any[], mainArray: any[]): boolean {
|
||||||
return subArray.every(element => mainArray.includes(element))
|
return subArray.every(element => mainArray.includes(element))
|
||||||
@@ -112,9 +44,24 @@ export function isIntersected(array1: any[], array2: any[]): boolean {
|
|||||||
|
|
||||||
export function isNullOrEmptyObject(obj: any): boolean {
|
export function isNullOrEmptyObject(obj: any): boolean {
|
||||||
// 首先判断是否为 null 或 undefined
|
// 首先判断是否为 null 或 undefined
|
||||||
if (obj === null || obj === undefined)
|
if (obj === null || obj === undefined) return true
|
||||||
return true
|
|
||||||
|
|
||||||
// 然后判断是否为空对象
|
// 然后判断是否为空对象
|
||||||
return !!(typeof obj === 'object' && Object.keys(obj).length === 0)
|
return !!(typeof obj === 'object' && Object.keys(obj).length === 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断系统配置色是否是黑暗的
|
||||||
|
export function checkPrefersColorSchemeIsDark(): boolean {
|
||||||
|
try {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从URL中获取参数值
|
||||||
|
export function getQueryValue(key: string, url = window.location.href): string {
|
||||||
|
const reg = new RegExp(`[?&]${key}=([^&#]*)`, 'i')
|
||||||
|
const res = reg.exec(url)
|
||||||
|
return res ? res[1] : ''
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,3 +28,17 @@ export async function copyToClipboard(content: string) {
|
|||||||
document.body.removeChild(input)
|
document.body.removeChild(input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VAPID公钥转Uint8Array
|
||||||
|
export function urlBase64ToUint8Array(base64String: string) {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
|
||||||
|
const rawData = window.atob(base64)
|
||||||
|
const outputArray = new Uint8Array(rawData.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return outputArray
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ defineProps<{
|
|||||||
.nav-link a {
|
.nav-link a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border-radius: 0 3.125rem 3.125rem 0 !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin-inline-end: 1.125em;
|
||||||
|
padding-inline: 1.375rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,3 +18,12 @@ defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.layout-vertical-nav {
|
||||||
|
.nav-section-title {
|
||||||
|
padding-left: 1.375rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
17
src/@layouts/types.d.ts
vendored
17
src/@layouts/types.d.ts
vendored
@@ -6,19 +6,19 @@ export interface UserConfig {
|
|||||||
app: {
|
app: {
|
||||||
title: Lowercase<string>
|
title: Lowercase<string>
|
||||||
logo: VNode
|
logo: VNode
|
||||||
contentWidth: typeof ContentWidth[keyof typeof ContentWidth]
|
contentWidth: (typeof ContentWidth)[keyof typeof ContentWidth]
|
||||||
contentLayoutNav: typeof AppContentLayoutNav[keyof typeof AppContentLayoutNav]
|
contentLayoutNav: (typeof AppContentLayoutNav)[keyof typeof AppContentLayoutNav]
|
||||||
overlayNavFromBreakpoint: number
|
overlayNavFromBreakpoint: number
|
||||||
enableI18n: boolean
|
enableI18n: boolean
|
||||||
isRtl: boolean
|
isRtl: boolean
|
||||||
iconRenderer?: Component
|
iconRenderer?: Component
|
||||||
}
|
}
|
||||||
navbar: {
|
navbar: {
|
||||||
type: typeof NavbarType[keyof typeof NavbarType]
|
type: (typeof NavbarType)[keyof typeof NavbarType]
|
||||||
navbarBlur: boolean
|
navbarBlur: boolean
|
||||||
}
|
}
|
||||||
footer: {
|
footer: {
|
||||||
type:typeof FooterType[keyof typeof FooterType]
|
type: (typeof FooterType)[keyof typeof FooterType]
|
||||||
}
|
}
|
||||||
verticalNav: {
|
verticalNav: {
|
||||||
isVerticalNavCollapsed: boolean
|
isVerticalNavCollapsed: boolean
|
||||||
@@ -120,6 +120,12 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
|
|||||||
disable?: boolean
|
disable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NavMenu extends NavLink {
|
||||||
|
header: string
|
||||||
|
admin: boolean
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
// 👉 Vertical nav group
|
// 👉 Vertical nav group
|
||||||
export interface NavGroup extends Partial<AclProperties> {
|
export interface NavGroup extends Partial<AclProperties> {
|
||||||
title: string
|
title: string
|
||||||
@@ -143,7 +149,7 @@ interface I18nLanguage {
|
|||||||
// avatar | text | icon
|
// avatar | text | icon
|
||||||
// Thanks: https://stackoverflow.com/a/60617060/10796681
|
// Thanks: https://stackoverflow.com/a/60617060/10796681
|
||||||
type Notification = {
|
type Notification = {
|
||||||
id:number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle: string
|
||||||
time: string
|
time: string
|
||||||
@@ -157,5 +163,6 @@ type Notification = {
|
|||||||
|
|
||||||
interface ThemeSwitcherTheme {
|
interface ThemeSwitcherTheme {
|
||||||
name: string
|
name: string
|
||||||
|
title: string
|
||||||
icon: string
|
icon: string
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/App.vue
79
src/App.vue
@@ -1,66 +1,49 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toast-notification'
|
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import api from '@/api'
|
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||||
import store from './store'
|
|
||||||
|
|
||||||
|
const { global: globalTheme } = useTheme()
|
||||||
|
|
||||||
// 提示框
|
// 生效主题
|
||||||
const $toast = useToast()
|
async function setTheme() {
|
||||||
|
let themeValue = localStorage.getItem('theme') || 'light'
|
||||||
// 设置主题
|
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||||
function setTheme() {
|
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||||
const { global: globalTheme } = useTheme()
|
|
||||||
let theme = localStorage.getItem('theme') || 'light'
|
|
||||||
if (theme === 'auto')
|
|
||||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
||||||
globalTheme.name.value = theme
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE持续接收消息
|
// ApexCharts 全局配置
|
||||||
function startSSEMessager() {
|
declare global {
|
||||||
const token = store.state.auth.token
|
interface Window {
|
||||||
if (token) {
|
Apex: any
|
||||||
const eventSource = new EventSource(
|
|
||||||
`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
eventSource.addEventListener('message', (event) => {
|
|
||||||
const message = event.data
|
|
||||||
if (message)
|
|
||||||
$toast.info(message)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
eventSource.close()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载用户监控面板配置
|
if (window.Apex) {
|
||||||
async function loadDashboardConfig() {
|
// 数据标签
|
||||||
const response = await api.get('/user/config/Dashboard')
|
window.Apex.dataLabels = {
|
||||||
if (response && response.data && response.data.value) {
|
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||||
const data = JSON.stringify(response.data.value)
|
// 如果有小数点,保留两位小数,否则保留整数
|
||||||
if (data != localStorage.getItem("MP_DASHBOARD")) {
|
const data = w.config.series[seriesIndex]
|
||||||
localStorage.setItem("MP_DASHBOARD", data)
|
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
// 图例
|
||||||
|
window.Apex.legend = {
|
||||||
// 尝试加载用户监控面板配置(本地无配置时才加载)
|
labels: {
|
||||||
async function tryLoadDashboardConfig() {
|
useSeriesColors: true,
|
||||||
if (localStorage.getItem("MP_DASHBOARD")) {
|
},
|
||||||
return
|
}
|
||||||
|
// 标题
|
||||||
|
window.Apex.title = {
|
||||||
|
style: {
|
||||||
|
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
await loadDashboardConfig()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面加载时,加载当前用户数据
|
// 页面加载时,加载当前用户数据
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
setTheme()
|
setTheme()
|
||||||
startSSEMessager()
|
|
||||||
await tryLoadDashboardConfig()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import modeHtmlUrl from 'ace-builds/src-noconflict/mode-html?url'
|
|||||||
|
|
||||||
import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
|
import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
|
||||||
|
|
||||||
|
import modeCssUrl from 'ace-builds/src-noconflict/mode-css?url'
|
||||||
|
|
||||||
|
import modePythonUrl from 'ace-builds/src-noconflict/mode-python?url'
|
||||||
|
|
||||||
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
|
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
|
||||||
|
|
||||||
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
|
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
|
||||||
@@ -24,6 +28,8 @@ import workerHtmlUrl from 'ace-builds/src-noconflict/worker-html?url'
|
|||||||
|
|
||||||
import workerYamlUrl from 'ace-builds/src-noconflict/worker-yaml?url'
|
import workerYamlUrl from 'ace-builds/src-noconflict/worker-yaml?url'
|
||||||
|
|
||||||
|
import workerCssUrl from 'ace-builds/src-noconflict/worker-css?url'
|
||||||
|
|
||||||
import snippetsHtmlUrl from 'ace-builds/src-noconflict/snippets/html?url'
|
import snippetsHtmlUrl from 'ace-builds/src-noconflict/snippets/html?url'
|
||||||
|
|
||||||
import snippetsJsUrl from 'ace-builds/src-noconflict/snippets/javascript?url'
|
import snippetsJsUrl from 'ace-builds/src-noconflict/snippets/javascript?url'
|
||||||
@@ -32,12 +38,18 @@ import snippetsYamlUrl from 'ace-builds/src-noconflict/snippets/yaml?url'
|
|||||||
|
|
||||||
import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
|
import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
|
||||||
|
|
||||||
|
import snippertsCssUrl from 'ace-builds/src-noconflict/snippets/css?url'
|
||||||
|
|
||||||
|
import snippetsPythonUrl from 'ace-builds/src-noconflict/snippets/python?url'
|
||||||
|
|
||||||
import 'ace-builds/src-noconflict/ext-language_tools'
|
import 'ace-builds/src-noconflict/ext-language_tools'
|
||||||
|
|
||||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||||
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
|
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
|
||||||
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
|
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
|
||||||
ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
|
ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
|
||||||
|
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
|
||||||
|
ace.config.setModuleUrl('ace/mode/python', modePythonUrl)
|
||||||
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
|
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
|
||||||
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
|
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
|
||||||
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
|
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
|
||||||
@@ -46,9 +58,12 @@ ace.config.setModuleUrl('ace/mode/json_worker', workerJsonUrl)
|
|||||||
ace.config.setModuleUrl('ace/mode/javascript_worker', workerJavascriptUrl)
|
ace.config.setModuleUrl('ace/mode/javascript_worker', workerJavascriptUrl)
|
||||||
ace.config.setModuleUrl('ace/mode/html_worker', workerHtmlUrl)
|
ace.config.setModuleUrl('ace/mode/html_worker', workerHtmlUrl)
|
||||||
ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
|
ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
|
||||||
|
ace.config.setModuleUrl('ace/mode/css_worker', workerCssUrl)
|
||||||
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
|
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
|
||||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
|
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
|
||||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
|
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
|
||||||
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
|
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
|
||||||
|
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
|
||||||
|
ace.config.setModuleUrl('ace/snippets/python', snippetsPythonUrl)
|
||||||
|
|
||||||
ace.require('ace/ext/language_tools')
|
ace.require('ace/ext/language_tools')
|
||||||
|
|||||||
@@ -8,32 +8,32 @@ const api = axios.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 添加请求拦截器
|
// 添加请求拦截器
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use(config => {
|
||||||
// 在请求头中添加token
|
// 在请求头中添加token
|
||||||
const token = store.state.auth.token
|
const token = store.state.auth.token
|
||||||
if (token)
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
|
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加响应拦截器
|
// 添加响应拦截器
|
||||||
api.interceptors.response.use((response) => {
|
api.interceptors.response.use(
|
||||||
return response.data
|
response => {
|
||||||
}, (error) => {
|
return response.data
|
||||||
if (!error.response) {
|
},
|
||||||
// 请求超时
|
error => {
|
||||||
|
if (!error.response) {
|
||||||
|
// 请求超时
|
||||||
|
return Promise.reject(new Error(error))
|
||||||
|
} else if (error.response.status === 403) {
|
||||||
|
// 清除登录状态信息
|
||||||
|
store.dispatch('auth/logout')
|
||||||
|
// token验证失败,跳转到登录页面
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
},
|
||||||
else if (error.response.status === 403) {
|
)
|
||||||
// 清除登录状态信息
|
|
||||||
store.dispatch('auth/clearToken')
|
|
||||||
|
|
||||||
// token验证失败,跳转到登录页面
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(error)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
515
src/api/types.ts
515
src/api/types.ts
File diff suppressed because it is too large
Load Diff
BIN
src/assets/images/logos/bangumi.png
Normal file
BIN
src/assets/images/logos/bangumi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
src/assets/images/logos/douban-black.png
Normal file
BIN
src/assets/images/logos/douban-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
1
src/assets/images/no-data.svg
Normal file
1
src/assets/images/no-data.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 38 KiB |
@@ -1,19 +1,28 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Axios } from 'axios'
|
import type { Axios } from 'axios'
|
||||||
import axios from 'axios'
|
|
||||||
import FileList from './filebrowser/FileList.vue'
|
import FileList from './filebrowser/FileList.vue'
|
||||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||||
import type { EndPoints } from '@/api/types'
|
import type { EndPoints, FileItem } from '@/api/types'
|
||||||
|
import api from '@/api'
|
||||||
|
import AliyunAuthDialog from './dialog/AliyunAuthDialog.vue'
|
||||||
|
import U115AuthDialog from './dialog/U115AuthDialog.vue'
|
||||||
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
storages: String,
|
storages: String,
|
||||||
storage: String,
|
|
||||||
path: String,
|
|
||||||
tree: Boolean,
|
tree: Boolean,
|
||||||
endpoints: Object as PropType<EndPoints>,
|
endpoints: Object as PropType<EndPoints>,
|
||||||
axios: Object as PropType<Axios>,
|
axios: {
|
||||||
|
type: Object as PropType<Axios>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
axiosconfig: Object,
|
axiosconfig: Object,
|
||||||
|
item: {
|
||||||
|
type: Object as PropType<FileItem>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
itemstack: Array as PropType<FileItem[]>,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 对外事件
|
// 对外事件
|
||||||
@@ -25,6 +34,16 @@ const availableStorages = [
|
|||||||
code: 'local',
|
code: 'local',
|
||||||
icon: 'mdi-folder-multiple-outline',
|
icon: 'mdi-folder-multiple-outline',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '阿里云盘',
|
||||||
|
code: 'aliyun',
|
||||||
|
icon: 'mdi-cloud-outline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '115网盘',
|
||||||
|
code: 'u115',
|
||||||
|
icon: 'mdi-cloud-outline',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const fileIcons = {
|
const fileIcons = {
|
||||||
@@ -57,8 +76,14 @@ const activeStorage = ref('local')
|
|||||||
const refreshPending = ref(false)
|
const refreshPending = ref(false)
|
||||||
// 排序
|
// 排序
|
||||||
const sort = ref('name')
|
const sort = ref('name')
|
||||||
// axios实例
|
// 阿里云盘认证对话框
|
||||||
const axiosInstance = ref<Axios>()
|
const aliyunAuthDialog = ref(false)
|
||||||
|
// 阿里云盘用户信息
|
||||||
|
const aliyunUserInfo = ref<{ [key: string]: any }>({})
|
||||||
|
// 115网盘认证对话框
|
||||||
|
const u115AuthDialog = ref(false)
|
||||||
|
// 115网盘用户信息
|
||||||
|
const u115UserInfo = ref<{ [key: string]: any }>({})
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const storagesArray = computed(() => {
|
const storagesArray = computed(() => {
|
||||||
@@ -68,19 +93,56 @@ const storagesArray = computed(() => {
|
|||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
function loadingChanged(loading: number) {
|
function loadingChanged(loading: number) {
|
||||||
if (loading)
|
if (loading) loading++
|
||||||
loading++
|
else if (loading > 0) loading--
|
||||||
else if (loading > 0)
|
|
||||||
loading--
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function storageChanged(storage: string) {
|
// 查询阿里云
|
||||||
|
async function loadAliyunUserInfo() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('aliyun/userinfo')
|
||||||
|
if (result.success) {
|
||||||
|
aliyunUserInfo.value = result
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询115
|
||||||
|
async function loadU115UserInfo() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('u115/storage')
|
||||||
|
if (result.success) {
|
||||||
|
u115UserInfo.value = result
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储切换
|
||||||
|
async function storageChanged(storage: string) {
|
||||||
|
if (storage == 'aliyun') {
|
||||||
|
await loadAliyunUserInfo()
|
||||||
|
if (isNullOrEmptyObject(aliyunUserInfo.value)) {
|
||||||
|
aliyunAuthDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (storage == 'u115') {
|
||||||
|
await loadU115UserInfo()
|
||||||
|
if (isNullOrEmptyObject(u115UserInfo.value)) {
|
||||||
|
u115AuthDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
activeStorage.value = storage
|
activeStorage.value = storage
|
||||||
|
emit('pathchanged', { path: '/', fileid: 'root' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路径变化
|
// 路径变化
|
||||||
function pathChanged(_path: string) {
|
function pathChanged(item: FileItem) {
|
||||||
emit('pathchanged', _path)
|
emit('pathchanged', item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序变化
|
// 排序变化
|
||||||
@@ -89,33 +151,40 @@ function sortChanged(s: string) {
|
|||||||
refreshPending.value = true
|
refreshPending.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化
|
// aliyun认证完成
|
||||||
onMounted(() => {
|
function aliyunAuthDone() {
|
||||||
activeStorage.value = props.storage ?? 'local'
|
aliyunAuthDialog.value = false
|
||||||
axiosInstance.value = props.axios ?? axios.create(props.axiosconfig)
|
activeStorage.value = 'aliyun'
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// u115认证完成
|
||||||
|
function u115AuthDone() {
|
||||||
|
u115AuthDialog.value = false
|
||||||
|
activeStorage.value = 'u115'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard class="mx-auto" :loading="loading > 0 || !path">
|
<VCard class="mx-auto" :loading="loading > 0">
|
||||||
<div v-if="path">
|
<div v-if="activeStorage && item">
|
||||||
<FileToolbar
|
<FileToolbar
|
||||||
:path="path"
|
:item="item"
|
||||||
|
:itemstack="itemstack"
|
||||||
:storages="storagesArray"
|
:storages="storagesArray"
|
||||||
:storage="activeStorage"
|
:storage="activeStorage"
|
||||||
:endpoints="endpoints"
|
:endpoints="endpoints"
|
||||||
:axios="axiosInstance"
|
:axios="axios"
|
||||||
@storagechanged="storageChanged"
|
@storagechanged="storageChanged"
|
||||||
@pathchanged="pathChanged"
|
@pathchanged="pathChanged"
|
||||||
@foldercreated="refreshPending = true"
|
@foldercreated="refreshPending = true"
|
||||||
@sortchanged="sortChanged"
|
@sortchanged="sortChanged"
|
||||||
/>
|
/>
|
||||||
<FileList
|
<FileList
|
||||||
:path="path"
|
:item="item"
|
||||||
:storage="activeStorage"
|
:storage="activeStorage"
|
||||||
:icons="fileIcons"
|
:icons="fileIcons"
|
||||||
:endpoints="endpoints"
|
:endpoints="endpoints"
|
||||||
:axios="axiosInstance"
|
:axios="axios"
|
||||||
:refreshpending="refreshPending"
|
:refreshpending="refreshPending"
|
||||||
:sort="sort"
|
:sort="sort"
|
||||||
@pathchanged="pathChanged"
|
@pathchanged="pathChanged"
|
||||||
@@ -126,4 +195,11 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
<AliyunAuthDialog
|
||||||
|
v-if="aliyunAuthDialog"
|
||||||
|
v-model="aliyunAuthDialog"
|
||||||
|
@close="aliyunAuthDialog = false"
|
||||||
|
@done="aliyunAuthDone"
|
||||||
|
/>
|
||||||
|
<U115AuthDialog v-if="u115AuthDialog" v-model="u115AuthDialog" @close="u115AuthDialog = false" @done="u115AuthDone" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import image from '@images/misc/teamwork.png'
|
import image from '@images/no-data.svg'
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
@@ -11,10 +11,7 @@ interface Props {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VEmptyState
|
<VEmptyState :image="image" size="250">
|
||||||
:image="image"
|
|
||||||
size="250"
|
|
||||||
>
|
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="mt-8 text-2xl">
|
<div class="mt-8 text-2xl">
|
||||||
{{ props.errorTitle }}
|
{{ props.errorTitle }}
|
||||||
@@ -22,7 +19,7 @@ interface Props {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #text>
|
<template #text>
|
||||||
<div class="text-subtitle">
|
<div class="text-subtitle mt-3">
|
||||||
{{ props.errorDescription }}
|
{{ props.errorDescription }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ function imageLoadHandler() {
|
|||||||
|
|
||||||
// 跳转播放
|
// 跳转播放
|
||||||
function goPlay() {
|
function goPlay() {
|
||||||
if (props.media?.link)
|
if (props.media?.link) window.open(props.media?.link, '_blank')
|
||||||
window.open(props.media?.link, '_blank')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算图片地址
|
// 计算图片地址
|
||||||
@@ -30,11 +29,7 @@ const getImgUrl = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VHover
|
<VHover v-bind="props">
|
||||||
v-bind="props"
|
|
||||||
:height="props.height"
|
|
||||||
:width="props.width"
|
|
||||||
>
|
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard
|
<VCard
|
||||||
v-bind="hover.props"
|
v-bind="hover.props"
|
||||||
@@ -48,12 +43,7 @@ const getImgUrl = computed(() => {
|
|||||||
@click="goPlay"
|
@click="goPlay"
|
||||||
>
|
>
|
||||||
<template #image>
|
<template #image>
|
||||||
<VImg
|
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler">
|
||||||
:src="getImgUrl"
|
|
||||||
aspect-ratio="2/3"
|
|
||||||
cover
|
|
||||||
@load="imageLoadHandler"
|
|
||||||
>
|
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||||
@@ -62,7 +52,9 @@ const getImgUrl = computed(() => {
|
|||||||
<VCardText
|
<VCardText
|
||||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||||
>
|
>
|
||||||
<h1 class="mb-1 text-white text-shadow font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
<h1
|
||||||
|
class="mb-1 text-white text-shadow font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||||
|
>
|
||||||
{{ props.media?.title }}
|
{{ props.media?.title }}
|
||||||
</h1>
|
</h1>
|
||||||
<span class="text-shadow">{{ props.media?.subtitle }}</span>
|
<span class="text-shadow">{{ props.media?.subtitle }}</span>
|
||||||
@@ -83,7 +75,7 @@ const getImgUrl = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.text-shadow{
|
.text-shadow {
|
||||||
text-shadow:1px 1px #777;
|
text-shadow: 1px 1px #777;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import personIcon from '@images/misc/person-icon.png'
|
|
||||||
import type { BangumiPerson } from '@/api/types'
|
|
||||||
|
|
||||||
const personProps = defineProps({
|
|
||||||
person: Object as PropType<BangumiPerson>,
|
|
||||||
width: String,
|
|
||||||
height: String,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 当前人物
|
|
||||||
const personInfo = ref(personProps.person)
|
|
||||||
|
|
||||||
// 人物图片是否加载
|
|
||||||
const isImageLoaded = ref(false)
|
|
||||||
|
|
||||||
// 人物图片地址
|
|
||||||
function getPersonImage() {
|
|
||||||
if (!personInfo.value?.images)
|
|
||||||
return personIcon
|
|
||||||
return personInfo.value?.images?.medium
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用、拼装人物角色
|
|
||||||
function getPersonCharacter() {
|
|
||||||
if (!personInfo.value?.career)
|
|
||||||
return ''
|
|
||||||
return personInfo.value?.career.join('、')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开人物详情
|
|
||||||
function goPersonDetail() {
|
|
||||||
if (!personInfo.value?.id)
|
|
||||||
return
|
|
||||||
window.open(`https://bangumi.tv/person/${personInfo.value?.id}`, '_blank')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VHover>
|
|
||||||
<template #default="hover">
|
|
||||||
<VCard
|
|
||||||
v-bind="hover.props"
|
|
||||||
:height="personProps.height"
|
|
||||||
:width="personProps.width"
|
|
||||||
class="rounded-lg"
|
|
||||||
:class="{
|
|
||||||
'transition transform-cpu duration-300 scale-105': hover.isHovering,
|
|
||||||
}"
|
|
||||||
@click.stop="goPersonDetail"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="person-card relative transform-gpu cursor-pointer rounded shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700"
|
|
||||||
>
|
|
||||||
<div style="padding-bottom: 150%;">
|
|
||||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
|
||||||
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
|
||||||
<VAvatar
|
|
||||||
size="120"
|
|
||||||
:class="{
|
|
||||||
'ring-1 ring-gray-700': isImageLoaded,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<VImg
|
|
||||||
v-img
|
|
||||||
:src="getPersonImage()"
|
|
||||||
cover
|
|
||||||
@load="isImageLoaded = true"
|
|
||||||
/>
|
|
||||||
</VAvatar>
|
|
||||||
</div>
|
|
||||||
<div class="w-full truncate text-center font-bold">
|
|
||||||
{{ personInfo?.name }}
|
|
||||||
</div>
|
|
||||||
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;">
|
|
||||||
{{ getPersonCharacter() }}
|
|
||||||
</div>
|
|
||||||
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</template>
|
|
||||||
</VHover>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.person-card {
|
|
||||||
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-card:hover {
|
|
||||||
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-custom-background)) 60%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
104
src/components/cards/DirectoryCard.vue
Normal file
104
src/components/cards/DirectoryCard.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MediaDirectory } from '@/api/types'
|
||||||
|
import { VTextField } from 'vuetify/lib/components/index.mjs'
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const props = defineProps({
|
||||||
|
type: String, // download/library
|
||||||
|
directory: {
|
||||||
|
type: Object as PropType<MediaDirectory>,
|
||||||
|
required: true, // 必填参数
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
type: Object as PropType<{ [key: string]: any }>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
width: String,
|
||||||
|
height: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 路径
|
||||||
|
const path = ref<string>('')
|
||||||
|
|
||||||
|
// 类型下拉字典
|
||||||
|
const typeItems = [
|
||||||
|
{ title: '全部', value: '' },
|
||||||
|
{ title: '电影', value: '电影' },
|
||||||
|
{ title: '电视剧', value: '电视剧' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 定义触发的自定义事件
|
||||||
|
const emit = defineEmits(['close', 'changed', 'update:modelValue'])
|
||||||
|
|
||||||
|
// 按钮点击
|
||||||
|
function onClose() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径更新
|
||||||
|
function updatePath(value: string) {
|
||||||
|
path.value = value
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据选中的媒体类型,获取对应的媒体类别
|
||||||
|
const getCategories = computed(() => {
|
||||||
|
const default_value = [{ title: '全部', value: '' }]
|
||||||
|
if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value
|
||||||
|
return default_value.concat(props.categories[props.directory.media_type ?? ''])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VCard variant="tonal" :width="props.width" :height="props.height">
|
||||||
|
<DialogCloseBtn @click="onClose" />
|
||||||
|
<VCardItem>
|
||||||
|
<VTextField
|
||||||
|
v-model="props.directory.name"
|
||||||
|
variant="underlined"
|
||||||
|
label="别名"
|
||||||
|
class="me-20 text-high-emphasis font-weight-bold"
|
||||||
|
/>
|
||||||
|
<span class="absolute top-3 right-12">
|
||||||
|
<IconBtn>
|
||||||
|
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||||
|
</IconBtn>
|
||||||
|
</span>
|
||||||
|
</VCardItem>
|
||||||
|
<VCardText>
|
||||||
|
<VForm>
|
||||||
|
<VRow>
|
||||||
|
<VCol>
|
||||||
|
<VPathField @update:modelValue="updatePath">
|
||||||
|
<template #activator="{ menuprops }">
|
||||||
|
<VTextField v-model="props.directory.path" v-bind="menuprops" variant="underlined" label="路径" />
|
||||||
|
</template>
|
||||||
|
</VPathField>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="4">
|
||||||
|
<VSelect
|
||||||
|
v-model="props.directory.media_type"
|
||||||
|
variant="underlined"
|
||||||
|
:items="typeItems"
|
||||||
|
label="媒体类型"
|
||||||
|
@update:modelValue="props.directory.category = ''"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol>
|
||||||
|
<VSelect v-model="props.directory.category" variant="underlined" :items="getCategories" label="媒体类别" />
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
<VRow>
|
||||||
|
<VCol v-if="!props.directory.category || props.directory.category === ''">
|
||||||
|
<VSwitch v-model="props.directory.auto_category" label="自动分类"></VSwitch>
|
||||||
|
</VCol>
|
||||||
|
<VCol v-if="type === 'library'">
|
||||||
|
<VSwitch v-model="props.directory.scrape" label="刮削元数据"></VSwitch>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import personIcon from '@images/misc/person-icon.png'
|
|
||||||
import type { DoubanPerson } from '@/api/types'
|
|
||||||
|
|
||||||
const personProps = defineProps({
|
|
||||||
person: Object as PropType<DoubanPerson>,
|
|
||||||
width: String,
|
|
||||||
height: String,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 当前人物
|
|
||||||
const personInfo = ref(personProps.person)
|
|
||||||
|
|
||||||
// 人物图片是否加载
|
|
||||||
const isImageLoaded = ref(false)
|
|
||||||
|
|
||||||
// 人物图片地址
|
|
||||||
function getPersonImage() {
|
|
||||||
if (!personInfo.value?.avatar)
|
|
||||||
return personIcon
|
|
||||||
return personInfo.value?.avatar?.large
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开人物详情
|
|
||||||
function goPersonDetail() {
|
|
||||||
if (!personInfo.value?.id)
|
|
||||||
return
|
|
||||||
window.open(`https://movie.douban.com/celebrity/${personInfo.value?.id}/`, '_blank')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VHover>
|
|
||||||
<template #default="hover">
|
|
||||||
<VCard
|
|
||||||
v-bind="hover.props"
|
|
||||||
:height="personProps.height"
|
|
||||||
:width="personProps.width"
|
|
||||||
class="rounded-lg"
|
|
||||||
:class="{
|
|
||||||
'transition transform-cpu duration-300 scale-105': hover.isHovering,
|
|
||||||
}"
|
|
||||||
@click.stop="goPersonDetail"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="person-card relative transform-gpu cursor-pointer rounded shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700"
|
|
||||||
>
|
|
||||||
<div style="padding-bottom: 150%;">
|
|
||||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
|
||||||
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
|
||||||
<VAvatar
|
|
||||||
size="120"
|
|
||||||
:class="{
|
|
||||||
'ring-1 ring-gray-700': isImageLoaded,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<VImg
|
|
||||||
v-img
|
|
||||||
:src="getPersonImage()"
|
|
||||||
cover
|
|
||||||
@load="isImageLoaded = true"
|
|
||||||
/>
|
|
||||||
</VAvatar>
|
|
||||||
</div>
|
|
||||||
<div class="w-full truncate text-center font-bold">
|
|
||||||
{{ personInfo?.name }}
|
|
||||||
</div>
|
|
||||||
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;">
|
|
||||||
{{ personInfo?.character }}
|
|
||||||
</div>
|
|
||||||
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</template>
|
|
||||||
</VHover>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.person-card {
|
|
||||||
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-card:hover {
|
|
||||||
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-custom-background)) 60%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -9,28 +9,26 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['close', 'changed', 'levelup', 'leveldown'])
|
const emit = defineEmits(['close', 'changed'])
|
||||||
|
|
||||||
// 按钮点击
|
// 按钮点击
|
||||||
function onClose() {
|
function onClose() {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上升优先级
|
|
||||||
function onLevelUp() {
|
|
||||||
emit('levelup', props.pri)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下降优先级
|
|
||||||
function onLevelDown() {
|
|
||||||
emit('leveldown', props.pri)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选项变化
|
// 选项变化
|
||||||
function filtersChanged(value: string[]) {
|
function filtersChanged(value: string[]) {
|
||||||
emit('changed', props.pri, value)
|
emit('changed', props.pri, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清洗规则中的换行符和多余空格,并在前后添加空格
|
||||||
|
const cleanedRules = computed(() => {
|
||||||
|
return props.rules.map(rule => {
|
||||||
|
rule = rule ?? ''
|
||||||
|
return ` ${rule.replace(/[\r\n]/g, '').replace(/\s+/g, '')} `
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// 过滤规则下拉框
|
// 过滤规则下拉框
|
||||||
const selectFilterOptions = ref<{ [key: string]: string }[]>([
|
const selectFilterOptions = ref<{ [key: string]: string }[]>([
|
||||||
{ title: '特效字幕', value: ' SPECSUB ' },
|
{ title: '特效字幕', value: ' SPECSUB ' },
|
||||||
@@ -76,18 +74,9 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard variant="tonal" :width="props.width" :height="props.height">
|
<VCard variant="tonal" :width="props.width" :height="props.height">
|
||||||
<span class="absolute top-3 right-14">
|
<span class="absolute top-3 right-12">
|
||||||
<IconBtn
|
<IconBtn>
|
||||||
v-if="props.pri !== '1'"
|
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||||
@click.stop="onLevelUp"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-arrow-up" />
|
|
||||||
</IconBtn>
|
|
||||||
<IconBtn
|
|
||||||
v-if="props.pri !== props.maxpri"
|
|
||||||
@click.stop="onLevelDown"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-arrow-down" />
|
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</span>
|
</span>
|
||||||
<DialogCloseBtn @click="onClose" />
|
<DialogCloseBtn @click="onClose" />
|
||||||
@@ -96,8 +85,7 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
|
|||||||
<VRow>
|
<VRow>
|
||||||
<VCol>
|
<VCol>
|
||||||
<VSelect
|
<VSelect
|
||||||
:key="props.pri"
|
v-model="cleanedRules"
|
||||||
v-model="props.rules"
|
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
:items="selectFilterOptions"
|
:items="selectFilterOptions"
|
||||||
chips
|
chips
|
||||||
|
|||||||
@@ -35,36 +35,28 @@ function imageErrorHandler() {
|
|||||||
|
|
||||||
// 默认图片
|
// 默认图片
|
||||||
function getDefaultImage() {
|
function getDefaultImage() {
|
||||||
if (props.media?.server === 'plex')
|
if (props.media?.server === 'plex') return plex
|
||||||
return plex
|
else if (props.media?.server === 'emby') return emby
|
||||||
else if (props.media?.server === 'emby')
|
else if (props.media?.server === 'jellyfin') return jellyfin
|
||||||
return emby
|
else return plex
|
||||||
else if (props.media?.server === 'jellyfin')
|
|
||||||
return jellyfin
|
|
||||||
else
|
|
||||||
return plex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳转播放
|
// 跳转播放
|
||||||
function goPlay() {
|
function goPlay() {
|
||||||
if (props.media?.link)
|
if (props.media?.link) window.open(props.media?.link, '_blank')
|
||||||
window.open(props.media?.link, '_blank')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成图片代理路径
|
// 生成图片代理路径
|
||||||
function getImgUrl(url: string) {
|
function getImgUrl(url: string) {
|
||||||
if (!url)
|
if (!url) return getDefaultImage()
|
||||||
return getDefaultImage()
|
else return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||||
else
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据多张图片生成媒体库封面
|
// 根据多张图片生成媒体库封面
|
||||||
async function drawImages(imageList: string[]) {
|
async function drawImages(imageList: string[]) {
|
||||||
// 图片
|
// 图片
|
||||||
const IMAGES = imageList
|
const IMAGES = imageList
|
||||||
if (IMAGES.length === 0)
|
if (IMAGES.length === 0) return getDefaultImage()
|
||||||
return getDefaultImage()
|
|
||||||
|
|
||||||
// 为所有图片添加system/img前缀
|
// 为所有图片添加system/img前缀
|
||||||
for (let i = 0; i < IMAGES.length; i++)
|
for (let i = 0; i < IMAGES.length; i++)
|
||||||
@@ -72,8 +64,7 @@ async function drawImages(imageList: string[]) {
|
|||||||
|
|
||||||
// canvas
|
// canvas
|
||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
if (!canvas)
|
if (!canvas) return getDefaultImage()
|
||||||
return getDefaultImage()
|
|
||||||
|
|
||||||
// 画布参数
|
// 画布参数
|
||||||
const POSTER_WIDTH = (canvas.width - 32) / 4
|
const POSTER_WIDTH = (canvas.width - 32) / 4
|
||||||
@@ -85,8 +76,7 @@ async function drawImages(imageList: string[]) {
|
|||||||
|
|
||||||
// 获取画布上下文
|
// 获取画布上下文
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx)
|
if (!ctx) return getDefaultImage()
|
||||||
return getDefaultImage()
|
|
||||||
|
|
||||||
// 设置背景色为黑色
|
// 设置背景色为黑色
|
||||||
ctx.fillStyle = '#000000'
|
ctx.fillStyle = '#000000'
|
||||||
@@ -94,16 +84,14 @@ async function drawImages(imageList: string[]) {
|
|||||||
|
|
||||||
// 绘制图片
|
// 绘制图片
|
||||||
async function drawImageWithReflection(imgSrc: string, index: number) {
|
async function drawImageWithReflection(imgSrc: string, index: number) {
|
||||||
if (!canvas)
|
if (!canvas) return
|
||||||
return
|
|
||||||
|
|
||||||
if (!ctx)
|
if (!ctx) return
|
||||||
return
|
|
||||||
|
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.setAttribute('crossorigin', 'anonymous')
|
img.setAttribute('crossorigin', 'anonymous')
|
||||||
img.src = imgSrc
|
img.src = imgSrc
|
||||||
await new Promise(resolve => img.onload = resolve)
|
await new Promise(resolve => (img.onload = resolve))
|
||||||
|
|
||||||
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
|
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
|
||||||
const y = MARGIN_HEIGHT
|
const y = MARGIN_HEIGHT
|
||||||
@@ -125,12 +113,7 @@ async function drawImages(imageList: string[]) {
|
|||||||
REFLECTION_HEIGHT,
|
REFLECTION_HEIGHT,
|
||||||
)
|
)
|
||||||
|
|
||||||
const gradient = ctx.createLinearGradient(
|
const gradient = ctx.createLinearGradient(0, REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT, 0, REFLECTION_HEIGHT)
|
||||||
0,
|
|
||||||
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
|
|
||||||
0,
|
|
||||||
REFLECTION_HEIGHT,
|
|
||||||
)
|
|
||||||
|
|
||||||
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
|
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
|
||||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)')
|
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)')
|
||||||
@@ -142,8 +125,7 @@ async function drawImages(imageList: string[]) {
|
|||||||
|
|
||||||
// 绘制多张图片
|
// 绘制多张图片
|
||||||
const loopCount = Math.min(4, IMAGES.length)
|
const loopCount = Math.min(4, IMAGES.length)
|
||||||
for (let i = 0; i < loopCount; i++)
|
for (let i = 0; i < loopCount; i++) await drawImageWithReflection(IMAGES[i], i + 1)
|
||||||
await drawImageWithReflection(IMAGES[i], i + 1)
|
|
||||||
|
|
||||||
// 转换为图片地址
|
// 转换为图片地址
|
||||||
return canvas.toDataURL('image/png')
|
return canvas.toDataURL('image/png')
|
||||||
@@ -152,17 +134,12 @@ async function drawImages(imageList: string[]) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (props.media?.image_list && props.media?.image_list.length > 0)
|
if (props.media?.image_list && props.media?.image_list.length > 0)
|
||||||
imgUrl.value = await drawImages(props.media?.image_list || [])
|
imgUrl.value = await drawImages(props.media?.image_list || [])
|
||||||
else
|
else imgUrl.value = getImgUrl(props.media?.image || '')
|
||||||
imgUrl.value = getImgUrl(props.media?.image || '')
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VHover
|
<VHover v-bind="props" :height="props.height" :width="props.width">
|
||||||
v-bind="props"
|
|
||||||
:height="props.height"
|
|
||||||
:width="props.width"
|
|
||||||
>
|
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard
|
<VCard
|
||||||
v-bind="hover.props"
|
v-bind="hover.props"
|
||||||
@@ -175,13 +152,7 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
<template #image>
|
<template #image>
|
||||||
<canvas ref="canvasRef" class="w-full h-full hidden" />
|
<canvas ref="canvasRef" class="w-full h-full hidden" />
|
||||||
<VImg
|
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||||
:src="imgUrl"
|
|
||||||
aspect-ratio="2/3"
|
|
||||||
cover
|
|
||||||
@load="imageLoadHandler"
|
|
||||||
@error="imageErrorHandler"
|
|
||||||
>
|
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||||
@@ -190,7 +161,7 @@ onMounted(async () => {
|
|||||||
<VCardText
|
<VCardText
|
||||||
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
|
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
|
||||||
>
|
>
|
||||||
<h1 class="mb-1 text-white font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
|
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||||
{{ props.media?.name }}
|
{{ props.media?.name }}
|
||||||
</h1>
|
</h1>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
@@ -200,3 +171,9 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.text-shadow {
|
||||||
|
text-shadow: 1px 1px #777;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
|
|||||||
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
|
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import noImage from '@images/no-image.jpeg'
|
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'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -16,6 +19,8 @@ const props = defineProps({
|
|||||||
height: String,
|
height: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
@@ -52,31 +57,33 @@ const seasonInfos = ref<TmdbSeason[]>([])
|
|||||||
// 选中的订阅季
|
// 选中的订阅季
|
||||||
const seasonsSelected = ref<TmdbSeason[]>([])
|
const seasonsSelected = ref<TmdbSeason[]>([])
|
||||||
|
|
||||||
|
// 来源角标字典
|
||||||
|
const sourceIconDict: { [key: string]: any } = {
|
||||||
|
themoviedb: tmdbImage,
|
||||||
|
douban: doubanImage,
|
||||||
|
bangumi: bangumiImage,
|
||||||
|
}
|
||||||
|
|
||||||
// 获得mediaid
|
// 获得mediaid
|
||||||
function getMediaId() {
|
function getMediaId() {
|
||||||
return props.media?.tmdb_id
|
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||||
? `tmdb:${props.media?.tmdb_id}`
|
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||||
: props.media?.douban_id
|
else return `bangumi:${props.media?.bangumi_id}`
|
||||||
? `douban:${props.media?.douban_id}`
|
|
||||||
: `bangumi:${props.media?.bangumi_id}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 订阅弹窗选择的多季
|
// 订阅弹窗选择的多季
|
||||||
function subscribeSeasons() {
|
function subscribeSeasons() {
|
||||||
subscribeSeasonDialog.value = false
|
subscribeSeasonDialog.value = false
|
||||||
seasonsSelected.value.forEach((season) => {
|
seasonsSelected.value.forEach(season => {
|
||||||
addSubscribe(season.season_number)
|
addSubscribe(season.season_number)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 角标颜色
|
// 角标颜色
|
||||||
function getChipColor(type: string) {
|
function getChipColor(type: string) {
|
||||||
if (type === '电影')
|
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||||
return 'border-blue-500 bg-blue-600'
|
else if (type === '电视剧') return ' bg-indigo-500 border-indigo-600'
|
||||||
else if (type === '电视剧')
|
else return 'border-purple-600 bg-purple-600'
|
||||||
return ' bg-indigo-500 border-indigo-600'
|
|
||||||
else
|
|
||||||
return 'border-purple-600 bg-purple-600'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加订阅处理
|
// 添加订阅处理
|
||||||
@@ -90,29 +97,24 @@ async function handleAddSubscribe() {
|
|||||||
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
|
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查各季的缺失状态
|
// 检查各季的缺失状态
|
||||||
await checkSeasonsNotExists()
|
await checkSeasonsNotExists()
|
||||||
if (!tmdbFlag.value)
|
if (!tmdbFlag.value) return
|
||||||
return
|
|
||||||
|
|
||||||
if (seasonInfos.value.length === 1) {
|
if (seasonInfos.value.length === 1) {
|
||||||
// 添加订阅
|
// 添加订阅
|
||||||
addSubscribe(1)
|
addSubscribe(1)
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
// 弹出季选择列表,支持多选
|
// 弹出季选择列表,支持多选
|
||||||
seasonsSelected.value = []
|
seasonsSelected.value = []
|
||||||
subscribeSeasonDialog.value = true
|
subscribeSeasonDialog.value = true
|
||||||
}
|
}
|
||||||
}
|
} else if (props.media?.type === '电视剧') {
|
||||||
else if (props.media?.type === '电视剧') {
|
|
||||||
// 豆瓣电视剧,只会有一季
|
// 豆瓣电视剧,只会有一季
|
||||||
const season = props.media?.season ?? 1
|
const season = props.media?.season ?? 1
|
||||||
// 添加订阅
|
// 添加订阅
|
||||||
addSubscribe(season)
|
addSubscribe(season)
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
// 电影
|
// 电影
|
||||||
addSubscribe()
|
addSubscribe()
|
||||||
}
|
}
|
||||||
@@ -147,13 +149,7 @@ async function addSubscribe(season = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 提示
|
// 提示
|
||||||
showSubscribeAddToast(
|
showSubscribeAddToast(result.success, props.media?.title ?? '', season, result.message, best_version)
|
||||||
result.success,
|
|
||||||
props.media?.title ?? '',
|
|
||||||
season,
|
|
||||||
result.message,
|
|
||||||
best_version,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 弹出订阅编辑弹窗
|
// 弹出订阅编辑弹窗
|
||||||
if (result.success && seasonsSelected.value.length <= 1) {
|
if (result.success && seasonsSelected.value.length <= 1) {
|
||||||
@@ -163,8 +159,7 @@ async function addSubscribe(season = 0) {
|
|||||||
subscribeEditDialog.value = true
|
subscribeEditDialog.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
doneNProgress()
|
doneNProgress()
|
||||||
@@ -172,22 +167,14 @@ async function addSubscribe(season = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 弹出添加订阅提示
|
// 弹出添加订阅提示
|
||||||
function showSubscribeAddToast(result: boolean,
|
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
|
||||||
title: string,
|
if (season) title = `${title} ${formatSeason(season.toString())}`
|
||||||
season: number,
|
|
||||||
message: string,
|
|
||||||
best_version: number) {
|
|
||||||
if (season)
|
|
||||||
title = `${title} ${formatSeason(season.toString())}`
|
|
||||||
|
|
||||||
let subname = '订阅'
|
let subname = '订阅'
|
||||||
if (best_version > 0)
|
if (best_version > 0) subname = '洗版订阅'
|
||||||
subname = '洗版订阅'
|
|
||||||
|
|
||||||
if (result && seasonsSelected.value.length > 1)
|
if (result) $toast.success(`${title} 添加${subname}成功!`)
|
||||||
$toast.success(`${title} 添加${subname}成功!`)
|
else if (!result) $toast.error(`${title} 添加${subname}失败:${message}!`)
|
||||||
else if (!result)
|
|
||||||
$toast.error(`${title} 添加${subname}失败:${message}!`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用API取消订阅
|
// 调用API取消订阅
|
||||||
@@ -197,37 +184,31 @@ async function removeSubscribe() {
|
|||||||
try {
|
try {
|
||||||
const mediaid = getMediaId()
|
const mediaid = getMediaId()
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.delete(
|
const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {
|
||||||
`subscribe/media/${mediaid}`,
|
params: {
|
||||||
{
|
season: props.media?.season,
|
||||||
params: {
|
|
||||||
season: props.media?.season,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
isSubscribed.value = false
|
isSubscribed.value = false
|
||||||
$toast.success(`${props.media?.title} 已取消订阅!`)
|
$toast.success(`${props.media?.title} 已取消订阅!`)
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$toast.error(`${props.media?.title} 取消订阅失败:${result.message}!`)
|
$toast.error(`${props.media?.title} 取消订阅失败:${result.message}!`)
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
doneNProgress()
|
||||||
}
|
}
|
||||||
doneNProgress()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询当前媒体是否已订阅
|
// 查询当前媒体是否已订阅
|
||||||
async function handleCheckSubscribe() {
|
async function handleCheckSubscribe() {
|
||||||
try {
|
try {
|
||||||
const result = await checkSubscribe(props.media?.season)
|
const result = await checkSubscribe(props.media?.season)
|
||||||
if (result)
|
if (result) isSubscribed.value = true
|
||||||
isSubscribed.value = true
|
} catch (error) {
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,10 +226,8 @@ async function handleCheckExists() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success)
|
if (result.success) isExists.value = true
|
||||||
isExists.value = true
|
} catch (error) {
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,67 +245,60 @@ async function checkSubscribe(season = 0) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return result.id || null
|
return result.id || null
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查所有季的缺失状态
|
// 检查所有季的缺失状态(数据库)
|
||||||
async function checkSeasonsNotExists() {
|
async function checkSeasonsNotExists() {
|
||||||
// 开始处理
|
// 开始处理
|
||||||
startNProgress()
|
startNProgress()
|
||||||
try {
|
try {
|
||||||
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
|
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
|
||||||
if (result) {
|
if (result) {
|
||||||
result.forEach((item) => {
|
result.forEach(item => {
|
||||||
// 0-已入库 1-部分缺失 2-全部缺失
|
// 0-已入库 1-部分缺失 2-全部缺失
|
||||||
let state = 0
|
let state = 0
|
||||||
if (item.episodes.length === 0)
|
if (item.episodes.length === 0) state = 2
|
||||||
state = 2
|
else if (item.episodes.length < item.total_episode) state = 1
|
||||||
else if (item.episodes.length < item.total_episode)
|
|
||||||
state = 1
|
|
||||||
|
|
||||||
seasonsNotExisted.value[item.season] = state
|
seasonsNotExisted.value[item.season] = state
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
$toast.error(`${props.media?.title}无法识别TMDB媒体信息!`)
|
$toast.error(`${props.media?.title}无法识别TMDB媒体信息!`)
|
||||||
tmdbFlag.value = false
|
tmdbFlag.value = false
|
||||||
|
} finally {
|
||||||
|
// 处理完成
|
||||||
|
doneNProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理完成
|
|
||||||
doneNProgress()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询TMDB的所有季信息
|
// 查询TMDB的所有季信息
|
||||||
async function getMediaSeasons() {
|
async function getMediaSeasons() {
|
||||||
try {
|
try {
|
||||||
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
|
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询订阅弹窗规则
|
// 查询订阅弹窗规则
|
||||||
async function queryDefaultSubscribeConfig() {
|
async function queryDefaultSubscribeConfig() {
|
||||||
|
// 非管理员不显示
|
||||||
|
if (!store.state.auth.superUser) return false
|
||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (props.media?.type === '电影')
|
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||||
subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||||
else
|
|
||||||
subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||||
|
|
||||||
if (result.data?.value)
|
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||||
return result.data.value.show_edit_dialog
|
} catch (error) {
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -334,38 +306,28 @@ async function queryDefaultSubscribeConfig() {
|
|||||||
|
|
||||||
// 爱心订阅按钮响应
|
// 爱心订阅按钮响应
|
||||||
function handleSubscribe() {
|
function handleSubscribe() {
|
||||||
if (isSubscribed.value)
|
if (isSubscribed.value) removeSubscribe()
|
||||||
removeSubscribe()
|
else handleAddSubscribe()
|
||||||
else
|
|
||||||
handleAddSubscribe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算存在状态的颜色
|
// 计算存在状态的颜色
|
||||||
function getExistColor(season: number) {
|
function getExistColor(season: number) {
|
||||||
const state = seasonsNotExisted.value[season]
|
const state = seasonsNotExisted.value[season]
|
||||||
if (!state)
|
if (!state) return 'success'
|
||||||
return 'success'
|
|
||||||
|
|
||||||
if (state === 1)
|
if (state === 1) return 'warning'
|
||||||
return 'warning'
|
else if (state === 2) return 'error'
|
||||||
else if (state === 2)
|
else return 'success'
|
||||||
return 'error'
|
|
||||||
else
|
|
||||||
return 'success'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算存在状态的文本
|
// 计算存在状态的文本
|
||||||
function getExistText(season: number) {
|
function getExistText(season: number) {
|
||||||
const state = seasonsNotExisted.value[season]
|
const state = seasonsNotExisted.value[season]
|
||||||
if (!state)
|
if (!state) return '已入库'
|
||||||
return '已入库'
|
|
||||||
|
|
||||||
if (state === 1)
|
if (state === 1) return '部分缺失'
|
||||||
return '部分缺失'
|
else if (state === 2) return '缺失'
|
||||||
else if (state === 2)
|
else return '已入库'
|
||||||
return '缺失'
|
|
||||||
else
|
|
||||||
return '已入库'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开详情页
|
// 打开详情页
|
||||||
@@ -402,8 +364,7 @@ onBeforeMount(() => {
|
|||||||
|
|
||||||
// 计算图片地址
|
// 计算图片地址
|
||||||
const getImgUrl: Ref<string> = computed(() => {
|
const getImgUrl: Ref<string> = computed(() => {
|
||||||
if (imageLoadError.value)
|
if (imageLoadError.value) return noImage
|
||||||
return noImage
|
|
||||||
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
||||||
// 如果地址中包含douban则使用中转代理
|
// 如果地址中包含douban则使用中转代理
|
||||||
if (url.includes('doubanio.com'))
|
if (url.includes('doubanio.com'))
|
||||||
@@ -414,22 +375,19 @@ const getImgUrl: Ref<string> = computed(() => {
|
|||||||
|
|
||||||
// 拼装季图片地址
|
// 拼装季图片地址
|
||||||
function getSeasonPoster(posterPath: string) {
|
function getSeasonPoster(posterPath: string) {
|
||||||
if (!posterPath)
|
if (!posterPath) return ''
|
||||||
return ''
|
|
||||||
return `https://image.tmdb.org/t/p/w500${posterPath}`
|
return `https://image.tmdb.org/t/p/w500${posterPath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
||||||
function formatAirDate(airDate: string) {
|
function formatAirDate(airDate: string) {
|
||||||
if (!airDate)
|
if (!airDate) return ''
|
||||||
return ''
|
|
||||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||||
}
|
}
|
||||||
// 从yyyy-mm-dd中提取年份
|
// 从yyyy-mm-dd中提取年份
|
||||||
function getYear(airDate: string) {
|
function getYear(airDate: string) {
|
||||||
if (!airDate)
|
if (!airDate) return ''
|
||||||
return ''
|
|
||||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||||
return date.getFullYear()
|
return date.getFullYear()
|
||||||
}
|
}
|
||||||
@@ -475,10 +433,10 @@ function getYear(airDate: string) {
|
|||||||
{{ props.media?.type }}
|
{{ props.media?.type }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<!-- 本地存在标识 -->
|
<!-- 本地存在标识 -->
|
||||||
<ExistIcon v-if="isExists" />
|
<ExistIcon v-if="isExists && !hover.isHovering" />
|
||||||
<!-- 评分角标 -->
|
<!-- 评分角标 -->
|
||||||
<VChip
|
<VChip
|
||||||
v-if="isImageLoaded && props.media?.vote_average && !isExists"
|
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
size="small"
|
size="small"
|
||||||
:class="getChipColor('rating')"
|
:class="getChipColor('rating')"
|
||||||
@@ -499,43 +457,33 @@ function getYear(airDate: string) {
|
|||||||
{{ props.media?.overview }}
|
{{ props.media?.overview }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex align-center justify-between">
|
<div class="flex align-center justify-between">
|
||||||
<IconBtn
|
<IconBtn icon="mdi-magnify" color="white" @click.stop="handleSearch" />
|
||||||
icon="mdi-magnify"
|
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
|
||||||
color="white"
|
|
||||||
@click.stop="handleSearch"
|
|
||||||
/>
|
|
||||||
<IconBtn
|
|
||||||
icon="mdi-heart"
|
|
||||||
:color="isSubscribed ? 'error' : 'white'"
|
|
||||||
@click.stop="handleSubscribe"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</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>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
<!-- 订阅季弹窗 -->
|
<!-- 订阅季弹窗 -->
|
||||||
<VBottomSheet
|
<VBottomSheet v-if="subscribeSeasonDialog" v-model="subscribeSeasonDialog" inset scrollable>
|
||||||
v-if="subscribeSeasonDialog"
|
|
||||||
v-model="subscribeSeasonDialog"
|
|
||||||
inset
|
|
||||||
scrollable
|
|
||||||
>
|
|
||||||
<VCard class="rounded-t">
|
<VCard class="rounded-t">
|
||||||
<DialogCloseBtn @click="subscribeSeasonDialog = false" />
|
<DialogCloseBtn @click="subscribeSeasonDialog = false" />
|
||||||
<VCardTitle class="pe-10">
|
<VCardItem>
|
||||||
订阅 - {{ props.media?.title }}
|
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
|
||||||
</VCardTitle>
|
</VCardItem>
|
||||||
|
<VDivider />
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VList
|
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
|
||||||
v-model:selected="seasonsSelected"
|
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
|
||||||
lines="three"
|
|
||||||
select-strategy="classic"
|
|
||||||
>
|
|
||||||
<VListItem
|
|
||||||
v-for="(item, i) in seasonInfos" :key="i"
|
|
||||||
:value="item"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VImg
|
<VImg
|
||||||
height="90"
|
height="90"
|
||||||
@@ -552,16 +500,9 @@ function getYear(airDate: string) {
|
|||||||
</template>
|
</template>
|
||||||
</VImg>
|
</VImg>
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle>
|
<VListItemTitle> 第 {{ item.season_number }} 季 </VListItemTitle>
|
||||||
第 {{ item.season_number }} 季
|
|
||||||
</VListItemTitle>
|
|
||||||
<VListItemSubtitle class="mt-1 me-2">
|
<VListItemSubtitle class="mt-1 me-2">
|
||||||
<VChip
|
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
|
||||||
v-if="item.vote_average"
|
|
||||||
color="primary"
|
|
||||||
size="small"
|
|
||||||
class="mb-1"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-star" /> {{ item.vote_average }}
|
<VIcon icon="mdi-star" /> {{ item.vote_average }}
|
||||||
</VChip>
|
</VChip>
|
||||||
{{ getYear(item.air_date || '') }} • {{ item.episode_count }} 集
|
{{ getYear(item.air_date || '') }} • {{ item.episode_count }} 集
|
||||||
@@ -570,12 +511,7 @@ function getYear(airDate: string) {
|
|||||||
《{{ media?.title }}》第 {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播。
|
《{{ media?.title }}》第 {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播。
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
<VListItemSubtitle>
|
<VListItemSubtitle>
|
||||||
<VChip
|
<VChip v-if="seasonsNotExisted" class="mt-2" size="small" :color="getExistColor(item.season_number || 0)">
|
||||||
v-if="seasonsNotExisted"
|
|
||||||
class="mt-2"
|
|
||||||
size="small"
|
|
||||||
:color="getExistColor(item.season_number || 0)"
|
|
||||||
>
|
|
||||||
{{ getExistText(item.season_number || 0) }}
|
{{ getExistText(item.season_number || 0) }}
|
||||||
</VChip>
|
</VChip>
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
@@ -588,11 +524,7 @@ function getYear(airDate: string) {
|
|||||||
</VList>
|
</VList>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<div class="my-2 text-center">
|
<div class="my-2 text-center">
|
||||||
<VBtn
|
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
|
||||||
:disabled="seasonsSelected.length === 0"
|
|
||||||
width="30%"
|
|
||||||
@click="subscribeSeasons"
|
|
||||||
>
|
|
||||||
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
|
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
@@ -605,7 +537,12 @@ function getYear(airDate: string) {
|
|||||||
:subid="subscribeId"
|
:subid="subscribeId"
|
||||||
@close="subscribeEditDialog = false"
|
@close="subscribeEditDialog = false"
|
||||||
@save="subscribeEditDialog = false"
|
@save="subscribeEditDialog = false"
|
||||||
@remove="() => { subscribeEditDialog = false; handleCheckSubscribe(); }"
|
@remove="
|
||||||
|
() => {
|
||||||
|
subscribeEditDialog = false
|
||||||
|
handleCheckSubscribe()
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import type { Context } from '@/api/types'
|
import type { Context } from '@/api/types'
|
||||||
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -9,15 +10,13 @@ const props = defineProps({
|
|||||||
|
|
||||||
// TMDB图片转换为w500大小
|
// TMDB图片转换为w500大小
|
||||||
function getW500Image(url = '') {
|
function getW500Image(url = '') {
|
||||||
if (!url)
|
if (!url) return ''
|
||||||
return ''
|
|
||||||
return url.replace('original', 'w500')
|
return url.replace('original', 'w500')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开TMDB详情页面
|
// 打开TMDB详情页面
|
||||||
function openTmdbPage(type: string, tmdbId: number) {
|
function openTmdbPage(type: string, tmdbId: number) {
|
||||||
if (!type || !tmdbId)
|
if (!type || !tmdbId) return
|
||||||
return
|
|
||||||
|
|
||||||
const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}`
|
const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}`
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
@@ -31,10 +30,7 @@ function openTmdbPage(type: string, tmdbId: number) {
|
|||||||
v-if="context?.meta_info?.name"
|
v-if="context?.meta_info?.name"
|
||||||
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
|
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
|
||||||
>
|
>
|
||||||
<div
|
<div v-if="context?.media_info?.poster_path" class="ma-auto">
|
||||||
v-if="context?.media_info?.poster_path"
|
|
||||||
class="ma-auto"
|
|
||||||
>
|
|
||||||
<VImg
|
<VImg
|
||||||
width="10rem"
|
width="10rem"
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
@@ -75,16 +71,10 @@ function openTmdbPage(type: string, tmdbId: number) {
|
|||||||
variant="elevated"
|
variant="elevated"
|
||||||
class="me-1 mb-1 text-white bg-blue-500"
|
class="me-1 mb-1 text-white bg-blue-500"
|
||||||
>
|
>
|
||||||
{{
|
{{ context?.media_info?.type || context?.meta_info?.type }}
|
||||||
context?.media_info?.type || context?.meta_info?.type
|
|
||||||
}}
|
|
||||||
</VChip>
|
</VChip>
|
||||||
<!-- 二级分类 -->
|
<!-- 二级分类 -->
|
||||||
<VChip
|
<VChip v-if="context?.media_info?.category" variant="elevated" class="me-1 mb-1 text-white bg-blue-500">
|
||||||
v-if="context?.media_info?.category"
|
|
||||||
variant="elevated"
|
|
||||||
class="me-1 mb-1 text-white bg-blue-500"
|
|
||||||
>
|
|
||||||
{{ context?.media_info?.category }}
|
{{ context?.media_info?.category }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<!-- TMDBID -->
|
<!-- TMDBID -->
|
||||||
@@ -98,18 +88,10 @@ function openTmdbPage(type: string, tmdbId: number) {
|
|||||||
{{ context?.media_info?.tmdb_id }}
|
{{ context?.media_info?.tmdb_id }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<!-- meta_info -->
|
<!-- meta_info -->
|
||||||
<VChip
|
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||||
v-if="context?.meta_info?.edition"
|
|
||||||
variant="elevated"
|
|
||||||
class="me-1 mb-1 text-white bg-red-500"
|
|
||||||
>
|
|
||||||
{{ context?.meta_info?.edition }}
|
{{ context?.meta_info?.edition }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="context?.meta_info?.resource_pix" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||||
v-if="context?.meta_info?.resource_pix"
|
|
||||||
variant="elevated"
|
|
||||||
class="me-1 mb-1 text-white bg-red-500"
|
|
||||||
>
|
|
||||||
{{ context?.meta_info?.resource_pix }}
|
{{ context?.meta_info?.resource_pix }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
@@ -126,36 +108,19 @@ function openTmdbPage(type: string, tmdbId: number) {
|
|||||||
>
|
>
|
||||||
{{ context?.meta_info?.audio_encode }}
|
{{ context?.meta_info?.audio_encode }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="context?.meta_info?.resource_team" variant="elevated" class="me-1 mb-1 text-white bg-cyan-500">
|
||||||
v-if="context?.meta_info?.resource_team"
|
|
||||||
variant="elevated"
|
|
||||||
class="me-1 mb-1 text-white bg-cyan-500"
|
|
||||||
>
|
|
||||||
{{ context?.meta_info?.resource_team }}
|
{{ context?.meta_info?.resource_team }}
|
||||||
</VChip>
|
</VChip>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VAlert
|
<VAlert v-if="!context?.meta_info?.name" icon="mdi-alert-circle-outline"> 识别失败,无法识别到有效信息! </VAlert>
|
||||||
v-if="!context?.meta_info?.name"
|
|
||||||
icon="mdi-alert-circle-outline"
|
|
||||||
>
|
|
||||||
识别失败,无法识别到有效信息!
|
|
||||||
</VAlert>
|
|
||||||
</VCol>
|
</VCol>
|
||||||
<VExpansionPanels
|
<VExpansionPanels v-show="!isNullOrEmptyObject(context?.meta_info.apply_words)">
|
||||||
v-show="context?.meta_info?.title !== context?.meta_info.org_string"
|
|
||||||
>
|
|
||||||
<VExpansionPanel>
|
<VExpansionPanel>
|
||||||
<VExpansionPanelTitle>
|
<VExpansionPanelTitle> 识别词应用详情 </VExpansionPanelTitle>
|
||||||
识别词应用详情
|
|
||||||
</VExpansionPanelTitle>
|
|
||||||
<VExpansionPanelText>
|
<VExpansionPanelText>
|
||||||
<VChip
|
<VChip variant="elevated" class="me-1 mb-1 break-all" color="primary">
|
||||||
variant="elevated"
|
|
||||||
class="me-1 mb-1 break-all"
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{{ context?.meta_info.org_string }}
|
{{ context?.meta_info.org_string }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import personIcon from '@images/misc/person-icon.png'
|
import personIcon from '@images/misc/person-icon.png'
|
||||||
import type { TmdbPerson } from '@/api/types'
|
import type { Person } from '@/api/types'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
|
||||||
const personProps = defineProps({
|
const personProps = defineProps({
|
||||||
person: Object as PropType<TmdbPerson>,
|
person: Object as PropType<Person>,
|
||||||
width: String,
|
width: String,
|
||||||
height: String,
|
height: String,
|
||||||
})
|
})
|
||||||
@@ -17,19 +17,47 @@ const isImageLoaded = ref(false)
|
|||||||
|
|
||||||
// 人物图片地址
|
// 人物图片地址
|
||||||
function getPersonImage() {
|
function getPersonImage() {
|
||||||
if (!personInfo.value?.profile_path)
|
if (personProps.person?.source === 'themoviedb') {
|
||||||
|
if (!personInfo.value?.profile_path) return personIcon
|
||||||
|
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
|
||||||
|
} else if (personProps.person?.source === 'douban') {
|
||||||
|
if (!personInfo.value?.avatar) return personIcon
|
||||||
|
if (typeof personInfo.value?.avatar === 'object') {
|
||||||
|
return personInfo.value?.avatar?.normal
|
||||||
|
} else {
|
||||||
|
return personInfo.value?.avatar
|
||||||
|
}
|
||||||
|
} else if (personProps.person?.source === 'bangumi') {
|
||||||
|
if (!personInfo.value?.images) return personIcon
|
||||||
|
return personInfo.value?.images?.medium
|
||||||
|
} else {
|
||||||
return personIcon
|
return personIcon
|
||||||
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 人物姓名
|
||||||
|
function getPersonName() {
|
||||||
|
return personInfo.value?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 人物角色
|
||||||
|
function getPersonCharacter() {
|
||||||
|
if (personProps.person?.source === 'bangumi') {
|
||||||
|
if (!personInfo.value?.career) return ''
|
||||||
|
return personInfo.value?.career.join('、')
|
||||||
|
} else {
|
||||||
|
return personInfo.value?.character
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 人物详情
|
// 人物详情
|
||||||
function goPersonDetail() {
|
function goPersonDetail() {
|
||||||
if (!personInfo.value?.id)
|
if (!personInfo.value?.id) return
|
||||||
return
|
|
||||||
router.push({
|
router.push({
|
||||||
path: '/person',
|
path: '/person',
|
||||||
query: {
|
query: {
|
||||||
personid: personInfo.value?.id,
|
personid: personInfo.value?.id,
|
||||||
|
source: personInfo.value?.source,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -49,9 +77,9 @@ function goPersonDetail() {
|
|||||||
@click.stop="goPersonDetail"
|
@click.stop="goPersonDetail"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="person-card relative transform-gpu cursor-pointer rounded shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700"
|
class="person-card relative transform-gpu cursor-pointer rounded shadow transition duration-150 ease-in-out scale-100 ring-gray-700"
|
||||||
>
|
>
|
||||||
<div style="padding-bottom: 150%;">
|
<div style="padding-block-end: 150%">
|
||||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||||
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
||||||
<VAvatar
|
<VAvatar
|
||||||
@@ -60,18 +88,17 @@ function goPersonDetail() {
|
|||||||
'ring-1 ring-gray-700': isImageLoaded,
|
'ring-1 ring-gray-700': isImageLoaded,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<VImg
|
<VImg :src="getPersonImage()" cover @load="isImageLoaded = true" />
|
||||||
:src="getPersonImage()"
|
|
||||||
cover
|
|
||||||
@load="isImageLoaded = true"
|
|
||||||
/>
|
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full truncate text-center font-bold">
|
<div class="w-full truncate text-center font-bold">
|
||||||
{{ personInfo?.name }}
|
{{ getPersonName() }}
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;">
|
<div
|
||||||
{{ personInfo?.character }}
|
class="overflow-hidden whitespace-normal text-center text-sm"
|
||||||
|
style="display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2"
|
||||||
|
>
|
||||||
|
{{ getPersonCharacter() }}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
|
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
|
||||||
</div>
|
</div>
|
||||||
@@ -6,6 +6,7 @@ import type { Plugin } from '@/api/types'
|
|||||||
import noImage from '@images/logos/plugin.png'
|
import noImage from '@images/logos/plugin.png'
|
||||||
import { getDominantColor } from '@/@core/utils/image'
|
import { getDominantColor } from '@/@core/utils/image'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
|
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -42,6 +43,12 @@ const imageLoadError = ref(false)
|
|||||||
// 更新日志弹窗
|
// 更新日志弹窗
|
||||||
const releaseDialog = ref(false)
|
const releaseDialog = ref(false)
|
||||||
|
|
||||||
|
// 计算插件标签
|
||||||
|
const pluginLabels = computed(() => {
|
||||||
|
if (!props.plugin?.plugin_label) return []
|
||||||
|
return props.plugin.plugin_label.split(',')
|
||||||
|
})
|
||||||
|
|
||||||
// 图片加载完成
|
// 图片加载完成
|
||||||
async function imageLoaded() {
|
async function imageLoaded() {
|
||||||
isImageLoaded.value = true
|
isImageLoaded.value = true
|
||||||
@@ -57,15 +64,12 @@ async function installPlugin() {
|
|||||||
progressDialog.value = true
|
progressDialog.value = true
|
||||||
progressText.value = `正在安装 ${props.plugin?.plugin_name} v${props?.plugin?.plugin_version} ...`
|
progressText.value = `正在安装 ${props.plugin?.plugin_name} v${props?.plugin?.plugin_version} ...`
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(
|
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
`plugin/install/${props.plugin?.id}`,
|
params: {
|
||||||
{
|
repo_url: props.plugin?.repo_url,
|
||||||
params: {
|
force: props.plugin?.has_update,
|
||||||
repo_url: props.plugin?.repo_url,
|
|
||||||
force: props.plugin?.has_update,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
|
|
||||||
// 隐藏等待提示框
|
// 隐藏等待提示框
|
||||||
progressDialog.value = false
|
progressDialog.value = false
|
||||||
@@ -75,20 +79,17 @@ async function installPlugin() {
|
|||||||
|
|
||||||
// 通知父组件刷新
|
// 通知父组件刷新
|
||||||
emit('install')
|
emit('install')
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}`)
|
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}`)
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算图标路径
|
// 计算图标路径
|
||||||
const iconPath: Ref<string> = computed(() => {
|
const iconPath: Ref<string> = computed(() => {
|
||||||
if (imageLoadError.value)
|
if (imageLoadError.value) return noImage
|
||||||
return noImage
|
|
||||||
// 如果是网络图片则使用代理后返回
|
// 如果是网络图片则使用代理后返回
|
||||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
|
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
|
||||||
@@ -102,22 +103,18 @@ function visitPluginPage() {
|
|||||||
let repoUrl = props.plugin?.repo_url
|
let repoUrl = props.plugin?.repo_url
|
||||||
if (repoUrl) {
|
if (repoUrl) {
|
||||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||||
if (!repoUrl.endsWith('/'))
|
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||||
repoUrl += '/'
|
|
||||||
|
|
||||||
if (repoUrl.split('/').length < 6)
|
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
||||||
repoUrl = `${repoUrl}main/`
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
||||||
repoUrl = `https://github.com/${user}/${repo}`
|
repoUrl = `https://github.com/${user}/${repo}`
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
repoUrl = props.plugin?.author_url
|
repoUrl = props.plugin?.author_url
|
||||||
}
|
}
|
||||||
window.open(repoUrl, '_blank')
|
window.open(repoUrl, '_blank')
|
||||||
@@ -138,7 +135,8 @@ const dropdownItems = ref([
|
|||||||
prependIcon: 'mdi-github',
|
prependIcon: 'mdi-github',
|
||||||
click: visitPluginPage,
|
click: visitPluginPage,
|
||||||
},
|
},
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
title: '更新说明',
|
title: '更新说明',
|
||||||
value: 2,
|
value: 2,
|
||||||
show: !isNullOrEmptyObject(props.plugin?.history || {}),
|
show: !isNullOrEmptyObject(props.plugin?.history || {}),
|
||||||
@@ -151,68 +149,63 @@ const dropdownItems = ref([
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard
|
<VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
|
||||||
:width="props.width"
|
<div class="me-n3 absolute bottom-0 right-3">
|
||||||
:height="props.height"
|
<IconBtn>
|
||||||
@click="installPlugin"
|
<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
|
<div
|
||||||
class="relative pa-4 text-center card-cover-blurred"
|
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||||
:style="{ background: `${backgroundColor}` }"
|
:style="{ background: `${backgroundColor}` }"
|
||||||
>
|
>
|
||||||
<div class="me-n3 absolute top-0 right-3">
|
<div
|
||||||
<IconBtn>
|
class="absolute inset-0 bg-cover bg-center"
|
||||||
<VIcon icon="mdi-dots-vertical" class="text-white" />
|
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
|
||||||
<VMenu
|
></div>
|
||||||
activator="parent"
|
<div class="relative flex-1 min-w-0">
|
||||||
close-on-content-click
|
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
|
||||||
>
|
{{ props.plugin?.plugin_name }}
|
||||||
<VList>
|
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||||
<VListItem
|
</VCardTitle>
|
||||||
v-for="(item, i) in dropdownItems"
|
<VCardText class="text-white px-2 py-1 text-shadow line-clamp-3">
|
||||||
v-show="item.show"
|
{{ props.plugin?.plugin_desc }}
|
||||||
:key="i"
|
</VCardText>
|
||||||
variant="plain"
|
</div>
|
||||||
@click="item.props.click"
|
<div class="relative flex-shrink-0 self-center">
|
||||||
>
|
<VAvatar size="64">
|
||||||
<template #prepend>
|
<VImg
|
||||||
<VIcon :icon="item.props.prependIcon" />
|
ref="imageRef"
|
||||||
</template>
|
:src="iconPath"
|
||||||
<VListItemTitle v-text="item.title" />
|
aspect-ratio="4/3"
|
||||||
</VListItem>
|
cover
|
||||||
</VList>
|
:class="{ shadow: isImageLoaded }"
|
||||||
</VMenu>
|
@load="imageLoaded"
|
||||||
</IconBtn>
|
@error="imageLoadError = true"
|
||||||
|
/>
|
||||||
|
</VAvatar>
|
||||||
</div>
|
</div>
|
||||||
<VAvatar
|
|
||||||
size="8rem"
|
|
||||||
>
|
|
||||||
<VImg
|
|
||||||
ref="imageRef"
|
|
||||||
:src="iconPath"
|
|
||||||
aspect-ratio="4/3"
|
|
||||||
cover
|
|
||||||
:class="{ shadow: isImageLoaded }"
|
|
||||||
@load="imageLoaded"
|
|
||||||
@error="imageLoadError = true"
|
|
||||||
/>
|
|
||||||
</VAvatar>
|
|
||||||
</div>
|
</div>
|
||||||
<VCardTitle>
|
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||||
{{ props.plugin?.plugin_name }}
|
|
||||||
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span>
|
|
||||||
</VCardTitle>
|
|
||||||
<VCardText class="pb-2">
|
|
||||||
{{ props.plugin?.plugin_desc }}
|
|
||||||
</VCardText>
|
|
||||||
<VCardText class="flex items-center justify-start pb-2">
|
|
||||||
<span>
|
<span>
|
||||||
<VIcon icon="mdi-account" class="me-1" />
|
<VIcon icon="mdi-github" class="me-1" />
|
||||||
<a
|
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||||
:href="props.plugin?.author_url"
|
|
||||||
target="_blank"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
{{ props.plugin?.plugin_author }}
|
{{ props.plugin?.plugin_author }}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
@@ -223,47 +216,13 @@ const dropdownItems = ref([
|
|||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
<!-- 安装插件进度框 -->
|
<!-- 安装插件进度框 -->
|
||||||
<VDialog
|
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||||
v-model="progressDialog"
|
|
||||||
:scrim="false"
|
|
||||||
width="25rem"
|
|
||||||
>
|
|
||||||
<VCard
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
<VCardText class="text-center">
|
|
||||||
{{ progressText }}
|
|
||||||
<VProgressLinear
|
|
||||||
indeterminate
|
|
||||||
color="white"
|
|
||||||
class="mb-0 mt-1"
|
|
||||||
/>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
<!-- 更新日志 -->
|
<!-- 更新日志 -->
|
||||||
<VDialog
|
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||||
v-if="releaseDialog"
|
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
|
||||||
v-model="releaseDialog"
|
|
||||||
width="600"
|
|
||||||
scrollable
|
|
||||||
>
|
|
||||||
<VCard>
|
|
||||||
<DialogCloseBtn @click="releaseDialog = false" />
|
<DialogCloseBtn @click="releaseDialog = false" />
|
||||||
<VCardTitle>{{ props.plugin?.plugin_name }} 更新说明</VCardTitle>
|
<VDivider />
|
||||||
<VersionHistory :history="props.plugin?.history" />
|
<VersionHistory :history="props.plugin?.history" />
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.card-cover-blurred::before {
|
|
||||||
position: absolute;
|
|
||||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
|
||||||
-webkit-backdrop-filter: blur(2px);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
background: rgba(29, 39, 59, 48%);
|
|
||||||
content: "";
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import noImage from '@images/logos/plugin.png'
|
|||||||
import { getDominantColor } from '@/@core/utils/image'
|
import { getDominantColor } from '@/@core/utils/image'
|
||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -107,14 +108,6 @@ async function uninstallPlugin() {
|
|||||||
const isConfirmed = await createConfirm({
|
const isConfirmed = await createConfirm({
|
||||||
title: '确认',
|
title: '确认',
|
||||||
content: `是否确认卸载插件 ${props.plugin?.plugin_name} ?`,
|
content: `是否确认卸载插件 ${props.plugin?.plugin_name} ?`,
|
||||||
confirmationText: '确认',
|
|
||||||
cancellationText: '取消',
|
|
||||||
dialogProps: {
|
|
||||||
maxWidth: '50rem',
|
|
||||||
},
|
|
||||||
confirmationButtonProps: {
|
|
||||||
variant: 'tonal',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isConfirmed) return
|
if (!isConfirmed) return
|
||||||
@@ -228,14 +221,6 @@ async function resetPlugin() {
|
|||||||
const isConfirmed = await createConfirm({
|
const isConfirmed = await createConfirm({
|
||||||
title: '确认',
|
title: '确认',
|
||||||
content: `是否确认重置插件 ${props.plugin?.plugin_name} 的配置数据?`,
|
content: `是否确认重置插件 ${props.plugin?.plugin_name} 的配置数据?`,
|
||||||
confirmationText: '确认',
|
|
||||||
cancellationText: '取消',
|
|
||||||
dialogProps: {
|
|
||||||
maxWidth: '50rem',
|
|
||||||
},
|
|
||||||
confirmationButtonProps: {
|
|
||||||
variant: 'tonal',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isConfirmed) return
|
if (!isConfirmed) return
|
||||||
@@ -380,81 +365,109 @@ const dropdownItems = ref([
|
|||||||
// 监听插件状态变化
|
// 监听插件状态变化
|
||||||
watch(
|
watch(
|
||||||
() => props.plugin?.has_update,
|
() => props.plugin?.has_update,
|
||||||
(newHasUpdate, oldHasUpdate) => {
|
(newHasUpdate, _) => {
|
||||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
||||||
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
|
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听插件窗口状态变化
|
||||||
|
watch(
|
||||||
|
() => props.plugin?.page_open,
|
||||||
|
(newOpenState, _) => {
|
||||||
|
if (newOpenState) openPluginDetail()
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 插件卡片 -->
|
<!-- 插件卡片 -->
|
||||||
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail">
|
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
|
||||||
<div class="relative pa-4 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
|
<div class="me-n3 absolute bottom-0 right-3">
|
||||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 left-1">
|
<IconBtn>
|
||||||
<VIcon icon="mdi-new-box" class="text-white" />
|
<VIcon icon="mdi-dots-vertical" />
|
||||||
</div>
|
<VMenu activator="parent" close-on-content-click>
|
||||||
<div class="me-n3 absolute top-0 right-3">
|
<VList>
|
||||||
<IconBtn>
|
<VListItem
|
||||||
<VIcon icon="mdi-dots-vertical" class="text-white" />
|
v-for="(item, i) in dropdownItems"
|
||||||
<VMenu activator="parent" close-on-content-click>
|
v-show="item.show"
|
||||||
<VList>
|
:key="i"
|
||||||
<VListItem
|
variant="plain"
|
||||||
v-for="(item, i) in dropdownItems"
|
:base-color="item.props.color"
|
||||||
v-show="item.show"
|
@click="item.props.click"
|
||||||
:key="i"
|
>
|
||||||
variant="plain"
|
<template #prepend>
|
||||||
:base-color="item.props.color"
|
<VIcon :icon="item.props.prependIcon" />
|
||||||
@click="item.props.click"
|
</template>
|
||||||
>
|
<VListItemTitle v-text="item.title" />
|
||||||
<template #prepend>
|
</VListItem>
|
||||||
<VIcon :icon="item.props.prependIcon" />
|
</VList>
|
||||||
</template>
|
</VMenu>
|
||||||
<VListItemTitle v-text="item.title" />
|
</IconBtn>
|
||||||
</VListItem>
|
|
||||||
</VList>
|
|
||||||
</VMenu>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
<VAvatar size="8rem">
|
|
||||||
<VImg
|
|
||||||
ref="imageRef"
|
|
||||||
:src="iconPath"
|
|
||||||
aspect-ratio="4/3"
|
|
||||||
cover
|
|
||||||
:class="{ shadow: isImageLoaded }"
|
|
||||||
@load="imageLoaded"
|
|
||||||
@error="imageLoadError = true"
|
|
||||||
/>
|
|
||||||
</VAvatar>
|
|
||||||
</div>
|
</div>
|
||||||
<span v-if="props.count" class="absolute bottom-1 right-2 flex items-center">
|
<div
|
||||||
<VIcon icon="mdi-fire" />
|
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||||
<span class="text-sm ms-1">{{ props.count?.toLocaleString() }}</span>
|
:style="{ background: `${backgroundColor}` }"
|
||||||
</span>
|
>
|
||||||
<VCardItem class="py-2">
|
<div
|
||||||
<VCardTitle class="flex items-center flex-row">
|
class="absolute inset-0 bg-cover bg-center"
|
||||||
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" />
|
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
|
||||||
{{ props.plugin?.plugin_name
|
/>
|
||||||
}}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span>
|
<div class="relative flex-1 min-w-0">
|
||||||
</VCardTitle>
|
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
|
||||||
</VCardItem>
|
<VBadge v-if="props.plugin?.state" dot inline color="success" />
|
||||||
<VCardText>
|
{{ props.plugin?.plugin_name }}
|
||||||
{{ props.plugin?.plugin_desc }}
|
<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>
|
||||||
|
<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>
|
</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>
|
</VCard>
|
||||||
|
|
||||||
<!-- 插件配置页面 -->
|
<!-- 插件配置页面 -->
|
||||||
<VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
|
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
|
||||||
<DialogCloseBtn v-model="pluginConfigDialog" />
|
<DialogCloseBtn v-model="pluginConfigDialog" />
|
||||||
|
<VDivider />
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
|
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="pt-3">
|
||||||
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo"> 查看数据 </VBtn>
|
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
|
||||||
|
查看数据
|
||||||
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn variant="tonal" @click="savePluginConf"> 保存 </VBtn>
|
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
@@ -463,33 +476,23 @@ watch(
|
|||||||
<VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
|
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
|
||||||
<DialogCloseBtn v-model="pluginInfoDialog" />
|
<DialogCloseBtn v-model="pluginInfoDialog" />
|
||||||
<VCardText>
|
<VCardText class="min-h-40">
|
||||||
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VFab icon="mdi-cog" location="bottom" size="x-large" fixed app appear @click="showPluginConfig" />
|
||||||
<VBtn @click="showPluginConfig"> 配置 </VBtn>
|
|
||||||
<VSpacer />
|
|
||||||
<VBtn variant="tonal" @click="pluginInfoDialog = false"> 关闭 </VBtn>
|
|
||||||
</VCardActions>
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|
||||||
<!-- 更新插件进度框 -->
|
<!-- 进度框 -->
|
||||||
<VDialog v-model="progressDialog" :scrim="false" width="25rem">
|
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||||
<VCard color="primary">
|
|
||||||
<VCardText class="text-center">
|
|
||||||
{{ progressText }}
|
|
||||||
<VProgressLinear indeterminate color="white" class="mb-0 mt-1" />
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
|
|
||||||
<!-- 更新日志 -->
|
<!-- 更新日志 -->
|
||||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||||
<VCard>
|
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
|
||||||
<DialogCloseBtn @click="releaseDialog = false" />
|
<DialogCloseBtn @click="releaseDialog = false" />
|
||||||
<VCardTitle>{{ props.plugin?.plugin_name }} 更新说明</VCardTitle>
|
<VDivider />
|
||||||
<VersionHistory :history="props.plugin?.history" />
|
<VersionHistory :history="props.plugin?.history" />
|
||||||
|
<VDivider />
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VBtn @click="updatePlugin" block>
|
<VBtn @click="updatePlugin" block>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
|
|||||||
@@ -18,26 +18,21 @@ const imageLoadError = ref(false)
|
|||||||
|
|
||||||
// 角标颜色
|
// 角标颜色
|
||||||
function getChipColor(type: string) {
|
function getChipColor(type: string) {
|
||||||
if (type === '电影')
|
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||||
return 'border-blue-500 bg-blue-600'
|
else if (type === '电视剧') return ' bg-indigo-500 border-indigo-600'
|
||||||
else if (type === '电视剧')
|
else return 'border-purple-600 bg-purple-600'
|
||||||
return ' bg-indigo-500 border-indigo-600'
|
|
||||||
else
|
|
||||||
return 'border-purple-600 bg-purple-600'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算图片地址
|
// 计算图片地址
|
||||||
const getImgUrl = computed(() => {
|
const getImgUrl = computed(() => {
|
||||||
if (imageLoadError.value)
|
if (imageLoadError.value) return noImage
|
||||||
return noImage
|
|
||||||
const image = props.media?.image || ''
|
const image = props.media?.image || ''
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 跳转播放
|
// 跳转播放
|
||||||
function goPlay(isHovering = false) {
|
function goPlay(isHovering = false) {
|
||||||
if (props.media?.link && isHovering)
|
if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
|
||||||
window.open(props.media?.link, '_blank')
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -72,24 +67,24 @@ function goPlay(isHovering = false) {
|
|||||||
</VImg>
|
</VImg>
|
||||||
<!-- 类型角标 -->
|
<!-- 类型角标 -->
|
||||||
<VChip
|
<VChip
|
||||||
v-show="isImageLoaded"
|
v-show="isImageLoaded"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
size="small"
|
size="small"
|
||||||
:class="getChipColor(props.media?.type || '')"
|
:class="getChipColor(props.media?.type || '')"
|
||||||
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||||
>
|
>
|
||||||
{{ props.media?.type }}
|
{{ props.media?.type }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<!-- 详情 -->
|
<!-- 详情 -->
|
||||||
<VCardText
|
<VCardText
|
||||||
v-show="hover.isHovering || imageLoadError"
|
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 flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||||
>
|
>
|
||||||
<span class="font-bold">{{ props.media?.subtitle }}</span>
|
<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 ...">
|
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||||
{{ props.media?.title }}
|
{{ props.media?.title }}
|
||||||
</h1>
|
</h1>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import SiteTorrentTable from '../table/SiteTorrentTable.vue'
|
|||||||
import { requiredValidator } from '@/@validators'
|
import { requiredValidator } from '@/@validators'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Site, SiteStatistic } from '@/api/types'
|
import type { Site, SiteStatistic } from '@/api/types'
|
||||||
import ExistIcon from '@core/components/ExistIcon.vue'
|
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -167,6 +167,12 @@ watch(resourceDialog, value => {
|
|||||||
if (!value) getSiteStats()
|
if (!value) getSiteStats()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 保存站点
|
||||||
|
function saveSite() {
|
||||||
|
siteEditDialog.value = false
|
||||||
|
emit('update')
|
||||||
|
}
|
||||||
|
|
||||||
// 装载时查询站点图标
|
// 装载时查询站点图标
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getSiteIcon()
|
getSiteIcon()
|
||||||
@@ -175,155 +181,142 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard
|
<div>
|
||||||
:height="cardProps.height"
|
<VCard
|
||||||
:width="cardProps.width"
|
:height="cardProps.height"
|
||||||
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
|
:width="cardProps.width"
|
||||||
class="overflow-hidden"
|
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
|
||||||
@click="siteEditDialog = true"
|
class="overflow-hidden"
|
||||||
>
|
@click="siteEditDialog = true"
|
||||||
<template #image>
|
>
|
||||||
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
<template #image>
|
||||||
<VImg :src="siteIcon" />
|
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
||||||
</VAvatar>
|
<VImg :src="siteIcon" />
|
||||||
</template>
|
</VAvatar>
|
||||||
|
</template>
|
||||||
<VCardItem>
|
<VCardItem style="padding-block-end: 0;">
|
||||||
<VCardTitle class="font-bold">
|
<VCardTitle class="font-bold">
|
||||||
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
|
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
<VCardSubtitle>
|
<VCardSubtitle>
|
||||||
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
|
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
|
||||||
</VCardSubtitle>
|
</VCardSubtitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
<VCardText class="py-2" style="block-size: 36px;">
|
||||||
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
|
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
|
||||||
|
<template #activator="{ props }">
|
||||||
<VCardText class="py-2">
|
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
|
||||||
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
|
</template>
|
||||||
<template #activator="{ props }">
|
</VTooltip>
|
||||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
|
<VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
|
||||||
</template>
|
<template #activator="{ props }">
|
||||||
</VTooltip>
|
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
|
||||||
|
</template>
|
||||||
<VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
|
</VTooltip>
|
||||||
<template #activator="{ props }">
|
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
|
||||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
|
<template #activator="{ props }">
|
||||||
</template>
|
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
|
||||||
</VTooltip>
|
</template>
|
||||||
|
</VTooltip>
|
||||||
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
|
<VTooltip v-if="cardProps.site?.filter" text="过滤">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
|
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-filter-cog-outline" />
|
||||||
</template>
|
</template>
|
||||||
</VTooltip>
|
</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>
|
|
||||||
|
|
||||||
<VDivider class="opacity-75" style="border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity))" />
|
|
||||||
|
|
||||||
<VCardActions>
|
|
||||||
<VBtn v-if="!cardProps.site?.public" :disabled="updateButtonDisable" @click.stop="handleSiteUpdate">
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon icon="mdi-refresh" />
|
|
||||||
</template>
|
|
||||||
更新
|
|
||||||
</VBtn>
|
|
||||||
<VBtn :disabled="testButtonDisable" @click.stop="testSite">
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon icon="mdi-link" />
|
|
||||||
</template>
|
|
||||||
{{ testButtonText }}
|
|
||||||
</VBtn>
|
|
||||||
<VBtn @click.stop="handleResourceBrowse">
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon icon="mdi-web" />
|
|
||||||
</template>
|
|
||||||
浏览
|
|
||||||
</VBtn>
|
|
||||||
</VCardActions>
|
|
||||||
</VCard>
|
|
||||||
<!-- 更新站点Cookie & UA弹窗 -->
|
|
||||||
<VDialog v-model="siteCookieDialog" max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
|
||||||
<!-- Dialog Content -->
|
|
||||||
<VCard title="更新站点Cookie & UA">
|
|
||||||
<DialogCloseBtn @click="siteCookieDialog = false" />
|
|
||||||
<VCardText>
|
|
||||||
<VForm @submit.prevent="() => {}">
|
|
||||||
<VRow>
|
|
||||||
<VCol cols="12" md="4">
|
|
||||||
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
|
|
||||||
</VCol>
|
|
||||||
<VCol cols="12" md="4">
|
|
||||||
<VTextField
|
|
||||||
v-model="userPwForm.password"
|
|
||||||
label="密码"
|
|
||||||
:type="isPasswordVisible ? 'text' : 'password'"
|
|
||||||
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
|
||||||
:rules="[requiredValidator]"
|
|
||||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
|
||||||
@keydown.enter="updateSiteCookie"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
<VCol cols="12" md="4">
|
|
||||||
<VTextField v-model="userPwForm.code" label="两步验证" />
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
</VForm>
|
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
<VDivider />
|
||||||
<VCardActions>
|
<VCardActions>
|
||||||
<VSpacer />
|
<VBtn v-if="!cardProps.site?.public" :disabled="updateButtonDisable" @click.stop="handleSiteUpdate">
|
||||||
<VBtn variant="tonal" @click="updateSiteCookie"> 开始更新 </VBtn>
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-refresh" />
|
||||||
|
</template>
|
||||||
|
更新
|
||||||
|
</VBtn>
|
||||||
|
<VBtn :disabled="testButtonDisable" @click.stop="testSite">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-link" />
|
||||||
|
</template>
|
||||||
|
{{ testButtonText }}
|
||||||
|
</VBtn>
|
||||||
|
<VBtn @click.stop="handleResourceBrowse">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-web" />
|
||||||
|
</template>
|
||||||
|
浏览
|
||||||
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
|
||||||
|
<span class="absolute top-1 right-8">
|
||||||
|
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||||
|
</span>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
<!-- 更新站点Cookie & UA弹窗 -->
|
||||||
<SiteAddEditDialog
|
<VDialog v-model="siteCookieDialog" max-width="50rem">
|
||||||
v-if="siteEditDialog"
|
<!-- Dialog Content -->
|
||||||
v-model="siteEditDialog"
|
<VCard title="更新站点Cookie & UA">
|
||||||
:siteid="cardProps.site?.id"
|
<DialogCloseBtn @click="siteCookieDialog = false" />
|
||||||
@save="
|
<VCardText>
|
||||||
() => {
|
<VForm @submit.prevent="() => {}">
|
||||||
siteEditDialog = false
|
<VRow>
|
||||||
emit('update')
|
<VCol cols="12" md="4">
|
||||||
}
|
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
|
||||||
"
|
</VCol>
|
||||||
@remove="emit('remove')"
|
<VCol cols="12" md="4">
|
||||||
@close="siteEditDialog = false"
|
<VTextField
|
||||||
/>
|
v-model="userPwForm.password"
|
||||||
<!-- 站点资源弹窗 -->
|
label="密码"
|
||||||
<VDialog
|
:type="isPasswordVisible ? 'text' : 'password'"
|
||||||
v-if="resourceDialog"
|
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||||
v-model="resourceDialog"
|
:rules="[requiredValidator]"
|
||||||
max-width="80rem"
|
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||||
scrollable
|
@keydown.enter="updateSiteCookie"
|
||||||
z-index="1010"
|
/>
|
||||||
:fullscreen="!display.mdAndUp.value"
|
</VCol>
|
||||||
>
|
<VCol cols="12" md="4">
|
||||||
<!-- Dialog Content -->
|
<VTextField v-model="userPwForm.code" label="两步验证" />
|
||||||
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
|
</VCol>
|
||||||
<DialogCloseBtn @click="resourceDialog = false" />
|
</VRow>
|
||||||
<VCardText class="pt-2">
|
</VForm>
|
||||||
<SiteTorrentTable :site="cardProps.site?.id" />
|
</VCardText>
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
<VCardActions>
|
||||||
</VDialog>
|
<VSpacer />
|
||||||
<VDialog v-model="progressDialog" :scrim="false" width="25rem">
|
<VBtn variant="elevated" @click="updateSiteCookie" prepend-icon="mdi-refresh" class="px-5"> 开始更新 </VBtn>
|
||||||
<VCard color="primary">
|
</VCardActions>
|
||||||
<VCardText class="text-center">
|
</VCard>
|
||||||
{{ progressText }}
|
</VDialog>
|
||||||
<VProgressLinear indeterminate color="white" class="mb-0 mt-1" />
|
<!-- 站点编辑弹窗 -->
|
||||||
</VCardText>
|
<SiteAddEditDialog
|
||||||
</VCard>
|
v-if="siteEditDialog"
|
||||||
</VDialog>
|
v-model="siteEditDialog"
|
||||||
|
:siteid="cardProps.site?.id"
|
||||||
|
@save="saveSite"
|
||||||
|
@remove="emit('remove')"
|
||||||
|
@close="siteEditDialog = false"
|
||||||
|
/>
|
||||||
|
<!-- 站点资源弹窗 -->
|
||||||
|
<VDialog
|
||||||
|
v-if="resourceDialog"
|
||||||
|
v-model="resourceDialog"
|
||||||
|
max-width="80rem"
|
||||||
|
scrollable
|
||||||
|
z-index="1010"
|
||||||
|
:fullscreen="!display.mdAndUp.value"
|
||||||
|
>
|
||||||
|
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
|
||||||
|
<DialogCloseBtn @click="resourceDialog = false" />
|
||||||
|
<VDivider />
|
||||||
|
<VCardText class="pt-2">
|
||||||
|
<SiteTorrentTable :site="cardProps.site?.id" />
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
<!-- 进度框 -->
|
||||||
|
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" scoped>
|
||||||
.v-table th {
|
.v-table th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang='ts' setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toast-notification'
|
import { useToast } from 'vue-toast-notification'
|
||||||
|
import { useConfirm } from 'vuetify-use-dialog'
|
||||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||||
import { formatSeason } from '@/@core/utils/formatters'
|
import { formatSeason } from '@/@core/utils/formatters'
|
||||||
@@ -15,6 +16,9 @@ const props = defineProps({
|
|||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['remove', 'save'])
|
const emit = defineEmits(['remove', 'save'])
|
||||||
|
|
||||||
|
// 确认框
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
@@ -25,11 +29,7 @@ const imageLoaded = ref(false)
|
|||||||
const subscribeEditDialog = ref(false)
|
const subscribeEditDialog = ref(false)
|
||||||
|
|
||||||
// 上一次更新时间
|
// 上一次更新时间
|
||||||
const lastUpdateText = ref(
|
const lastUpdateText = ref(props.media && props.media.last_update ? formatDateDifference(props.media.last_update) : '')
|
||||||
props.media && props.media.last_update
|
|
||||||
? formatDateDifference(props.media.last_update)
|
|
||||||
: '',
|
|
||||||
)
|
|
||||||
|
|
||||||
// 图片加载完成响应
|
// 图片加载完成响应
|
||||||
function imageLoadHandler() {
|
function imageLoadHandler() {
|
||||||
@@ -38,49 +38,30 @@ function imageLoadHandler() {
|
|||||||
|
|
||||||
// 根据 type 返回不同的图标
|
// 根据 type 返回不同的图标
|
||||||
function getIcon() {
|
function getIcon() {
|
||||||
if (props.media?.type === '电影')
|
if (props.media?.type === '电影') return 'mdi-movie-open'
|
||||||
return 'mdi-movie'
|
else if (props.media?.type === '电视剧') return 'mdi-television-play'
|
||||||
else if (props.media?.type === '电视剧')
|
else return 'mdi-help-circle'
|
||||||
return 'mdi-television-classic'
|
|
||||||
else
|
|
||||||
return 'mdi-help-circle'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算百分比
|
// 计算百分比
|
||||||
function getPercentage() {
|
function getPercentage() {
|
||||||
if (props.media?.total_episode === 0)
|
if (props.media?.total_episode === 0) return 0
|
||||||
return 0
|
|
||||||
|
|
||||||
return Math.round(
|
return Math.round(
|
||||||
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0))
|
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0)) / (props.media?.total_episode ?? 1)) * 100,
|
||||||
/ (props.media?.total_episode ?? 1))
|
|
||||||
* 100,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算文本颜色
|
|
||||||
function getTextColor() {
|
|
||||||
return imageLoaded.value ? 'white' : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算文本类
|
|
||||||
function getTextClass() {
|
|
||||||
return imageLoaded.value ? 'text-white' : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除订阅
|
// 删除订阅
|
||||||
async function removeSubscribe() {
|
async function removeSubscribe() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.delete(
|
const result: { [key: string]: any } = await api.delete(`subscribe/${props.media?.id}`)
|
||||||
`subscribe/${props.media?.id}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 通知父组件刷新
|
// 通知父组件刷新
|
||||||
emit('remove')
|
emit('remove')
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,15 +69,32 @@ async function removeSubscribe() {
|
|||||||
// 搜索订阅
|
// 搜索订阅
|
||||||
async function searchSubscribe() {
|
async function searchSubscribe() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get(
|
const result: { [key: string]: any } = await api.get(`subscribe/search/${props.media?.id}`)
|
||||||
`subscribe/search/${props.media?.id}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 提示
|
// 提示
|
||||||
if (result.success)
|
if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
|
||||||
$toast.success(`${props.media?.name} 提交搜索请求成功!`)
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
}
|
}
|
||||||
catch (e) {
|
}
|
||||||
|
|
||||||
|
// 重置订阅
|
||||||
|
async function resetSubscribe() {
|
||||||
|
// 确认
|
||||||
|
try {
|
||||||
|
const isConfirmed = await createConfirm({
|
||||||
|
title: '确认',
|
||||||
|
content: `重置后 ${props.media?.name} 已下载记录将被清除,未入库的剧集将会重新下载,是否确认?`,
|
||||||
|
})
|
||||||
|
if (!isConfirmed) return
|
||||||
|
// 重置
|
||||||
|
const result: { [key: string]: any } = await api.get(`subscribe/reset/${props.media?.id}`)
|
||||||
|
// 提示
|
||||||
|
if (result.success) {
|
||||||
|
$toast.success(`${props.media?.name} 重置成功!`)
|
||||||
|
emit('save')
|
||||||
|
} else $toast.error(`${props.media?.name} 重置失败:${result.message}`)
|
||||||
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +104,17 @@ async function editSubscribeDialog() {
|
|||||||
subscribeEditDialog.value = true
|
subscribeEditDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
async function viewMediaDetail() {
|
||||||
|
router.push({
|
||||||
|
path: '/media',
|
||||||
|
query: {
|
||||||
|
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||||
|
type: props.media?.type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 弹出菜单
|
// 弹出菜单
|
||||||
const dropdownItems = ref([
|
const dropdownItems = ref([
|
||||||
{
|
{
|
||||||
@@ -129,24 +138,22 @@ const dropdownItems = ref([
|
|||||||
value: 3,
|
value: 3,
|
||||||
props: {
|
props: {
|
||||||
prependIcon: 'mdi-open-in-new',
|
prependIcon: 'mdi-open-in-new',
|
||||||
click: () => {
|
click: viewMediaDetail,
|
||||||
router.push({
|
|
||||||
path: '/media',
|
|
||||||
query: {
|
|
||||||
mediaid: `${
|
|
||||||
props.media?.tmdbid
|
|
||||||
? `tmdb:${props.media?.tmdbid}`
|
|
||||||
: `douban:${props.media?.doubanid}`
|
|
||||||
}`,
|
|
||||||
type: props.media?.type,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '取消订阅',
|
title: '重置',
|
||||||
value: 4,
|
value: 4,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-restore-alert',
|
||||||
|
click: resetSubscribe,
|
||||||
|
color: 'warning',
|
||||||
|
},
|
||||||
|
show: props.media?.type === '电视剧',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '取消订阅',
|
||||||
|
value: 5,
|
||||||
props: {
|
props: {
|
||||||
prependIcon: 'mdi-trash-can-outline',
|
prependIcon: 'mdi-trash-can-outline',
|
||||||
color: 'error',
|
color: 'error',
|
||||||
@@ -154,140 +161,144 @@ const dropdownItems = ref([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 监听插件窗口状态变化
|
||||||
|
watch(
|
||||||
|
() => props.media?.page_open,
|
||||||
|
(newOpenState, _) => {
|
||||||
|
if (newOpenState) editSubscribeDialog()
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard
|
<VHover>
|
||||||
:key="props.media?.id"
|
<template #default="hover">
|
||||||
:class="`${props.media?.best_version ? 'outline-dashed outline-1' : ''}`"
|
<VCard
|
||||||
@click="editSubscribeDialog"
|
v-bind="hover.props"
|
||||||
>
|
:key="props.media?.id"
|
||||||
<template #image>
|
class="flex flex-col rounded-lg"
|
||||||
<VImg
|
:class="{
|
||||||
:src="props.media?.backdrop || props.media?.poster"
|
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||||
aspect-ratio="2/3"
|
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||||
cover
|
}"
|
||||||
class="brightness-50"
|
min-height="170"
|
||||||
@load="imageLoadHandler"
|
@click="editSubscribeDialog"
|
||||||
/>
|
>
|
||||||
</template>
|
<div class="me-n3 absolute top-1 right-2">
|
||||||
<VCardItem>
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon
|
|
||||||
size="1.9rem"
|
|
||||||
:color="getTextColor()"
|
|
||||||
:icon="getIcon()"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<VCardTitle :class="getTextClass()">
|
|
||||||
{{ props.media?.name }}
|
|
||||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
|
||||||
</VCardTitle>
|
|
||||||
<template #append>
|
|
||||||
<div class="me-n3">
|
|
||||||
<IconBtn>
|
<IconBtn>
|
||||||
<VIcon
|
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||||
icon="mdi-dots-vertical"
|
<VMenu activator="parent" close-on-content-click>
|
||||||
:color="getTextColor()"
|
|
||||||
/>
|
|
||||||
<VMenu
|
|
||||||
activator="parent"
|
|
||||||
close-on-content-click
|
|
||||||
>
|
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||||
v-for="(item, i) in dropdownItems"
|
<VListItem
|
||||||
:key="i"
|
v-if="item.show !== false"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
:base-color="item.props.color"
|
:base-color="item.props.color"
|
||||||
@click="item.props.click"
|
@click="item.props.click"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon :icon="item.props.prependIcon" />
|
<VIcon :icon="item.props.prependIcon" />
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle v-text="item.title" />
|
<VListItemTitle v-text="item.title" />
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
</template>
|
||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<template #image>
|
||||||
</VCardItem>
|
<VImg
|
||||||
|
:src="props.media?.backdrop || props.media?.poster"
|
||||||
<VCardText>
|
aspect-ratio="3/2"
|
||||||
<p
|
cover
|
||||||
class="clamp-text mb-0"
|
@load="imageLoadHandler"
|
||||||
:class="getTextClass()"
|
position="top"
|
||||||
>
|
>
|
||||||
{{ props.media?.description }}
|
<template #placeholder>
|
||||||
</p>
|
<div class="w-full h-full">
|
||||||
</VCardText>
|
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||||
|
</div>
|
||||||
<VCardText class="d-flex justify-space-between align-center flex-wrap">
|
</template>
|
||||||
<div class="d-flex align-center">
|
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||||
<IconBtn
|
</VImg>
|
||||||
icon="mdi-star"
|
</template>
|
||||||
:color="getTextColor()"
|
<div>
|
||||||
class="me-1"
|
<VCardText class="flex items-center">
|
||||||
/>
|
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||||
<span
|
<VImg :src="props.media?.poster" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||||
class="text-subtitle-2 me-4"
|
<template #placeholder>
|
||||||
:class="getTextClass()"
|
<div class="w-full h-full">
|
||||||
>{{
|
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||||
props.media?.vote
|
</div>
|
||||||
}}</span>
|
</template>
|
||||||
<IconBtn
|
</VImg>
|
||||||
v-if="props.media?.total_episode"
|
</div>
|
||||||
v-bind="props"
|
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||||
icon="mdi-progress-clock"
|
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||||
:color="getTextColor()"
|
<div class="mr-2 min-w-0 text-lg font-bold text-white">
|
||||||
class="me-1"
|
{{ props.media?.name }}
|
||||||
/>
|
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||||
<span
|
</div>
|
||||||
v-if="props.media?.season"
|
</div>
|
||||||
class="text-subtitle-2 me-4"
|
</VCardText>
|
||||||
:class="getTextClass()"
|
<VCardText class="flex justify-space-between align-center flex-wrap">
|
||||||
>{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
<div class="flex align-center">
|
||||||
{{ props.media?.total_episode }}</span>
|
<IconBtn
|
||||||
<IconBtn
|
v-if="props.media?.total_episode"
|
||||||
v-if="props.media?.username"
|
v-bind="props"
|
||||||
icon="mdi-account"
|
icon="mdi-progress-download"
|
||||||
:color="getTextColor()"
|
color="white"
|
||||||
class="me-1"
|
class="me-1"
|
||||||
/>
|
/>
|
||||||
<span
|
<div v-if="props.media?.season" class="text-subtitle-2 me-4 text-white">
|
||||||
v-if="props.media?.username"
|
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||||
class="text-subtitle-2 me-4"
|
{{ props.media?.total_episode }}
|
||||||
:class="getTextClass()"
|
</div>
|
||||||
>
|
<IconBtn v-if="props.media?.username" icon="mdi-account" color="white" class="me-1" />
|
||||||
{{ props.media?.username }}
|
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
|
||||||
</span>
|
{{ props.media?.username }}
|
||||||
</div>
|
</span>
|
||||||
</VCardText>
|
</div>
|
||||||
<VCardText
|
</VCardText>
|
||||||
v-if="lastUpdateText"
|
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300"
|
<VIcon icon="mdi-download" class="me-1" />
|
||||||
>
|
{{ lastUpdateText }}
|
||||||
<VIcon
|
</VCardText>
|
||||||
icon="mdi-download"
|
<div class="w-full absolute bottom-0">
|
||||||
class="me-1"
|
<VProgressLinear
|
||||||
/>
|
v-if="getPercentage() > 0"
|
||||||
{{ lastUpdateText }}
|
:model-value="getPercentage()"
|
||||||
</VCardText>
|
bg-color="success"
|
||||||
<VProgressLinear
|
color="success"
|
||||||
v-if="getPercentage() > 0"
|
/>
|
||||||
:model-value="getPercentage()"
|
</div>
|
||||||
bg-color="success"
|
</div>
|
||||||
color="success"
|
</VCard>
|
||||||
/>
|
</template>
|
||||||
</VCard>
|
</VHover>
|
||||||
<!-- 订阅编辑弹窗 -->
|
<!-- 订阅编辑弹窗 -->
|
||||||
<SubscribeEditDialog
|
<SubscribeEditDialog
|
||||||
v-if="subscribeEditDialog"
|
v-if="subscribeEditDialog"
|
||||||
v-model="subscribeEditDialog"
|
v-model="subscribeEditDialog"
|
||||||
:subid="props.media?.id"
|
:subid="props.media?.id"
|
||||||
@remove="() => { emit('remove');subscribeEditDialog = false; }"
|
@remove="
|
||||||
@save="() => { emit('save');subscribeEditDialog = false; }"
|
() => {
|
||||||
|
emit('remove')
|
||||||
|
subscribeEditDialog = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@save="
|
||||||
|
() => {
|
||||||
|
emit('save')
|
||||||
|
subscribeEditDialog = false
|
||||||
|
}
|
||||||
|
"
|
||||||
@close="subscribeEditDialog = false"
|
@close="subscribeEditDialog = false"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<style lang="scss">
|
||||||
|
.subscribe-card-background {
|
||||||
|
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -43,16 +43,13 @@ const downloaded = ref<String[]>([])
|
|||||||
async function getSiteIcon() {
|
async function getSiteIcon() {
|
||||||
try {
|
try {
|
||||||
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
|
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 询问并添加下载
|
// 询问并添加下载
|
||||||
async function handleAddDownload(_site: any = undefined,
|
async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
|
||||||
_media: any = undefined,
|
|
||||||
_torrent: any = undefined) {
|
|
||||||
if (!_media || !_torrent || !_site) {
|
if (!_media || !_torrent || !_site) {
|
||||||
_site = torrent.value?.site_name
|
_site = torrent.value?.site_name
|
||||||
_media = media.value
|
_media = media.value
|
||||||
@@ -62,18 +59,9 @@ async function handleAddDownload(_site: any = undefined,
|
|||||||
const isConfirmed = await createConfirm({
|
const isConfirmed = await createConfirm({
|
||||||
title: '确认',
|
title: '确认',
|
||||||
content: `是否确认下载【${_site}】${_torrent?.title} ?`,
|
content: `是否确认下载【${_site}】${_torrent?.title} ?`,
|
||||||
confirmationText: '确认',
|
|
||||||
cancellationText: '取消',
|
|
||||||
dialogProps: {
|
|
||||||
maxWidth: '50rem',
|
|
||||||
},
|
|
||||||
confirmationButtonProps: {
|
|
||||||
variant: 'tonal',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isConfirmed)
|
if (!isConfirmed) return
|
||||||
return
|
|
||||||
|
|
||||||
addDownload(_media, _torrent)
|
addDownload(_media, _torrent)
|
||||||
}
|
}
|
||||||
@@ -82,22 +70,26 @@ async function handleAddDownload(_site: any = undefined,
|
|||||||
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
|
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
|
||||||
startNProgress()
|
startNProgress()
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.post('download/', {
|
let result: { [key: string]: any }
|
||||||
media_in: _media,
|
|
||||||
torrent_in: _torrent,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
if (_media) {
|
||||||
|
result = await api.post('download/', {
|
||||||
|
media_in: _media,
|
||||||
|
torrent_in: _torrent,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
result = await api.post('download/add', _torrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && result.success) {
|
||||||
// 添加下载成功
|
// 添加下载成功
|
||||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
|
||||||
downloaded.value.push(_torrent?.enclosure || '')
|
downloaded.value.push(_torrent?.enclosure || '')
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
// 添加下载失败
|
// 添加下载失败
|
||||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
|
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败:${result?.message}!`)
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
doneNProgress()
|
doneNProgress()
|
||||||
@@ -115,14 +107,10 @@ async function downloadTorrentFile() {
|
|||||||
|
|
||||||
// 促销Chip类
|
// 促销Chip类
|
||||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||||
if (downloadVolume === 0)
|
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||||
return 'text-white bg-lime-500'
|
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||||
else if (downloadVolume < 1)
|
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||||
return 'text-white bg-green-500'
|
else return 'text-white bg-gray-500'
|
||||||
else if (uploadVolume !== 1)
|
|
||||||
return 'text-white bg-sky-500'
|
|
||||||
else
|
|
||||||
return 'text-white bg-gray-500'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 装载时查询站点图标
|
// 装载时查询站点图标
|
||||||
@@ -138,39 +126,24 @@ onMounted(() => {
|
|||||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
|
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
|
||||||
@click="handleAddDownload"
|
@click="handleAddDownload"
|
||||||
>
|
>
|
||||||
<template
|
<template v-if="!showMoreTorrents" #image>
|
||||||
v-if="!showMoreTorrents"
|
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
||||||
#image
|
|
||||||
>
|
|
||||||
<VAvatar
|
|
||||||
class="absolute right-2 bottom-2 rounded"
|
|
||||||
variant="flat"
|
|
||||||
rounded="0"
|
|
||||||
>
|
|
||||||
<VImg :src="siteIcon" />
|
<VImg :src="siteIcon" />
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
</template>
|
</template>
|
||||||
<VCardItem class="py-1">
|
<VCardItem class="py-1">
|
||||||
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
|
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
|
||||||
{{ media?.title }} {{ meta?.season_episode }}
|
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
|
||||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</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>
|
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
<div class="me-n3">
|
<div class="me-n3">
|
||||||
<IconBtn>
|
<IconBtn>
|
||||||
<VIcon
|
<VIcon icon="mdi-dots-vertical" />
|
||||||
icon="mdi-dots-vertical"
|
<VMenu activator="parent" close-on-content-click>
|
||||||
/>
|
|
||||||
<VMenu
|
|
||||||
activator="parent"
|
|
||||||
close-on-content-click
|
|
||||||
>
|
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<VListItem variant="plain" @click="openTorrentDetail()">
|
||||||
variant="plain"
|
|
||||||
@click="openTorrentDetail()"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-information" />
|
<VIcon icon="mdi-information" />
|
||||||
</template>
|
</template>
|
||||||
@@ -196,25 +169,11 @@ onMounted(() => {
|
|||||||
{{ torrent?.title }}
|
{{ torrent?.title }}
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText>{{ torrent?.description }}</VCardText>
|
<VCardText>{{ torrent?.description }}</VCardText>
|
||||||
<VCardItem
|
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
|
||||||
v-if="torrent?.labels"
|
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||||
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
|
H&R
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||||
v-if="torrent?.freedate_diff"
|
|
||||||
variant="elevated"
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1"
|
|
||||||
>
|
|
||||||
{{ torrent?.freedate_diff }}
|
{{ torrent?.freedate_diff }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
@@ -227,51 +186,24 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||||
v-if="meta?.edition"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-red-500"
|
|
||||||
>
|
|
||||||
{{ meta?.edition }}
|
{{ meta?.edition }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||||
v-if="meta?.resource_pix"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-red-500"
|
|
||||||
>
|
|
||||||
{{ meta?.resource_pix }}
|
{{ meta?.resource_pix }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
||||||
v-if="meta?.video_encode"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-orange-500"
|
|
||||||
>
|
|
||||||
{{ meta?.video_encode }}
|
{{ meta?.video_encode }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
||||||
v-if="torrent?.size"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-yellow-500"
|
|
||||||
>
|
|
||||||
{{ formatFileSize(torrent?.size) }}
|
{{ formatFileSize(torrent?.size) }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
||||||
v-if="meta?.resource_team"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-cyan-500"
|
|
||||||
>
|
|
||||||
{{ meta?.resource_team }}
|
{{ meta?.resource_team }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||||
:class="
|
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||||
getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)
|
|
||||||
"
|
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
size="small"
|
size="small"
|
||||||
class="me-1 mb-1"
|
class="me-1 mb-1"
|
||||||
@@ -280,10 +212,7 @@ onMounted(() => {
|
|||||||
</VChip>
|
</VChip>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VCardActions>
|
<VCardActions>
|
||||||
<VBtn
|
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
|
||||||
v-if="props.more && props.more.length > 0"
|
|
||||||
@click.stop="showMoreTorrents = !showMoreTorrents"
|
|
||||||
>
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
|
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
|
||||||
</template>
|
</template>
|
||||||
@@ -297,26 +226,12 @@ onMounted(() => {
|
|||||||
<VChip
|
<VChip
|
||||||
v-for="(item, index) in props.more"
|
v-for="(item, index) in props.more"
|
||||||
:key="index"
|
:key="index"
|
||||||
@click.stop="
|
@click.stop="handleAddDownload(item.torrent_info?.site_name, item.media_info, item.torrent_info)"
|
||||||
handleAddDownload(
|
|
||||||
item.torrent_info?.site_name,
|
|
||||||
item.media_info,
|
|
||||||
item.torrent_info,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
|
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
|
||||||
<VBadge
|
<VBadge
|
||||||
color="primary"
|
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||||
: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"
|
:content="item.torrent_info?.volume_factor"
|
||||||
inline
|
inline
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useConfirm } from 'vuetify-use-dialog'
|
|||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||||
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
|
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -40,16 +40,13 @@ const downloaded = ref<String[]>([])
|
|||||||
async function getSiteIcon() {
|
async function getSiteIcon() {
|
||||||
try {
|
try {
|
||||||
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
|
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 询问并添加下载
|
// 询问并添加下载
|
||||||
async function handleAddDownload(_site: any = undefined,
|
async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
|
||||||
_media: any = undefined,
|
|
||||||
_torrent: any = undefined) {
|
|
||||||
if (!_media || !_torrent || !_site) {
|
if (!_media || !_torrent || !_site) {
|
||||||
_site = torrent.value?.site_name
|
_site = torrent.value?.site_name
|
||||||
_media = media.value
|
_media = media.value
|
||||||
@@ -59,18 +56,9 @@ async function handleAddDownload(_site: any = undefined,
|
|||||||
const isConfirmed = await createConfirm({
|
const isConfirmed = await createConfirm({
|
||||||
title: '确认',
|
title: '确认',
|
||||||
content: `是否确认下载【${_site}】${_torrent?.title} ?`,
|
content: `是否确认下载【${_site}】${_torrent?.title} ?`,
|
||||||
confirmationText: '确认',
|
|
||||||
cancellationText: '取消',
|
|
||||||
dialogProps: {
|
|
||||||
maxWidth: '50rem',
|
|
||||||
},
|
|
||||||
confirmationButtonProps: {
|
|
||||||
variant: 'tonal',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isConfirmed)
|
if (!isConfirmed) return
|
||||||
return
|
|
||||||
|
|
||||||
addDownload(_media, _torrent)
|
addDownload(_media, _torrent)
|
||||||
}
|
}
|
||||||
@@ -79,22 +67,26 @@ async function handleAddDownload(_site: any = undefined,
|
|||||||
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
|
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
|
||||||
startNProgress()
|
startNProgress()
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.post('download/', {
|
let result: { [key: string]: any }
|
||||||
media_in: _media,
|
|
||||||
torrent_in: _torrent,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
if (_media) {
|
||||||
|
result = await api.post('download/', {
|
||||||
|
media_in: _media,
|
||||||
|
torrent_in: _torrent,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
result = await api.post('download/add', _torrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && result.success) {
|
||||||
// 添加下载成功
|
// 添加下载成功
|
||||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
|
||||||
downloaded.value.push(_torrent?.enclosure || '')
|
downloaded.value.push(_torrent?.enclosure || '')
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
// 添加下载失败
|
// 添加下载失败
|
||||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
|
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败:${result?.message}!`)
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
doneNProgress()
|
doneNProgress()
|
||||||
@@ -112,14 +104,10 @@ async function downloadTorrentFile() {
|
|||||||
|
|
||||||
// 促销Chip类
|
// 促销Chip类
|
||||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||||
if (downloadVolume === 0)
|
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||||
return 'text-white bg-lime-500'
|
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||||
else if (downloadVolume < 1)
|
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||||
return 'text-white bg-green-500'
|
else return 'text-white bg-gray-500'
|
||||||
else if (uploadVolume !== 1)
|
|
||||||
return 'text-white bg-sky-500'
|
|
||||||
else
|
|
||||||
return 'text-white bg-gray-500'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 装载时查询站点图标
|
// 装载时查询站点图标
|
||||||
@@ -129,19 +117,9 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VListItem
|
<VListItem @click="handleAddDownload" :variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'">
|
||||||
@click="handleAddDownload"
|
<template v-if="!showMoreTorrents" #prepend>
|
||||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
|
||||||
>
|
|
||||||
<template
|
|
||||||
v-if="!showMoreTorrents"
|
|
||||||
#prepend
|
|
||||||
>
|
|
||||||
<VAvatar
|
|
||||||
class="rounded"
|
|
||||||
variant="flat"
|
|
||||||
@click.stop="openTorrentDetail"
|
|
||||||
>
|
|
||||||
<VImg :src="siteIcon" />
|
<VImg :src="siteIcon" />
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
</template>
|
</template>
|
||||||
@@ -153,25 +131,11 @@ onMounted(() => {
|
|||||||
<VListItemSubtitle>
|
<VListItemSubtitle>
|
||||||
{{ torrent?.description }}
|
{{ torrent?.description }}
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
<div
|
<div v-if="torrent?.labels" class="pt-2">
|
||||||
v-if="torrent?.labels"
|
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||||
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
|
H&R
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||||
v-if="torrent?.freedate_diff"
|
|
||||||
variant="elevated"
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1"
|
|
||||||
>
|
|
||||||
{{ torrent?.freedate_diff }}
|
{{ torrent?.freedate_diff }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
@@ -184,51 +148,24 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||||
v-if="meta?.edition"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-red-500"
|
|
||||||
>
|
|
||||||
{{ meta?.edition }}
|
{{ meta?.edition }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||||
v-if="meta?.resource_pix"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-red-500"
|
|
||||||
>
|
|
||||||
{{ meta?.resource_pix }}
|
{{ meta?.resource_pix }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
||||||
v-if="meta?.video_encode"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-orange-500"
|
|
||||||
>
|
|
||||||
{{ meta?.video_encode }}
|
{{ meta?.video_encode }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
||||||
v-if="torrent?.size"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-yellow-500"
|
|
||||||
>
|
|
||||||
{{ formatFileSize(torrent?.size) }}
|
{{ formatFileSize(torrent?.size) }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
||||||
v-if="meta?.resource_team"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-cyan-500"
|
|
||||||
>
|
|
||||||
{{ meta?.resource_team }}
|
{{ meta?.resource_team }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||||
:class="
|
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||||
getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)
|
|
||||||
"
|
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
size="small"
|
size="small"
|
||||||
class="me-1 mb-1"
|
class="me-1 mb-1"
|
||||||
@@ -239,18 +176,10 @@ onMounted(() => {
|
|||||||
<template #append>
|
<template #append>
|
||||||
<div class="me-n3">
|
<div class="me-n3">
|
||||||
<IconBtn>
|
<IconBtn>
|
||||||
<VIcon
|
<VIcon icon="mdi-dots-vertical" />
|
||||||
icon="mdi-dots-vertical"
|
<VMenu activator="parent" close-on-content-click>
|
||||||
/>
|
|
||||||
<VMenu
|
|
||||||
activator="parent"
|
|
||||||
close-on-content-click
|
|
||||||
>
|
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<VListItem variant="plain" @click="openTorrentDetail()">
|
||||||
variant="plain"
|
|
||||||
@click="openTorrentDetail()"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-information" />
|
<VIcon icon="mdi-information" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
109
src/components/dialog/AliyunAuthDialog.vue
Normal file
109
src/components/dialog/AliyunAuthDialog.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import QrcodeVue from 'qrcode.vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
// 定义事件
|
||||||
|
const emit = defineEmits(['done', 'close'])
|
||||||
|
|
||||||
|
// 二维码内容
|
||||||
|
const qrCodeContent = ref('')
|
||||||
|
|
||||||
|
// ck参数
|
||||||
|
const ck = ref('')
|
||||||
|
|
||||||
|
// t参数
|
||||||
|
const t = ref('')
|
||||||
|
|
||||||
|
// 下方的提示信息
|
||||||
|
const text = ref('请用阿里云盘 App 扫码')
|
||||||
|
|
||||||
|
// 提醒类型
|
||||||
|
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
|
||||||
|
|
||||||
|
// timeout定时器
|
||||||
|
let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||||
|
|
||||||
|
// 完成
|
||||||
|
async function handleDone() {
|
||||||
|
emit('done')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用/aliyun/qrcode api生成二维码
|
||||||
|
async function getQrcode() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('/aliyun/qrcode')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
qrCodeContent.value = result.data.codeContent
|
||||||
|
ck.value = result.data.ck
|
||||||
|
t.value = result.data.t
|
||||||
|
} else {
|
||||||
|
text.value = result.message
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用/aliyun/check api验证二维码
|
||||||
|
async function checkQrcode() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('/aliyun/check', {
|
||||||
|
params: {
|
||||||
|
ck: ck.value,
|
||||||
|
t: t.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const qrCodeStatus = result.data.qrCodeStatus
|
||||||
|
text.value = result.data.tip
|
||||||
|
if (qrCodeStatus == 'CONFIRMED') {
|
||||||
|
// 已确认完成
|
||||||
|
alertType.value = 'success'
|
||||||
|
handleDone()
|
||||||
|
} else if (qrCodeStatus == 'NEW' || qrCodeStatus == 'SCANED') {
|
||||||
|
alertType.value = 'info'
|
||||||
|
// 新建、待扫码
|
||||||
|
clearTimeout(timeoutTimer)
|
||||||
|
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||||
|
} else {
|
||||||
|
// 过期或者已取消
|
||||||
|
alertType.value = 'error'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alertType.value = 'error'
|
||||||
|
text.value = result.message
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await getQrcode()
|
||||||
|
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog width="40rem" scrollable max-height="85vh">
|
||||||
|
<VCard title="阿里云盘登录" class="rounded-t">
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||||
|
<template #prepend />
|
||||||
|
</VAlert>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
@@ -18,27 +18,15 @@ function handleImport() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog
|
<VDialog width="40rem" scrollable max-height="85vh">
|
||||||
width="40rem"
|
<VCard :title="props.title" class="rounded-t">
|
||||||
scrollable
|
|
||||||
max-height="85vh"
|
|
||||||
>
|
|
||||||
<VCard
|
|
||||||
:title="props.title"
|
|
||||||
class="rounded-t"
|
|
||||||
>
|
|
||||||
<DialogCloseBtn @click="emit('close')" />
|
<DialogCloseBtn @click="emit('close')" />
|
||||||
<VCardText class="pt-2">
|
<VCardText class="pt-2">
|
||||||
<VTextarea v-model="codeString" />
|
<VTextarea v-model="codeString" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn
|
<VBtn variant="elevated" @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3"> 导入 </VBtn>
|
||||||
variant="tonal"
|
|
||||||
@click="handleImport"
|
|
||||||
>
|
|
||||||
导入
|
|
||||||
</VBtn>
|
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|||||||
17
src/components/dialog/ProgressDialog.vue
Normal file
17
src/components/dialog/ProgressDialog.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
value: Number,
|
||||||
|
text: String,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<!-- 手动整理进度框 -->
|
||||||
|
<VDialog :scrim="false" width="25rem">
|
||||||
|
<VCard color="primary">
|
||||||
|
<VCardText class="text-center">
|
||||||
|
{{ props.text }}
|
||||||
|
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
@@ -1,37 +1,46 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toast-notification'
|
import { useToast } from 'vue-toast-notification'
|
||||||
import TmdbSelector from '../misc/TmdbSelector.vue'
|
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { numberValidator } from '@/@validators'
|
import { numberValidator } from '@/@validators'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import ProgressDialog from './ProgressDialog.vue'
|
||||||
|
import { FileItem, MediaDirectory } from '@/api/types'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
path: String,
|
storage: {
|
||||||
target: String,
|
type: String,
|
||||||
|
default: () => 'local',
|
||||||
|
},
|
||||||
logids: Array<number>,
|
logids: Array<number>,
|
||||||
|
items: Array<FileItem>,
|
||||||
|
target: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义事件
|
// 定义事件
|
||||||
const emit = defineEmits(['done', 'close'])
|
const emit = defineEmits(['done', 'close'])
|
||||||
|
|
||||||
// 生成1到50季的下拉框选项
|
// 生成1到100季的下拉框选项
|
||||||
const seasonItems = ref(
|
const seasonItems = ref(
|
||||||
Array.from({ length: 51 }, (_, i) => i).map(item => ({
|
Array.from({ length: 101 }, (_, i) => i).map(item => ({
|
||||||
title: `第 ${item} 季`,
|
title: `第 ${item} 季`,
|
||||||
value: item,
|
value: item,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 当前识别类型
|
||||||
|
const mediaSource = ref('themoviedb')
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
// TMDB选择对话框
|
// TMDB选择对话框
|
||||||
const tmdbSelectorDialog = ref(false)
|
const mediaSelectorDialog = ref(false)
|
||||||
|
|
||||||
// 加载进度SSE
|
// 加载进度SSE
|
||||||
const progressEventSource = ref<EventSource>()
|
const progressEventSource = ref<EventSource>()
|
||||||
@@ -45,12 +54,28 @@ const progressText = ref('请稍候 ...')
|
|||||||
// 整理进度
|
// 整理进度
|
||||||
const progressValue = ref(0)
|
const progressValue = ref(0)
|
||||||
|
|
||||||
// 文件转移表单
|
// 标题
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
if (props.items) {
|
||||||
|
if (props.items.length > 1) return `整理 - 共 ${props.items.length} 项`
|
||||||
|
return `整理 - ${props.items[0].path}`
|
||||||
|
} else if (props.logids) {
|
||||||
|
return `整理 - 共 ${props.logids.length} 项`
|
||||||
|
}
|
||||||
|
return '手动整理'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表单
|
||||||
const transferForm = reactive({
|
const transferForm = reactive({
|
||||||
|
storage: props.storage,
|
||||||
logid: 0,
|
logid: 0,
|
||||||
path: '',
|
path: '',
|
||||||
target: props.target ?? '',
|
drive_id: '',
|
||||||
|
fileid: '',
|
||||||
|
filetype: '',
|
||||||
|
target: props.target ?? null,
|
||||||
tmdbid: null,
|
tmdbid: null,
|
||||||
|
doubanid: null,
|
||||||
season: null,
|
season: null,
|
||||||
type_name: '',
|
type_name: '',
|
||||||
transfer_type: '',
|
transfer_type: '',
|
||||||
@@ -59,11 +84,26 @@ const transferForm = reactive({
|
|||||||
episode_part: '',
|
episode_part: '',
|
||||||
episode_offset: null,
|
episode_offset: null,
|
||||||
min_filesize: 0,
|
min_filesize: 0,
|
||||||
|
scrape: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
// 所有媒体库目录
|
||||||
transferForm.path = props.path ?? ''
|
const libraryDirectories = ref<MediaDirectory[]>([])
|
||||||
transferForm.target = props.target ?? ''
|
|
||||||
|
// 目的目录下拉框
|
||||||
|
const targetDirectories = computed(() => {
|
||||||
|
const directories = libraryDirectories.value.map(item => item.path)
|
||||||
|
return [...new Set(directories)]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听目的路径变化,自动查询目录的刮削配置
|
||||||
|
watch(transferForm, async () => {
|
||||||
|
if (transferForm.target) {
|
||||||
|
const directory = libraryDirectories.value.find(item => item.path === transferForm.target)
|
||||||
|
if (directory) {
|
||||||
|
transferForm.scrape = directory.scrape ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用SSE监听加载进度
|
// 使用SSE监听加载进度
|
||||||
@@ -90,47 +130,25 @@ function stopLoadingProgress() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 整理文件
|
// 整理文件
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
async function transfer() {
|
async function transfer() {
|
||||||
if (!props.logids && !props.path) return
|
if (!props.logids && !props.items) return
|
||||||
|
|
||||||
// 显示进度条
|
// 显示进度条
|
||||||
progressDialog.value = true
|
progressDialog.value = true
|
||||||
// 开始监听进度
|
// 开始监听进度
|
||||||
startLoadingProgress()
|
startLoadingProgress()
|
||||||
|
|
||||||
if (props.path) {
|
// 文件整理
|
||||||
// 文件整理
|
if (props.items) {
|
||||||
try {
|
for (const item of props.items) {
|
||||||
const result: { [key: string]: any } = await api.post(
|
await handleTransfer(item)
|
||||||
'transfer/manual',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
params: transferForm,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// 显示结果
|
|
||||||
if (result.success) $toast.success(`${props.path} 整理完成!`)
|
|
||||||
else $toast.error(`${props.path} 整理失败:${result.message}!`)
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
}
|
}
|
||||||
} else if (props.logids) {
|
}
|
||||||
// 日志整理
|
|
||||||
|
// 日志整理
|
||||||
|
if (props.logids) {
|
||||||
for (const logid of props.logids) {
|
for (const logid of props.logids) {
|
||||||
transferForm.logid = logid
|
await handleTransferLog(logid)
|
||||||
try {
|
|
||||||
const result: { [key: string]: any } = await api.post(
|
|
||||||
'transfer/manual',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
params: transferForm,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}!`)
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,27 +159,80 @@ async function transfer() {
|
|||||||
// 重新加载
|
// 重新加载
|
||||||
emit('done')
|
emit('done')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 整理文件
|
||||||
|
async function handleTransfer(item: FileItem) {
|
||||||
|
transferForm.path = item.path
|
||||||
|
transferForm.fileid = item.fileid || ''
|
||||||
|
transferForm.drive_id = item.drive_id || ''
|
||||||
|
transferForm.filetype = item.type || 'dir'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
|
||||||
|
if (!result.success) $toast.error(`文件 ${item.path} 整理失败:${result.message}!`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整理日志
|
||||||
|
async function handleTransferLog(logid: number) {
|
||||||
|
transferForm.logid = logid
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
|
||||||
|
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}!`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用API,加载当前系统环境设置
|
||||||
|
async function loadSystemSettings() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('system/env')
|
||||||
|
if (result) mediaSource.value = result.data?.RECOGNIZE_SOURCE || 'themoviedb'
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询媒体库目录
|
||||||
|
async function loadLibraryDirectories() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('system/setting/LibraryDirectories')
|
||||||
|
if (result.success && result.data?.value) {
|
||||||
|
libraryDirectories.value = result.data.value
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSystemSettings()
|
||||||
|
loadLibraryDirectories()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard
|
<VCard :title="dialogTitle" class="rounded-t">
|
||||||
:title="`${props.path ? `整理 - ${props.path}` : `整理 - 共 ${props.logids?.length} 条记录`}`"
|
|
||||||
class="rounded-t"
|
|
||||||
>
|
|
||||||
<DialogCloseBtn @click="emit('close')" />
|
<DialogCloseBtn @click="emit('close')" />
|
||||||
<VCardText class="pt-2">
|
<VDivider />
|
||||||
|
<VCardText>
|
||||||
<VForm @submit.prevent="() => {}">
|
<VForm @submit.prevent="() => {}">
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12" md="8">
|
<VCol v-if="props.storage == 'local'" cols="12" md="8">
|
||||||
<VTextField
|
<VCombobox
|
||||||
v-model="transferForm.target"
|
v-model="transferForm.target"
|
||||||
|
:items="targetDirectories"
|
||||||
label="目的路径"
|
label="目的路径"
|
||||||
placeholder="留空自动"
|
placeholder="留空自动"
|
||||||
hint="留空将自动整理到媒体库目录"
|
hint="整理目的路径,留空将自动匹配"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol v-if="props.storage == 'local'" cols="12" md="4">
|
||||||
<VSelect
|
<VSelect
|
||||||
v-model="transferForm.transfer_type"
|
v-model="transferForm.transfer_type"
|
||||||
label="整理方式"
|
label="整理方式"
|
||||||
@@ -174,6 +245,8 @@ async function transfer() {
|
|||||||
{ title: 'Rclone复制', value: 'rclone_copy' },
|
{ title: 'Rclone复制', value: 'rclone_copy' },
|
||||||
{ title: 'Rclone移动', value: 'rclone_move' },
|
{ title: 'Rclone移动', value: 'rclone_move' },
|
||||||
]"
|
]"
|
||||||
|
hint="文件操作整理方式"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
@@ -187,18 +260,34 @@ async function transfer() {
|
|||||||
{ title: '电影', value: '电影' },
|
{ title: '电影', value: '电影' },
|
||||||
{ title: '电视剧', value: '电视剧' },
|
{ title: '电视剧', value: '电视剧' },
|
||||||
]"
|
]"
|
||||||
|
hint="文件的媒体类型"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
<VTextField
|
<VTextField
|
||||||
|
v-if="mediaSource === 'themoviedb'"
|
||||||
v-model="transferForm.tmdbid"
|
v-model="transferForm.tmdbid"
|
||||||
:disabled="transferForm.type_name === ''"
|
:disabled="transferForm.type_name === ''"
|
||||||
label="TMDBID"
|
label="TheMovieDb编号"
|
||||||
placeholder="留空自动识别"
|
placeholder="留空自动识别"
|
||||||
:rules="[numberValidator]"
|
:rules="[numberValidator]"
|
||||||
append-inner-icon="mdi-magnify"
|
append-inner-icon="mdi-magnify"
|
||||||
hint="点击图标按名称搜索,留空将自动重新识别"
|
hint="按名称查询媒体编号,留空自动识别"
|
||||||
@click:append-inner="tmdbSelectorDialog = true"
|
persistent-hint
|
||||||
|
@click:append-inner="mediaSelectorDialog = true"
|
||||||
|
/>
|
||||||
|
<VTextField
|
||||||
|
v-else
|
||||||
|
v-model="transferForm.doubanid"
|
||||||
|
:disabled="transferForm.type_name === ''"
|
||||||
|
label="豆瓣编号"
|
||||||
|
placeholder="留空自动识别"
|
||||||
|
:rules="[numberValidator]"
|
||||||
|
append-inner-icon="mdi-magnify"
|
||||||
|
hint="按名称查询媒体编号,留空自动识别"
|
||||||
|
persistent-hint
|
||||||
|
@click:append-inner="mediaSelectorDialog = true"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
@@ -207,6 +296,8 @@ async function transfer() {
|
|||||||
v-model.number="transferForm.season"
|
v-model.number="transferForm.season"
|
||||||
label="季"
|
label="季"
|
||||||
:items="seasonItems"
|
:items="seasonItems"
|
||||||
|
hint="指定季数"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
@@ -216,7 +307,8 @@ async function transfer() {
|
|||||||
v-model="transferForm.episode_format"
|
v-model="transferForm.episode_format"
|
||||||
label="集数定位"
|
label="集数定位"
|
||||||
placeholder="使用{ep}定位集数"
|
placeholder="使用{ep}定位集数"
|
||||||
hint="使用{ep}定位文件名中的集数部分,其余相同部分直接填写,不同部分使用{a}进行忽略,例如:{a}葬送的芙莉莲_Sousou no Frieren 第{ep}话{b}"
|
hint="使用{ep}定位文件名中的集数部分以辅助识别"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
@@ -224,7 +316,8 @@ async function transfer() {
|
|||||||
v-model="transferForm.episode_detail"
|
v-model="transferForm.episode_detail"
|
||||||
label="指定集数"
|
label="指定集数"
|
||||||
placeholder="起始集,终止集,如1或1,2"
|
placeholder="起始集,终止集,如1或1,2"
|
||||||
hint="直接指定集数或者范围,格式:起始集,终止集,如1或1,2"
|
hint="指定集数或范围,如1或1,2"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
@@ -232,7 +325,8 @@ async function transfer() {
|
|||||||
v-model="transferForm.episode_part"
|
v-model="transferForm.episode_part"
|
||||||
label="指定Part"
|
label="指定Part"
|
||||||
placeholder="如part1"
|
placeholder="如part1"
|
||||||
hint="指定集数的Part,如part1"
|
hint="指定Part,如part1"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
@@ -240,7 +334,8 @@ async function transfer() {
|
|||||||
v-model.number="transferForm.episode_offset"
|
v-model.number="transferForm.episode_offset"
|
||||||
label="集数偏移"
|
label="集数偏移"
|
||||||
placeholder="如-10"
|
placeholder="如-10"
|
||||||
hint="对集数进行偏移运算,如-10表示文件名中的集数减10为整理后集数"
|
hint="集数偏移运算,如-10或EP*2"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
@@ -249,30 +344,44 @@ async function transfer() {
|
|||||||
label="最小文件大小(MB)"
|
label="最小文件大小(MB)"
|
||||||
:rules="[numberValidator]"
|
:rules="[numberValidator]"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
hint="最小文件大小,小于此大小的文件将被忽略不进行整理"
|
hint="只整理大于最小文件大小的文件"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VSwitch
|
||||||
|
v-model="transferForm.scrape"
|
||||||
|
label="刮削元数据"
|
||||||
|
hint="整理完成后自动刮削元数据"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="pt-3">
|
||||||
<VBtn depressed @click="emit('close')"> 取消 </VBtn>
|
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn variant="tonal" @click="transfer"> 开始整理 </VBtn>
|
<VBtn variant="elevated" @click="transfer" prepend-icon="mdi-arrow-right-bold" class="px-5"> 开始整理 </VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
<!-- 手动整理进度框 -->
|
<!-- 手动整理进度框 -->
|
||||||
<VDialog v-model="progressDialog" :scrim="false" width="25rem">
|
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||||||
<VCard color="primary">
|
|
||||||
<VCardText class="text-center">
|
|
||||||
{{ progressText }}
|
|
||||||
<VProgressLinear v-if="progressValue" color="white" class="mb-0 mt-1" :model-value="progressValue" />
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
<!-- TMDB ID搜索框 -->
|
<!-- TMDB ID搜索框 -->
|
||||||
<VDialog v-model="tmdbSelectorDialog" width="40rem" scrollable max-height="85vh">
|
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||||
<TmdbSelector v-model="transferForm.tmdbid" @close="tmdbSelectorDialog = false" />
|
<MediaIdSelector
|
||||||
|
v-if="mediaSource === 'themoviedb'"
|
||||||
|
v-model="transferForm.tmdbid"
|
||||||
|
@close="mediaSelectorDialog = false"
|
||||||
|
:type="mediaSource"
|
||||||
|
/>
|
||||||
|
<MediaIdSelector
|
||||||
|
v-else
|
||||||
|
v-model="transferForm.doubanid"
|
||||||
|
@close="mediaSelectorDialog = false"
|
||||||
|
:type="mediaSource"
|
||||||
|
/>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
|
|||||||
import { numberValidator, requiredValidator } from '@/@validators'
|
import { numberValidator, requiredValidator } from '@/@validators'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useConfirm } from 'vuetify-use-dialog'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
|
||||||
|
// 确认框
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
siteid: Number,
|
siteid: Number,
|
||||||
@@ -44,7 +48,7 @@ const statusItems = [
|
|||||||
|
|
||||||
// 生成1到50的优先级下拉框选项
|
// 生成1到50的优先级下拉框选项
|
||||||
const priorityItems = ref(
|
const priorityItems = ref(
|
||||||
Array.from({ length: 50 }, (_, i) => i + 1).map(item => ({
|
Array.from({ length: 100 }, (_, i) => i + 1).map(item => ({
|
||||||
title: item,
|
title: item,
|
||||||
value: item,
|
value: item,
|
||||||
})),
|
})),
|
||||||
@@ -86,6 +90,13 @@ async function addSite() {
|
|||||||
|
|
||||||
// 调用API删除站点信息
|
// 调用API删除站点信息
|
||||||
async function deleteSiteInfo() {
|
async function deleteSiteInfo() {
|
||||||
|
const isConfirmed = await createConfirm({
|
||||||
|
title: '确认',
|
||||||
|
content: `是否确认删除站点?`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isConfirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.delete(`site/${siteForm.value?.id}`)
|
const result: { [key: string]: any } = await api.delete(`site/${siteForm.value?.id}`)
|
||||||
if (result.success) emit('remove')
|
if (result.success) emit('remove')
|
||||||
@@ -116,13 +127,14 @@ async function updateSiteInfo() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog scrollable :close-on-back="false" persistent eager max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard
|
<VCard
|
||||||
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
|
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
|
||||||
class="rounded-t"
|
class="rounded-t"
|
||||||
>
|
>
|
||||||
<DialogCloseBtn @click="emit('close')" />
|
<DialogCloseBtn @click="emit('close')" />
|
||||||
<VCardText class="pt-2">
|
<VDivider />
|
||||||
|
<VCardText>
|
||||||
<VForm @submit.prevent="() => {}">
|
<VForm @submit.prevent="() => {}">
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
@@ -131,41 +143,66 @@ async function updateSiteInfo() {
|
|||||||
label="站点地址"
|
label="站点地址"
|
||||||
:rules="[requiredValidator]"
|
:rules="[requiredValidator]"
|
||||||
hint="格式:http://www.example.com/"
|
hint="格式:http://www.example.com/"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="3">
|
<VCol cols="6" md="3">
|
||||||
<VSelect
|
<VSelect
|
||||||
v-model="siteForm.pri"
|
v-model="siteForm.pri"
|
||||||
label="优先级"
|
label="优先级"
|
||||||
:items="priorityItems"
|
:items="priorityItems"
|
||||||
:rules="[requiredValidator]"
|
:rules="[requiredValidator]"
|
||||||
hint="站点资源下载优先级,优先级数字越小越优先下载"
|
hint="优先级越小越优先"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="3">
|
<VCol cols="6" md="3">
|
||||||
<VSelect v-model="siteForm.is_active" :items="statusItems" label="状态" />
|
<VSelect
|
||||||
|
v-model="siteForm.is_active"
|
||||||
|
:items="statusItems"
|
||||||
|
label="状态"
|
||||||
|
hint="站点启用/停用"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12">
|
<VCol cols="12" md="9">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="siteForm.rss"
|
v-model="siteForm.rss"
|
||||||
label="RSS地址"
|
label="RSS地址"
|
||||||
hint="订阅模式为站点RSS时,将会使用此地址获取站点种子资源,该地址一般会自动获取,也可手动补充"
|
hint="订阅模式为`站点RSS`时使用的订阅链接,如未自动获取需手动补充"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
<VCol cols="12" md="3">
|
||||||
|
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
|
||||||
|
</VCol>
|
||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
<VTextarea
|
<VTextarea v-model="siteForm.cookie" label="站点Cookie" hint="站点请求头中的Cookie信息" persistent-hint />
|
||||||
v-model="siteForm.cookie"
|
</VCol>
|
||||||
label="站点Cookie"
|
<VCol cols="12" md="6">
|
||||||
hint="浏览器打开站点首页,打开开发人员工具,刷新页面后在网络选项中找到首页地址,在请求头中获取Cookie信息"
|
<VTextField
|
||||||
|
v-model="siteForm.token"
|
||||||
|
label="请求头(Authorization)"
|
||||||
|
hint="站点请求头中的Authorization信息,特殊站点需要"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VTextField
|
||||||
|
v-model="siteForm.apikey"
|
||||||
|
label="令牌(API Key)"
|
||||||
|
hint="站点的访问API Key,特殊站点需要"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="siteForm.ua"
|
v-model="siteForm.ua"
|
||||||
label="站点User-Agent"
|
label="站点User-Agent"
|
||||||
hint="在开发人员工具,网络请求头中获取User-Agent信息,需与站点Cookie配套使用"
|
hint="获取Cookie的浏览器对应的User-Agent"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
@@ -175,15 +212,17 @@ async function updateSiteInfo() {
|
|||||||
v-model="siteForm.limit_interval"
|
v-model="siteForm.limit_interval"
|
||||||
label="单位周期(秒)"
|
label="单位周期(秒)"
|
||||||
:rules="[numberValidator]"
|
:rules="[numberValidator]"
|
||||||
hint="设定站点限流的单位周期,单位为秒,0为不限流"
|
hint="限流控制的单位周期时长"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="siteForm.limit_count"
|
v-model="siteForm.limit_count"
|
||||||
label="访问次数"
|
label="周期内访问次数"
|
||||||
:rules="[numberValidator]"
|
:rules="[numberValidator]"
|
||||||
hint="设定单位周期内站点允许的访问次数,0为不限制"
|
hint="单位周期内允许的访问次数"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
@@ -191,30 +230,46 @@ async function updateSiteInfo() {
|
|||||||
v-model="siteForm.limit_seconds"
|
v-model="siteForm.limit_seconds"
|
||||||
label="访问间隔(秒)"
|
label="访问间隔(秒)"
|
||||||
:rules="[numberValidator]"
|
:rules="[numberValidator]"
|
||||||
hint="设定单位周期内每次站点访问需间隔时间,单位为秒,0为不限制"
|
hint="每次访问需要间隔的最小时间"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
<VSwitch v-model="siteForm.proxy" label="代理" hint="站点是否需要代理访问,需要设置好代理服务器信息" />
|
<VSwitch v-model="siteForm.proxy" label="代理" hint="使用代理服务器访问该站点" persistent-hint />
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
<VSwitch
|
<VSwitch v-model="siteForm.render" label="仿真" hint="使用浏览器模拟真实访问该站点" persistent-hint />
|
||||||
v-model="siteForm.render"
|
|
||||||
label="仿真"
|
|
||||||
hint="站点是否需要使用浏览器模拟访问,开启可以一定程度上提升连通性,但会大大增加站点请求时间"
|
|
||||||
/>
|
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="pt-3">
|
||||||
<VBtn v-if="props.oper === 'add'" @click="emit('close')"> 取消 </VBtn>
|
<VBtn v-if="props.oper !== 'add'" color="error" @click="deleteSiteInfo" variant="outlined" class="me-3">
|
||||||
<VBtn v-else color="error" @click="deleteSiteInfo"> 删除 </VBtn>
|
删除
|
||||||
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn v-if="props.oper === 'add'" color="primary" variant="tonal" @click="addSite"> 新增 </VBtn>
|
<VBtn
|
||||||
<VBtn v-else color="primary" variant="tonal" @click="updateSiteInfo"> 保存 </VBtn>
|
v-if="props.oper === 'add'"
|
||||||
|
color="primary"
|
||||||
|
variant="elevated"
|
||||||
|
@click="addSite"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
class="px-5"
|
||||||
|
>
|
||||||
|
新增
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
v-else
|
||||||
|
color="primary"
|
||||||
|
variant="elevated"
|
||||||
|
@click="updateSiteInfo"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
import { useToast } from 'vue-toast-notification'
|
import { useToast } from 'vue-toast-notification'
|
||||||
import { numberValidator } from '@/@validators'
|
import { numberValidator } from '@/@validators'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Site, Subscribe } from '@/api/types'
|
import type { MediaDirectory, Site, Subscribe } from '@/api/types'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useConfirm } from 'vuetify-use-dialog'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
|
||||||
|
// 确认框
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
subid: Number,
|
subid: Number,
|
||||||
@@ -21,6 +25,9 @@ const emit = defineEmits(['remove', 'save', 'close'])
|
|||||||
// 站点数据列表
|
// 站点数据列表
|
||||||
const siteList = ref<Site[]>([])
|
const siteList = ref<Site[]>([])
|
||||||
|
|
||||||
|
// 下载目录列表
|
||||||
|
const downloadDirectories = ref<MediaDirectory[]>([])
|
||||||
|
|
||||||
// 站点选择下载框
|
// 站点选择下载框
|
||||||
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
|
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
|
||||||
|
|
||||||
@@ -46,7 +53,7 @@ const subscribeForm = ref<Subscribe>({
|
|||||||
last_update: '',
|
last_update: '',
|
||||||
username: '',
|
username: '',
|
||||||
current_priority: 0,
|
current_priority: 0,
|
||||||
save_path: '',
|
save_path: undefined,
|
||||||
date: '',
|
date: '',
|
||||||
show_edit_dialog: false,
|
show_edit_dialog: false,
|
||||||
})
|
})
|
||||||
@@ -145,6 +152,12 @@ async function getSubscribeInfo() {
|
|||||||
|
|
||||||
// 删除订阅
|
// 删除订阅
|
||||||
async function removeSubscribe() {
|
async function removeSubscribe() {
|
||||||
|
const isConfirmed = await createConfirm({
|
||||||
|
title: '确认',
|
||||||
|
content: `是否确认取消订阅?`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isConfirmed) return
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
|
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
|
||||||
|
|
||||||
@@ -157,6 +170,25 @@ async function removeSubscribe() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询下载目录
|
||||||
|
async function loadDownloadDirectories() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
|
||||||
|
if (result.success && result.data?.value) {
|
||||||
|
downloadDirectories.value = result.data.value
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存目录下拉框
|
||||||
|
const targetDirectories = computed(() => {
|
||||||
|
// 去重后的下载目录
|
||||||
|
const directories = downloadDirectories.value.map(item => item.path)
|
||||||
|
return [...new Set(directories)]
|
||||||
|
})
|
||||||
|
|
||||||
// 质量选择框数据
|
// 质量选择框数据
|
||||||
const qualityOptions = ref([
|
const qualityOptions = ref([
|
||||||
{
|
{
|
||||||
@@ -242,15 +274,15 @@ const effectOptions = ref([
|
|||||||
])
|
])
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadDownloadDirectories()
|
||||||
getSiteList()
|
getSiteList()
|
||||||
if (props.subid) getSubscribeInfo()
|
if (props.subid) getSubscribeInfo()
|
||||||
|
|
||||||
if (props.default) queryDefaultSubscribeConfig()
|
if (props.default) queryDefaultSubscribeConfig()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard
|
<VCard
|
||||||
:title="`${
|
:title="`${
|
||||||
props.default
|
props.default
|
||||||
@@ -259,7 +291,8 @@ onMounted(() => {
|
|||||||
}`"
|
}`"
|
||||||
class="rounded-t"
|
class="rounded-t"
|
||||||
>
|
>
|
||||||
<VCardText class="pt-2">
|
<VDivider />
|
||||||
|
<VCardText>
|
||||||
<DialogCloseBtn @click="emit('close')" />
|
<DialogCloseBtn @click="emit('close')" />
|
||||||
<VForm @submit.prevent="() => {}">
|
<VForm @submit.prevent="() => {}">
|
||||||
<VRow>
|
<VRow>
|
||||||
@@ -268,7 +301,8 @@ onMounted(() => {
|
|||||||
v-if="!props.default"
|
v-if="!props.default"
|
||||||
v-model="subscribeForm.keyword"
|
v-model="subscribeForm.keyword"
|
||||||
label="搜索关键词"
|
label="搜索关键词"
|
||||||
hint="设定搜索关键词后,将使用此关键词搜索站点资源,否则自动使用themoviedb中的名称搜索"
|
hint="指定搜索站点时使用的关键词"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
|
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
|
||||||
@@ -276,7 +310,8 @@ onMounted(() => {
|
|||||||
v-model="subscribeForm.total_episode"
|
v-model="subscribeForm.total_episode"
|
||||||
label="总集数"
|
label="总集数"
|
||||||
:rules="[numberValidator]"
|
:rules="[numberValidator]"
|
||||||
hint="设定剧集的总集数,以应对themoviedb中剧集信息未维护完整,导致提前结束订阅的情况"
|
hint="剧集总集数"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
|
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
|
||||||
@@ -284,19 +319,38 @@ onMounted(() => {
|
|||||||
v-model="subscribeForm.start_episode"
|
v-model="subscribeForm.start_episode"
|
||||||
label="开始集数"
|
label="开始集数"
|
||||||
:rules="[numberValidator]"
|
:rules="[numberValidator]"
|
||||||
hint="只订阅下载此集数及之后的剧集"
|
hint="开始订阅集数"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
<VSelect v-model="subscribeForm.quality" label="质量" :items="qualityOptions" />
|
<VSelect
|
||||||
|
v-model="subscribeForm.quality"
|
||||||
|
label="质量"
|
||||||
|
:items="qualityOptions"
|
||||||
|
hint="订阅资源质量"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
<VSelect v-model="subscribeForm.resolution" label="分辨率" :items="resolutionOptions" />
|
<VSelect
|
||||||
|
v-model="subscribeForm.resolution"
|
||||||
|
label="分辨率"
|
||||||
|
:items="resolutionOptions"
|
||||||
|
hint="订阅资源分辨率"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
<VSelect v-model="subscribeForm.effect" label="特效" :items="effectOptions" />
|
<VSelect
|
||||||
|
v-model="subscribeForm.effect"
|
||||||
|
label="特效"
|
||||||
|
:items="effectOptions"
|
||||||
|
hint="订阅资源特效"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow>
|
<VRow>
|
||||||
@@ -304,14 +358,16 @@ onMounted(() => {
|
|||||||
<VTextField
|
<VTextField
|
||||||
v-model="subscribeForm.include"
|
v-model="subscribeForm.include"
|
||||||
label="包含(关键字、正则式)"
|
label="包含(关键字、正则式)"
|
||||||
hint="支持正则表达式,多个关键字用 | 分隔表示或"
|
hint="包含规则,支持正则表达式"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="subscribeForm.exclude"
|
v-model="subscribeForm.exclude"
|
||||||
label="排除(关键字、正则式)"
|
label="排除(关键字、正则式)"
|
||||||
hint="支持正则表达式,多个关键字用 | 分隔表示或"
|
hint="排除规则,支持正则表达式"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
@@ -321,16 +377,19 @@ onMounted(() => {
|
|||||||
chips
|
chips
|
||||||
label="订阅站点"
|
label="订阅站点"
|
||||||
multiple
|
multiple
|
||||||
hint="只订阅选中的订阅站点,不选则订阅所有可订阅站点"
|
hint="订阅的站点范围,不选使用系统设置"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
<VTextField
|
<VCombobox
|
||||||
v-model="subscribeForm.save_path"
|
v-model="subscribeForm.save_path"
|
||||||
|
:items="targetDirectories"
|
||||||
label="保存路径"
|
label="保存路径"
|
||||||
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
|
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
@@ -339,31 +398,40 @@ onMounted(() => {
|
|||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="subscribeForm.best_version"
|
v-model="subscribeForm.best_version"
|
||||||
label="洗版"
|
label="洗版"
|
||||||
hint="开启后不管媒体库是否存在,均会根据洗版优先级进行过滤下载,直到下载到了最高优先级的资源为止"
|
hint="根据洗版优先级进行洗版订阅"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" md="4">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="subscribeForm.search_imdbid"
|
v-model="subscribeForm.search_imdbid"
|
||||||
label="使用 ImdbID 搜索"
|
label="使用 ImdbID 搜索"
|
||||||
hint="开启后将使用 ImdbID 搜索资源,搜索结果更精确,但不是所有站点都支持"
|
hint="开使用 ImdbID 精确搜索资源"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol v-if="props.default" cols="12" md="4">
|
<VCol v-if="props.default" cols="12" md="4">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="subscribeForm.show_edit_dialog"
|
v-model="subscribeForm.show_edit_dialog"
|
||||||
label="订阅时编辑更多规则"
|
label="订阅时编辑更多规则"
|
||||||
hint="开启后将在添加订阅后弹出编辑订阅的对话框,方便用户编辑订阅规则"
|
hint="添加订阅时显示此编辑订阅对话框"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
<VCardActions class="pt-3">
|
||||||
<VCardActions>
|
<VBtn v-if="!props.default" color="error" @click="removeSubscribe" variant="outlined" class="me-3">
|
||||||
<VBtn v-if="!props.default" color="error" @click="removeSubscribe"> 取消订阅 </VBtn>
|
取消订阅
|
||||||
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn variant="tonal" @click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`">
|
<VBtn
|
||||||
|
variant="elevated"
|
||||||
|
@click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
>
|
||||||
保存
|
保存
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import api from '@/api'
|
|||||||
import { Subscribe } from '@/api/types'
|
import { Subscribe } from '@/api/types'
|
||||||
import { formatDateDifference } from '@core/utils/formatters'
|
import { formatDateDifference } from '@core/utils/formatters'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import ProgressDialog from './ProgressDialog.vue'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -133,9 +134,10 @@ const dropdownItems = ref([
|
|||||||
<template>
|
<template>
|
||||||
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard class="mx-auto" width="100%">
|
<VCard class="mx-auto" width="100%">
|
||||||
<VCardItem class="pb-0">
|
<VCardItem>
|
||||||
<VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle>
|
<VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
<VDivider />
|
||||||
<DialogCloseBtn
|
<DialogCloseBtn
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
@@ -204,13 +206,6 @@ const dropdownItems = ref([
|
|||||||
</VList>
|
</VList>
|
||||||
</VCard>
|
</VCard>
|
||||||
<!-- 进度框 -->
|
<!-- 进度框 -->
|
||||||
<VDialog v-model="progressDialog" :scrim="false" width="25rem">
|
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||||
<VCard color="primary">
|
|
||||||
<VCardText class="text-center">
|
|
||||||
{{ progressText }}
|
|
||||||
<VProgressLinear indeterminate color="white" class="mb-0 mt-1" />
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
98
src/components/dialog/U115AuthDialog.vue
Normal file
98
src/components/dialog/U115AuthDialog.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import QrcodeVue from 'qrcode.vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
// 定义事件
|
||||||
|
const emit = defineEmits(['done', 'close'])
|
||||||
|
|
||||||
|
// 二维码内容
|
||||||
|
const qrCodeContent = ref('')
|
||||||
|
|
||||||
|
// 下方的提示信息
|
||||||
|
const text = ref('请使用微信或115客户端扫码')
|
||||||
|
|
||||||
|
// 提醒类型
|
||||||
|
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
|
||||||
|
|
||||||
|
// timeout定时器
|
||||||
|
let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||||
|
|
||||||
|
// 完成
|
||||||
|
async function handleDone() {
|
||||||
|
emit('done')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用/aliyun/qrcode api生成二维码
|
||||||
|
async function getQrcode() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('/u115/qrcode')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
qrCodeContent.value = result.data.codeContent
|
||||||
|
} else {
|
||||||
|
text.value = result.message
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用/aliyun/check api验证二维码
|
||||||
|
async function checkQrcode() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('/u115/check')
|
||||||
|
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) {
|
||||||
|
alertType.value = 'info'
|
||||||
|
// 新建、待扫码
|
||||||
|
clearTimeout(timeoutTimer)
|
||||||
|
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||||
|
} else {
|
||||||
|
// 过期或者已取消
|
||||||
|
alertType.value = 'error'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alertType.value = 'error'
|
||||||
|
text.value = result.message
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await getQrcode()
|
||||||
|
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog width="40rem" scrollable max-height="85vh">
|
||||||
|
<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>
|
||||||
|
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||||
|
<template #prepend />
|
||||||
|
</VAlert>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Axios } from 'axios'
|
import type { Axios, AxiosRequestConfig } from 'axios'
|
||||||
import type { PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import { useConfirm } from 'vuetify-use-dialog'
|
import { useConfirm } from 'vuetify-use-dialog'
|
||||||
import axios from 'axios'
|
|
||||||
import { useToast } from 'vue-toast-notification'
|
import { useToast } from 'vue-toast-notification'
|
||||||
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
|
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
|
||||||
import { formatBytes } from '@core/utils/formatters'
|
import { formatBytes } from '@core/utils/formatters'
|
||||||
@@ -10,27 +9,52 @@ import type { Context, EndPoints, FileItem } from '@/api/types'
|
|||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
|
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
|
||||||
|
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
// 显示器宽度
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
|
// APP
|
||||||
|
const appMode = computed(() => {
|
||||||
|
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||||
|
})
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const inProps = defineProps({
|
const inProps = defineProps({
|
||||||
icons: Object,
|
icons: Object,
|
||||||
storage: String,
|
storage: String,
|
||||||
path: String,
|
|
||||||
endpoints: Object as PropType<EndPoints>,
|
endpoints: Object as PropType<EndPoints>,
|
||||||
axios: Object as PropType<Axios>,
|
axios: {
|
||||||
|
type: Object as PropType<Axios>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
refreshpending: Boolean,
|
refreshpending: Boolean,
|
||||||
|
item: {
|
||||||
|
type: Object as PropType<FileItem>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
sort: String,
|
sort: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 对外事件
|
// 对外事件
|
||||||
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
|
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
|
||||||
|
|
||||||
|
// 确认框
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
|
// 是否选择模式
|
||||||
|
const selectMode = ref(false)
|
||||||
|
|
||||||
// 是否正在加载
|
// 是否正在加载
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// 重命名loading
|
||||||
|
const renameLoading = ref(false)
|
||||||
|
|
||||||
// 识别进度条
|
// 识别进度条
|
||||||
const progressDialog = ref(false)
|
const progressDialog = ref(false)
|
||||||
|
|
||||||
@@ -40,15 +64,6 @@ const progressText = ref('请稍候 ...')
|
|||||||
// 识别进度
|
// 识别进度
|
||||||
const progressValue = ref(0)
|
const progressValue = ref(0)
|
||||||
|
|
||||||
// 确认框
|
|
||||||
const createConfirm = useConfirm()
|
|
||||||
|
|
||||||
// 存储空间类型
|
|
||||||
const storage = ref(inProps.storage ?? '')
|
|
||||||
|
|
||||||
// axios实例
|
|
||||||
const axiosInstance = ref<Axios>(inProps.axios ?? axios)
|
|
||||||
|
|
||||||
// 内容列表
|
// 内容列表
|
||||||
const items = ref<FileItem[]>([])
|
const items = ref<FileItem[]>([])
|
||||||
|
|
||||||
@@ -64,189 +79,358 @@ const transferPopper = ref(false)
|
|||||||
// 新名称
|
// 新名称
|
||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
|
|
||||||
// 当前名称
|
// 处理目录内所有文件
|
||||||
|
const renameAll = ref(false)
|
||||||
|
|
||||||
|
// 当前操作项
|
||||||
const currentItem = ref<FileItem>()
|
const currentItem = ref<FileItem>()
|
||||||
|
|
||||||
|
// 选中的项目
|
||||||
|
const selected = ref<FileItem[]>([])
|
||||||
|
|
||||||
// 识别结果
|
// 识别结果
|
||||||
const nameTestResult = ref<Context>()
|
const nameTestResult = ref<Context>()
|
||||||
|
|
||||||
// 识别结果对话框
|
// 识别结果对话框
|
||||||
const nameTestDialog = ref(false)
|
const nameTestDialog = ref(false)
|
||||||
|
|
||||||
// 延迟加载
|
// 弹出菜单
|
||||||
const defer = (_: number) => true
|
const dropdownItems = ref<{ [key: string]: any }[]>([])
|
||||||
|
|
||||||
|
// 加载进度SSE
|
||||||
|
const progressEventSource = ref<EventSource>()
|
||||||
|
|
||||||
// 目录过滤
|
// 目录过滤
|
||||||
const dirs = computed(() =>
|
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.name.includes(filter.value)))
|
||||||
items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 文件过滤
|
// 文件过滤
|
||||||
const files = computed(() =>
|
const files = computed(() => items.value.filter(item => item.type === 'file' && item.name.includes(filter.value)))
|
||||||
items.value.filter(item => item.type === 'file' && item.basename.includes(filter.value)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 是否目录
|
// 是否目录
|
||||||
const isDir = computed(() => inProps.path?.endsWith('/'))
|
const isDir = computed(() => inProps.item.path?.endsWith('/'))
|
||||||
|
|
||||||
// 是否文件
|
// 是否文件
|
||||||
const isFile = computed(() => !isDir.value)
|
const isFile = computed(() => !isDir.value)
|
||||||
|
|
||||||
|
// 需要整理的文件项
|
||||||
|
const transferItems = ref<FileItem[]>([])
|
||||||
|
|
||||||
|
// 大小控制
|
||||||
|
const scrollStyle = computed(() => {
|
||||||
|
return appMode.value
|
||||||
|
? '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 isImage = computed(() => {
|
||||||
const ext = inProps.path?.split('.').pop()?.toLowerCase()
|
const ext = inProps.item.path?.split('.').pop()?.toLowerCase()
|
||||||
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
|
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
|
||||||
})
|
})
|
||||||
|
|
||||||
// 调API加载内容
|
// 调整选择模式
|
||||||
async function load() {
|
function changeSelectMode() {
|
||||||
|
selectMode.value = !selectMode.value
|
||||||
|
if (!selectMode.value) selected.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调API加载文件夹内的内容
|
||||||
|
async function list_files() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
emit('loading', true)
|
emit('loading', true)
|
||||||
|
|
||||||
// 参数
|
// 参数
|
||||||
const url = inProps.endpoints?.list.url
|
const url = inProps.endpoints?.list.url
|
||||||
.replace(/{storage}/g, storage.value)
|
.replace(/{storage}/g, inProps.storage)
|
||||||
.replace(/{path}/g, encodeURIComponent(inProps.path || ''))
|
|
||||||
.replace(/{sort}/g, inProps.sort || 'name')
|
.replace(/{sort}/g, inProps.sort || 'name')
|
||||||
|
|
||||||
const config = {
|
const config: AxiosRequestConfig<FileItem> = {
|
||||||
url,
|
url,
|
||||||
method: inProps.endpoints?.list.method || 'get',
|
method: inProps.endpoints?.list.method || 'get',
|
||||||
|
data: inProps.item,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
items.value = await axiosInstance.value.request(config) ?? []
|
items.value = (await inProps.axios.request(config)) ?? []
|
||||||
emit('loading', false)
|
emit('loading', false)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除项目
|
// 删除项目
|
||||||
async function deleteItem(item: FileItem) {
|
async function deleteItem(item: FileItem, confirm: boolean = true) {
|
||||||
|
if (confirm) {
|
||||||
|
const confirmed = await createConfirm({
|
||||||
|
title: '确认',
|
||||||
|
content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.name}?`,
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载中
|
||||||
|
emit('loading', true)
|
||||||
|
|
||||||
|
// 请求API
|
||||||
|
const url = inProps.endpoints?.delete.url.replace(/{storage}/g, inProps.storage)
|
||||||
|
const config: AxiosRequestConfig<FileItem> = {
|
||||||
|
url,
|
||||||
|
method: inProps.endpoints?.delete.method || 'post',
|
||||||
|
data: item,
|
||||||
|
}
|
||||||
|
await inProps.axios.request(config)
|
||||||
|
|
||||||
|
// 删除完成
|
||||||
|
emit('loading', false)
|
||||||
|
emit('filedeleted')
|
||||||
|
|
||||||
|
// 重新加载
|
||||||
|
list_files()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
async function batchDelete() {
|
||||||
const confirmed = await createConfirm({
|
const confirmed = await createConfirm({
|
||||||
title: '确认',
|
title: '确认',
|
||||||
content: `是否确认删除${
|
content: `是否确认删除选中的 ${selected.value.length} 个项目?`,
|
||||||
item.type === 'dir' ? '目录' : '文件'
|
|
||||||
} ${item.basename}?`,
|
|
||||||
confirmationText: '确认',
|
|
||||||
cancellationText: '取消',
|
|
||||||
dialogProps: {
|
|
||||||
maxWidth: '50rem',
|
|
||||||
},
|
|
||||||
cancellationButtonProps: {
|
|
||||||
variant: 'tonal',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (confirmed) {
|
if (!confirmed) return
|
||||||
emit('loading', true)
|
|
||||||
const url = inProps.endpoints?.delete.url
|
|
||||||
.replace(/{storage}/g, storage.value)
|
|
||||||
.replace(/{path}/g, encodeURIComponent(item.path))
|
|
||||||
|
|
||||||
const config = {
|
// 显示进度条
|
||||||
url,
|
progressDialog.value = true
|
||||||
method: inProps.endpoints?.delete.method || 'post',
|
progressValue.value = 0
|
||||||
}
|
|
||||||
|
|
||||||
await axiosInstance.value.request(config)
|
// 删除选中的项目
|
||||||
emit('filedeleted')
|
selected.value.every(async item => {
|
||||||
emit('loading', false)
|
progressText.value = `正在删除 ${item.name} ...`
|
||||||
// 重新加载
|
await deleteItem(item, false)
|
||||||
load()
|
})
|
||||||
}
|
|
||||||
|
// 关闭进度条
|
||||||
|
progressDialog.value = false
|
||||||
|
|
||||||
|
// 重新加载
|
||||||
|
list_files()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换路径
|
// 切换路径
|
||||||
function changePath(_path: string) {
|
function changePath(item: FileItem) {
|
||||||
emit('pathchanged', _path)
|
item.path = inProps.item.path + item.name + (item.type === 'dir' ? '/' : '')
|
||||||
|
emit('pathchanged', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击列表项
|
||||||
|
function listItemClick(item: FileItem) {
|
||||||
|
if (selectMode.value) {
|
||||||
|
if (selected.value.includes(item)) {
|
||||||
|
selected.value = selected.value.filter(i => i !== item)
|
||||||
|
} else {
|
||||||
|
selected.value.push(item)
|
||||||
|
}
|
||||||
|
// 去重
|
||||||
|
selected.value = Array.from(new Set(selected.value))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changePath(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新窗口中下载文件
|
// 新窗口中下载文件
|
||||||
function download(path: string) {
|
async function download(item: FileItem) {
|
||||||
if (!path)
|
const url = inProps.endpoints?.download.url.replace(/{storage}/g, inProps.storage)
|
||||||
return
|
const filterEntries = Object.entries(item).filter(([key, value]) => !['children', 'thumbnail'].includes(key) && value)
|
||||||
const token = store.state.auth.token
|
const queryParams = filterEntries.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
|
||||||
const url_path = inProps.endpoints?.download.url
|
window.open(
|
||||||
.replace(/{storage}/g, storage.value)
|
`${import.meta.env.VITE_API_BASE_URL}${url.slice(1)}?${queryParams}&token=${store.state.auth.token}`,
|
||||||
.replace(/{path}/g, encodeURIComponent(path))
|
'_blank',
|
||||||
const url = `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
|
)
|
||||||
// 下载文件
|
|
||||||
window.open(url, '_blank')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示图片
|
// 获取图片地址
|
||||||
function getImgLink(path: string) {
|
function getImgLink(item: FileItem) {
|
||||||
if (!path)
|
let url = inProps.endpoints?.image.url.replace(/{storage}/g, inProps.storage)
|
||||||
return ''
|
const filterEntries = Object.entries(item).filter(([key, value]) => !['children', 'thumbnail'].includes(key) && value)
|
||||||
const token = store.state.auth.token
|
const queryParams = filterEntries.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
|
||||||
const url_path = inProps.endpoints?.image.url
|
return `${import.meta.env.VITE_API_BASE_URL}${url.slice(1)}?${queryParams}&token=${store.state.auth.token}`
|
||||||
.replace(/{storage}/g, storage.value)
|
|
||||||
.replace(/{path}/g, encodeURIComponent(path))
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示重命名弹窗
|
// 显示重命名弹窗
|
||||||
function showRenmae(item: FileItem) {
|
function showRenmae(item: FileItem) {
|
||||||
currentItem.value = item
|
currentItem.value = item
|
||||||
newName.value = item.name
|
newName.value = item.name
|
||||||
|
renameAll.value = false
|
||||||
renamePopper.value = true
|
renamePopper.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调用API获取新名称
|
||||||
|
async function get_recommend_name() {
|
||||||
|
renameLoading.value = true
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('transfer/name', {
|
||||||
|
params: {
|
||||||
|
path: `${inProps.item.path}${currentItem.value?.name}`,
|
||||||
|
filetype: currentItem.value?.type ?? 'file',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (result.success && result.data) {
|
||||||
|
newName.value = result.data.name
|
||||||
|
} else {
|
||||||
|
$toast.error(result.message)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
renameLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// 重命名
|
// 重命名
|
||||||
async function rename() {
|
async function rename() {
|
||||||
emit('loading', true)
|
emit('loading', true)
|
||||||
const url = inProps.endpoints?.rename.url
|
|
||||||
.replace(/{storage}/g, inProps.storage)
|
|
||||||
.replace(/{path}/g, encodeURIComponent(currentItem.value?.path || ''))
|
|
||||||
.replace(/{newname}/g, encodeURIComponent(newName.value))
|
|
||||||
|
|
||||||
const config = {
|
// 关闭弹窗
|
||||||
url,
|
renamePopper.value = false
|
||||||
method: inProps.endpoints?.mkdir.method || 'post',
|
|
||||||
|
// 显示进度条
|
||||||
|
progressDialog.value = true
|
||||||
|
progressValue.value = 0
|
||||||
|
if (renameAll.value) {
|
||||||
|
progressText.value = `正在重命名 ${currentItem.value?.path} 及目录内所有文件 ...`
|
||||||
|
} else {
|
||||||
|
progressText.value = `正在重命名 ${currentItem.value?.name} ...`
|
||||||
|
}
|
||||||
|
if (renameAll.value) {
|
||||||
|
startLoadingProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调API
|
// 调API
|
||||||
await inProps.axios?.request(config)
|
let url = inProps.endpoints?.rename.url
|
||||||
|
.replace(/{storage}/g, inProps.storage)
|
||||||
|
.replace(/{newname}/g, encodeURIComponent(newName.value))
|
||||||
|
if (renameAll.value) {
|
||||||
|
url += '&recursive=true'
|
||||||
|
}
|
||||||
|
|
||||||
renamePopper.value = false
|
const config: AxiosRequestConfig<FileItem> = {
|
||||||
newName.value = ''
|
url,
|
||||||
emit('loading', false)
|
method: inProps.endpoints?.rename.method || 'post',
|
||||||
|
data: currentItem.value,
|
||||||
|
}
|
||||||
|
const result: { [key: string]: any } = await inProps.axios?.request(config)
|
||||||
|
if (!result.success) {
|
||||||
|
$toast.error(result.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭进度条
|
||||||
|
if (renameAll.value) {
|
||||||
|
stopLoadingProgress()
|
||||||
|
}
|
||||||
|
progressDialog.value = false
|
||||||
|
|
||||||
// 通知重新加载
|
// 通知重新加载
|
||||||
|
newName.value = ''
|
||||||
|
renameAll.value = false
|
||||||
|
emit('loading', false)
|
||||||
emit('renamed')
|
emit('renamed')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示整理对话框
|
// 显示整理对话框
|
||||||
function showTransfer(item: FileItem) {
|
function showTransfer(item: FileItem) {
|
||||||
currentItem.value = item
|
transferItems.value = [item]
|
||||||
transferPopper.value = true
|
transferPopper.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示批量整理对话框
|
||||||
|
function showBatchTransfer() {
|
||||||
|
transferItems.value = selected.value
|
||||||
|
transferPopper.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整理完成
|
||||||
|
function transferDone() {
|
||||||
|
transferPopper.value = false
|
||||||
|
list_files()
|
||||||
|
}
|
||||||
|
|
||||||
// 将文件修改时间(timestape)转换为本地时间
|
// 将文件修改时间(timestape)转换为本地时间
|
||||||
function formatTime(timestape: number) {
|
function formatTime(timestape: number) {
|
||||||
return new Date(timestape * 1000).toLocaleString()
|
return new Date(timestape * 1000).toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听path变化
|
|
||||||
watch(
|
|
||||||
() => inProps.path,
|
|
||||||
async () => {
|
|
||||||
items.value = []
|
|
||||||
nameTestResult.value = undefined
|
|
||||||
nameTestDialog.value = false
|
|
||||||
await load()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 监听refreshPending变化
|
// 监听refreshPending变化
|
||||||
watch(
|
watch(
|
||||||
() => inProps.refreshpending,
|
() => inProps.refreshpending,
|
||||||
async () => {
|
async () => {
|
||||||
if (inProps.refreshpending) {
|
if (inProps.refreshpending) {
|
||||||
await load()
|
await list_files()
|
||||||
emit('refreshed')
|
emit('refreshed')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听item变化或者storage变化
|
||||||
|
watch(
|
||||||
|
[() => inProps.item, () => inProps.storage],
|
||||||
|
async () => {
|
||||||
|
// 清空列表
|
||||||
|
items.value = []
|
||||||
|
// 关闭弹窗
|
||||||
|
nameTestResult.value = undefined
|
||||||
|
nameTestDialog.value = false
|
||||||
|
// 重置菜单
|
||||||
|
dropdownItems.value = [
|
||||||
|
{
|
||||||
|
title: '识别',
|
||||||
|
value: 1,
|
||||||
|
show: true,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-text-recognition',
|
||||||
|
click: (_item: FileItem) => {
|
||||||
|
recognize(_item.path || '')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '刮削',
|
||||||
|
value: 2,
|
||||||
|
show: true,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-auto-fix',
|
||||||
|
click: (_item: FileItem) => {
|
||||||
|
scrape(_item)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '重命名',
|
||||||
|
value: 3,
|
||||||
|
show: true,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-rename',
|
||||||
|
click: showRenmae,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '整理',
|
||||||
|
value: 4,
|
||||||
|
show: true,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-folder-arrow-right',
|
||||||
|
click: showTransfer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '删除',
|
||||||
|
value: 5,
|
||||||
|
show: true,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-delete-outline',
|
||||||
|
color: 'error',
|
||||||
|
click: deleteItem,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await list_files()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
// 调用API识别
|
// 调用API识别
|
||||||
async function recognize(path: string) {
|
async function recognize(path: string) {
|
||||||
try {
|
try {
|
||||||
@@ -261,86 +445,79 @@ async function recognize(path: string) {
|
|||||||
})
|
})
|
||||||
// 关闭进度条
|
// 关闭进度条
|
||||||
progressDialog.value = false
|
progressDialog.value = false
|
||||||
if (!nameTestResult.value)
|
if (!nameTestResult.value) $toast.error(`${path} 识别失败!`)
|
||||||
$toast.error(`${path} 识别失败!`)
|
|
||||||
nameTestDialog.value = !!nameTestResult.value?.meta_info?.name
|
nameTestDialog.value = !!nameTestResult.value?.meta_info?.name
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用API刮削
|
// 调用API刮削
|
||||||
async function scrape(path: string) {
|
async function scrape(item: FileItem, confirm: boolean = true) {
|
||||||
try {
|
try {
|
||||||
|
if (confirm) {
|
||||||
|
// 确认
|
||||||
|
const confirmed = await createConfirm({
|
||||||
|
title: '确认',
|
||||||
|
content: `是否确认刮削 ${item.path}?`,
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
|
||||||
// 显示进度条
|
// 显示进度条
|
||||||
progressDialog.value = true
|
progressDialog.value = true
|
||||||
progressText.value = `正在刮削 ${path} ...`
|
progressText.value = `正在刮削 ${item.path} ...`
|
||||||
const result: { [key: string]: any } = await api.get('media/scrape', {
|
|
||||||
params: {
|
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.storage}`, item)
|
||||||
path,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// 关闭进度条
|
// 关闭进度条
|
||||||
progressDialog.value = false
|
progressDialog.value = false
|
||||||
if (!result.success)
|
if (!result.success) $toast.error(result.message)
|
||||||
$toast.error(result.message)
|
else $toast.success(`${item.path} 削刮完成!`)
|
||||||
else
|
} catch (error) {
|
||||||
$toast.success(`${path}削刮完成!`)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 弹出菜单
|
|
||||||
const dropdownItems = ref([
|
// 批量刮削
|
||||||
{
|
async function batchScrape() {
|
||||||
title: '识别',
|
// 确认
|
||||||
value: 1,
|
const confirmed = await createConfirm({
|
||||||
props: {
|
title: '确认',
|
||||||
prependIcon: 'mdi-text-recognition',
|
content: `是否确认刮削选中的 ${selected.value.length} 项?`,
|
||||||
click: (_item: FileItem) => {
|
})
|
||||||
recognize(_item.path || '')
|
if (!confirmed) return
|
||||||
},
|
|
||||||
},
|
selected.value.map(item => {
|
||||||
}, {
|
scrape(item, false)
|
||||||
title: '刮削',
|
})
|
||||||
value: 2,
|
}
|
||||||
props: {
|
|
||||||
prependIcon: 'mdi-auto-fix',
|
// 使用SSE监听加载进度
|
||||||
click: (_item: FileItem) => {
|
function startLoadingProgress() {
|
||||||
scrape(_item.path || '')
|
progressText.value = '请稍候 ...'
|
||||||
},
|
|
||||||
},
|
const token = store.state.auth.token
|
||||||
}, {
|
|
||||||
title: '重命名',
|
progressEventSource.value = new EventSource(
|
||||||
value: 3,
|
`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename?token=${token}`,
|
||||||
props: {
|
)
|
||||||
prependIcon: 'mdi-rename',
|
progressEventSource.value.onmessage = event => {
|
||||||
click: showRenmae,
|
const progress = JSON.parse(event.data)
|
||||||
},
|
if (progress) {
|
||||||
},
|
progressText.value = progress.text
|
||||||
{
|
progressValue.value = progress.value
|
||||||
title: '整理',
|
}
|
||||||
value: 4,
|
}
|
||||||
props: {
|
}
|
||||||
prependIcon: 'mdi-folder-arrow-right',
|
|
||||||
click: showTransfer,
|
// 停止监听加载进度
|
||||||
},
|
function stopLoadingProgress() {
|
||||||
},
|
progressEventSource.value?.close()
|
||||||
{
|
}
|
||||||
title: '删除',
|
|
||||||
value: 5,
|
|
||||||
props: {
|
|
||||||
prependIcon: 'mdi-delete-outline',
|
|
||||||
color: 'error',
|
|
||||||
click: deleteItem,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
load()
|
list_files()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -360,128 +537,131 @@ onMounted(() => {
|
|||||||
rounded="0"
|
rounded="0"
|
||||||
/>
|
/>
|
||||||
<VSpacer v-if="isFile" />
|
<VSpacer v-if="isFile" />
|
||||||
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
|
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||||
<VIcon color="primary">
|
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
|
||||||
mdi-text-recognition
|
<VIcon color="primary" v-else>mdi-select</VIcon>
|
||||||
</VIcon>
|
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
|
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
|
||||||
<VIcon color="primary">
|
<VIcon color="primary"> mdi-text-recognition </VIcon>
|
||||||
mdi-download
|
|
||||||
</VIcon>
|
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
<IconBtn v-if="!isFile" @click="load">
|
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
|
||||||
<VIcon color="primary">
|
<VIcon color="primary"> mdi-download </VIcon>
|
||||||
mdi-refresh
|
|
||||||
</VIcon>
|
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
|
<IconBtn v-if="!isFile" @click="list_files">
|
||||||
|
<VIcon color="primary"> mdi-refresh </VIcon>
|
||||||
|
</IconBtn>
|
||||||
|
<!-- 批量操作按钮 -->
|
||||||
|
<span v-if="selected.length > 0">
|
||||||
|
<IconBtn @click.stop="batchScrape">
|
||||||
|
<VIcon color="primary" icon="mdi-auto-fix" />
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn @click.stop="showBatchTransfer">
|
||||||
|
<VIcon color="primary" icon="mdi-folder-arrow-right" />
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn @click.stop="batchDelete">
|
||||||
|
<VIcon icon="mdi-delete-outline" color="error" />
|
||||||
|
</IconBtn>
|
||||||
|
</span>
|
||||||
</VToolbar>
|
</VToolbar>
|
||||||
<VCardText
|
<VCardText v-if="loading" class="text-center flex flex-col items-center">
|
||||||
v-if="loading"
|
<VProgressCircular size="48" indeterminate color="primary" />
|
||||||
class="text-center flex flex-col items-center"
|
|
||||||
>
|
|
||||||
<VProgressCircular
|
|
||||||
size="48"
|
|
||||||
indeterminate
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText
|
<!-- 文件详情 -->
|
||||||
v-if="!path"
|
<VCardText v-else-if="isFile && !isImage && items.length > 0" class="text-center break-all">
|
||||||
class="grow d-flex justify-center align-center grey--text"
|
<div v-if="items[0]?.thumbnail" class="flex justify-center">
|
||||||
>
|
<VImg max-width="15rem" cover :src="items[0]?.thumbnail" class="rounded border shadow-lg">
|
||||||
选择目录或文件
|
<template #placeholder>
|
||||||
|
<VSkeletonLoader class="object-cover w-full h-full" />
|
||||||
|
</template>
|
||||||
|
</VImg>
|
||||||
|
</div>
|
||||||
|
<div class="text-xl text-high-emphasis mt-3">{{ items[0]?.name }}</div>
|
||||||
|
<p class="mt-2" v-if="items[0]?.size && items[0].modify_time">
|
||||||
|
大小:{{ formatBytes(items[0]?.size || 0) }}<br />
|
||||||
|
修改时间:{{ formatTime(items[0]?.modify_time || 0) }}
|
||||||
|
</p>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText
|
<!-- 图片 -->
|
||||||
v-else-if="isFile && !isImage"
|
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
|
||||||
class="text-center break-all"
|
<VImg :src="getImgLink(items[0])" max-width="100%" max-height="100%" />
|
||||||
>
|
|
||||||
<strong>{{ items[0]?.name }}</strong><br>
|
|
||||||
大小:{{ formatBytes(items[0]?.size || 0) }}<br>
|
|
||||||
修改时间:{{ formatTime(items[0]?.modify_time || 0) }}
|
|
||||||
</VCardText>
|
|
||||||
<VCardText
|
|
||||||
v-else-if="isFile && isImage"
|
|
||||||
class="grow d-flex justify-center align-center"
|
|
||||||
>
|
|
||||||
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
|
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
<!-- 目录和文件列表 -->
|
||||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||||
<VList subheader>
|
<VList subheader>
|
||||||
<VVirtualScroll class="virtual-scroll-div" :items="[...dirs, ...files]">
|
<VVirtualScroll :items="[...dirs, ...files]" :style="scrollStyle">
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VListItem
|
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
|
||||||
v-bind="hover.props"
|
|
||||||
class="px-3 pe-1"
|
|
||||||
@click="changePath(item.path)"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon v-if="inProps.icons && item.extension" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
|
<VListItemAction v-if="selectMode">
|
||||||
<VIcon v-else icon="mdi-folder-outline" />
|
<VCheckbox v-model="selected" :value="item" />
|
||||||
|
</VListItemAction>
|
||||||
|
<template v-else>
|
||||||
|
<VIcon
|
||||||
|
v-if="inProps.icons && item.extension"
|
||||||
|
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
|
||||||
|
/>
|
||||||
|
<VIcon v-else icon="mdi-folder-outline" />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle v-text="item.name" />
|
<VListItemTitle v-text="item.name" />
|
||||||
<VListItemSubtitle v-if="item.size">
|
<VListItemSubtitle v-if="item.size">
|
||||||
{{ formatBytes(item.size) }}
|
{{ formatBytes(item.size) }}
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
<IconBtn class="d-sm-none">
|
<IconBtn v-if="display.smAndDown.value && !selectMode">
|
||||||
<VIcon
|
<VIcon icon="mdi-dots-vertical" />
|
||||||
icon="mdi-dots-vertical"
|
<VMenu activator="parent" close-on-content-click>
|
||||||
/>
|
|
||||||
<VMenu
|
|
||||||
activator="parent"
|
|
||||||
close-on-content-click
|
|
||||||
>
|
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<template v-for="(menu, i) in dropdownItems" :key="i">
|
||||||
v-for="(menu, i) in dropdownItems"
|
<VListItem
|
||||||
:key="i"
|
v-if="menu.show"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
:base-color="menu.props.color"
|
:base-color="menu.props.color"
|
||||||
@click="menu.props.click(item)"
|
@click="menu.props.click(item)"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon :icon="menu.props.prependIcon" />
|
<VIcon :icon="menu.props.prependIcon" />
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle v-text="menu.title" />
|
<VListItemTitle v-text="menu.title" />
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
</template>
|
||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
<span v-if="hover.isHovering" class="flex">
|
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
|
||||||
<VTooltip text="识别">
|
<VTooltip text="识别">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
|
<IconBtn v-bind="props" @click.stop="recognize(item.path)">
|
||||||
<VIcon icon="mdi-text-recognition" />
|
<VIcon icon="mdi-text-recognition" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
</VTooltip>
|
</VTooltip>
|
||||||
<VTooltip text="刮削">
|
<VTooltip text="刮削">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
|
<IconBtn v-bind="props" @click.stop="scrape(item)">
|
||||||
<VIcon icon="mdi-auto-fix" />
|
<VIcon icon="mdi-auto-fix" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
</VTooltip>
|
</VTooltip>
|
||||||
<VTooltip text="重命名">
|
<VTooltip text="重命名">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
|
<IconBtn v-bind="props" @click.stop="showRenmae(item)">
|
||||||
<VIcon icon="mdi-rename" />
|
<VIcon icon="mdi-rename" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
</VTooltip>
|
</VTooltip>
|
||||||
<VTooltip text="整理">
|
<VTooltip text="整理">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
|
<IconBtn v-bind="props" @click.stop="showTransfer(item)">
|
||||||
<VIcon icon="mdi-folder-arrow-right" />
|
<VIcon icon="mdi-folder-arrow-right" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
</VTooltip>
|
</VTooltip>
|
||||||
<VTooltip text="删除">
|
<VTooltip text="删除">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
|
<IconBtn v-bind="props" @click.stop="deleteItem(item)">
|
||||||
<VIcon icon="mdi-delete-outline" color="error" />
|
<VIcon icon="mdi-delete-outline" color="error" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
@@ -495,41 +675,32 @@ onMounted(() => {
|
|||||||
</VVirtualScroll>
|
</VVirtualScroll>
|
||||||
</VList>
|
</VList>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText
|
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
|
||||||
v-else-if="filter"
|
|
||||||
class="grow d-flex justify-center align-center grey--text py-5"
|
|
||||||
>
|
|
||||||
没有目录或文件
|
没有目录或文件
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText
|
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5"> 空目录 </VCardText>
|
||||||
v-else-if="!loading"
|
|
||||||
class="grow d-flex justify-center align-center grey--text py-5"
|
|
||||||
>
|
|
||||||
空目录
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
</VCard>
|
||||||
<!-- 重命名弹窗 -->
|
<!-- 重命名弹窗 -->
|
||||||
<VDialog
|
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="50rem">
|
||||||
v-if="renamePopper"
|
|
||||||
v-model="renamePopper"
|
|
||||||
max-width="50rem"
|
|
||||||
>
|
|
||||||
<VCard title="重命名">
|
<VCard title="重命名">
|
||||||
|
<DialogCloseBtn @click="renamePopper = false" />
|
||||||
|
<VDivider />
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VTextField v-model="newName" label="名称" />
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VTextField v-model="newName" label="新名称" :loading="renameLoading" />
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6" v-if="currentItem && currentItem.type == 'dir'">
|
||||||
|
<VSwitch v-model="renameAll" label="自动重命名目录内所有媒体文件" />
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions>
|
||||||
<VBtn depressed @click="renamePopper = false">
|
<VBtn color="success" variant="elevated" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
|
||||||
取消
|
自动识别名称
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VBtn :disabled="!newName" variant="elevated" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
|
||||||
<VBtn
|
确定
|
||||||
:disabled="!newName"
|
|
||||||
depressed
|
|
||||||
variant="tonal"
|
|
||||||
@click="rename"
|
|
||||||
>
|
|
||||||
重命名
|
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
@@ -538,36 +709,15 @@ onMounted(() => {
|
|||||||
<ReorganizeDialog
|
<ReorganizeDialog
|
||||||
v-if="transferPopper"
|
v-if="transferPopper"
|
||||||
v-model="transferPopper"
|
v-model="transferPopper"
|
||||||
:path="currentItem?.path"
|
:storage="inProps.storage"
|
||||||
@done="transferPopper = false; load()"
|
:items="transferItems"
|
||||||
|
@done="transferDone"
|
||||||
@close="transferPopper = false"
|
@close="transferPopper = false"
|
||||||
/>
|
/>
|
||||||
<!-- 手动整理进度框 -->
|
<!-- 进度框 -->
|
||||||
<VDialog
|
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||||||
v-model="progressDialog"
|
|
||||||
:scrim="false"
|
|
||||||
width="25rem"
|
|
||||||
>
|
|
||||||
<VCard
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
<VCardText class="text-center">
|
|
||||||
{{ progressText }}
|
|
||||||
<VProgressLinear
|
|
||||||
v-if="progressValue"
|
|
||||||
color="white"
|
|
||||||
class="mb-0 mt-1"
|
|
||||||
:model-value="progressValue"
|
|
||||||
/>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
<!-- 识别结果对话框 -->
|
<!-- 识别结果对话框 -->
|
||||||
<VDialog
|
<VDialog v-if="nameTestDialog" v-model="nameTestDialog" width="50rem">
|
||||||
v-if="nameTestDialog"
|
|
||||||
v-model="nameTestDialog"
|
|
||||||
width="50rem"
|
|
||||||
>
|
|
||||||
<VCard>
|
<VCard>
|
||||||
<DialogCloseBtn @click="nameTestDialog = false" />
|
<DialogCloseBtn @click="nameTestDialog = false" />
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
@@ -579,21 +729,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.v-card {
|
.v-card {
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-toolbar{
|
.v-toolbar {
|
||||||
background: rgb(var(--v-table-header-background));
|
background: rgb(var(--v-table-header-background));
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-scroll-div {
|
|
||||||
block-size: calc(100vh - 14rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width <= 768px) {
|
|
||||||
.virtual-scroll-div {
|
|
||||||
block-size: calc(100vh - 17rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Axios } from 'axios'
|
import type { Axios, AxiosRequestConfig } from 'axios'
|
||||||
import type { EndPoints } from '@/api/types'
|
import type { EndPoints, FileItem } from '@/api/types'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
// 显示器宽度
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const inProps = defineProps({
|
const inProps = defineProps({
|
||||||
storages: Array as PropType<any[]>,
|
storages: Array as PropType<any[]>,
|
||||||
storage: String,
|
storage: String,
|
||||||
path: String,
|
item: {
|
||||||
|
type: Object as PropType<FileItem>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
itemstack: {
|
||||||
|
type: Array as PropType<FileItem[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
endpoints: Object as PropType<EndPoints>,
|
endpoints: Object as PropType<EndPoints>,
|
||||||
axios: Object as PropType<Axios>,
|
axios: {
|
||||||
|
type: Object as PropType<Axios>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 对外事件
|
// 对外事件
|
||||||
@@ -25,10 +39,8 @@ const sort = ref('name')
|
|||||||
|
|
||||||
// 调整排序方式
|
// 调整排序方式
|
||||||
function changeSort() {
|
function changeSort() {
|
||||||
if (sort.value === 'name')
|
if (sort.value === 'name') sort.value = 'time'
|
||||||
sort.value = 'time'
|
else sort.value = 'name'
|
||||||
else
|
|
||||||
sort.value = 'name'
|
|
||||||
|
|
||||||
emit('sortchanged', sort.value)
|
emit('sortchanged', sort.value)
|
||||||
}
|
}
|
||||||
@@ -36,18 +48,20 @@ function changeSort() {
|
|||||||
// 计算PATH面包屑
|
// 计算PATH面包屑
|
||||||
const pathSegments = computed(() => {
|
const pathSegments = computed(() => {
|
||||||
let path_str = ''
|
let path_str = ''
|
||||||
const isFolder = inProps.path?.endsWith('/')
|
const isFolder = inProps.item.path?.endsWith('/')
|
||||||
const segments = inProps.path?.split('/').filter(item => item)
|
const segments = inProps.item.path?.split('/').filter(item => item)
|
||||||
|
return (
|
||||||
return segments?.map((item, index) => {
|
segments?.map((item, index) => {
|
||||||
path_str += item + ((index < segments.length - 1 || isFolder) ? '/' : '')
|
path_str += item + (index < segments.length - 1 || isFolder ? '/' : '')
|
||||||
return {
|
return {
|
||||||
name: item,
|
name: item,
|
||||||
path: path_str,
|
path: path_str,
|
||||||
}
|
}
|
||||||
}) ?? []
|
}) ?? []
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 当前存储
|
||||||
const storageObject = computed(() => {
|
const storageObject = computed(() => {
|
||||||
return inProps.storages?.find(item => item.code === inProps.storage)
|
return inProps.storages?.find(item => item.code === inProps.storage)
|
||||||
})
|
})
|
||||||
@@ -56,20 +70,19 @@ const storageObject = computed(() => {
|
|||||||
function changeStorage(code: string) {
|
function changeStorage(code: string) {
|
||||||
if (inProps.storage !== code) {
|
if (inProps.storage !== code) {
|
||||||
emit('storagechanged', code)
|
emit('storagechanged', code)
|
||||||
emit('pathchanged', '')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路径变化
|
// 路径变化
|
||||||
function changePath(_path: string) {
|
function changePath(item: FileItem) {
|
||||||
emit('pathchanged', _path)
|
emit('pathchanged', item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回上一级
|
// 返回上一级
|
||||||
function goUp() {
|
function goUp() {
|
||||||
const segments = pathSegments.value ?? []
|
const segments = pathSegments.value ?? []
|
||||||
const path = segments?.length === 1 ? '/' : segments[segments.length - 2].path
|
const fileitem = inProps.itemstack[segments.length - 1]
|
||||||
changePath(path)
|
changePath(fileitem)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建目录
|
// 创建目录
|
||||||
@@ -77,15 +90,16 @@ async function mkdir() {
|
|||||||
emit('loading', true)
|
emit('loading', true)
|
||||||
const url = inProps.endpoints?.mkdir.url
|
const url = inProps.endpoints?.mkdir.url
|
||||||
.replace(/{storage}/g, inProps.storage)
|
.replace(/{storage}/g, inProps.storage)
|
||||||
.replace(/{path}/g, encodeURIComponent(inProps.path + newFolderName.value))
|
.replace(/{name}/g, newFolderName.value)
|
||||||
|
|
||||||
const config = {
|
const config: AxiosRequestConfig<FileItem> = {
|
||||||
url,
|
url,
|
||||||
method: inProps.endpoints?.mkdir.method || 'post',
|
method: inProps.endpoints?.mkdir.method || 'post',
|
||||||
|
data: inProps.item,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调API
|
// 调API
|
||||||
await inProps.axios?.request(config)
|
await inProps.axios.request(config)
|
||||||
|
|
||||||
newFolderPopper.value = false
|
newFolderPopper.value = false
|
||||||
newFolderName.value = ''
|
newFolderName.value = ''
|
||||||
@@ -97,10 +111,8 @@ async function mkdir() {
|
|||||||
|
|
||||||
// 计算排序图标
|
// 计算排序图标
|
||||||
const sortIcon = computed(() => {
|
const sortIcon = computed(() => {
|
||||||
if (sort.value === 'time')
|
if (sort.value === 'time') return 'mdi-sort-clock-ascending-outline'
|
||||||
return 'mdi-sort-clock-ascending-outline'
|
else return 'mdi-sort-alphabetical-ascending'
|
||||||
else
|
|
||||||
return 'mdi-sort-alphabetical-ascending'
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -127,16 +139,17 @@ const sortIcon = computed(() => {
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
<VBtn variant="text" :input-value="path === '/'" class="px-1" @click="changePath('/')">
|
<VBtn variant="text" :input-value="item.path === '/'" class="px-1" @click="changePath(inProps.itemstack[0])">
|
||||||
<VIcon :icon="storageObject?.icon" class="mr-2" />
|
<VIcon :icon="storageObject?.icon" class="mr-2" />
|
||||||
{{ storageObject?.name }}
|
{{ storageObject?.name }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<template v-for="(segment, index) in pathSegments" :key="index">
|
<template v-for="(segment, index) in pathSegments" :key="index">
|
||||||
<VBtn
|
<VBtn
|
||||||
|
v-if="display.mdAndUp.value"
|
||||||
variant="text"
|
variant="text"
|
||||||
:input-value="index === pathSegments.length - 1"
|
:input-value="index === pathSegments.length - 1"
|
||||||
class="px-1 d-none d-md-block"
|
class="px-1"
|
||||||
@click="changePath(segment.path)"
|
@click="changePath(inProps.itemstack[index + 1])"
|
||||||
>
|
>
|
||||||
<VIcon icon=" mdi-chevron-right" />
|
<VIcon icon=" mdi-chevron-right" />
|
||||||
{{ segment.name }}
|
{{ segment.name }}
|
||||||
@@ -158,10 +171,7 @@ const sortIcon = computed(() => {
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
</VTooltip>
|
</VTooltip>
|
||||||
<VDialog
|
<VDialog v-model="newFolderPopper" max-width="50rem">
|
||||||
v-model="newFolderPopper"
|
|
||||||
max-width="50rem"
|
|
||||||
>
|
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn v-bind="props">
|
<IconBtn v-bind="props">
|
||||||
<VTooltip text="新建文件夹">
|
<VTooltip text="新建文件夹">
|
||||||
@@ -172,20 +182,14 @@ const sortIcon = computed(() => {
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
<VCard title="新建文件夹">
|
<VCard title="新建文件夹">
|
||||||
|
<DialogCloseBtn @click="newFolderPopper = false" />
|
||||||
|
<VDivider />
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VTextField v-model="newFolderName" label="名称" />
|
<VTextField v-model="newFolderName" label="名称" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions>
|
||||||
<div class="flex-grow-1" />
|
<div class="flex-grow-1" />
|
||||||
<VBtn depressed @click="newFolderPopper = false">
|
<VBtn :disabled="!newFolderName" variant="elevated" @click="mkdir" prepend-icon="mdi-check" class="px-5 me-3">
|
||||||
取消
|
|
||||||
</VBtn>
|
|
||||||
<VBtn
|
|
||||||
:disabled="!newFolderName"
|
|
||||||
depressed
|
|
||||||
variant="tonal"
|
|
||||||
@click="mkdir"
|
|
||||||
>
|
|
||||||
新建
|
新建
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
90
src/components/input/PathField.vue
Normal file
90
src/components/input/PathField.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<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: '/',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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: '',
|
||||||
|
basename: props.root,
|
||||||
|
extension: '',
|
||||||
|
size: 0,
|
||||||
|
modify_time: 0,
|
||||||
|
fileid: '',
|
||||||
|
parent_fileid: '',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// 拉取子目录
|
||||||
|
async function fetchDirs(item: any) {
|
||||||
|
return api
|
||||||
|
.get('/local/listdir?path=' + item.path)
|
||||||
|
.then((data: any) => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDirs(treeItems.value[0])
|
||||||
|
})
|
||||||
|
</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>
|
||||||
79
src/components/misc/DashboardElement.vue
Normal file
79
src/components/misc/DashboardElement.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DashboardItem } from '@/api/types'
|
||||||
|
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
|
||||||
|
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
|
||||||
|
import AnalyticsSpeed from '@/views/dashboard/AnalyticsSpeed.vue'
|
||||||
|
import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
|
||||||
|
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
|
||||||
|
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'
|
||||||
|
import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
|
||||||
|
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
|
||||||
|
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
|
||||||
|
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
|
||||||
|
import DashboardRender from '@/components/render/DashboardRender.vue'
|
||||||
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const props = defineProps({
|
||||||
|
// 仪表板配置
|
||||||
|
config: Object as PropType<DashboardItem>,
|
||||||
|
// 刷新状态
|
||||||
|
refreshStatus: Boolean,
|
||||||
|
// 是否允许刷新数据
|
||||||
|
allowRefresh: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:refreshStatus'])
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 组件卸载时禁用刷新状态
|
||||||
|
emit('update:refreshStatus', false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<!-- 系统内置的仪表板 -->
|
||||||
|
<AnalyticsStorage v-if="config?.id === 'storage'" />
|
||||||
|
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
|
||||||
|
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
|
||||||
|
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
|
||||||
|
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
|
||||||
|
<AnalyticsCpu v-else-if="config?.id === 'cpu'" :allowRefresh="props.allowRefresh" />
|
||||||
|
<AnalyticsMemory v-else-if="config?.id === 'memory'" :allowRefresh="props.allowRefresh" />
|
||||||
|
<MediaServerLibrary v-else-if="config?.id === 'library'" />
|
||||||
|
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
||||||
|
<MediaServerLatest v-else-if="config?.id === 'latest'" />
|
||||||
|
<!-- 插件仪表板 -->
|
||||||
|
<VHover v-else-if="!isNullOrEmptyObject(props.config)">
|
||||||
|
<template #default="hover">
|
||||||
|
<!-- 无边框 -->
|
||||||
|
<div v-if="props.config?.attrs.border === false">
|
||||||
|
<VCard v-bind="hover.props">
|
||||||
|
<VCardText class="p-0">
|
||||||
|
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
|
||||||
|
</VCardText>
|
||||||
|
<div v-if="hover.isHovering" class="absolute right-5 top-5">
|
||||||
|
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||||
|
</div>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
<!-- 有边框 -->
|
||||||
|
<VCard v-else v-bind="hover.props">
|
||||||
|
<VCardItem v-if="props.config?.attrs.border !== false">
|
||||||
|
<template #append>
|
||||||
|
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||||
|
</template>
|
||||||
|
<VCardTitle>
|
||||||
|
{{ props.config?.attrs?.title ?? props.config?.name }}
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VCardText>
|
||||||
|
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
|
</VHover>
|
||||||
|
</template>
|
||||||
@@ -2,10 +2,16 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaInfo } from '@/api/types'
|
import type { MediaInfo } from '@/api/types'
|
||||||
|
|
||||||
|
// 定义输入变量
|
||||||
|
const props = defineProps({
|
||||||
|
type: String, // 来源 themoviedb | douban
|
||||||
|
})
|
||||||
|
|
||||||
interface TmdbItem {
|
interface TmdbItem {
|
||||||
title: string
|
title: string
|
||||||
overview: string
|
overview: string
|
||||||
tmdbid: number
|
tmdbid: number
|
||||||
|
doubanid: string
|
||||||
poster: string
|
poster: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,25 +27,23 @@ const keyword = ref('')
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// ref
|
// ref
|
||||||
const tmdbKeyword = ref<HTMLElement | null>(null)
|
const inputKeyword = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// 选中条目
|
// 选中条目
|
||||||
function selectMedia(item: TmdbItem) {
|
function selectMedia(item: TmdbItem) {
|
||||||
emit('update:modelValue', item.tmdbid)
|
emit('update:modelValue', item.tmdbid || item.doubanid)
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
// TMDB图片转换为w500大小
|
// TMDB图片转换为w500大小
|
||||||
function getW500Image(url = '') {
|
function getW500Image(url = '') {
|
||||||
if (!url)
|
if (!url) return ''
|
||||||
return ''
|
|
||||||
return url.replace('original', 'w500')
|
return url.replace('original', 'w500')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索词条
|
// 搜索词条
|
||||||
async function searchMedias() {
|
async function searchMedias() {
|
||||||
if (!keyword)
|
if (!keyword) return
|
||||||
return
|
|
||||||
|
|
||||||
// 调用API搜索词条
|
// 调用API搜索词条
|
||||||
try {
|
try {
|
||||||
@@ -57,16 +61,17 @@ async function searchMedias() {
|
|||||||
|
|
||||||
// 赋值
|
// 赋值
|
||||||
for (const item of result) {
|
for (const item of result) {
|
||||||
|
if (props.type && props.type !== item.source) continue
|
||||||
items.value.push({
|
items.value.push({
|
||||||
tmdbid: item.tmdb_id || 0,
|
tmdbid: item.tmdb_id || 0,
|
||||||
|
doubanid: item.douban_id || '',
|
||||||
poster: getW500Image(item.poster_path),
|
poster: getW500Image(item.poster_path),
|
||||||
title: `${item.title}(${item.year})`,
|
title: `${item.title}(${item.year})`,
|
||||||
overview: `<span class="text-primary">${item.type}</span> ${item.overview}`,
|
overview: `<span class="text-primary">${item.type}</span> ${item.overview}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,19 +80,16 @@ async function searchMedias() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 500ms后聚焦
|
// 500ms后聚焦
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
tmdbKeyword.value?.focus()
|
inputKeyword.value?.focus()
|
||||||
}, 500)
|
}, 500)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard
|
<VCard class="mx-auto" width="100%">
|
||||||
class="mx-auto"
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<VToolbar flat class="p-0">
|
<VToolbar flat class="p-0">
|
||||||
<VTextField
|
<VTextField
|
||||||
ref="tmdbKeyword"
|
ref="inputKeyword"
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
label="输入名称搜索"
|
label="输入名称搜索"
|
||||||
single-line
|
single-line
|
||||||
@@ -101,15 +103,17 @@ onMounted(() => {
|
|||||||
@keydown.enter="searchMedias"
|
@keydown.enter="searchMedias"
|
||||||
/>
|
/>
|
||||||
</VToolbar>
|
</VToolbar>
|
||||||
<DialogCloseBtn @click="() => { emit('close') }" />
|
<DialogCloseBtn
|
||||||
<VList
|
@click="
|
||||||
v-if="items.length > 0"
|
() => {
|
||||||
lines="three"
|
emit('close')
|
||||||
>
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<VDivider />
|
||||||
|
<VList v-if="items.length > 0" lines="three">
|
||||||
<template v-for="(item, i) in items" :key="i">
|
<template v-for="(item, i) in items" :key="i">
|
||||||
<VListItem
|
<VListItem @click="selectMedia(item)">
|
||||||
@click="selectMedia(item)"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VImg
|
<VImg
|
||||||
height="75"
|
height="75"
|
||||||
@@ -8,17 +8,16 @@ const props = defineProps({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCardItem>
|
<VCardText>
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<VListItem v-for="(value, key) in props.history" :key="key">
|
||||||
v-for="(value, key) in props.history"
|
<VListItemTitle class="font-bold text-lg">
|
||||||
:key="key"
|
{{ key }}
|
||||||
>
|
</VListItemTitle>
|
||||||
<VListItemTitle>{{ key }}</VListItemTitle>
|
|
||||||
<div class="text-gray-500">
|
<div class="text-gray-500">
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</div>
|
</div>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
</VCardItem>
|
</VCardText>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
31
src/components/render/DashboardRender.vue
Normal file
31
src/components/render/DashboardRender.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { RenderProps } from '@/api/types'
|
||||||
|
import { type PropType } from 'vue'
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const elementProps = defineProps({
|
||||||
|
config: Object as PropType<RenderProps>,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Component :is="elementProps.config?.component" v-if="!elementProps.config?.html" v-bind="elementProps.config?.props">
|
||||||
|
{{ elementProps.config?.text }}
|
||||||
|
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
|
||||||
|
<slot :name="name" v-bind="_props">
|
||||||
|
<DashboardRender v-for="(slotItem, slotIndex) in content || []" :key="slotIndex" :config="slotItem" />
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
<DashboardRender
|
||||||
|
v-for="(innerItem, innerIndex) in elementProps.config?.content || []"
|
||||||
|
:key="innerIndex"
|
||||||
|
:config="innerItem"
|
||||||
|
/>
|
||||||
|
</Component>
|
||||||
|
<Component
|
||||||
|
:is="elementProps.config?.component"
|
||||||
|
v-if="elementProps.config?.html"
|
||||||
|
v-bind="elementProps.config?.props"
|
||||||
|
v-html="elementProps.config?.html"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { RenderProps } from '@/api/types'
|
||||||
import { type PropType, ref } from 'vue'
|
import { type PropType, ref } from 'vue'
|
||||||
|
|
||||||
// 组件接口
|
|
||||||
interface RenderProps {
|
|
||||||
component: string
|
|
||||||
text: string
|
|
||||||
html: string
|
|
||||||
content?: any
|
|
||||||
props?: any
|
|
||||||
}
|
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const elementProps = defineProps({
|
const elementProps = defineProps({
|
||||||
config: Object as PropType<RenderProps>,
|
config: Object as PropType<RenderProps>,
|
||||||
@@ -17,13 +9,15 @@ const elementProps = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 配置元素
|
// 配置元素
|
||||||
const formItem = ref<RenderProps>(elementProps.config ?? {
|
const formItem = ref<RenderProps>(
|
||||||
component: 'div',
|
elementProps.config ?? {
|
||||||
text: '',
|
component: 'div',
|
||||||
html: '',
|
text: '',
|
||||||
props: {},
|
html: '',
|
||||||
content: [],
|
props: {},
|
||||||
})
|
content: [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 配置数据
|
// 配置数据
|
||||||
const formData = ref<any>(elementProps.form || {})
|
const formData = ref<any>(elementProps.form || {})
|
||||||
@@ -37,53 +31,27 @@ const formData = ref<any>(elementProps.form || {})
|
|||||||
v-model:value="formData[formItem.props?.modelvalue]"
|
v-model:value="formData[formItem.props?.modelvalue]"
|
||||||
>
|
>
|
||||||
{{ formItem.text }}
|
{{ formItem.text }}
|
||||||
<template
|
<template v-for="(innerItem, innerIndex) in formItem.content || []" :key="innerIndex">
|
||||||
v-for="(innerItem, innerIndex) in (formItem.content || [])"
|
|
||||||
:key="innerIndex"
|
|
||||||
>
|
|
||||||
<FormRender
|
<FormRender
|
||||||
v-if="!!innerItem.props?.modelvalue"
|
v-if="!!innerItem.props?.modelvalue"
|
||||||
v-model:value="formData[innerItem.props?.modelvalue]"
|
v-model:value="formData[innerItem.props?.modelvalue]"
|
||||||
:config="innerItem"
|
:config="innerItem"
|
||||||
:form="formData"
|
:form="formData"
|
||||||
/>
|
/>
|
||||||
<FormRender
|
<FormRender v-else v-model="formData[innerItem.props?.model]" :config="innerItem" :form="formData" />
|
||||||
v-else
|
|
||||||
v-model="formData[innerItem.props?.model]"
|
|
||||||
:config="innerItem"
|
|
||||||
:form="formData"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</Component>
|
</Component>
|
||||||
<Component
|
<Component :is="formItem.component" v-else-if="formItem.html" v-bind="formItem.props" v-html="formItem.html" />
|
||||||
:is="formItem.component"
|
<Component :is="formItem.component" v-else v-bind="formItem.props" v-model="formData[formItem.props?.model]">
|
||||||
v-else-if="formItem.html"
|
|
||||||
v-bind="formItem.props"
|
|
||||||
v-html="formItem.html"
|
|
||||||
/>
|
|
||||||
<Component
|
|
||||||
:is="formItem.component"
|
|
||||||
v-else
|
|
||||||
v-bind="formItem.props"
|
|
||||||
v-model="formData[formItem.props?.model]"
|
|
||||||
>
|
|
||||||
{{ formItem.text }}
|
{{ formItem.text }}
|
||||||
<template
|
<template v-for="(innerItem, innerIndex) in formItem.content || []" :key="innerIndex">
|
||||||
v-for="(innerItem, innerIndex) in (formItem.content || [])"
|
|
||||||
:key="innerIndex"
|
|
||||||
>
|
|
||||||
<FormRender
|
<FormRender
|
||||||
v-if="!!innerItem.props?.modelvalue"
|
v-if="!!innerItem.props?.modelvalue"
|
||||||
v-model:value="formData[innerItem.props?.modelvalue]"
|
v-model:value="formData[innerItem.props?.modelvalue]"
|
||||||
:config="innerItem"
|
:config="innerItem"
|
||||||
:form="formData"
|
:form="formData"
|
||||||
/>
|
/>
|
||||||
<FormRender
|
<FormRender v-else v-model="formData[innerItem.props?.model]" :config="innerItem" :form="formData" />
|
||||||
v-else
|
|
||||||
v-model="formData[innerItem.props?.model]"
|
|
||||||
:config="innerItem"
|
|
||||||
:form="formData"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</Component>
|
</Component>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,53 +1,56 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { type PropType, ref } from 'vue'
|
import { type PropType } from 'vue'
|
||||||
|
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||||
|
import { RenderProps } from '@/api/types'
|
||||||
|
|
||||||
// 定议外部事件
|
// 定议外部事件
|
||||||
const emit = defineEmits(['action'])
|
const emit = defineEmits(['action'])
|
||||||
|
|
||||||
// 组件接口
|
|
||||||
interface RenderProps {
|
|
||||||
component: string
|
|
||||||
text: string
|
|
||||||
html: string
|
|
||||||
content?: any
|
|
||||||
slots?: any
|
|
||||||
props?: any
|
|
||||||
events?: any
|
|
||||||
}
|
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const elementProps = defineProps({
|
const elementProps = defineProps({
|
||||||
config: Object as PropType<RenderProps>,
|
config: Object as PropType<RenderProps>,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 进度框
|
||||||
|
const progressDialog = ref(false)
|
||||||
|
|
||||||
|
// 进度框文本
|
||||||
|
const progressText = ref('正在处理...')
|
||||||
|
|
||||||
// 元素API事件响应
|
// 元素API事件响应
|
||||||
async function commonAction(api_path: string, method: string, params = {}) {
|
async function commonAction(api_path: string, method: string, params = {}) {
|
||||||
if (!api_path || !method) return
|
if (!api_path || !method) return
|
||||||
if (method.toUpperCase() === 'GET') {
|
progressDialog.value = true
|
||||||
await api.get(api_path, {
|
try {
|
||||||
params: params,
|
if (method.toUpperCase() === 'GET') {
|
||||||
})
|
await api.get(api_path, {
|
||||||
} else {
|
params: params,
|
||||||
await api.post(api_path, params)
|
})
|
||||||
|
} else {
|
||||||
|
await api.post(api_path, params)
|
||||||
|
}
|
||||||
|
emit('action')
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
}
|
}
|
||||||
emit('action')
|
progressDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组装事件
|
// 组装事件
|
||||||
let componentEvents: { [key: string]: any } = {}
|
let componentEvents = reactive<{ [key: string]: any }>({})
|
||||||
if (!isNullOrEmptyObject(elementProps.config?.events)) {
|
watchEffect(() => {
|
||||||
for (const key in elementProps.config?.events) {
|
if (!isNullOrEmptyObject(elementProps.config?.events)) {
|
||||||
const attr = elementProps.config?.events[key]
|
for (const key in elementProps.config?.events) {
|
||||||
const func = async () => {
|
const attr = elementProps.config?.events[key]
|
||||||
await commonAction(attr['api'], attr['method'], attr['params'])
|
const func = async () => {
|
||||||
|
await commonAction(attr['api'], attr['method'], attr['params'])
|
||||||
|
}
|
||||||
|
componentEvents[key] = func
|
||||||
}
|
}
|
||||||
componentEvents[key] = func
|
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
componentEvents = {}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -82,4 +85,6 @@ if (!isNullOrEmptyObject(elementProps.config?.events)) {
|
|||||||
v-html="elementProps.config?.html"
|
v-html="elementProps.config?.html"
|
||||||
v-on="componentEvents"
|
v-on="componentEvents"
|
||||||
/>
|
/>
|
||||||
|
<!-- 进度框 -->
|
||||||
|
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
// 显示器宽度
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
// 元素
|
// 元素
|
||||||
const slideview_content = ref()
|
const slideview_content = ref()
|
||||||
@@ -27,12 +31,10 @@ function slideNext(next: boolean) {
|
|||||||
if (run_to_left_px >= slideview_content.value.scrollWidth - slideview_content.value.clientWidth)
|
if (run_to_left_px >= slideview_content.value.scrollWidth - slideview_content.value.clientWidth)
|
||||||
run_to_left_px = slideview_content.value.scrollWidth - slideview_content.value.clientWidth
|
run_to_left_px = slideview_content.value.scrollWidth - slideview_content.value.clientWidth
|
||||||
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
|
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
const card_index = card_current - card_max
|
const card_index = card_current - card_max
|
||||||
run_to_left_px = card_index * card_width
|
run_to_left_px = card_index * card_width
|
||||||
if (run_to_left_px <= 0)
|
if (run_to_left_px <= 0) run_to_left_px = 0
|
||||||
run_to_left_px = 0
|
|
||||||
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
|
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
|
||||||
}
|
}
|
||||||
slideview_content.value.scrollTo({
|
slideview_content.value.scrollTo({
|
||||||
@@ -46,7 +48,7 @@ function slideNext(next: boolean) {
|
|||||||
function countMaxNumber() {
|
function countMaxNumber() {
|
||||||
slide_card_length = slideview_content.value.children.length
|
slide_card_length = slideview_content.value.children.length
|
||||||
card_width = slideview_content.value.firstElementChild.getBoundingClientRect().width
|
card_width = slideview_content.value.firstElementChild.getBoundingClientRect().width
|
||||||
slide_gap_px = (slideview_content.value.scrollWidth / slide_card_length) - card_width
|
slide_gap_px = slideview_content.value.scrollWidth / slide_card_length - card_width
|
||||||
card_width += slide_gap_px
|
card_width += slide_gap_px
|
||||||
card_max = Math.trunc(slideview_content.value.clientWidth / card_width)
|
card_max = Math.trunc(slideview_content.value.clientWidth / card_width)
|
||||||
countDisabled()
|
countDisabled()
|
||||||
@@ -55,16 +57,18 @@ function countMaxNumber() {
|
|||||||
// 修改分页切换按钮状态
|
// 修改分页切换按钮状态
|
||||||
function countDisabled() {
|
function countDisabled() {
|
||||||
slideview_scrollLeft.value = slideview_content.value.scrollLeft
|
slideview_scrollLeft.value = slideview_content.value.scrollLeft
|
||||||
card_current = slideview_content.value.scrollLeft === 0 ? 0 : Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
|
card_current =
|
||||||
if (slide_card_length * card_width <= slideview_content.value.clientWidth)
|
slideview_content.value.scrollLeft === 0
|
||||||
disabled.value = 3
|
? 0
|
||||||
else if (slideview_content.value.scrollLeft === 0)
|
: Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
|
||||||
disabled.value = 0
|
if (slide_card_length * card_width <= slideview_content.value.clientWidth) disabled.value = 3
|
||||||
else if (slideview_content.value.scrollLeft >= slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2)
|
else if (slideview_content.value.scrollLeft === 0) disabled.value = 0
|
||||||
|
else if (
|
||||||
|
slideview_content.value.scrollLeft >=
|
||||||
|
slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2
|
||||||
|
)
|
||||||
disabled.value = 2
|
disabled.value = 2
|
||||||
|
else disabled.value = 1
|
||||||
else
|
|
||||||
disabled.value = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件加载完成
|
// 组件加载完成
|
||||||
@@ -91,7 +95,7 @@ onActivated(() => {
|
|||||||
<slot name="title">
|
<slot name="title">
|
||||||
<SlideViewTitle />
|
<SlideViewTitle />
|
||||||
</slot>
|
</slot>
|
||||||
<div v-if="disabled !== 3" class="me-1 d-none d-md-flex">
|
<div v-if="disabled !== 3 && display.mdAndUp.value" class="me-1 d-flex">
|
||||||
<VBtn
|
<VBtn
|
||||||
class="rounded-circle"
|
class="rounded-circle"
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -122,9 +126,8 @@ onActivated(() => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.slideview_content {
|
.slideview_content {
|
||||||
|
overflow: scroll hidden !important;
|
||||||
-ms-overflow-style: none !important;
|
-ms-overflow-style: none !important;
|
||||||
overflow-x: scroll !important;
|
|
||||||
overflow-y: hidden !important;
|
|
||||||
overscroll-behavior-x: contain !important;
|
overscroll-behavior-x: contain !important;
|
||||||
scrollbar-width: none !important;
|
scrollbar-width: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = inject('rankingPropsKey')
|
const props: any = inject('rankingPropsKey')
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="ms-1">
|
||||||
class="ms-1"
|
<RouterLink :to="props?.linkurl ? props?.linkurl : ''" class="slider-title">
|
||||||
>
|
<span>{{ props?.title }}</span>
|
||||||
<RouterLink
|
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
|
||||||
:to="props.linkurl ? props.linkurl : ''"
|
|
||||||
class="slider-title"
|
|
||||||
>
|
|
||||||
<span>{{ props.title }}</span>
|
|
||||||
<VIcon
|
|
||||||
icon="mdi-arrow-right-circle-outline"
|
|
||||||
class="ms-1"
|
|
||||||
/>
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -60,22 +60,17 @@ async function getResourceList() {
|
|||||||
try {
|
try {
|
||||||
resourceDataList.value = await api.get(`site/resource/${props.site}`)
|
resourceDataList.value = await api.get(`site/resource/${props.site}`)
|
||||||
resourceLoading.value = false
|
resourceLoading.value = false
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 促销Chip类
|
// 促销Chip类
|
||||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||||
if (downloadVolume === 0)
|
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||||
return 'text-white bg-lime-500'
|
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||||
else if (downloadVolume < 1)
|
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||||
return 'text-white bg-green-500'
|
else return 'text-white bg-gray-500'
|
||||||
else if (uploadVolume !== 1)
|
|
||||||
return 'text-white bg-sky-500'
|
|
||||||
else
|
|
||||||
return 'text-white bg-gray-500'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加下载
|
// 添加下载
|
||||||
@@ -83,18 +78,9 @@ async function addDownload(_torrent: any) {
|
|||||||
const isConfirmed = await createConfirm({
|
const isConfirmed = await createConfirm({
|
||||||
title: '确认',
|
title: '确认',
|
||||||
content: `是否确认下载【${_torrent.site_name}】${_torrent?.title} ?`,
|
content: `是否确认下载【${_torrent.site_name}】${_torrent?.title} ?`,
|
||||||
confirmationText: '确认',
|
|
||||||
cancellationText: '取消',
|
|
||||||
dialogProps: {
|
|
||||||
maxWidth: '50rem',
|
|
||||||
},
|
|
||||||
confirmationButtonProps: {
|
|
||||||
variant: 'tonal',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isConfirmed)
|
if (!isConfirmed) return
|
||||||
return
|
|
||||||
|
|
||||||
startNProgress()
|
startNProgress()
|
||||||
try {
|
try {
|
||||||
@@ -103,13 +89,11 @@ async function addDownload(_torrent: any) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 添加下载成功
|
// 添加下载成功
|
||||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
// 添加下载失败
|
// 添加下载失败
|
||||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`)
|
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`)
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
doneNProgress()
|
doneNProgress()
|
||||||
@@ -146,21 +130,10 @@ onMounted(() => {
|
|||||||
<div class="text-sm my-1">
|
<div class="text-sm my-1">
|
||||||
{{ item.description }}
|
{{ item.description }}
|
||||||
</div>
|
</div>
|
||||||
<VChip
|
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||||
v-if="item.hit_and_run"
|
|
||||||
variant="elevated"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1 text-white bg-black"
|
|
||||||
>
|
|
||||||
H&R
|
H&R
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||||
v-if="item.freedate_diff"
|
|
||||||
variant="elevated"
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
class="me-1 mb-1"
|
|
||||||
>
|
|
||||||
{{ item.freedate_diff }}
|
{{ item.freedate_diff }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
@@ -175,9 +148,7 @@ onMounted(() => {
|
|||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||||
:class="
|
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||||
getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)
|
|
||||||
"
|
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
size="small"
|
size="small"
|
||||||
class="me-1 mb-1"
|
class="me-1 mb-1"
|
||||||
@@ -206,18 +177,10 @@ onMounted(() => {
|
|||||||
<template #item.actions="{ item }">
|
<template #item.actions="{ item }">
|
||||||
<div class="me-n3">
|
<div class="me-n3">
|
||||||
<IconBtn>
|
<IconBtn>
|
||||||
<VIcon
|
<VIcon icon="mdi-dots-vertical" />
|
||||||
icon="mdi-dots-vertical"
|
<VMenu activator="parent" close-on-content-click>
|
||||||
/>
|
|
||||||
<VMenu
|
|
||||||
activator="parent"
|
|
||||||
close-on-content-click
|
|
||||||
>
|
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<VListItem variant="plain" @click="openTorrentDetail(item.page_url || '')">
|
||||||
variant="plain"
|
|
||||||
@click="openTorrentDetail(item.page_url || '')"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-information" />
|
<VIcon icon="mdi-information" />
|
||||||
</template>
|
</template>
|
||||||
@@ -238,8 +201,6 @@ onMounted(() => {
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #no-data>
|
<template #no-data> 没有数据 </template>
|
||||||
没有数据
|
|
||||||
</template>
|
|
||||||
</VDataTable>
|
</VDataTable>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,17 +2,34 @@
|
|||||||
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
|
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
|
||||||
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
||||||
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
|
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
|
||||||
|
|
||||||
// Components
|
|
||||||
import Footer from '@/layouts/components/Footer.vue'
|
import Footer from '@/layouts/components/Footer.vue'
|
||||||
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
|
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
|
||||||
|
import UserNofification from '@/layouts/components/UserNotification.vue'
|
||||||
import SearchBar from '@/layouts/components/SearchBar.vue'
|
import SearchBar from '@/layouts/components/SearchBar.vue'
|
||||||
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
|
import { SystemNavMenus } from '@/router/menu'
|
||||||
|
import { NavMenu } from '@/@layouts/types'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
const display = useDisplay()
|
||||||
|
const appMode = computed(() => {
|
||||||
|
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||||
|
})
|
||||||
|
|
||||||
// 从Vuex Store中获取superuser信息
|
// 从Vuex Store中获取superuser信息
|
||||||
const superUser = store.state.auth.superUser
|
const superUser = store.state.auth.superUser
|
||||||
|
|
||||||
|
// 根据分类获取菜单列表
|
||||||
|
const getMenuList = (header: string) => {
|
||||||
|
return SystemNavMenus.filter((item: NavMenu) => item.header === header && (!item.admin || superUser))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回上一页
|
||||||
|
function goBack() {
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -21,126 +38,51 @@ const superUser = store.state.auth.superUser
|
|||||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||||||
<div class="d-flex h-100 align-center mx-1">
|
<div class="d-flex h-100 align-center mx-1">
|
||||||
<!-- 👉 Vertical Nav Toggle -->
|
<!-- 👉 Vertical Nav Toggle -->
|
||||||
<IconBtn
|
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
|
||||||
class="ms-n2 d-lg-none"
|
|
||||||
@click="toggleVerticalOverlayNavActive(true)"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-menu" />
|
<VIcon icon="mdi-menu" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
|
<!-- 👉 Back Button -->
|
||||||
|
<IconBtn v-if="appMode" class="ms-n2" @click="goBack">
|
||||||
|
<VIcon icon="mdi-arrow-left" size="32" />
|
||||||
|
</IconBtn>
|
||||||
<!-- 👉 Search Bar -->
|
<!-- 👉 Search Bar -->
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
<!-- 👉 Spacer -->
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<!-- 👉 Github -->
|
|
||||||
<IconBtn
|
|
||||||
class="me-2"
|
|
||||||
href="https://github.com/jxxghp/MoviePilot"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-github" />
|
|
||||||
</IconBtn>
|
|
||||||
|
|
||||||
<!-- 👉 Shortcuts -->
|
<!-- 👉 Shortcuts -->
|
||||||
<ShortcutBar v-if="superUser" />
|
<ShortcutBar v-if="superUser" />
|
||||||
|
|
||||||
<!-- 👉 Theme -->
|
<!-- 👉 Theme -->
|
||||||
<NavbarThemeSwitcher class="me-2" />
|
<NavbarThemeSwitcher />
|
||||||
|
<!-- 👉 Notification -->
|
||||||
|
<UserNofification />
|
||||||
<!-- 👉 UserProfile -->
|
<!-- 👉 UserProfile -->
|
||||||
<UserProfile />
|
<UserProfile />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #vertical-nav-content>
|
<template #vertical-nav-content>
|
||||||
<VerticalNavLink
|
<VerticalNavLink v-for="item in getMenuList('开始')" :item="item" />
|
||||||
:item="{
|
|
||||||
title: '仪表板',
|
|
||||||
icon: 'mdi-home-outline',
|
|
||||||
to: '/dashboard',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 👉 发现 -->
|
<!-- 👉 发现 -->
|
||||||
<VerticalNavSectionTitle
|
<VerticalNavSectionTitle
|
||||||
:item="{
|
:item="{
|
||||||
heading: '发现',
|
heading: '发现',
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<VerticalNavLink
|
<VerticalNavLink v-for="item in getMenuList('发现')" :item="item" />
|
||||||
:item="{
|
|
||||||
title: '推荐',
|
|
||||||
icon: 'mdi-table-star',
|
|
||||||
to: '/ranking',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<VerticalNavLink
|
|
||||||
:item="{
|
|
||||||
title: '资源搜索',
|
|
||||||
icon: 'mdi-magnify',
|
|
||||||
to: '/resource',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 👉 订阅 -->
|
<!-- 👉 订阅 -->
|
||||||
<VerticalNavSectionTitle
|
<VerticalNavSectionTitle
|
||||||
:item="{
|
:item="{
|
||||||
heading: '订阅',
|
heading: '订阅',
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<VerticalNavLink
|
<VerticalNavLink v-for="item in getMenuList('订阅')" :item="item" />
|
||||||
:item="{
|
|
||||||
title: '电影',
|
|
||||||
icon: 'mdi-movie-check-outline',
|
|
||||||
to: '/subscribe-movie',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<VerticalNavLink
|
|
||||||
:item="{
|
|
||||||
title: '电视剧',
|
|
||||||
icon: 'mdi-television-classic',
|
|
||||||
to: '/subscribe-tv',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<VerticalNavLink
|
|
||||||
:item="{
|
|
||||||
title: '日历',
|
|
||||||
icon: 'mdi-calendar',
|
|
||||||
to: '/calendar',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<!-- 👉 整理 -->
|
<!-- 👉 整理 -->
|
||||||
<VerticalNavSectionTitle
|
<VerticalNavSectionTitle
|
||||||
:item="{
|
:item="{
|
||||||
heading: '整理',
|
heading: '整理',
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<VerticalNavLink
|
<VerticalNavLink v-for="item in getMenuList('整理')" :item="item" />
|
||||||
:item="{
|
|
||||||
title: '正在下载',
|
|
||||||
icon: 'mdi-download-outline',
|
|
||||||
to: '/downloading',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<VerticalNavLink
|
|
||||||
v-if="superUser"
|
|
||||||
:item="{
|
|
||||||
title: '历史记录',
|
|
||||||
icon: 'mdi-history',
|
|
||||||
to: '/history',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<VerticalNavLink
|
|
||||||
v-if="superUser"
|
|
||||||
:item="{
|
|
||||||
title: '文件管理',
|
|
||||||
icon: 'mdi-folder-multiple-outline',
|
|
||||||
to: '/filemanager',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 👉 系统 -->
|
<!-- 👉 系统 -->
|
||||||
<VerticalNavSectionTitle
|
<VerticalNavSectionTitle
|
||||||
v-if="superUser"
|
v-if="superUser"
|
||||||
@@ -148,37 +90,12 @@ const superUser = store.state.auth.superUser
|
|||||||
heading: '系统',
|
heading: '系统',
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<VerticalNavLink
|
<VerticalNavLink v-for="item in getMenuList('系统')" :item="item" />
|
||||||
v-if="superUser"
|
|
||||||
:item="{
|
|
||||||
title: '插件',
|
|
||||||
icon: 'mdi-apps',
|
|
||||||
to: '/plugins',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<VerticalNavLink
|
|
||||||
v-if="superUser"
|
|
||||||
:item="{
|
|
||||||
title: '站点管理',
|
|
||||||
icon: 'mdi-web',
|
|
||||||
to: '/site',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<VerticalNavLink
|
|
||||||
v-if="superUser"
|
|
||||||
:item="{
|
|
||||||
title: '设定',
|
|
||||||
icon: 'mdi-cog',
|
|
||||||
to: '/setting',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #after-vertical-nav-items />
|
<template #after-vertical-nav-items />
|
||||||
|
|
||||||
<!-- 👉 Pages -->
|
<!-- 👉 Pages -->
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<!-- 👉 Footer -->
|
<!-- 👉 Footer -->
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -1,3 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
const display = useDisplay()
|
||||||
|
const appMode = computed(() => {
|
||||||
|
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 各按钮活动状态
|
||||||
|
const activeState = computed(() => {
|
||||||
|
return {
|
||||||
|
home: route.path === '/dashboard',
|
||||||
|
ranking: route.path === '/ranking',
|
||||||
|
movie: route.path === '/subscribe/movie',
|
||||||
|
tv: route.path === '/subscribe/tv',
|
||||||
|
apps: route.path === '/apps',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-100 d-flex align-center justify-space-between" />
|
<div v-if="appMode" class="w-100" style="block-size: calc(3.5rem + env(safe-area-inset-bottom))">
|
||||||
|
<VBottomNavigation
|
||||||
|
grow
|
||||||
|
horizontal
|
||||||
|
color="primary"
|
||||||
|
class="footer-nav border-t"
|
||||||
|
style="block-size: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||||
|
>
|
||||||
|
<VBtn to="/dashboard" :ripple="false">
|
||||||
|
<VIcon v-if="activeState.home" size="28">mdi-home</VIcon>
|
||||||
|
<VIcon v-else size="28">mdi-home-outline</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn to="/ranking" :ripple="false">
|
||||||
|
<VIcon v-if="activeState.ranking" size="28">mdi-star</VIcon>
|
||||||
|
<VIcon v-else size="28">mdi-star-outline</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn to="/subscribe/movie" :ripple="false">
|
||||||
|
<VIcon v-if="activeState.movie" size="28">mdi-movie-open</VIcon>
|
||||||
|
<VIcon v-else size="28">mdi-movie-open-outline</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn to="/subscribe/tv" :ripple="false">
|
||||||
|
<VIcon v-if="activeState.tv" size="28">mdi-television-play</VIcon>
|
||||||
|
<VIcon v-else size="28">mdi-television</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn to="/apps" :ripple="false">
|
||||||
|
<VIcon v-if="activeState.apps" size="28">mdi-dots-horizontal-circle</VIcon>
|
||||||
|
<VIcon v-else size="28">mdi-dots-horizontal</VIcon>
|
||||||
|
</VBtn>
|
||||||
|
</VBottomNavigation>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.footer-nav {
|
||||||
|
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
background-color: rgb(var(--v-theme-surface), 0.8);
|
||||||
|
padding-block-end: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav .v-btn--variant-text .v-btn__overlay {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,25 +2,29 @@
|
|||||||
import type { ThemeSwitcherTheme } from '@layouts/types'
|
import type { ThemeSwitcherTheme } from '@layouts/types'
|
||||||
|
|
||||||
const themes: ThemeSwitcherTheme[] = [
|
const themes: ThemeSwitcherTheme[] = [
|
||||||
|
{
|
||||||
|
name: 'auto',
|
||||||
|
title: '跟随系统',
|
||||||
|
icon: 'mdi-laptop',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'light',
|
name: 'light',
|
||||||
|
title: '明亮',
|
||||||
icon: 'mdi-weather-sunny',
|
icon: 'mdi-weather-sunny',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'dark',
|
name: 'dark',
|
||||||
|
title: '暗黑',
|
||||||
icon: 'mdi-weather-night',
|
icon: 'mdi-weather-night',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'purple',
|
name: 'purple',
|
||||||
|
title: '紫韵幽兰',
|
||||||
icon: 'mdi-brightness-4',
|
icon: 'mdi-brightness-4',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'auto',
|
|
||||||
icon: 'mdi-brightness-auto',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ThemeSwitcher :themes="themes" />
|
<ThemeSwitcher class="ms-2" :themes="themes" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,106 +1,52 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
// 路由
|
import * as Mousetrap from 'mousetrap'
|
||||||
const router = useRouter()
|
import SearchBarView from '@/views/system/SearchBarView.vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
// 搜索词
|
const display = useDisplay()
|
||||||
const searchWord = ref<string>('')
|
|
||||||
|
|
||||||
// 搜索弹窗
|
|
||||||
const searchDialog = ref(false)
|
const searchDialog = ref(false)
|
||||||
|
|
||||||
// ref
|
// 注册快捷键
|
||||||
const searchWordInput = ref<HTMLElement | null>(null)
|
Mousetrap.bind(['command+k', 'ctrl+k'], openSearchDialog)
|
||||||
|
|
||||||
// Search
|
|
||||||
function search() {
|
|
||||||
if (!searchWord.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
searchDialog.value = false
|
|
||||||
router.push({
|
|
||||||
path: '/browse/media/search',
|
|
||||||
query: {
|
|
||||||
title: searchWord.value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开搜索弹窗
|
// 打开搜索弹窗
|
||||||
function openSearchDialog() {
|
function openSearchDialog() {
|
||||||
searchDialog.value = true
|
searchDialog.value = true
|
||||||
nextTick(() => {
|
return false
|
||||||
searchWordInput.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测操作系统是否是Mac
|
||||||
|
function isMac() {
|
||||||
|
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算属性:根据操作系统显示不同的按键提示
|
||||||
|
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 👉 Search Button -->
|
|
||||||
<div
|
|
||||||
class="d-flex align-center cursor-pointer"
|
|
||||||
style="user-select: none;"
|
|
||||||
>
|
|
||||||
<VDialog
|
|
||||||
v-model="searchDialog"
|
|
||||||
max-width="50rem"
|
|
||||||
transition="dialog-top-transition"
|
|
||||||
>
|
|
||||||
<!-- Dialog Content -->
|
|
||||||
<VCard title="搜索">
|
|
||||||
<VCardText>
|
|
||||||
<VRow>
|
|
||||||
<VCol cols="12">
|
|
||||||
<VTextField
|
|
||||||
ref="searchWordInput"
|
|
||||||
v-model="searchWord"
|
|
||||||
label="电影、电视剧名称"
|
|
||||||
@keydown.enter="search"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
</VCardText>
|
|
||||||
|
|
||||||
<VCardActions>
|
|
||||||
<VSpacer />
|
|
||||||
<VBtn
|
|
||||||
variant="tonal"
|
|
||||||
@click="search"
|
|
||||||
>
|
|
||||||
搜索
|
|
||||||
</VBtn>
|
|
||||||
</VCardActions>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
</div>
|
|
||||||
<!-- 👉 Search Icon -->
|
<!-- 👉 Search Icon -->
|
||||||
<IconBtn
|
<div class="d-flex align-center cursor-pointer ms-lg-n2" style="user-select: none">
|
||||||
class="d-lg-none"
|
<IconBtn @click="openSearchDialog">
|
||||||
@click="openSearchDialog"
|
<VIcon icon="ri-search-line" />
|
||||||
>
|
</IconBtn>
|
||||||
<VIcon icon="mdi-magnify" />
|
<span v-if="display.lgAndUp.value" class="flex align-center text-disabled ms-2" @click="openSearchDialog">
|
||||||
</IconBtn>
|
<span class="me-3">搜索</span>
|
||||||
<!-- 👉 Search Textfield -->
|
<span class="meta-key">{{ metaKey }}</span>
|
||||||
<span class="w-1/5">
|
</span>
|
||||||
<VTextField
|
</div>
|
||||||
key="search_navbar"
|
<!-- 搜索弹窗 -->
|
||||||
v-model="searchWord"
|
<SearchBarView v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
|
||||||
class="d-none d-lg-block text-disabled search-box"
|
|
||||||
density="compact"
|
|
||||||
variant="solo"
|
|
||||||
label="搜索电影、电视剧"
|
|
||||||
append-inner-icon="mdi-magnify"
|
|
||||||
single-line
|
|
||||||
hide-details
|
|
||||||
flat
|
|
||||||
rounded
|
|
||||||
@click:append-inner="search"
|
|
||||||
@keydown.enter="search"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style type="scss" scoped>
|
||||||
.search-box div.v-input__control div[role="textbox"] {
|
.meta-key {
|
||||||
border: 1px solid rgb(var(--v-theme-background));
|
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 6px;
|
||||||
|
block-size: 1.75rem;
|
||||||
|
padding-block: 0.1rem;
|
||||||
|
padding-inline: 0.25rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -8,6 +8,7 @@ import MessageView from '@/views/system/MessageView.vue'
|
|||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { getQueryValue } from '@/@core/utils'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -75,6 +76,29 @@ async function sendMessage() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scrollMessageToEnd()
|
scrollMessageToEnd()
|
||||||
|
const shortcut = getQueryValue('shortcut')
|
||||||
|
if (shortcut) {
|
||||||
|
switch (shortcut) {
|
||||||
|
case 'nameTest':
|
||||||
|
nameTestDialog.value = true
|
||||||
|
break
|
||||||
|
case 'netTest':
|
||||||
|
netTestDialog.value = true
|
||||||
|
break
|
||||||
|
case 'logging':
|
||||||
|
loggingDialog.value = true
|
||||||
|
break
|
||||||
|
case 'ruleTest':
|
||||||
|
ruleTestDialog.value = true
|
||||||
|
break
|
||||||
|
case 'systemTest':
|
||||||
|
systemTestDialog.value = true
|
||||||
|
break
|
||||||
|
case 'message':
|
||||||
|
messageDialog.value = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -91,7 +115,7 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<!-- Menu Activator -->
|
<!-- Menu Activator -->
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn class="me-2" v-bind="props">
|
<IconBtn class="ms-2" v-bind="props">
|
||||||
<VIcon icon="mdi-checkbox-multiple-blank-outline" />
|
<VIcon icon="mdi-checkbox-multiple-blank-outline" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
102
src/layouts/components/UserNotification.vue
Normal file
102
src/layouts/components/UserNotification.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import store from '@/store'
|
||||||
|
import { formatDateDifference } from '@core/utils/formatters'
|
||||||
|
import { SystemNotification } from '@/api/types'
|
||||||
|
|
||||||
|
// 是否有新消息
|
||||||
|
const hasNewMessage = ref(false)
|
||||||
|
|
||||||
|
// 通知列表
|
||||||
|
const notificationList = ref<SystemNotification[]>([])
|
||||||
|
|
||||||
|
// 事件源
|
||||||
|
let eventSource: EventSource | null = null
|
||||||
|
|
||||||
|
// 弹窗
|
||||||
|
const appsMenu = ref(false)
|
||||||
|
|
||||||
|
// SSE持续接收消息
|
||||||
|
function startSSEMessager() {
|
||||||
|
const token = store.state.auth.token
|
||||||
|
if (token) {
|
||||||
|
eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}`)
|
||||||
|
eventSource.addEventListener('message', event => {
|
||||||
|
if (event.data) {
|
||||||
|
const noti: SystemNotification = JSON.parse(event.data)
|
||||||
|
notificationList.value.unshift(noti)
|
||||||
|
hasNewMessage.value = true
|
||||||
|
// TODO 在顶部显示消息汽泡
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时,加载当前用户数据
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
startSSEMessager()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 页面卸载时,关闭事件源
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (eventSource) eventSource.close()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<VMenu v-model="appsMenu" width="400" transition="scale-transition" close-on-content-click>
|
||||||
|
<!-- Menu Activator -->
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||||
|
<IconBtn>
|
||||||
|
<VIcon icon="mdi-bell-outline" />
|
||||||
|
</IconBtn>
|
||||||
|
</VBadge>
|
||||||
|
<IconBtn v-else v-bind="props">
|
||||||
|
<VIcon icon="mdi-bell-outline" />
|
||||||
|
</IconBtn>
|
||||||
|
</template>
|
||||||
|
<!-- Menu Content -->
|
||||||
|
<VCard>
|
||||||
|
<VCardItem class="border-b">
|
||||||
|
<VCardTitle>通知</VCardTitle>
|
||||||
|
<template #append>
|
||||||
|
<VTooltip text="设为已读">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<IconBtn
|
||||||
|
v-bind="props"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
hasNewMessage = false
|
||||||
|
appsMenu = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-email-mark-as-unread" />
|
||||||
|
</IconBtn>
|
||||||
|
</template>
|
||||||
|
</VTooltip>
|
||||||
|
</template>
|
||||||
|
</VCardItem>
|
||||||
|
<VList lines="two" v-if="notificationList.length > 0" max-height="600">
|
||||||
|
<VListItem v-for="(item, i) in notificationList" :key="i">
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar rounded>
|
||||||
|
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
|
||||||
|
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot-happy" size="large"></VIcon>
|
||||||
|
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="overflow-visiable break-words whitespace-break-spaces">
|
||||||
|
{{ item.title }}
|
||||||
|
</VListItemTitle>
|
||||||
|
<VListItemSubtitle class="mt-2">{{ item.text }}</VListItemSubtitle>
|
||||||
|
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
<VList v-else>
|
||||||
|
<VListItem>
|
||||||
|
<VListItemTitle class="text-center">暂无通知</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VCard>
|
||||||
|
</VMenu>
|
||||||
|
</template>
|
||||||
@@ -5,6 +5,10 @@ import { useToast } from 'vue-toast-notification'
|
|||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import avatar1 from '@images/avatars/avatar-1.png'
|
import avatar1 from '@images/avatars/avatar-1.png'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
// Vuex Store
|
// Vuex Store
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
@@ -21,8 +25,7 @@ const progressDialog = ref(false)
|
|||||||
// 执行注销操作
|
// 执行注销操作
|
||||||
function logout() {
|
function logout() {
|
||||||
// 清除登录状态信息
|
// 清除登录状态信息
|
||||||
store.dispatch('auth/clearToken')
|
store.dispatch('auth/logout')
|
||||||
|
|
||||||
// 重定向到登录页面或其他适当的页面
|
// 重定向到登录页面或其他适当的页面
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
@@ -33,14 +36,6 @@ async function restart() {
|
|||||||
const confirmed = await createConfirm({
|
const confirmed = await createConfirm({
|
||||||
title: '确认',
|
title: '确认',
|
||||||
content: '确认重启系统吗?',
|
content: '确认重启系统吗?',
|
||||||
confirmationText: '确认',
|
|
||||||
cancellationText: '取消',
|
|
||||||
dialogProps: {
|
|
||||||
maxWidth: '30rem',
|
|
||||||
},
|
|
||||||
cancellationButtonProps: {
|
|
||||||
variant: 'tonal',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
@@ -56,8 +51,7 @@ async function restart() {
|
|||||||
$toast.error(result.message)
|
$toast.error(result.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
// 注销
|
// 注销
|
||||||
@@ -65,132 +59,95 @@ async function restart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 是否精简模式
|
||||||
|
const isCompactMode = ref(localStorage.getItem('MP_APPMODE') != '0')
|
||||||
|
|
||||||
// 从Vuex Store中获取信息
|
// 从Vuex Store中获取信息
|
||||||
const superUser = store.state.auth.superUser
|
const superUser = store.state.auth.superUser
|
||||||
const userName = store.state.auth.userName
|
const userName = store.state.auth.userName
|
||||||
const avatar = store.state.auth.avatar
|
const avatar = store.state.auth.avatar
|
||||||
|
|
||||||
|
// 监听精简模式切换
|
||||||
|
watch(isCompactMode, value => {
|
||||||
|
localStorage.setItem('MP_APPMODE', value ? '1' : '0')
|
||||||
|
//刷新页面
|
||||||
|
location.reload()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VAvatar
|
<VAvatar class="cursor-pointer ms-3" color="primary" variant="tonal">
|
||||||
class="cursor-pointer"
|
|
||||||
color="primary"
|
|
||||||
variant="tonal"
|
|
||||||
>
|
|
||||||
<VImg :src="avatar ?? avatar1" />
|
<VImg :src="avatar ?? avatar1" />
|
||||||
|
|
||||||
<!-- SECTION Menu -->
|
<!-- SECTION Menu -->
|
||||||
<VMenu
|
<VMenu activator="parent" width="230" location="bottom end" offset="14px">
|
||||||
activator="parent"
|
|
||||||
width="230"
|
|
||||||
location="bottom end"
|
|
||||||
offset="14px"
|
|
||||||
>
|
|
||||||
<VList>
|
<VList>
|
||||||
<!-- 👉 User Avatar & Name -->
|
<!-- 👉 User Avatar & Name -->
|
||||||
<VListItem>
|
<VListItem>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VListItemAction start>
|
<VListItemAction start>
|
||||||
<VAvatar
|
<VAvatar color="primary" variant="tonal">
|
||||||
color="primary"
|
|
||||||
variant="tonal"
|
|
||||||
>
|
|
||||||
<VImg :src="avatar ?? avatar1" />
|
<VImg :src="avatar ?? avatar1" />
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
</VListItemAction>
|
</VListItemAction>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<VListItemTitle class="font-weight-semibold">
|
<VListItemTitle class="font-weight-semibold">
|
||||||
{{ superUser ? "管理员" : "普通用户" }}
|
{{ superUser ? '管理员' : '普通用户' }}
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
|
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<VDivider v-if="display.mdAndDown.value" class="my-2" />
|
||||||
|
|
||||||
|
<!-- 👉 AppMode -->
|
||||||
|
<VListItem v-if="display.mdAndDown.value">
|
||||||
|
<template #prepend>
|
||||||
|
<VSwitch class="me-2" v-model="isCompactMode"></VSwitch>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>App模式</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
<VDivider class="my-2" />
|
<VDivider class="my-2" />
|
||||||
|
|
||||||
<!-- 👉 Profile -->
|
<!-- 👉 Profile -->
|
||||||
<VListItem
|
<VListItem v-if="superUser" link @click="router.push('/setting?tab=account')">
|
||||||
v-if="superUser"
|
|
||||||
link
|
|
||||||
to="setting"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon
|
<VIcon class="me-2" icon="mdi-account-outline" size="22" />
|
||||||
class="me-2"
|
|
||||||
icon="mdi-account-outline"
|
|
||||||
size="22"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<VListItemTitle>设定</VListItemTitle>
|
<VListItemTitle>设定</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- 👉 FAQ -->
|
||||||
<VDivider class="my-2" />
|
<VListItem href="https://wiki.movie-pilot.org" target="_blank">
|
||||||
|
|
||||||
<!-- 👉 restart -->
|
|
||||||
<VListItem
|
|
||||||
v-if="superUser"
|
|
||||||
@click="restart"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon
|
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
|
||||||
class="me-2"
|
|
||||||
icon="mdi-restart"
|
|
||||||
size="22"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
<VListItemTitle>帮助</VListItemTitle>
|
||||||
<VListItemTitle>重启</VListItemTitle>
|
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<!-- 👉 FAQ -->
|
<!-- Divider -->
|
||||||
<VListItem
|
<VDivider v-if="superUser" class="my-2" />
|
||||||
href="https://github.com/jxxghp/MoviePilot/blob/main/README.md"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon
|
|
||||||
class="me-2"
|
|
||||||
icon="mdi-help-circle-outline"
|
|
||||||
size="22"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VListItemTitle>帮助</VListItemTitle>
|
<!-- 👉 restart -->
|
||||||
|
<VListItem v-if="superUser" @click="restart">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon class="me-2" icon="mdi-restart" size="22" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>重启</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<!-- 👉 Logout -->
|
<!-- 👉 Logout -->
|
||||||
<VListItem @click="logout">
|
<VListItem @click="logout">
|
||||||
<template #prepend>
|
<VBtn color="error" block>
|
||||||
<VIcon
|
<template #append> <VIcon size="small" icon="mdi-logout" /> </template>
|
||||||
class="me-2"
|
退出登录
|
||||||
icon="mdi-logout"
|
</VBtn>
|
||||||
size="22"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VListItemTitle>注销</VListItemTitle>
|
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
<!-- !SECTION -->
|
<!-- !SECTION -->
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
<!-- 重启进度框 -->
|
<!-- 重启进度框 -->
|
||||||
<VDialog
|
<ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在重启 ..." />
|
||||||
v-model="progressDialog"
|
|
||||||
width="25rem"
|
|
||||||
>
|
|
||||||
<VCard
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
<VCardText class="text-center">
|
|
||||||
正在重启 ...
|
|
||||||
<VProgressLinear
|
|
||||||
indeterminate
|
|
||||||
color="white"
|
|
||||||
class="mb-0 mt-1"
|
|
||||||
/>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import DefaultLayoutWithVerticalNav from './components/DefaultLayoutWithVerticalNav.vue'
|
import DefaultLayoutWithVerticalNav from './components/DefaultLayoutWithVerticalNav.vue'
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
api.get('user/current')
|
|
||||||
.catch(() => {
|
|
||||||
router.replace('/login')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -27,5 +17,5 @@ document.addEventListener('visibilitychange', () => {
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
// As we are using `layouts` plugin we need its styles to be imported
|
// As we are using `layouts` plugin we need its styles to be imported
|
||||||
@use "@layouts/styles/default-layout";
|
@use '@layouts/styles/default-layout';
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
73
src/main.ts
73
src/main.ts
@@ -1,38 +1,51 @@
|
|||||||
import { VAceEditor } from 'vue3-ace-editor'
|
import '@/@core/utils/compatibility'
|
||||||
import { createApp } from 'vue'
|
|
||||||
import '@/@iconify/icons-bundle'
|
|
||||||
import ToastPlugin from 'vue-toast-notification'
|
|
||||||
import VuetifyUseDialog from 'vuetify-use-dialog'
|
|
||||||
import './ace-config'
|
import './ace-config'
|
||||||
import VueApexCharts from 'vue3-apexcharts'
|
import '@/@iconify/icons-bundle'
|
||||||
import { removeEl } from './@core/utils/dom'
|
import '@/plugins/webfontloader'
|
||||||
import App from '@/App.vue'
|
import App from '@/App.vue'
|
||||||
import vuetify from '@/plugins/vuetify'
|
import vuetify from '@/plugins/vuetify'
|
||||||
import { loadFonts } from '@/plugins/webfontloader'
|
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
|
import { VAceEditor } from 'vue3-ace-editor'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { removeEl } from './@core/utils/dom'
|
||||||
|
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||||
|
import { VTreeview } from 'vuetify/labs/VTreeview'
|
||||||
|
import ToastPlugin from 'vue-toast-notification'
|
||||||
|
import VuetifyUseDialog from 'vuetify-use-dialog'
|
||||||
|
import VueApexCharts from 'vue3-apexcharts'
|
||||||
|
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||||
|
import MediaCard from './components/cards/MediaCard.vue'
|
||||||
|
import PosterCard from './components/cards/PosterCard.vue'
|
||||||
|
import BackdropCard from './components/cards/BackdropCard.vue'
|
||||||
|
import PersonCard from './components/cards/PersonCard.vue'
|
||||||
|
import MediaInfoCard from './components/cards/MediaInfoCard.vue'
|
||||||
|
import TorrentCard from './components/cards/TorrentCard.vue'
|
||||||
|
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
|
||||||
|
import PathField from './components/input/PathField.vue'
|
||||||
import '@core/scss/template/index.scss'
|
import '@core/scss/template/index.scss'
|
||||||
import '@layouts/styles/index.scss'
|
import '@layouts/styles/index.scss'
|
||||||
import '@styles/styles.scss'
|
import '@styles/styles.scss'
|
||||||
import 'vue-toast-notification/dist/theme-bootstrap.css'
|
import 'vue-toast-notification/dist/theme-bootstrap.css'
|
||||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar';
|
import 'vue3-perfect-scrollbar/style.css'
|
||||||
import 'vue3-perfect-scrollbar/style.css';
|
|
||||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
|
||||||
import { fixArrayAt } from '@/@core/utils/compatibility'
|
|
||||||
|
|
||||||
// 修复低版本Safari等浏览器数组不支持at函数的问题
|
|
||||||
fixArrayAt()
|
|
||||||
|
|
||||||
// 加载字体
|
|
||||||
loadFonts()
|
|
||||||
|
|
||||||
// 创建Vue实例
|
// 创建Vue实例
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
// 注册全局组件
|
// 注册全局组件
|
||||||
app.component('VAceEditor', VAceEditor)
|
app
|
||||||
|
.component('VAceEditor', VAceEditor)
|
||||||
.component('VApexChart', VueApexCharts)
|
.component('VApexChart', VueApexCharts)
|
||||||
.component('VDialogCloseBtn', DialogCloseBtn)
|
.component('VDialogCloseBtn', DialogCloseBtn)
|
||||||
|
.component('VMediaCard', MediaCard)
|
||||||
|
.component('VPosterCard', PosterCard)
|
||||||
|
.component('VBackdropCard', BackdropCard)
|
||||||
|
.component('VPersonCard', PersonCard)
|
||||||
|
.component('VMediaInfoCard', MediaInfoCard)
|
||||||
|
.component('VTorrentCard', TorrentCard)
|
||||||
|
.component('VMediaIdSelector', MediaIdSelector)
|
||||||
|
.component('VTreeview', VTreeview)
|
||||||
|
.component('VPathField', PathField)
|
||||||
|
|
||||||
// 注册插件
|
// 注册插件
|
||||||
app
|
app
|
||||||
@@ -42,7 +55,27 @@ app
|
|||||||
.use(ToastPlugin, {
|
.use(ToastPlugin, {
|
||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
})
|
})
|
||||||
.use(VuetifyUseDialog)
|
.use(VuetifyUseDialog, {
|
||||||
|
confirmDialog: {
|
||||||
|
dialogProps: {
|
||||||
|
maxWidth: '40rem',
|
||||||
|
},
|
||||||
|
confirmationButtonProps: {
|
||||||
|
variant: 'elevated',
|
||||||
|
color: 'primary',
|
||||||
|
class: 'me-3 px-5',
|
||||||
|
'prepend-icon': 'mdi-check',
|
||||||
|
},
|
||||||
|
cancellationButtonProps: {
|
||||||
|
variant: 'outlined',
|
||||||
|
color: 'secondary',
|
||||||
|
class: 'me-3',
|
||||||
|
},
|
||||||
|
confirmationText: '确认',
|
||||||
|
cancellationText: '取消',
|
||||||
|
},
|
||||||
|
})
|
||||||
.use(PerfectScrollbarPlugin)
|
.use(PerfectScrollbarPlugin)
|
||||||
|
.use(VueApexCharts)
|
||||||
.mount('#app')
|
.mount('#app')
|
||||||
.$nextTick(() => removeEl('#loading-bg'))
|
.$nextTick(() => removeEl('#loading-bg'))
|
||||||
|
|||||||
71
src/pages/appcenter.vue
Normal file
71
src/pages/appcenter.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NavMenu } from '@/@layouts/types'
|
||||||
|
import { SystemNavMenus } from '@/router/menu'
|
||||||
|
import store from '@/store'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
|
||||||
|
// 从Vuex Store中获取superuser信息
|
||||||
|
const superUser = store.state.auth.superUser
|
||||||
|
|
||||||
|
// APP图标顺序
|
||||||
|
const appOrder = ref<string[]>([])
|
||||||
|
|
||||||
|
// 根据分类获取菜单列表
|
||||||
|
const getMenuList = () => {
|
||||||
|
return SystemNavMenus.filter((item: NavMenu) => !item.admin || superUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// APP列表
|
||||||
|
const appList = ref<NavMenu[]>(getMenuList())
|
||||||
|
|
||||||
|
// 保存APP图标顺序到localStorage
|
||||||
|
function saveAppsOrder() {
|
||||||
|
appOrder.value = appList.value.map(app => app.title)
|
||||||
|
localStorage.setItem('MP_APPS_ORDER', JSON.stringify(appOrder.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const localOrder = localStorage.getItem('MP_APPS_ORDER')
|
||||||
|
if (localOrder) {
|
||||||
|
appOrder.value = JSON.parse(localOrder)
|
||||||
|
// 对appList进行排序
|
||||||
|
appList.value.sort((a, b) => {
|
||||||
|
const aIndex = appOrder.value.findIndex(item => item === a.title)
|
||||||
|
const bIndex = appOrder.value.findIndex(item => item === b.title)
|
||||||
|
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="ps ps--active-y mx-3 appcenter-grid" tabindex="0">
|
||||||
|
<draggable
|
||||||
|
v-model="appList"
|
||||||
|
item-key="title"
|
||||||
|
tag="VRow"
|
||||||
|
delay="300"
|
||||||
|
@end="saveAppsOrder"
|
||||||
|
:component-data="{ 'class': 'ma-0 mt-n1' }"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<VCol cols="6" md="4" lg="3" class="text-center cursor-pointer shortcut-icon select-none">
|
||||||
|
<VCard class="pa-4" :to="element.to" variant="flat">
|
||||||
|
<VAvatar size="64" variant="text">
|
||||||
|
<VIcon size="48" :icon="element.icon" color="primary" />
|
||||||
|
</VAvatar>
|
||||||
|
<h6 class="text-base font-weight-medium mt-2 mb-0">{{ element.full_title || element.title }}</h6>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style type="scss">
|
||||||
|
.appcenter-grid .v-card {
|
||||||
|
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
background-color: rgb(var(--v-theme-surface), 0.8);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import api from '@/api'
|
||||||
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
|
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
|
||||||
|
import PersonCardListView from '@/views/discover/PersonCardListView.vue'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -11,14 +13,16 @@ const props = defineProps({
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
// 标题
|
// 标题
|
||||||
const title = route.query?.title?.toString()
|
let title = route.query?.title?.toString()
|
||||||
|
|
||||||
|
// 类型
|
||||||
|
const type = route.query?.type?.toString()
|
||||||
|
if (type === 'person') title = '演员:' + title
|
||||||
|
|
||||||
// 计算API路径
|
// 计算API路径
|
||||||
function getApiPath(paths: string[] | string) {
|
function getApiPath(paths: string[] | string) {
|
||||||
if (Array.isArray(paths))
|
if (Array.isArray(paths)) return paths.join('/')
|
||||||
return paths.join('/')
|
else return paths
|
||||||
else
|
|
||||||
return paths
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -26,14 +30,15 @@ function getApiPath(paths: string[] | string) {
|
|||||||
<div>
|
<div>
|
||||||
<div v-if="title" class="mt-3 md:flex md:items-center md:justify-between">
|
<div v-if="title" class="mt-3 md:flex md:items-center md:justify-between">
|
||||||
<div class="min-w-0 flex-1 mx-0">
|
<div class="min-w-0 flex-1 mx-0">
|
||||||
<h2 class="mb-4 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0" data-testid="page-header">
|
<h2
|
||||||
|
class="mb-4 ms-3 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0"
|
||||||
|
data-testid="page-header"
|
||||||
|
>
|
||||||
<span class="text-moviepilot">{{ title }}</span>
|
<span class="text-moviepilot">{{ title }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MediaCardListView
|
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths || '')" :params="route.query" />
|
||||||
:apipath="getApiPath(props.paths || '')"
|
<MediaCardListView v-else :apipath="getApiPath(props.paths || '')" :params="route.query" />
|
||||||
:params="route.query"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
|
import draggable from 'vuedraggable'
|
||||||
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
|
|
||||||
import AnalyticsSpeed from '@/views/dashboard/AnalyticsSpeed.vue'
|
|
||||||
import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
|
|
||||||
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
|
|
||||||
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'
|
|
||||||
import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
|
|
||||||
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
|
|
||||||
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
|
|
||||||
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
|
import { DashboardItem } from '@/api/types'
|
||||||
|
import store from '@/store'
|
||||||
|
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
// 显示器宽度
|
// APP
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
const appMode = computed(() => {
|
||||||
|
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||||
|
})
|
||||||
|
|
||||||
// 仪表盘配置
|
// 从Vuex Store中获取superuser信息
|
||||||
const dashboard_names = {
|
const superUser = store.state.auth.superUser
|
||||||
storage: '存储空间',
|
|
||||||
mediaStatistic: '媒体统计',
|
|
||||||
weeklyOverview: '最近入库',
|
|
||||||
speed: '实时速率',
|
|
||||||
scheduler: '后台任务',
|
|
||||||
cpu: 'CPU',
|
|
||||||
memory: '内存',
|
|
||||||
library: '我的媒体库',
|
|
||||||
playing: '继续观看',
|
|
||||||
latest: '最近添加',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 弹窗
|
// 是否拉升高度
|
||||||
const dialog = ref(false)
|
const isElevated = ref(true)
|
||||||
|
|
||||||
// 从localStorage中获取数据
|
// 是否发送请求的总开关
|
||||||
const default_config = {
|
const isRequest = ref(true)
|
||||||
|
|
||||||
|
// 计算属性,控制是否拉升高度
|
||||||
|
const elevatedConf = controlledComputed(
|
||||||
|
() => isElevated.value,
|
||||||
|
() => ({
|
||||||
|
class: { 'match-height': isElevated.value },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 所有组件刷新定时器的句柄
|
||||||
|
const refreshTimers = ref<{ [key: string]: NodeJS.Timeout }>({})
|
||||||
|
|
||||||
|
// 仪表板启用配置
|
||||||
|
const enableConfig = ref<{ [key: string]: boolean }>({
|
||||||
mediaStatistic: true,
|
mediaStatistic: true,
|
||||||
scheduler: false,
|
scheduler: false,
|
||||||
speed: false,
|
speed: false,
|
||||||
@@ -45,87 +45,345 @@ const default_config = {
|
|||||||
library: true,
|
library: true,
|
||||||
playing: true,
|
playing: true,
|
||||||
latest: true,
|
latest: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 仪表板顺序配置
|
||||||
|
const orderConfig = ref<{ id: string; key: string }[]>([])
|
||||||
|
|
||||||
|
// 仪表板配置
|
||||||
|
const dashboardConfigs = ref<DashboardItem[]>([
|
||||||
|
{
|
||||||
|
id: 'storage',
|
||||||
|
name: '存储空间',
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12, md: 4 },
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mediaStatistic',
|
||||||
|
name: '媒体统计',
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12, md: 8 },
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weeklyOverview',
|
||||||
|
name: '最近入库',
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12, md: 4 },
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speed',
|
||||||
|
name: '实时速率',
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12, md: 4 },
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scheduler',
|
||||||
|
name: '后台任务',
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12, md: 4 },
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cpu',
|
||||||
|
name: 'CPU',
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12, md: 6 },
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'memory',
|
||||||
|
name: '内存',
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12, md: 6 },
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'library',
|
||||||
|
name: '我的媒体库',
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12 },
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'playing',
|
||||||
|
name: '继续观看',
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12 },
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'latest',
|
||||||
|
name: '最近添加',
|
||||||
|
key: '',
|
||||||
|
attrs: {},
|
||||||
|
cols: { cols: 12 },
|
||||||
|
elements: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// 插件的仪表板元信息
|
||||||
|
const pluginDashboardMeta = ref<any[]>([])
|
||||||
|
|
||||||
|
// 插件仪表板的刷新状态
|
||||||
|
const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
|
||||||
|
|
||||||
|
// 弹窗
|
||||||
|
const dialog = ref(false)
|
||||||
|
|
||||||
|
// 加载用户监控面板配置(本地无配置时才加载)
|
||||||
|
async function loadDashboardConfig() {
|
||||||
|
// 显示配置
|
||||||
|
const local_enable = localStorage.getItem('MP_DASHBOARD')
|
||||||
|
if (local_enable) {
|
||||||
|
enableConfig.value = JSON.parse(local_enable)
|
||||||
|
} else {
|
||||||
|
const response = await api.get('/user/config/Dashboard')
|
||||||
|
if (response && response.data && response.data.value) {
|
||||||
|
enableConfig.value = response.data.value
|
||||||
|
localStorage.setItem('MP_DASHBOARD', JSON.stringify(response.data.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 顺序配置
|
||||||
|
const local_order = localStorage.getItem('MP_DASHBOARD_ORDER')
|
||||||
|
if (local_order) {
|
||||||
|
orderConfig.value = JSON.parse(local_order)
|
||||||
|
} else {
|
||||||
|
const response2 = await api.get('/user/config/DashboardOrder')
|
||||||
|
if (response2 && response2.data && response2.data.value) {
|
||||||
|
orderConfig.value = response2.data.value
|
||||||
|
localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify(orderConfig.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 是否拉升高度
|
||||||
|
const local_elevated = localStorage.getItem('MP_DASHBOARD_ELEVATED')
|
||||||
|
if (local_elevated) isElevated.value = local_elevated === 'true'
|
||||||
|
// 排序
|
||||||
|
if (orderConfig.value) {
|
||||||
|
sortDashboardConfigs()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化默认值
|
// 按order的顺序对dashboardConfigs进行排序
|
||||||
const config = ref(JSON.parse(localStorage.getItem('MP_DASHBOARD') || '{}'))
|
function sortDashboardConfigs() {
|
||||||
if (isNullOrEmptyObject(config.value)) {
|
dashboardConfigs.value.sort((a, b) => {
|
||||||
config.value = default_config
|
const aIndex = orderConfig.value.findIndex(
|
||||||
|
(item: { id: string; key: string }) => item.id === a.id && item.key === a.key,
|
||||||
|
)
|
||||||
|
const bIndex = orderConfig.value.findIndex(
|
||||||
|
(item: { id: string; key: string }) => item.id === b.id && item.key === b.key,
|
||||||
|
)
|
||||||
|
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置项目
|
// 设置项目
|
||||||
function setDashboardConfig() {
|
async function saveDashboardConfig() {
|
||||||
const data = JSON.stringify(config.value)
|
// 启用配置
|
||||||
|
const data = JSON.stringify(enableConfig.value)
|
||||||
localStorage.setItem('MP_DASHBOARD', data)
|
localStorage.setItem('MP_DASHBOARD', data)
|
||||||
|
// 顺序配置,从dashboardConfigs中提取
|
||||||
|
const order = JSON.stringify(dashboardConfigs.value.map(item => ({ id: item.id, key: item.key })))
|
||||||
|
localStorage.setItem('MP_DASHBOARD_ORDER', order)
|
||||||
|
// 是否拉升高度
|
||||||
|
localStorage.setItem('MP_DASHBOARD_ELEVATED', isElevated.value.toString())
|
||||||
// 保存到服务端
|
// 保存到服务端
|
||||||
api.post('/user/config/Dashboard', data, {
|
try {
|
||||||
headers: {
|
await api.post('/user/config/Dashboard', data, {
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
},
|
'Content-Type': 'application/json',
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
await api.post('/user/config/DashboardOrder', order, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
// 保存后重新获取插件仪表板
|
||||||
|
getPluginDashboardMeta()
|
||||||
dialog.value = false
|
dialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构造插件仪表板主ID
|
||||||
|
function buildPluginDashboardId(plugin_id: string, key: string) {
|
||||||
|
if (!key) return plugin_id
|
||||||
|
return plugin_id + ':' + key
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用API获取所有插件的仪表板元信息
|
||||||
|
async function getPluginDashboardMeta() {
|
||||||
|
// 只有超级用户才能获取
|
||||||
|
if (!superUser) return
|
||||||
|
pluginDashboardMeta.value = await api.get('/plugin/dashboard/meta')
|
||||||
|
try {
|
||||||
|
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
|
||||||
|
// 下载插件仪表板配置
|
||||||
|
pluginDashboardMeta.value.forEach(async (pluginDashboard: { id: string; key: string }) => {
|
||||||
|
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
|
||||||
|
// 初始化插件仪表板的刷新状态
|
||||||
|
pluginDashboardRefreshStatus.value[pluginDashboardId] = true
|
||||||
|
await getPluginDashboard(pluginDashboard.id, pluginDashboard.key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取一个插件的仪表板配置项
|
||||||
|
async function getPluginDashboard(id: string, key: string) {
|
||||||
|
try {
|
||||||
|
const url = key ? `/plugin/dashboard/${id}/${key}` : `/plugin/dashboard/${id}`
|
||||||
|
api.get(url).then((res: any) => {
|
||||||
|
if (res) {
|
||||||
|
// 名称替换为元信息的名称
|
||||||
|
const meta = pluginDashboardMeta.value.find(
|
||||||
|
(item: { id: string; key: string }) => item.id === id && item.key === key,
|
||||||
|
)
|
||||||
|
if (meta) res.name = meta.name
|
||||||
|
// 保存到仪表板配置中,如果已经存在则替换
|
||||||
|
const index = dashboardConfigs.value.findIndex(
|
||||||
|
(item: { id: string; key: string }) => item.id === id && item.key === key,
|
||||||
|
)
|
||||||
|
if (index !== -1) {
|
||||||
|
dashboardConfigs.value[index] = res
|
||||||
|
} else {
|
||||||
|
dashboardConfigs.value.push(res)
|
||||||
|
// 排序
|
||||||
|
sortDashboardConfigs()
|
||||||
|
}
|
||||||
|
const pluginDashboardId = buildPluginDashboardId(id, key)
|
||||||
|
// 定时刷新
|
||||||
|
if (
|
||||||
|
res.attrs?.refresh &&
|
||||||
|
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
|
||||||
|
enableConfig.value[pluginDashboardId] &&
|
||||||
|
isRequest.value
|
||||||
|
) {
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (refreshTimers.value[pluginDashboardId]) {
|
||||||
|
clearTimeout(refreshTimers.value[pluginDashboardId])
|
||||||
|
}
|
||||||
|
// 设置新的定时器
|
||||||
|
let timer = setTimeout(() => {
|
||||||
|
getPluginDashboard(id, key)
|
||||||
|
}, res.attrs.refresh * 1000)
|
||||||
|
refreshTimers.value[pluginDashboardId] = timer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖动排序结束
|
||||||
|
function dragOrderEnd() {
|
||||||
|
// 保存数据
|
||||||
|
saveDashboardConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
await loadDashboardConfig()
|
||||||
|
getPluginDashboardMeta()
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(async () => {
|
||||||
|
isRequest.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
isRequest.value = false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- 仪表板 -->
|
||||||
|
<draggable
|
||||||
|
v-model="dashboardConfigs"
|
||||||
|
@end="dragOrderEnd"
|
||||||
|
handle=".cursor-move"
|
||||||
|
item-key="id"
|
||||||
|
tag="VRow"
|
||||||
|
:component-data="elevatedConf"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<VCol v-if="enableConfig[buildPluginDashboardId(element.id, element.key)] && element.cols" v-bind:="element.cols">
|
||||||
|
<DashboardElement
|
||||||
|
:config="element"
|
||||||
|
:allow-refresh="isRequest"
|
||||||
|
v-model:refreshStatus="pluginDashboardRefreshStatus[buildPluginDashboardId(element.id, element.key)]"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
|
||||||
<!-- 底部操作按钮 -->
|
<!-- 底部操作按钮 -->
|
||||||
<VFab icon="mdi-view-dashboard-edit" location="bottom end" size="x-large" fixed app appear @click="dialog = true" />
|
<VFab
|
||||||
<VRow class="match-height">
|
icon="mdi-view-dashboard-edit"
|
||||||
<VCol v-if="config.storage" cols="12" md="4">
|
location="bottom"
|
||||||
<AnalyticsStorage />
|
size="x-large"
|
||||||
</VCol>
|
fixed
|
||||||
|
app
|
||||||
|
appear
|
||||||
|
@click="dialog = true"
|
||||||
|
:class="{ 'mb-12': appMode }"
|
||||||
|
/>
|
||||||
|
|
||||||
<VCol v-if="config.mediaStatistic" cols="12" md="8">
|
|
||||||
<AnalyticsMediaStatistic />
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol v-if="config.weeklyOverview" cols="12" md="4">
|
|
||||||
<AnalyticsWeeklyOverview />
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol v-if="config.speed" cols="12" md="4">
|
|
||||||
<AnalyticsSpeed />
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol v-if="config.scheduler" cols="12" md="4">
|
|
||||||
<AnalyticsScheduler />
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol v-if="config.cpu" cols="12" md="6">
|
|
||||||
<AnalyticsCpu />
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol v-if="config.memory" cols="12" md="6">
|
|
||||||
<AnalyticsMemory />
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol v-if="config.library" cols="12">
|
|
||||||
<MediaServerLibrary />
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol v-if="config.playing" cols="12">
|
|
||||||
<MediaServerPlaying />
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol v-if="config.latest" cols="12">
|
|
||||||
<MediaServerLatest />
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
<!-- 弹窗,根据配置生成选项 -->
|
<!-- 弹窗,根据配置生成选项 -->
|
||||||
<VDialog v-model="dialog" max-width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
|
<VDialog v-model="dialog" max-width="35rem" scrollable>
|
||||||
<VCard title="设置仪表板">
|
<VCard>
|
||||||
|
<VCardItem>
|
||||||
|
<VCardTitle>设置仪表板</VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VDivider />
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol v-for="(item, key) in dashboard_names" :key="key" cols="12" md="4" sm="4">
|
<VCol
|
||||||
<VCheckbox v-model="config[key]" :label="dashboard_names[key]" />
|
v-for="item in dashboardConfigs"
|
||||||
|
:key="buildPluginDashboardId(item.id, item.key)"
|
||||||
|
cols="6"
|
||||||
|
md="4"
|
||||||
|
sm="4"
|
||||||
|
>
|
||||||
|
<VCheckbox
|
||||||
|
v-model="enableConfig[buildPluginDashboardId(item.id, item.key)]"
|
||||||
|
:label="item.attrs?.title ?? item.name"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VSwitch v-model="isElevated" label="自适应组件高度" />
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VDivider />
|
||||||
<VBtn color="primary" @click="dialog = false"> 取消 </VBtn>
|
<VCardText class="pt-5 text-end">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" variant="tonal" @click="setDashboardConfig"> 保存 </VBtn>
|
<VBtn variant="outlined" color="secondary" class="me-4" @click="dialog = false"> 关闭 </VBtn>
|
||||||
</VCardActions>
|
<VBtn @click="saveDashboardConfig">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-content-save" />
|
||||||
|
</template>
|
||||||
|
保存
|
||||||
|
</VBtn>
|
||||||
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { requiredValidator } from '@/@validators'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import logo from '@images/logo.png'
|
import logo from '@images/logo.png'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||||
|
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
|
||||||
|
|
||||||
|
const { global: globalTheme } = useTheme()
|
||||||
|
|
||||||
// Vuex Store
|
// Vuex Store
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
@@ -29,6 +34,9 @@ const errorMessage = ref('')
|
|||||||
// 背景图片
|
// 背景图片
|
||||||
const backgroundImageUrl = ref('')
|
const backgroundImageUrl = ref('')
|
||||||
|
|
||||||
|
// 所有的背景图片
|
||||||
|
const backgroundImages = ref<string[]>([])
|
||||||
|
|
||||||
// 背景图片加载状态
|
// 背景图片加载状态
|
||||||
const isImageLoaded = ref(false)
|
const isImageLoaded = ref(false)
|
||||||
|
|
||||||
@@ -38,17 +46,21 @@ const isOTP = ref(false)
|
|||||||
// 用户名称输入框
|
// 用户名称输入框
|
||||||
const usernameInput = ref()
|
const usernameInput = ref()
|
||||||
|
|
||||||
|
// Interval定时器
|
||||||
|
let intervalTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
// 获取背景图片
|
// 获取背景图片
|
||||||
async function fetchBackgroundImage() {
|
async function fetchBackgroundImage() {
|
||||||
api
|
try {
|
||||||
.get('/login/wallpaper')
|
backgroundImages.value = await api.get('/login/wallpapers')
|
||||||
.then((response: any) => {
|
if (backgroundImages.value && backgroundImages.value.length > 0) {
|
||||||
backgroundImageUrl.value = response.message
|
backgroundImageUrl.value = backgroundImages.value[0]
|
||||||
})
|
}
|
||||||
.catch((error: any) => {
|
} catch (e) {
|
||||||
console.log(error)
|
console.log(e)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询是否开启双重验证
|
// 查询是否开启双重验证
|
||||||
const fetchOTP = debounce(async () => {
|
const fetchOTP = debounce(async () => {
|
||||||
const userid = usernameInput.value?.value
|
const userid = usernameInput.value?.value
|
||||||
@@ -66,30 +78,58 @@ const fetchOTP = debounce(async () => {
|
|||||||
})
|
})
|
||||||
}, 500)
|
}, 500)
|
||||||
|
|
||||||
// 加载用户监控面板配置
|
// 获取用户主题配置
|
||||||
async function loadDashboardConfig() {
|
async function fetchThemeConfig() {
|
||||||
const response = await api.get('/user/config/Dashboard')
|
const response = await api.get('/user/config/theme')
|
||||||
if (response && response.data && response.data.value) {
|
if (response && response.data && response.data.value) {
|
||||||
const data = JSON.stringify(response.data.value)
|
return response.data.value
|
||||||
if (data != localStorage.getItem('MP_DASHBOARD')) {
|
}
|
||||||
localStorage.setItem('MP_DASHBOARD', data)
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生效主题
|
||||||
|
async function setTheme() {
|
||||||
|
let themeValue = (await fetchThemeConfig()) || localStorage.getItem('theme') || 'light'
|
||||||
|
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||||
|
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||||
|
// 存储主题到本地
|
||||||
|
localStorage.setItem('theme', themeValue)
|
||||||
|
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅推送通知
|
||||||
|
async function subscribeForPushNotifications() {
|
||||||
|
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
// 获取订阅信息
|
||||||
|
const subscription = await registration.pushManager.getSubscription().then(function (subscription) {
|
||||||
|
if (subscription === null) {
|
||||||
|
const convertedVapidKey = urlBase64ToUint8Array(import.meta.env.VITE_PUBLIC_VAPID_KEY)
|
||||||
|
return registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: convertedVapidKey,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return subscription
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 发送订阅请求
|
||||||
|
try {
|
||||||
|
await api.post('/message/webpush/subscribe', subscription)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试加载用户监控面板配置(本地无配置时才加载)
|
// 登录后处理
|
||||||
async function tryLoadDashboardConfig() {
|
async function afterLogin(superuser: boolean) {
|
||||||
if (localStorage.getItem('MP_DASHBOARD')) {
|
// 生效主题配置
|
||||||
return
|
await setTheme()
|
||||||
}
|
|
||||||
await loadDashboardConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function afterLogin() {
|
|
||||||
// 尝试加载用户监控面板配置(本地无配置时才加载)
|
|
||||||
await tryLoadDashboardConfig()
|
|
||||||
// 跳转到首页或回原始页面
|
// 跳转到首页或回原始页面
|
||||||
router.push(store.state.auth.originalPath ?? '/')
|
router.push(store.state.auth.originalPath ?? '/')
|
||||||
|
// 订阅推送通知
|
||||||
|
if (superuser) await subscribeForPushNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录获取token事件
|
// 登录获取token事件
|
||||||
@@ -118,19 +158,17 @@ function login() {
|
|||||||
.then((response: any) => {
|
.then((response: any) => {
|
||||||
// 获取token
|
// 获取token
|
||||||
const token = response.access_token
|
const token = response.access_token
|
||||||
const superuser = response.super_user
|
const superUser = response.super_user
|
||||||
const username = response.user_name
|
const userName = response.user_name
|
||||||
const avatar = response.avatar
|
const avatar = response.avatar
|
||||||
|
const level = response.level
|
||||||
|
const remember = form.value.remember
|
||||||
|
|
||||||
// 更新token和remember状态到Vuex Store
|
// 更新token和remember状态到Vuex Store
|
||||||
store.dispatch('auth/updateToken', token)
|
store.dispatch('auth/login', { token, remember, superUser, userName, avatar, level })
|
||||||
store.dispatch('auth/updateRemember', form.value.remember)
|
|
||||||
store.dispatch('auth/updateSuperUser', superuser)
|
|
||||||
store.dispatch('auth/updateUserName', username)
|
|
||||||
store.dispatch('auth/updateAvatar', avatar)
|
|
||||||
|
|
||||||
// 登录后处理
|
// 登录后处理
|
||||||
afterLogin()
|
afterLogin(superUser)
|
||||||
})
|
})
|
||||||
.catch((error: any) => {
|
.catch((error: any) => {
|
||||||
// 登录失败,显示错误提示
|
// 登录失败,显示错误提示
|
||||||
@@ -143,7 +181,7 @@ function login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自动登录
|
// 自动登录
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// 从Vuex Store中获取token和remember状态
|
// 从Vuex Store中获取token和remember状态
|
||||||
const token = store.state.auth.token
|
const token = store.state.auth.token
|
||||||
const remember = store.state.auth.remember
|
const remember = store.state.auth.remember
|
||||||
@@ -153,81 +191,97 @@ onMounted(() => {
|
|||||||
router.push('/')
|
router.push('/')
|
||||||
} else {
|
} else {
|
||||||
// 获取背景图片
|
// 获取背景图片
|
||||||
fetchBackgroundImage()
|
await fetchBackgroundImage()
|
||||||
|
|
||||||
|
// 每隔5秒更换一次背景图片
|
||||||
|
intervalTimer = setInterval(() => {
|
||||||
|
if (backgroundImages.value.length > 0) {
|
||||||
|
const index = Math.floor(Math.random() * backgroundImages.value.length)
|
||||||
|
backgroundImageUrl.value = backgroundImages.value[index]
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (intervalTimer) clearInterval(intervalTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VImg
|
<template v-for="image in backgroundImages">
|
||||||
aspect-ratio="4/3"
|
<div v-if="backgroundImageUrl == image" class="absolute inset-0">
|
||||||
:src="backgroundImageUrl"
|
<VImg :src="image" class="w-full h-full" cover position="center top" @load="isImageLoaded = true">
|
||||||
class="w-full h-full overflow-hidden"
|
<template #placeholder>
|
||||||
cover
|
<VSkeletonLoader v-if="!isImageLoaded" class="object-cover" />
|
||||||
@load="isImageLoaded = true"
|
</template>
|
||||||
>
|
<div
|
||||||
<div class="auth-wrapper d-flex align-center justify-center pa-4">
|
class="absolute inset-0"
|
||||||
<VCard
|
style="background-image: linear-gradient(rgba(45, 55, 72, 33%) 0%, rgb(26, 32, 46) 100%)"
|
||||||
class="auth-card pa-7 w-full h-full"
|
/>
|
||||||
:class="isImageLoaded ? 'backdrop-blur-xl bg-white/50' : ''"
|
</VImg>
|
||||||
max-width="25rem"
|
|
||||||
:theme="isImageLoaded ? 'light' : ''"
|
|
||||||
>
|
|
||||||
<VCardItem class="justify-center mb-7">
|
|
||||||
<template #prepend>
|
|
||||||
<div class="d-flex pe-0">
|
|
||||||
<VImg :src="logo" width="64" height="64" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VCardTitle class="font-weight-semibold text-2xl text-uppercase"> MoviePilot </VCardTitle>
|
|
||||||
</VCardItem>
|
|
||||||
|
|
||||||
<VCardText>
|
|
||||||
<VForm ref="refForm" @submit.prevent="() => {}">
|
|
||||||
<VRow>
|
|
||||||
<!-- username -->
|
|
||||||
<VCol cols="12">
|
|
||||||
<VTextField
|
|
||||||
ref="usernameInput"
|
|
||||||
v-model="form.username"
|
|
||||||
label="用户名"
|
|
||||||
type="text"
|
|
||||||
:rules="[requiredValidator]"
|
|
||||||
@input="fetchOTP"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
<!-- password -->
|
|
||||||
<VCol cols="12">
|
|
||||||
<VTextField
|
|
||||||
v-model="form.password"
|
|
||||||
label="密码"
|
|
||||||
:type="isPasswordVisible ? 'text' : 'password'"
|
|
||||||
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
|
||||||
:rules="[requiredValidator]"
|
|
||||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
<VCol cols="12">
|
|
||||||
<VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
|
|
||||||
<!-- remember me checkbox -->
|
|
||||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
|
||||||
<VCheckbox v-model="form.remember" label="保持登录" required />
|
|
||||||
</div>
|
|
||||||
</VCol>
|
|
||||||
<VCol cols="12">
|
|
||||||
<!-- login button -->
|
|
||||||
<VBtn block type="submit" @click="login"> 登录 </VBtn>
|
|
||||||
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
|
|
||||||
{{ errorMessage }}
|
|
||||||
</div>
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
</VForm>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
</div>
|
||||||
</VImg>
|
</template>
|
||||||
|
<div class="auth-wrapper d-flex align-center justify-center pa-4">
|
||||||
|
<VCard
|
||||||
|
class="auth-card px-7 py-3 w-full h-full rounded-lg"
|
||||||
|
:class="{ 'opacity-85': isImageLoaded }"
|
||||||
|
max-width="24rem"
|
||||||
|
>
|
||||||
|
<VCardItem class="justify-center">
|
||||||
|
<template #prepend>
|
||||||
|
<div class="d-flex pe-0">
|
||||||
|
<VImg :src="logo" width="64" height="64" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VCardTitle class="font-weight-semibold text-2xl text-uppercase"> MoviePilot </VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
|
||||||
|
<VCardText>
|
||||||
|
<VForm ref="refForm" @submit.prevent="() => {}">
|
||||||
|
<VRow>
|
||||||
|
<!-- username -->
|
||||||
|
<VCol cols="12">
|
||||||
|
<VTextField
|
||||||
|
ref="usernameInput"
|
||||||
|
v-model="form.username"
|
||||||
|
label="用户名"
|
||||||
|
type="text"
|
||||||
|
:rules="[requiredValidator]"
|
||||||
|
@input="fetchOTP"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<!-- password -->
|
||||||
|
<VCol cols="12">
|
||||||
|
<VTextField
|
||||||
|
v-model="form.password"
|
||||||
|
label="密码"
|
||||||
|
:type="isPasswordVisible ? 'text' : 'password'"
|
||||||
|
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||||
|
:rules="[requiredValidator]"
|
||||||
|
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
|
||||||
|
<!-- remember me checkbox -->
|
||||||
|
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||||
|
<VCheckbox v-model="form.remember" label="保持登录" required />
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<!-- login button -->
|
||||||
|
<VBtn block type="submit" @click="login"> 登录 </VBtn>
|
||||||
|
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ const route = useRoute()
|
|||||||
// Person Id
|
// Person Id
|
||||||
const personid = route.query?.personid?.toString()
|
const personid = route.query?.personid?.toString()
|
||||||
|
|
||||||
|
// 来源
|
||||||
|
const source = route.query?.source?.toString()
|
||||||
|
|
||||||
// 类型
|
// 类型
|
||||||
const type = route.query?.type?.toString()
|
const type = route.query?.type?.toString()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PersonDetailView
|
<PersonDetailView :personid="personid" :type="type" :source="source" />
|
||||||
:personid="personid"
|
|
||||||
:type="type"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,82 +1,77 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
||||||
|
|
||||||
const viewList = reactive<{apipath: string, linkurl: string, title: string}[]>([
|
const viewList = reactive<{ apipath: string; linkurl: string; title: string }[]>([
|
||||||
{
|
{
|
||||||
apipath: 'tmdb/trending',
|
apipath: 'tmdb/trending',
|
||||||
linkurl: "/browse/tmdb/trending?title=流行趋势",
|
linkurl: '/browse/tmdb/trending?title=流行趋势',
|
||||||
title: "流行趋势",
|
title: '流行趋势',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/showing",
|
apipath: 'douban/showing',
|
||||||
linkurl: "/browse/douban/showing?title=正在热映",
|
linkurl: '/browse/douban/showing?title=正在热映',
|
||||||
title: "正在热映"
|
title: '正在热映',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "bangumi/calendar",
|
apipath: 'bangumi/calendar',
|
||||||
linkurl: "/browse/bangumi/calendar?title=Bangumi每日放送",
|
linkurl: '/browse/bangumi/calendar?title=Bangumi每日放送',
|
||||||
title: "Bangumi每日放送"
|
title: 'Bangumi每日放送',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "tmdb/movies",
|
apipath: 'tmdb/movies',
|
||||||
linkurl: "/browse/tmdb/movies?title=热门电影",
|
linkurl: '/browse/tmdb/movies?title=TMDB热门电影',
|
||||||
title: "热门电影"
|
title: 'TMDB热门电影',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "tmdb/tvs?with_original_language=zh|en|ja|ko",
|
apipath: 'tmdb/tvs?with_original_language=zh|en|ja|ko',
|
||||||
linkurl: "/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=热门电视剧",
|
linkurl: '/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=TMDB热门电视剧',
|
||||||
title: "热门电视剧"
|
title: 'TMDB热门电视剧',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/movie_hot",
|
apipath: 'douban/movie_hot',
|
||||||
linkurl: "/browse/douban/movie_hot?title=热门电影",
|
linkurl: '/browse/douban/movie_hot?title=豆瓣热门电影',
|
||||||
title: "热门电影"
|
title: '豆瓣热门电影',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/tv_hot",
|
apipath: 'douban/tv_hot',
|
||||||
linkurl: "/browse/douban/tv_hot?title=热门电视剧",
|
linkurl: '/browse/douban/tv_hot?title=豆瓣热门电视剧',
|
||||||
title: "热门电视剧"
|
title: '豆瓣热门电视剧',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/tv_animation",
|
apipath: 'douban/tv_animation',
|
||||||
linkurl: "/browse/douban/tv_animation?title=热门动漫",
|
linkurl: '/browse/douban/tv_animation?title=豆瓣热门动漫',
|
||||||
title: "热门动漫"
|
title: '豆瓣热门动漫',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/movies",
|
apipath: 'douban/movies',
|
||||||
linkurl: "/browse/douban/movies?title=最新电影",
|
linkurl: '/browse/douban/movies?title=豆瓣最新电影',
|
||||||
title: "最新电影"
|
title: '豆瓣最新电影',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/tvs",
|
apipath: 'douban/tvs',
|
||||||
linkurl: "/browse/douban/tvs?title=最新电视剧",
|
linkurl: '/browse/douban/tvs?title=豆瓣最新电视剧',
|
||||||
title: "最新电视剧"
|
title: '豆瓣最新电视剧',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/movie_top250",
|
apipath: 'douban/movie_top250',
|
||||||
linkurl: "/browse/douban/movie_top250?title=电影TOP250",
|
linkurl: '/browse/douban/movie_top250?title=电影TOP250',
|
||||||
title: "电影TOP250"
|
title: '豆瓣电影TOP250',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/tv_weekly_chinese",
|
apipath: 'douban/tv_weekly_chinese',
|
||||||
linkurl: "/browse/douban/tv_weekly_chinese?title=国产剧集榜",
|
linkurl: '/browse/douban/tv_weekly_chinese?title=豆瓣国产剧集榜',
|
||||||
title: "国产剧集榜"
|
title: '豆瓣国产剧集榜',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apipath: "douban/tv_weekly_global",
|
apipath: 'douban/tv_weekly_global',
|
||||||
linkurl: "/browse/douban/tv_weekly_global?title=全球剧集榜",
|
linkurl: '/browse/douban/tv_weekly_global?title=豆瓣全球剧集榜',
|
||||||
title: "全球剧集榜"
|
title: '豆瓣全球剧集榜',
|
||||||
}
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<MediaCardSlideView
|
<MediaCardSlideView v-for="(item, index) in viewList" :key="index" v-bind="item" />
|
||||||
v-for="item in viewList"
|
|
||||||
:key="item.apipath"
|
|
||||||
v-bind="item"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import type { Context } from '@/api/types'
|
|||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
|
import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
|
||||||
import TorrentRowListView from '@/views/discover/TorrentRowListView.vue'
|
import TorrentRowListView from '@/views/discover/TorrentRowListView.vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
// APP
|
||||||
|
const display = useDisplay()
|
||||||
|
const appMode = computed(() => {
|
||||||
|
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
|
||||||
|
})
|
||||||
|
|
||||||
// 路由参数
|
// 路由参数
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -54,7 +61,7 @@ function startLoadingProgress() {
|
|||||||
progressEventSource.value = new EventSource(
|
progressEventSource.value = new EventSource(
|
||||||
`${import.meta.env.VITE_API_BASE_URL}system/progress/search?token=${token}`,
|
`${import.meta.env.VITE_API_BASE_URL}system/progress/search?token=${token}`,
|
||||||
)
|
)
|
||||||
progressEventSource.value.onmessage = (event) => {
|
progressEventSource.value.onmessage = event => {
|
||||||
const progress = JSON.parse(event.data)
|
const progress = JSON.parse(event.data)
|
||||||
if (progress) {
|
if (progress) {
|
||||||
progressText.value = progress.text
|
progressText.value = progress.text
|
||||||
@@ -65,7 +72,7 @@ function startLoadingProgress() {
|
|||||||
|
|
||||||
// 停止监听加载进度
|
// 停止监听加载进度
|
||||||
function stopLoadingProgress() {
|
function stopLoadingProgress() {
|
||||||
progressEventSource.value?.close()
|
if (progressEventSource.value) progressEventSource.value?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置视图类型
|
// 设置视图类型
|
||||||
@@ -80,34 +87,38 @@ async function fetchData() {
|
|||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
// 查询上次搜索结果
|
// 查询上次搜索结果
|
||||||
dataList.value = await api.get('search/last')
|
dataList.value = await api.get('search/last')
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
startLoadingProgress()
|
startLoadingProgress()
|
||||||
|
let result: { [key: string]: any }
|
||||||
// 优先按TMDBID精确查询
|
// 优先按TMDBID精确查询
|
||||||
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
|
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
|
||||||
const result: {[key: string]: any} = await api.get(`search/media/${keyword}`, {
|
result = await api.get(`search/media/${keyword}`, {
|
||||||
params: {
|
params: {
|
||||||
mtype: type,
|
mtype: type,
|
||||||
area,
|
area,
|
||||||
season,
|
season,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (result.success){
|
} else {
|
||||||
dataList.value = result.data
|
|
||||||
} else {
|
|
||||||
errorDescription.value = result.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// 按标题模糊查询
|
// 按标题模糊查询
|
||||||
dataList.value = await api.get(`search/title/${keyword}`)
|
result = await api.get(`search/title`, {
|
||||||
|
params: {
|
||||||
|
keyword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (result && result.success) {
|
||||||
|
dataList.value = result.data
|
||||||
|
} else if (result && result.message) {
|
||||||
|
errorDescription.value = result.message
|
||||||
}
|
}
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
|
// 从浏览器历史中删除当前搜索
|
||||||
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
}
|
}
|
||||||
// 标记已刷新
|
// 标记已刷新
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
@@ -117,49 +128,46 @@ async function fetchData() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 卸载时停止加载进度
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopLoadingProgress()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoadingBanner
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" :text="progressText" :progress="progressValue" />
|
||||||
v-if="!isRefreshed"
|
|
||||||
class="mt-12"
|
|
||||||
:text="progressText"
|
|
||||||
:progress="progressValue"
|
|
||||||
/>
|
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
v-if="dataList.length === 0 && isRefreshed"
|
||||||
:error-title="errorTitle"
|
:error-title="errorTitle"
|
||||||
:error-description="errorDescription"
|
:error-description="errorDescription"
|
||||||
/>
|
/>
|
||||||
<div v-if="dataList.length > 0">
|
<div v-if="dataList.length > 0">
|
||||||
<TorrentRowListView
|
<TorrentRowListView v-if="viewType === 'list'" :items="dataList" />
|
||||||
v-if="viewType === 'list'"
|
<TorrentCardListView v-else :items="dataList" />
|
||||||
:items="dataList"
|
|
||||||
/>
|
|
||||||
<TorrentCardListView
|
|
||||||
v-else
|
|
||||||
:items="dataList"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 视图切换 -->
|
<!-- 视图切换 -->
|
||||||
<VFab
|
<VFab
|
||||||
v-if="viewType === 'list'"
|
v-if="viewType === 'list'"
|
||||||
|
class="mb-12"
|
||||||
icon="mdi-view-grid"
|
icon="mdi-view-grid"
|
||||||
location="bottom end"
|
location="bottom"
|
||||||
size="x-large"
|
size="x-large"
|
||||||
fixed
|
absolute
|
||||||
app
|
app
|
||||||
appear
|
appear
|
||||||
@click="setViewType('card')"
|
@click="setViewType('card')"
|
||||||
|
:class="{ 'mb-12': appMode }"
|
||||||
/>
|
/>
|
||||||
<VFab
|
<VFab
|
||||||
v-else
|
v-else
|
||||||
icon="mdi-view-list"
|
icon="mdi-view-list"
|
||||||
location="bottom end"
|
location="bottom"
|
||||||
size="x-large"
|
size="x-large"
|
||||||
fixed
|
fixed
|
||||||
app
|
app
|
||||||
appear
|
appear
|
||||||
@click="setViewType('list')"
|
@click="setViewType('list')"
|
||||||
|
:class="{ 'mb-12': appMode }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import router from '@/router'
|
||||||
import AccountSettingAccount from '@/views/setting/AccountSettingAccount.vue'
|
import AccountSettingAccount from '@/views/setting/AccountSettingAccount.vue'
|
||||||
import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'
|
import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'
|
||||||
import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
|
import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
|
||||||
@@ -9,137 +10,123 @@ import AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue'
|
|||||||
import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'
|
import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'
|
||||||
import AccountSettingService from '@/views/setting/AccountSettingService.vue'
|
import AccountSettingService from '@/views/setting/AccountSettingService.vue'
|
||||||
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
|
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
|
||||||
|
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
|
||||||
|
import { SettingTabs } from '@/router/menu'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const activeTab = ref(route.params.tab)
|
const activeTab = ref(route.query.tab)
|
||||||
|
|
||||||
// tabs
|
function jumpTab(tab: string) {
|
||||||
const tabs = [
|
router.push('/setting?tab=' + tab)
|
||||||
{
|
}
|
||||||
title: '用户',
|
|
||||||
icon: 'mdi-account',
|
|
||||||
tab: 'account',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '系统',
|
|
||||||
icon: 'mdi-cog',
|
|
||||||
tab: 'system',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '站点',
|
|
||||||
icon: 'mdi-web',
|
|
||||||
tab: 'site',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '搜索',
|
|
||||||
icon: 'mdi-magnify',
|
|
||||||
tab: 'search',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '订阅',
|
|
||||||
icon: 'mdi-rss',
|
|
||||||
tab: 'subscribe',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '服务',
|
|
||||||
icon: 'mdi-list-box',
|
|
||||||
tab: 'service',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '通知',
|
|
||||||
icon: 'mdi-bell',
|
|
||||||
tab: 'notification',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '词表',
|
|
||||||
icon: 'mdi-file-word-box',
|
|
||||||
tab: 'words',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '关于',
|
|
||||||
icon: 'mdi-information',
|
|
||||||
tab: 'about',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<VTabs
|
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill">
|
||||||
v-model="activeTab"
|
<VTab
|
||||||
show-arrows
|
v-for="item in SettingTabs"
|
||||||
>
|
:key="item.icon"
|
||||||
<VTab v-for="item in tabs" :key="item.icon" :value="item.tab">
|
:value="item.tab"
|
||||||
<VIcon size="20" start :icon="item.icon" />
|
@click="jumpTab(item.tab)"
|
||||||
{{ item.title }}
|
selected-class="v-slide-group-item--active v-tab--selected"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<VIcon size="20" start :icon="item.icon" />
|
||||||
|
{{ item.title }}
|
||||||
|
</div>
|
||||||
</VTab>
|
</VTab>
|
||||||
</VTabs>
|
</VTabs>
|
||||||
<VDivider />
|
|
||||||
|
|
||||||
<VWindow
|
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||||
v-model="activeTab"
|
|
||||||
class="mt-5 disable-tab-transition"
|
|
||||||
:touch="false"
|
|
||||||
>
|
|
||||||
<!-- 用户 -->
|
<!-- 用户 -->
|
||||||
<VWindowItem value="account">
|
<VWindowItem value="account">
|
||||||
<transition name="fade-slide" appear>
|
<transition name="fade-slide" appear>
|
||||||
<AccountSettingAccount />
|
<div>
|
||||||
|
<AccountSettingAccount />
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
|
||||||
<!-- 系统 -->
|
<!-- 连接 -->
|
||||||
<VWindowItem value="system">
|
<VWindowItem value="system">
|
||||||
<transition name="fade-slide" appear>
|
<transition name="fade-slide" appear>
|
||||||
<AccountSettingSystem />
|
<div>
|
||||||
|
<AccountSettingSystem />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</VWindowItem>
|
||||||
|
|
||||||
|
<!-- 目录 -->
|
||||||
|
<VWindowItem value="directory">
|
||||||
|
<transition name="fade-slide" appear>
|
||||||
|
<div>
|
||||||
|
<AccountSettingDirectory />
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
|
||||||
<!-- 站点 -->
|
<!-- 站点 -->
|
||||||
<VWindowItem value="site">
|
<VWindowItem value="site">
|
||||||
<transition name="fade-slide" appear>
|
<transition name="fade-slide" appear>
|
||||||
<AccountSettingSite />
|
<div>
|
||||||
|
<AccountSettingSite />
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
|
||||||
<!-- 搜索 -->
|
<!-- 搜索 -->
|
||||||
<VWindowItem value="search">
|
<VWindowItem value="search">
|
||||||
<transition name="fade-slide" appear>
|
<transition name="fade-slide" appear>
|
||||||
<AccountSettingSearch />
|
<div>
|
||||||
|
<AccountSettingSearch />
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
|
||||||
<!-- 订阅 -->
|
<!-- 订阅 -->
|
||||||
<VWindowItem value="subscribe">
|
<VWindowItem value="subscribe">
|
||||||
<transition name="fade-slide" appear>
|
<transition name="fade-slide" appear>
|
||||||
<AccountSettingSubscribe />
|
<div>
|
||||||
|
<AccountSettingSubscribe />
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
|
||||||
<!-- 服务 -->
|
<!-- 服务 -->
|
||||||
<VWindowItem value="service">
|
<VWindowItem value="service">
|
||||||
<transition name="fade-slide" appear>
|
<transition name="fade-slide" appear>
|
||||||
<AccountSettingService />
|
<div>
|
||||||
|
<AccountSettingService />
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
|
||||||
<!-- 通知 -->
|
<!-- 通知 -->
|
||||||
<VWindowItem value="notification">
|
<VWindowItem value="notification">
|
||||||
<transition name="fade-slide" appear>
|
<transition name="fade-slide" appear>
|
||||||
<AccountSettingNotification />
|
<div>
|
||||||
|
<AccountSettingNotification />
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
|
||||||
<!-- 词表 -->
|
<!-- 词表 -->
|
||||||
<VWindowItem value="words">
|
<VWindowItem value="words">
|
||||||
<transition name="fade-slide" appear>
|
<transition name="fade-slide" appear>
|
||||||
<AccountSettingWords />
|
<div>
|
||||||
|
<AccountSettingWords />
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
|
||||||
<!-- 关于 -->
|
<!-- 关于 -->
|
||||||
<VWindowItem value="about">
|
<VWindowItem value="about">
|
||||||
<transition name="fade-slide" appear>
|
<transition name="fade-slide" appear>
|
||||||
<AccountSettingAbout />
|
<div>
|
||||||
|
<AccountSettingAbout />
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
</VWindow>
|
</VWindow>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<SubscribeListView type="电影" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<SubscribeListView type="电视剧" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
39
src/pages/subscribe.vue
Normal file
39
src/pages/subscribe.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
||||||
|
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
|
||||||
|
import { SubscribeMovieTabs } from '@/router/menu'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const subType = route.meta.subType?.toString()
|
||||||
|
const subId = ref(route.query.id as string)
|
||||||
|
const activeTab = ref(route.query.tab)
|
||||||
|
|
||||||
|
function jumpTab(tab: string) {
|
||||||
|
router.push('/subscribe/movie?tab=' + tab)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<VTabs v-model="activeTab">
|
||||||
|
<VTab v-for="item in SubscribeMovieTabs" :value="item.tab" @to="jumpTab(item.tab)">
|
||||||
|
<span class="mx-5">{{ item.title }}</span>
|
||||||
|
</VTab>
|
||||||
|
</VTabs>
|
||||||
|
|
||||||
|
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||||
|
<VWindowItem value="mysub">
|
||||||
|
<transition name="fade-slide" appear>
|
||||||
|
<SubscribeListView :type="subType" :subid="subId" />
|
||||||
|
</transition>
|
||||||
|
</VWindowItem>
|
||||||
|
<VWindowItem value="popular">
|
||||||
|
<transition name="fade-slide" appear>
|
||||||
|
<SubscribePopularView :type="subType" />
|
||||||
|
</transition>
|
||||||
|
</VWindowItem>
|
||||||
|
</VWindow>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -4,12 +4,12 @@
|
|||||||
* webfontloader documentation: https://github.com/typekit/webfontloader
|
* webfontloader documentation: https://github.com/typekit/webfontloader
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export async function loadFonts() {
|
;(async function loadFonts() {
|
||||||
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */'webfontloader')
|
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */ 'webfontloader')
|
||||||
|
|
||||||
webFontLoader.load({
|
webFontLoader.load({
|
||||||
google: {
|
google: {
|
||||||
families: ['Inter:100,200,300,400,500,600,700&display=swap'],
|
families: ['Inter:100,200,300,400,500,600,700&display=swap'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
})()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import { configureNProgress, doneNProgress, startNProgress } from '@/api/nprogress'
|
import { configureNProgress } from '@/api/nprogress'
|
||||||
import store from '@/store'
|
import store from '@/store'
|
||||||
|
|
||||||
// Nprogress
|
// Nprogress
|
||||||
@@ -10,8 +10,7 @@ const router = createRouter({
|
|||||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||||
scrollBehavior(to, from, savedPosition) {
|
scrollBehavior(to, from, savedPosition) {
|
||||||
// 如果页面有缓存那么恢复其位置, 否则始终滚动到顶部
|
// 如果页面有缓存那么恢复其位置, 否则始终滚动到顶部
|
||||||
if (to.meta.keepAlive && savedPosition)
|
if (to.meta.keepAlive && savedPosition) return savedPosition
|
||||||
return savedPosition
|
|
||||||
return { top: 0 }
|
return { top: 0 }
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
@@ -21,14 +20,15 @@ const router = createRouter({
|
|||||||
component: () => import('../layouts/default.vue'),
|
component: () => import('../layouts/default.vue'),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: '/dashboard',
|
||||||
component: () => import('../pages/dashboard.vue'),
|
component: () => import('../pages/dashboard.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'ranking',
|
path: '/ranking',
|
||||||
component: () => import('../pages/ranking.vue'),
|
component: () => import('../pages/ranking.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
@@ -36,63 +36,70 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'resource',
|
path: '/resource',
|
||||||
component: () => import('../pages/resource.vue'),
|
component: () => import('../pages/resource.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'subscribe-movie',
|
path: '/subscribe/movie',
|
||||||
component: () => import('../pages/subscribe-movie.vue'),
|
component: () => import('../pages/subscribe.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
subType: '电影',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'subscribe-tv',
|
path: '/subscribe/tv',
|
||||||
component: () => import('../pages/subscribe-tv.vue'),
|
component: () => import('../pages/subscribe.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
subType: '电视剧',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'calendar',
|
path: '/calendar',
|
||||||
component: () => import('../pages/calendar.vue'),
|
component: () => import('../pages/calendar.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'downloading',
|
path: '/downloading',
|
||||||
component: () => import('../pages/downloading.vue'),
|
component: () => import('../pages/downloading.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'history',
|
path: '/history',
|
||||||
component: () => import('../pages/history.vue'),
|
component: () => import('../pages/history.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'site',
|
path: '/site',
|
||||||
component: () => import('../pages/site.vue'),
|
component: () => import('../pages/site.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'plugins',
|
path: '/plugins',
|
||||||
component: () => import('../pages/plugin.vue'),
|
component: () => import('../pages/plugin.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'setting',
|
path: '/setting',
|
||||||
component: () => import('../pages/setting.vue'),
|
component: () => import('../pages/setting.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
@@ -121,6 +128,7 @@ const router = createRouter({
|
|||||||
component: () => import('../pages/person.vue'),
|
component: () => import('../pages/person.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -128,12 +136,21 @@ const router = createRouter({
|
|||||||
path: '/media',
|
path: '/media',
|
||||||
component: () => import('../pages/media.vue'),
|
component: () => import('../pages/media.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/filemanager',
|
path: '/filemanager',
|
||||||
component: () => import('../pages/filemanager.vue'),
|
component: () => import('../pages/filemanager.vue'),
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/apps',
|
||||||
|
component: () => import('../pages/appcenter.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
@@ -159,20 +176,14 @@ const router = createRouter({
|
|||||||
|
|
||||||
// 路由导航守卫
|
// 路由导航守卫
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
|
// 总是记录非login路由
|
||||||
|
if (to.fullPath != '/login') store.state.auth.originalPath = to.fullPath
|
||||||
const isAuthenticated = store.state.auth.token !== null
|
const isAuthenticated = store.state.auth.token !== null
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
store.state.auth.originalPath = to.fullPath
|
|
||||||
next('/login')
|
next('/login')
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
startNProgress()
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.afterEach(() => {
|
|
||||||
doneNProgress()
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
224
src/router/menu.ts
Normal file
224
src/router/menu.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// 导般菜单
|
||||||
|
export const SystemNavMenus = [
|
||||||
|
{
|
||||||
|
title: '仪表板',
|
||||||
|
icon: 'mdi-home-outline',
|
||||||
|
to: '/dashboard',
|
||||||
|
header: '开始',
|
||||||
|
admin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '推荐',
|
||||||
|
icon: 'mdi-star-outline',
|
||||||
|
to: '/ranking',
|
||||||
|
header: '发现',
|
||||||
|
admin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '资源搜索',
|
||||||
|
icon: 'mdi-magnify',
|
||||||
|
to: '/resource',
|
||||||
|
header: '发现',
|
||||||
|
admin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '电影',
|
||||||
|
full_title: '电影订阅',
|
||||||
|
icon: 'mdi-movie-open-outline',
|
||||||
|
to: '/subscribe/movie',
|
||||||
|
header: '订阅',
|
||||||
|
admin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '电视剧',
|
||||||
|
full_title: '电视剧订阅',
|
||||||
|
icon: 'mdi-television',
|
||||||
|
to: '/subscribe/tv',
|
||||||
|
header: '订阅',
|
||||||
|
admin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '日历',
|
||||||
|
full_title: '订阅日历',
|
||||||
|
icon: 'mdi-calendar',
|
||||||
|
to: '/calendar',
|
||||||
|
header: '订阅',
|
||||||
|
admin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '正在下载',
|
||||||
|
icon: 'mdi-download-outline',
|
||||||
|
to: '/downloading',
|
||||||
|
header: '整理',
|
||||||
|
admin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '历史记录',
|
||||||
|
icon: 'mdi-history',
|
||||||
|
to: '/history',
|
||||||
|
header: '整理',
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '文件管理',
|
||||||
|
icon: 'mdi-folder-multiple-outline',
|
||||||
|
to: '/filemanager',
|
||||||
|
header: '整理',
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '插件',
|
||||||
|
icon: 'mdi-apps',
|
||||||
|
to: '/plugins',
|
||||||
|
header: '系统',
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '站点管理',
|
||||||
|
icon: 'mdi-web',
|
||||||
|
to: '/site',
|
||||||
|
header: '系统',
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '设定',
|
||||||
|
icon: 'mdi-cog',
|
||||||
|
to: '/setting',
|
||||||
|
header: '系统',
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 常用菜单功能
|
||||||
|
export const UserfulMenus = [
|
||||||
|
{
|
||||||
|
title: '搜索设置',
|
||||||
|
icon: 'mdi-magnify',
|
||||||
|
to: 'setting?tab=search',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '订阅设置',
|
||||||
|
icon: 'mdi-rss',
|
||||||
|
to: 'setting?tab=subscribe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '服务',
|
||||||
|
icon: 'mdi-list-box',
|
||||||
|
to: 'setting?tab=service',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '词表',
|
||||||
|
icon: 'mdi-file-word-box',
|
||||||
|
to: 'setting?tab=words',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '历史记录',
|
||||||
|
icon: 'mdi-history',
|
||||||
|
to: 'history',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 设定标签页
|
||||||
|
export const SettingTabs = [
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
icon: 'mdi-account',
|
||||||
|
tab: 'account',
|
||||||
|
description: '个人信息、用户管理、修改密码、双重认证',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '连接',
|
||||||
|
icon: 'mdi-server-network',
|
||||||
|
tab: 'system',
|
||||||
|
description: '下载器(Qbittorrent、Transmission)、媒体服务器(Emby、Jellyfin、Plex)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '目录',
|
||||||
|
icon: 'mdi-folder',
|
||||||
|
tab: 'directory',
|
||||||
|
description: '下载目录、媒体库目录、整理模式',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '站点',
|
||||||
|
icon: 'mdi-web',
|
||||||
|
tab: 'site',
|
||||||
|
description: '站点同步、下载优先规则、站点重置',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '搜索',
|
||||||
|
icon: 'mdi-magnify',
|
||||||
|
tab: 'search',
|
||||||
|
description: '媒体数据源(TheMovieDb、豆瓣、Bangumi)、搜索站点、搜索优先级、默认过滤规则',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '订阅',
|
||||||
|
icon: 'mdi-rss',
|
||||||
|
tab: 'subscribe',
|
||||||
|
description: '订阅站点、订阅模式、订阅优先级、洗版优先级、默认过滤规则',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '服务',
|
||||||
|
icon: 'mdi-list-box',
|
||||||
|
tab: 'service',
|
||||||
|
description: '定时作业',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '通知',
|
||||||
|
icon: 'mdi-bell',
|
||||||
|
tab: 'notification',
|
||||||
|
description: '通知渠道(微信、Telegram、Slack、SynologyChat、VoceChat)、消息类型',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '词表',
|
||||||
|
icon: 'mdi-file-word-box',
|
||||||
|
tab: 'words',
|
||||||
|
description: '自定义识别词、自定义制作组/字幕组、自定义占位符、文件整理屏蔽词',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '关于',
|
||||||
|
icon: 'mdi-information',
|
||||||
|
tab: 'about',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 电影订阅标签页
|
||||||
|
export const SubscribeMovieTabs = [
|
||||||
|
{
|
||||||
|
title: '我的订阅',
|
||||||
|
tab: 'mysub',
|
||||||
|
icon: 'mdi-movie-open-outline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '热门订阅',
|
||||||
|
tab: 'popular',
|
||||||
|
icon: 'mdi-movie-open-outline',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 电视剧订阅标签页
|
||||||
|
export const SubscribeTvTabs = [
|
||||||
|
{
|
||||||
|
title: '我的订阅',
|
||||||
|
tab: 'mysub',
|
||||||
|
icon: 'mdi-television',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '热门订阅',
|
||||||
|
tab: 'popular',
|
||||||
|
icon: 'mdi-television',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 插件标签页
|
||||||
|
export const PluginTabs = [
|
||||||
|
{
|
||||||
|
title: '我的插件',
|
||||||
|
tab: 'installed',
|
||||||
|
icon: 'mdi-puzzle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '插件市场',
|
||||||
|
tab: 'market',
|
||||||
|
icon: 'mdi-store',
|
||||||
|
},
|
||||||
|
]
|
||||||
74
src/service-worker.ts
Normal file
74
src/service-worker.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { createHandlerBoundToURL, cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||||
|
import { NavigationRoute, registerRoute } from 'workbox-routing'
|
||||||
|
import { clientsClaim } from 'workbox-core'
|
||||||
|
|
||||||
|
declare let self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
|
cleanupOutdatedCaches()
|
||||||
|
|
||||||
|
// self.__WB_MANIFEST is default injection point
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
|
|
||||||
|
// to allow work offline
|
||||||
|
registerRoute(new NavigationRoute(createHandlerBoundToURL('index.html'), { denylist: [/^(\/[\w-]+)*\/api/] }))
|
||||||
|
|
||||||
|
// 通知选项
|
||||||
|
const options = {
|
||||||
|
icon: '/logo.png',
|
||||||
|
vibrate: [100, 50, 100],
|
||||||
|
actions: [{ action: 'close', title: '关闭' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 push 事件,显示通知
|
||||||
|
self.addEventListener('push', function (event) {
|
||||||
|
console.log('notification push')
|
||||||
|
if (!event.data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 解析获取推送消息
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
payload = event.data?.json()
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
payload = {
|
||||||
|
title: event.data?.text(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 根据推送消息生成桌面通知并展现出来
|
||||||
|
try {
|
||||||
|
const content = {
|
||||||
|
body: payload.body || '',
|
||||||
|
icon: payload.icon || options.icon,
|
||||||
|
vibrate: [100, 50, 100],
|
||||||
|
data: { url: payload.url },
|
||||||
|
actions: options.actions,
|
||||||
|
}
|
||||||
|
event.waitUntil(self.registration.showNotification(payload.title, content))
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 安装
|
||||||
|
self.addEventListener('install', function (e) {
|
||||||
|
console.log('worker install')
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 激活
|
||||||
|
self.addEventListener('activate', function (e) {
|
||||||
|
console.log('worker activate')
|
||||||
|
e.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听通知点击事件
|
||||||
|
self.addEventListener('notificationclick', function (event) {
|
||||||
|
console.log('notification click')
|
||||||
|
const info = event.notification
|
||||||
|
if (event.action === 'close') {
|
||||||
|
info.close()
|
||||||
|
} else if (info.data?.url) {
|
||||||
|
event.waitUntil(self.clients.openWindow(info.data?.url))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ interface AuthState {
|
|||||||
userName: string
|
userName: string
|
||||||
avatar: string
|
avatar: string
|
||||||
originalPath: string | null
|
originalPath: string | null
|
||||||
|
level: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义根状态类型
|
// 定义根状态类型
|
||||||
@@ -25,6 +26,7 @@ const authModule: Module<AuthState, RootState> = {
|
|||||||
userName: '',
|
userName: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
originalPath: null,
|
originalPath: null,
|
||||||
|
level: 1,
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
setToken(state, token: string) {
|
setToken(state, token: string) {
|
||||||
@@ -45,25 +47,25 @@ const authModule: Module<AuthState, RootState> = {
|
|||||||
setAvatar(state, avatar: string) {
|
setAvatar(state, avatar: string) {
|
||||||
state.avatar = avatar
|
state.avatar = avatar
|
||||||
},
|
},
|
||||||
|
setOriginalPath(state, originalPath: string) {
|
||||||
|
state.originalPath = originalPath
|
||||||
|
},
|
||||||
|
setLevel(state, level: number) {
|
||||||
|
state.level = level
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
updateToken({ commit }, token: string) {
|
login({ commit }, { token, remember, superUser, userName, avatar, level }) {
|
||||||
commit('setToken', token)
|
commit('setToken', token)
|
||||||
},
|
|
||||||
clearToken({ commit }) {
|
|
||||||
commit('clearToken')
|
|
||||||
},
|
|
||||||
updateRemember({ commit }, remember: boolean) {
|
|
||||||
commit('setRemember', remember)
|
commit('setRemember', remember)
|
||||||
},
|
|
||||||
updateSuperUser({ commit }, superUser: boolean) {
|
|
||||||
commit('setSuperUser', superUser)
|
commit('setSuperUser', superUser)
|
||||||
},
|
|
||||||
updateUserName({ commit }, userName: string) {
|
|
||||||
commit('setUserName', userName)
|
commit('setUserName', userName)
|
||||||
},
|
|
||||||
updateAvatar({ commit }, avatar: string) {
|
|
||||||
commit('setAvatar', avatar)
|
commit('setAvatar', avatar)
|
||||||
|
commit('setLevel', level)
|
||||||
|
},
|
||||||
|
logout({ commit }) {
|
||||||
|
commit('clearToken')
|
||||||
|
commit('setOriginalPath', null)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
@@ -72,6 +74,8 @@ const authModule: Module<AuthState, RootState> = {
|
|||||||
getSuperUser: state => state.superUser,
|
getSuperUser: state => state.superUser,
|
||||||
getUserName: state => state.userName,
|
getUserName: state => state.userName,
|
||||||
getAvatar: state => state.avatar,
|
getAvatar: state => state.avatar,
|
||||||
|
getOriginalPath: state => state.originalPath,
|
||||||
|
getLevel: state => state.level,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.v-dialog > .v-overlay__content {
|
.v-dialog > .v-overlay__content {
|
||||||
|
inline-size: calc(100% - 1rem);
|
||||||
margin-block-start: calc(env(safe-area-inset-top) + 1rem);
|
margin-block-start: calc(env(safe-area-inset-top) + 1rem);
|
||||||
max-block-size: calc(100% - env(safe-area-inset-top) - 1rem);
|
max-block-size: calc(100% - env(safe-area-inset-top) - 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-dialog > .v-overlay__content{
|
|
||||||
inline-size: calc(100% - 1rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-dialog--fullscreen > .v-overlay__content{
|
.v-dialog--fullscreen > .v-overlay__content{
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
margin-block-start: env(safe-area-inset-top);
|
margin-block-start: env(safe-area-inset-top);
|
||||||
@@ -65,7 +62,6 @@
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
|
|
||||||
--tw-gradient-from: #818cf8;
|
--tw-gradient-from: #818cf8;
|
||||||
--tw-gradient-to: rgba(129,140,248,0%);
|
|
||||||
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
|
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
|
||||||
--tw-gradient-to: #c084fc;
|
--tw-gradient-to: #c084fc;
|
||||||
}
|
}
|
||||||
@@ -130,3 +126,86 @@
|
|||||||
.v-toast {
|
.v-toast {
|
||||||
z-index: 2500 !important;
|
z-index: 2500 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v-divider {
|
||||||
|
border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity));
|
||||||
|
opacity:0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apexcharts-title-text {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-site-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||||
|
padding-block-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-media-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-backdrop-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||||
|
padding-block-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-torrent-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||||
|
padding-block-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-plugin-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||||
|
padding-block-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-downloading-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||||
|
padding-block-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-directory-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||||
|
padding-block-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.grid-filterrule-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||||
|
padding-block-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-subscribe-card {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||||
|
padding-block-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
|
||||||
|
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-fab__container {
|
||||||
|
padding-block-end: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-overlay__content .v-list{
|
||||||
|
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
background-color: rgb(var(--v-theme-surface), 0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-overlay__content .v-card:not(.bg-primary){
|
||||||
|
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
background-color: rgb(var(--v-theme-surface), 0.95) !important;
|
||||||
|
|
||||||
|
.v-list, .v-table {
|
||||||
|
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,13 +4,28 @@ import { useTheme } from 'vuetify'
|
|||||||
import { hexToRgb } from '@layouts/utils'
|
import { hexToRgb } from '@layouts/utils'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const props = defineProps({
|
||||||
|
// 是否允许刷新数据
|
||||||
|
allowRefresh: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const vuetifyTheme = useTheme()
|
const vuetifyTheme = useTheme()
|
||||||
|
|
||||||
const currentTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.colors)
|
const currentTheme = controlledComputed(
|
||||||
const variableTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.variables)
|
() => vuetifyTheme.name.value,
|
||||||
|
() => vuetifyTheme.current.value.colors,
|
||||||
|
)
|
||||||
|
const variableTheme = controlledComputed(
|
||||||
|
() => vuetifyTheme.name.value,
|
||||||
|
() => vuetifyTheme.current.value.variables,
|
||||||
|
)
|
||||||
|
|
||||||
// 定时器
|
// 定时器
|
||||||
let refreshTimer: NodeJS.Timer | null = null
|
let refreshTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
// 时间序列
|
// 时间序列
|
||||||
const series = ref([
|
const series = ref([
|
||||||
@@ -22,83 +37,87 @@ const series = ref([
|
|||||||
// 当前值
|
// 当前值
|
||||||
const current = ref(0)
|
const current = ref(0)
|
||||||
|
|
||||||
const chartOptions = controlledComputed(() => vuetifyTheme.name.value, () => {
|
const chartOptions = controlledComputed(
|
||||||
return {
|
() => vuetifyTheme.name.value,
|
||||||
chart: {
|
() => {
|
||||||
parentHeightOffset: 0,
|
return {
|
||||||
toolbar: { show: false },
|
chart: {
|
||||||
animations: { enabled: false },
|
parentHeightOffset: 0,
|
||||||
},
|
toolbar: { show: false },
|
||||||
tooltip: { enabled: false },
|
animations: { enabled: false },
|
||||||
grid: {
|
},
|
||||||
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${variableTheme.value['border-opacity']})`,
|
tooltip: { enabled: false },
|
||||||
strokeDashArray: 6,
|
grid: {
|
||||||
|
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
|
||||||
|
variableTheme.value['border-opacity']
|
||||||
|
})`,
|
||||||
|
strokeDashArray: 6,
|
||||||
|
xaxis: {
|
||||||
|
lines: { show: false },
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
lines: { show: true },
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
top: -10,
|
||||||
|
left: -7,
|
||||||
|
right: 5,
|
||||||
|
bottom: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
width: 3,
|
||||||
|
lineCap: 'butt',
|
||||||
|
curve: 'smooth',
|
||||||
|
},
|
||||||
|
colors: [currentTheme.value.primary],
|
||||||
|
markers: {
|
||||||
|
size: 6,
|
||||||
|
offsetY: 4,
|
||||||
|
offsetX: -2,
|
||||||
|
strokeWidth: 3,
|
||||||
|
colors: ['transparent'],
|
||||||
|
strokeColors: 'transparent',
|
||||||
|
discrete: [
|
||||||
|
{
|
||||||
|
size: 5.5,
|
||||||
|
seriesIndex: 0,
|
||||||
|
strokeColor: currentTheme.value.primary,
|
||||||
|
fillColor: currentTheme.value.surface,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hover: { size: 7 },
|
||||||
|
},
|
||||||
xaxis: {
|
xaxis: {
|
||||||
lines: { show: false },
|
labels: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
axisBorder: { show: false },
|
||||||
},
|
},
|
||||||
yaxis: {
|
yaxis: {
|
||||||
lines: { show: true },
|
labels: { show: false },
|
||||||
|
max: 100,
|
||||||
},
|
},
|
||||||
padding: {
|
}
|
||||||
top: -10,
|
},
|
||||||
left: -7,
|
)
|
||||||
right: 5,
|
|
||||||
bottom: 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stroke: {
|
|
||||||
width: 3,
|
|
||||||
lineCap: 'butt',
|
|
||||||
curve: 'smooth',
|
|
||||||
},
|
|
||||||
colors: [currentTheme.value.primary],
|
|
||||||
markers: {
|
|
||||||
size: 6,
|
|
||||||
offsetY: 4,
|
|
||||||
offsetX: -2,
|
|
||||||
strokeWidth: 3,
|
|
||||||
colors: ['transparent'],
|
|
||||||
strokeColors: 'transparent',
|
|
||||||
discrete: [
|
|
||||||
{
|
|
||||||
size: 5.5,
|
|
||||||
seriesIndex: 0,
|
|
||||||
strokeColor: currentTheme.value.primary,
|
|
||||||
fillColor: currentTheme.value.surface,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
hover: { size: 7 },
|
|
||||||
},
|
|
||||||
xaxis: {
|
|
||||||
labels: { show: false },
|
|
||||||
axisTicks: { show: false },
|
|
||||||
axisBorder: { show: false },
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
labels: { show: false },
|
|
||||||
max: 100,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 调用API接口获取最新CPU使用率
|
// 调用API接口获取最新CPU使用率
|
||||||
async function getCpuUsage() {
|
async function getCpuUsage() {
|
||||||
|
if (!props.allowRefresh) return
|
||||||
try {
|
try {
|
||||||
// 请求数据
|
// 请求数据
|
||||||
current.value = await api.get('dashboard/cpu') ?? 0
|
current.value = (await api.get('dashboard/cpu')) ?? 0
|
||||||
// 添加到序列
|
// 添加到序列
|
||||||
series.value[0].data.push(current.value)
|
series.value[0].data.push(current.value)
|
||||||
// 序列超过30条记录时,清掉前面的
|
// 序列超过30条记录时,清掉前面的
|
||||||
if (series.value[0].data.length > 30)
|
if (series.value[0].data.length > 30) series.value[0].data.shift()
|
||||||
series.value[0].data.shift()
|
} catch (e) {
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getCpuUsage()// 启动定时器
|
getCpuUsage() // 启动定时器
|
||||||
refreshTimer = setInterval(() => {
|
refreshTimer = setInterval(() => {
|
||||||
getCpuUsage()
|
getCpuUsage()
|
||||||
}, 2000)
|
}, 2000)
|
||||||
@@ -114,21 +133,21 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard>
|
<VHover>
|
||||||
<VCardText>
|
<template #default="hover">
|
||||||
<h6 class="text-h6">
|
<VCard v-bind="hover.props">
|
||||||
CPU
|
<VCardItem>
|
||||||
</h6>
|
<template #append>
|
||||||
<VueApexCharts
|
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||||
type="line"
|
</template>
|
||||||
:options="chartOptions"
|
<VCardTitle>CPU</VCardTitle>
|
||||||
:series="series"
|
</VCardItem>
|
||||||
:height="150"
|
<VCardText>
|
||||||
/>
|
<VueApexCharts type="line" :options="chartOptions" :series="series" :height="150" />
|
||||||
|
|
||||||
<p class="text-center font-weight-medium mb-0">
|
<p class="text-center font-weight-medium mb-0">当前:{{ current }}%</p>
|
||||||
当前:{{ current }}%
|
</VCardText>
|
||||||
</p>
|
</VCard>
|
||||||
</VCardText>
|
</template>
|
||||||
</VCard>
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,14 +2,7 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaStatistic } from '@/api/types'
|
import type { MediaStatistic } from '@/api/types'
|
||||||
|
|
||||||
const statistics = ref([
|
const statistics = ref<{ [key: string]: string }[]>([])
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
stats: '',
|
|
||||||
icon: '',
|
|
||||||
color: '',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 调用API加载媒体统计数据
|
// 调用API加载媒体统计数据
|
||||||
async function loadMediaStatistic() {
|
async function loadMediaStatistic() {
|
||||||
@@ -42,8 +35,7 @@ async function loadMediaStatistic() {
|
|||||||
color: 'info',
|
color: 'info',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,43 +46,37 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard>
|
<VHover>
|
||||||
<VCardItem>
|
<template #default="hover">
|
||||||
<VCardTitle>媒体统计</VCardTitle>
|
<VCard v-bind="hover.props">
|
||||||
</VCardItem>
|
<VCardItem>
|
||||||
|
<template #append>
|
||||||
|
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||||
|
</template>
|
||||||
|
<VCardTitle>媒体统计</VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol
|
<VCol v-for="item in statistics" :key="item.title" cols="6" sm="3">
|
||||||
v-for="item in statistics"
|
<div class="d-flex align-center">
|
||||||
:key="item.title"
|
<div class="me-3">
|
||||||
cols="6"
|
<VAvatar :color="item.color" rounded size="42" class="elevation-1">
|
||||||
sm="3"
|
<VIcon size="24" :icon="item.icon" />
|
||||||
>
|
</VAvatar>
|
||||||
<div class="d-flex align-center">
|
</div>
|
||||||
<div class="me-3">
|
|
||||||
<VAvatar
|
|
||||||
:color="item.color"
|
|
||||||
rounded
|
|
||||||
size="42"
|
|
||||||
class="elevation-1"
|
|
||||||
>
|
|
||||||
<VIcon
|
|
||||||
size="24"
|
|
||||||
:icon="item.icon"
|
|
||||||
/>
|
|
||||||
</VAvatar>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<span class="text-caption">
|
<span class="text-caption">
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-h6">{{ item.stats }}</span>
|
<span class="text-h6">{{ item.stats }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
</template>
|
||||||
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,13 +5,28 @@ import { hexToRgb } from '@layouts/utils'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { formatBytes } from '@/@core/utils/formatters'
|
import { formatBytes } from '@/@core/utils/formatters'
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const props = defineProps({
|
||||||
|
// 是否允许刷新数据
|
||||||
|
allowRefresh: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const vuetifyTheme = useTheme()
|
const vuetifyTheme = useTheme()
|
||||||
|
|
||||||
const currentTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.colors)
|
const currentTheme = controlledComputed(
|
||||||
const variableTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.variables)
|
() => vuetifyTheme.name.value,
|
||||||
|
() => vuetifyTheme.current.value.colors,
|
||||||
|
)
|
||||||
|
const variableTheme = controlledComputed(
|
||||||
|
() => vuetifyTheme.name.value,
|
||||||
|
() => vuetifyTheme.current.value.variables,
|
||||||
|
)
|
||||||
|
|
||||||
// 定时器
|
// 定时器
|
||||||
let refreshTimer: NodeJS.Timer | null = null
|
let refreshTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
// 时间序列
|
// 时间序列
|
||||||
const series = ref([
|
const series = ref([
|
||||||
@@ -25,79 +40,83 @@ const usedMemory = ref(0)
|
|||||||
// 内存使用百分比
|
// 内存使用百分比
|
||||||
const memoryUsage = ref(0)
|
const memoryUsage = ref(0)
|
||||||
|
|
||||||
const chartOptions = controlledComputed(() => vuetifyTheme.name.value, () => {
|
const chartOptions = controlledComputed(
|
||||||
return {
|
() => vuetifyTheme.name.value,
|
||||||
chart: {
|
() => {
|
||||||
parentHeightOffset: 0,
|
return {
|
||||||
toolbar: { show: false },
|
chart: {
|
||||||
animations: { enabled: false },
|
parentHeightOffset: 0,
|
||||||
},
|
toolbar: { show: false },
|
||||||
tooltip: { enabled: false },
|
animations: { enabled: false },
|
||||||
grid: {
|
},
|
||||||
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${variableTheme.value['border-opacity']})`,
|
tooltip: { enabled: false },
|
||||||
strokeDashArray: 6,
|
grid: {
|
||||||
|
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
|
||||||
|
variableTheme.value['border-opacity']
|
||||||
|
})`,
|
||||||
|
strokeDashArray: 6,
|
||||||
|
xaxis: {
|
||||||
|
lines: { show: false },
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
lines: { show: true },
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
top: -10,
|
||||||
|
left: -7,
|
||||||
|
right: 5,
|
||||||
|
bottom: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
width: 3,
|
||||||
|
lineCap: 'butt',
|
||||||
|
curve: 'smooth',
|
||||||
|
},
|
||||||
|
colors: [currentTheme.value.primary],
|
||||||
|
markers: {
|
||||||
|
size: 6,
|
||||||
|
offsetY: 4,
|
||||||
|
offsetX: -2,
|
||||||
|
strokeWidth: 3,
|
||||||
|
colors: ['transparent'],
|
||||||
|
strokeColors: 'transparent',
|
||||||
|
discrete: [
|
||||||
|
{
|
||||||
|
size: 5.5,
|
||||||
|
seriesIndex: 0,
|
||||||
|
strokeColor: currentTheme.value.primary,
|
||||||
|
fillColor: currentTheme.value.surface,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hover: { size: 7 },
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
xaxis: {
|
xaxis: {
|
||||||
lines: { show: false },
|
labels: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
axisBorder: { show: false },
|
||||||
},
|
},
|
||||||
yaxis: {
|
yaxis: {
|
||||||
lines: { show: true },
|
labels: { show: false },
|
||||||
|
max: 100,
|
||||||
},
|
},
|
||||||
padding: {
|
}
|
||||||
top: -10,
|
},
|
||||||
left: -7,
|
)
|
||||||
right: 5,
|
|
||||||
bottom: 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stroke: {
|
|
||||||
width: 3,
|
|
||||||
lineCap: 'butt',
|
|
||||||
curve: 'smooth',
|
|
||||||
},
|
|
||||||
colors: [currentTheme.value.primary],
|
|
||||||
markers: {
|
|
||||||
size: 6,
|
|
||||||
offsetY: 4,
|
|
||||||
offsetX: -2,
|
|
||||||
strokeWidth: 3,
|
|
||||||
colors: ['transparent'],
|
|
||||||
strokeColors: 'transparent',
|
|
||||||
discrete: [
|
|
||||||
{
|
|
||||||
size: 5.5,
|
|
||||||
seriesIndex: 0,
|
|
||||||
strokeColor: currentTheme.value.primary,
|
|
||||||
fillColor: currentTheme.value.surface,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
hover: { size: 7 },
|
|
||||||
},
|
|
||||||
dataLabels: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
xaxis: {
|
|
||||||
labels: { show: false },
|
|
||||||
axisTicks: { show: false },
|
|
||||||
axisBorder: { show: false },
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
labels: { show: false },
|
|
||||||
max: 100,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 调用API接口获取最新内存使用量
|
// 调用API接口获取最新内存使用量
|
||||||
async function getMemorgUsage() {
|
async function getMemorgUsage() {
|
||||||
|
if (!props.allowRefresh) return
|
||||||
try {
|
try {
|
||||||
// 请求数据
|
// 请求数据
|
||||||
[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')
|
;[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')
|
||||||
series.value[0].data.push(memoryUsage.value)
|
series.value[0].data.push(memoryUsage.value)
|
||||||
// 序列超过30条记录时,清掉前面的
|
// 序列超过30条记录时,清掉前面的
|
||||||
if (series.value[0].data.length > 30)
|
if (series.value[0].data.length > 30) series.value[0].data.shift()
|
||||||
series.value[0].data.shift()
|
} catch (e) {
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,21 +139,21 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard>
|
<VHover>
|
||||||
<VCardText>
|
<template #default="hover">
|
||||||
<h6 class="text-h6">
|
<VCard v-bind="hover.props">
|
||||||
内存
|
<VCardItem>
|
||||||
</h6>
|
<template #append>
|
||||||
<VueApexCharts
|
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||||
type="area"
|
</template>
|
||||||
:options="chartOptions"
|
<VCardTitle>内存</VCardTitle>
|
||||||
:series="series"
|
</VCardItem>
|
||||||
:height="150"
|
<VCardText>
|
||||||
/>
|
<VueApexCharts type="area" :options="chartOptions" :series="series" :height="150" />
|
||||||
|
|
||||||
<p class="text-center font-weight-medium mb-0">
|
<p class="text-center font-weight-medium mb-0">当前:{{ formatBytes(usedMemory) }}</p>
|
||||||
当前:{{ formatBytes(usedMemory) }}
|
</VCardText>
|
||||||
</p>
|
</VCard>
|
||||||
</VCardText>
|
</template>
|
||||||
</VCard>
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const headers = ['进程ID', '进程名称', '运行时间', '内存占用']
|
|||||||
const processList = ref<Process[]>([])
|
const processList = ref<Process[]>([])
|
||||||
|
|
||||||
// 定时器
|
// 定时器
|
||||||
let refreshTimer: NodeJS.Timer | null = null
|
let refreshTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
// 调用API加载数据
|
// 调用API加载数据
|
||||||
async function loadProcessList() {
|
async function loadProcessList() {
|
||||||
@@ -18,8 +18,7 @@ async function loadProcessList() {
|
|||||||
const res: Process[] = await api.get('dashboard/processes')
|
const res: Process[] = await api.get('dashboard/processes')
|
||||||
|
|
||||||
processList.value = res
|
processList.value = res
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,47 +42,32 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard title="系统进程">
|
<VCard>
|
||||||
<VTable
|
<VCardItem>
|
||||||
item-key="fullName"
|
<template #append>
|
||||||
class="table-rounded"
|
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||||
hide-default-footer
|
</template>
|
||||||
disable-sort
|
<VCardTitle>系统进程</VCardTitle>
|
||||||
>
|
</VCardItem>
|
||||||
|
<VTable item-key="fullName" class="table-rounded" hide-default-footer disable-sort>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th v-for="header in headers" :id="header" :key="header">
|
||||||
v-for="header in headers"
|
|
||||||
:id="header"
|
|
||||||
:key="header"
|
|
||||||
>
|
|
||||||
{{ header }}
|
{{ header }}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr v-for="row in processList" :key="row.pid">
|
||||||
v-for="row in processList"
|
<td class="text-sm" v-text="row.pid" />
|
||||||
:key="row.pid"
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="text-sm"
|
|
||||||
v-text="row.pid"
|
|
||||||
/>
|
|
||||||
<!-- name -->
|
<!-- name -->
|
||||||
<td>
|
<td>
|
||||||
<h6 class="text-sm font-weight-medium">
|
<h6 class="text-sm font-weight-medium">
|
||||||
{{ row.name }}
|
{{ row.name }}
|
||||||
</h6>
|
</h6>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td class="text-sm" v-text="formatSeconds(row.run_time)" />
|
||||||
class="text-sm"
|
<td class="text-sm" v-text="`${row.memory} MB`" />
|
||||||
v-text="formatSeconds(row.run_time)"
|
|
||||||
/>
|
|
||||||
<td
|
|
||||||
class="text-sm"
|
|
||||||
v-text="`${row.memory} MB`"
|
|
||||||
/>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</VTable>
|
</VTable>
|
||||||
|
|||||||
@@ -2,20 +2,31 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { ScheduleInfo } from '@/api/types'
|
import type { ScheduleInfo } from '@/api/types'
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const props = defineProps({
|
||||||
|
// 是否允许刷新数据
|
||||||
|
allowRefresh: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 定时服务列表
|
// 定时服务列表
|
||||||
const schedulerList = ref<ScheduleInfo[]>([])
|
const schedulerList = ref<ScheduleInfo[]>([])
|
||||||
|
|
||||||
// 定时器
|
// 定时器
|
||||||
let refreshTimer: NodeJS.Timer | null = null
|
let refreshTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
// 调用API加载定时服务列表
|
// 调用API加载定时服务列表
|
||||||
async function loadSchedulerList() {
|
async function loadSchedulerList() {
|
||||||
|
if (!props.allowRefresh) {
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res: ScheduleInfo[] = await api.get('dashboard/schedule')
|
const res: ScheduleInfo[] = await api.get('dashboard/schedule')
|
||||||
|
|
||||||
schedulerList.value = res
|
schedulerList.value = res
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,55 +50,49 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard>
|
<VHover>
|
||||||
<VCardItem>
|
<template #default="hover">
|
||||||
<VCardTitle>后台任务</VCardTitle>
|
<VCard v-bind="hover.props">
|
||||||
</VCardItem>
|
<VCardItem>
|
||||||
|
|
||||||
<VCardText>
|
|
||||||
<VList
|
|
||||||
class="card-list"
|
|
||||||
height="250"
|
|
||||||
>
|
|
||||||
<VListItem
|
|
||||||
v-for="item in schedulerList"
|
|
||||||
:key="item.id"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<VAvatar
|
|
||||||
size="40"
|
|
||||||
variant="tonal"
|
|
||||||
color=""
|
|
||||||
class="me-3"
|
|
||||||
>
|
|
||||||
{{ item.name[0] }}
|
|
||||||
</VAvatar>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VListItemTitle class="mb-1">
|
|
||||||
<span class="text-sm font-weight-medium">{{ item.name }}</span>
|
|
||||||
</VListItemTitle>
|
|
||||||
|
|
||||||
<VListItemSubtitle class="text-xs">
|
|
||||||
{{ item.next_run }}
|
|
||||||
</VListItemSubtitle>
|
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<div>
|
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||||
<h4 class="font-weight-medium">
|
|
||||||
{{ item.status }}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</VListItem>
|
<VCardTitle>后台任务</VCardTitle>
|
||||||
<VListItem v-if="schedulerList.length === 0">
|
</VCardItem>
|
||||||
<VListItemTitle class="text-center">
|
|
||||||
没有后台服务
|
<VCardText>
|
||||||
</VListItemTitle>
|
<VList class="card-list" height="250">
|
||||||
</VListItem>
|
<VListItem v-for="item in schedulerList" :key="item.id">
|
||||||
</VList>
|
<template #prepend>
|
||||||
</VCardText>
|
<VAvatar size="40" variant="tonal" color="" class="me-3">
|
||||||
</VCard>
|
{{ item.name[0] }}
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VListItemTitle class="mb-1">
|
||||||
|
<span class="text-sm font-weight-medium">{{ item.name }}</span>
|
||||||
|
</VListItemTitle>
|
||||||
|
|
||||||
|
<VListItemSubtitle class="text-xs">
|
||||||
|
{{ item.next_run }}
|
||||||
|
</VListItemSubtitle>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-weight-medium">
|
||||||
|
{{ item.status }}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem v-if="schedulerList.length === 0">
|
||||||
|
<VListItemTitle class="text-center"> 没有后台服务 </VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -3,8 +3,17 @@ import { formatFileSize } from '@/@core/utils/formatters'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { DownloaderInfo } from '@/api/types'
|
import type { DownloaderInfo } from '@/api/types'
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const props = defineProps({
|
||||||
|
// 是否允许刷新数据
|
||||||
|
allowRefresh: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 定时器
|
// 定时器
|
||||||
let refreshTimer: NodeJS.Timer | null = null
|
let refreshTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
// 下载器信息
|
// 下载器信息
|
||||||
const downloadInfo = ref<DownloaderInfo>({
|
const downloadInfo = ref<DownloaderInfo>({
|
||||||
@@ -35,6 +44,10 @@ const infoItems = ref([
|
|||||||
|
|
||||||
// 调用API查询下载器数据
|
// 调用API查询下载器数据
|
||||||
async function loadDownloaderInfo() {
|
async function loadDownloaderInfo() {
|
||||||
|
if (!props.allowRefresh) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: DownloaderInfo = await api.get('dashboard/downloader')
|
const res: DownloaderInfo = await api.get('dashboard/downloader')
|
||||||
|
|
||||||
@@ -56,8 +69,7 @@ async function loadDownloaderInfo() {
|
|||||||
amount: formatFileSize(res.free_space),
|
amount: formatFileSize(res.free_space),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,47 +93,44 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard>
|
<VHover>
|
||||||
<VCardItem>
|
<template #default="hover">
|
||||||
<VCardTitle>实时速率</VCardTitle>
|
<VCard v-bind="hover.props">
|
||||||
</VCardItem>
|
<VCardItem>
|
||||||
|
|
||||||
<VCardText class="pt-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-h5 me-2">
|
|
||||||
↑{{ formatFileSize(downloadInfo.upload_speed) }}/s
|
|
||||||
</p>
|
|
||||||
<p class="text-h4 me-2">
|
|
||||||
↓{{ formatFileSize(downloadInfo.download_speed) }}/s
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<VList class="card-list mt-9">
|
|
||||||
<VListItem
|
|
||||||
v-for="item in infoItems"
|
|
||||||
:key="item.title"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<VIcon
|
|
||||||
rounded
|
|
||||||
:icon="item.avatar"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VListItemTitle class="text-sm font-weight-medium mb-1">
|
|
||||||
{{ item.title }}
|
|
||||||
</VListItemTitle>
|
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<div>
|
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||||
<h6 class="text-sm font-weight-medium mb-2">
|
|
||||||
{{ item.amount }}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</VListItem>
|
<VCardTitle>实时速率</VCardTitle>
|
||||||
</VList>
|
</VCardItem>
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
<VCardText class="pt-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-h5 me-2">↑{{ formatFileSize(downloadInfo.upload_speed) }}/s</p>
|
||||||
|
<p class="text-h4 me-2">↓{{ formatFileSize(downloadInfo.download_speed) }}/s</p>
|
||||||
|
</div>
|
||||||
|
<VList class="card-list mt-9">
|
||||||
|
<VListItem v-for="item in infoItems" :key="item.title">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon rounded :icon="item.avatar" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VListItemTitle class="text-sm font-weight-medium mb-1">
|
||||||
|
{{ item.title }}
|
||||||
|
</VListItemTitle>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-sm font-weight-medium mb-2">
|
||||||
|
{{ item.amount }}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import triangleLight from '@images/misc/triangle-light.png'
|
|||||||
|
|
||||||
const { global } = useTheme()
|
const { global } = useTheme()
|
||||||
|
|
||||||
const triangleBg = computed(() =>
|
const triangleBg = computed(() => (global.name.value === 'light' ? triangleLight : triangleDark))
|
||||||
global.name.value === 'light' ? triangleLight : triangleDark,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 总存储空间
|
// 总存储空间
|
||||||
const storage = ref(0)
|
const storage = ref(0)
|
||||||
@@ -30,8 +28,7 @@ async function getStorage() {
|
|||||||
|
|
||||||
storage.value = res.total_storage
|
storage.value = res.total_storage
|
||||||
used.value = res.used_storage
|
used.value = res.used_storage
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,42 +39,36 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard
|
<VHover>
|
||||||
title="存储空间"
|
<template #default="hover">
|
||||||
subtitle=""
|
<VCard v-bind="hover.props">
|
||||||
class="position-relative"
|
<!-- Triangle Background -->
|
||||||
>
|
<VImg :src="triangleBg" class="triangle-bg flip-in-rtl" />
|
||||||
<VCardText>
|
<VCardItem>
|
||||||
<h5 class="text-2xl font-weight-medium text-primary">
|
<template #append>
|
||||||
{{ formatFileSize(storage) }}
|
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||||
</h5>
|
</template>
|
||||||
<p class="mt-2">
|
<VCardTitle>存储空间</VCardTitle>
|
||||||
已使用 {{ usedPercent }}% 🚀
|
</VCardItem>
|
||||||
</p>
|
<VCardText>
|
||||||
<p class="mt-1">
|
<h5 class="text-2xl font-weight-medium text-primary">
|
||||||
<VProgressLinear
|
{{ formatFileSize(storage) }}
|
||||||
:model-value="usedPercent"
|
</h5>
|
||||||
color="primary"
|
<p class="mt-2">已使用 {{ usedPercent }}% 🚀</p>
|
||||||
/>
|
<p class="mt-1">
|
||||||
</p>
|
<VProgressLinear :model-value="usedPercent" color="primary" />
|
||||||
</VCardText>
|
</p>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
<!-- Triangle Background -->
|
<!-- Trophy -->
|
||||||
<VImg
|
<VImg :src="trophy" class="trophy" />
|
||||||
:src="triangleBg"
|
</VCard>
|
||||||
class="triangle-bg flip-in-rtl"
|
</template>
|
||||||
/>
|
</VHover>
|
||||||
|
|
||||||
<!-- Trophy -->
|
|
||||||
<VImg
|
|
||||||
:src="trophy"
|
|
||||||
class="trophy"
|
|
||||||
/>
|
|
||||||
</VCard>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use "@layouts/styles/mixins" as layoutsMixins;
|
@use '@layouts/styles/mixins' as layoutsMixins;
|
||||||
|
|
||||||
.v-card .triangle-bg {
|
.v-card .triangle-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -80,8 +80,13 @@ const options = controlledComputed(
|
|||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
},
|
},
|
||||||
|
|
||||||
formatter: (value: number) =>
|
formatter: (value: number) => {
|
||||||
value > 999 ? (value / 1000).toFixed(0) : value,
|
if (value > 999) {
|
||||||
|
return (value / 1000).toFixed(1) + 'k'
|
||||||
|
} else {
|
||||||
|
return value.toString()
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -100,8 +105,7 @@ async function getWeeklyData() {
|
|||||||
const res: number[] = await api.get('dashboard/transfer')
|
const res: number[] = await api.get('dashboard/transfer')
|
||||||
|
|
||||||
series.value = [{ data: res }]
|
series.value = [{ data: res }]
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,33 +116,29 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard>
|
<VHover>
|
||||||
<VCardItem>
|
<template #default="hover">
|
||||||
<VCardTitle>最近入库</VCardTitle>
|
<VCard v-bind="hover.props">
|
||||||
</VCardItem>
|
<VCardItem>
|
||||||
|
<template #append>
|
||||||
|
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||||
|
</template>
|
||||||
|
<VCardTitle>最近入库</VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VueApexCharts
|
<VueApexCharts type="bar" :options="options" :series="series" :height="160" />
|
||||||
type="bar"
|
|
||||||
:options="options"
|
|
||||||
:series="series"
|
|
||||||
:height="160"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="d-flex align-center mb-3">
|
<div class="d-flex align-center mb-3">
|
||||||
<h5 class="text-h5 me-4">
|
<h5 class="text-h5 me-4">
|
||||||
{{ totalCount }}
|
{{ totalCount }}
|
||||||
</h5>
|
</h5>
|
||||||
<p>最近一周入库了 {{ totalCount }} 部影片 😎</p>
|
<p>最近一周入库了 {{ totalCount }} 部影片 😎</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VBtn
|
<VBtn v-if="superUser" block to="/history"> 查看详情 </VBtn>
|
||||||
v-if="superUser"
|
</VCardText>
|
||||||
block
|
</VCard>
|
||||||
to="/history"
|
</template>
|
||||||
>
|
</VHover>
|
||||||
查看详情
|
|
||||||
</VBtn>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ const latestList = ref<MediaServerPlayItem[]>([])
|
|||||||
async function loadLatest() {
|
async function loadLatest() {
|
||||||
try {
|
try {
|
||||||
latestList.value = await api.get('mediaserver/latest')
|
latestList.value = await api.get('mediaserver/latest')
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,27 +21,20 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard>
|
<VHover>
|
||||||
<VCardItem>
|
<template #default="hover">
|
||||||
<VCardTitle>最近添加</VCardTitle>
|
<VCard v-bind="hover.props">
|
||||||
</VCardItem>
|
<VCardItem>
|
||||||
|
<template #append>
|
||||||
|
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||||
|
</template>
|
||||||
|
<VCardTitle >最近添加</VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
|
||||||
<div
|
<div v-if="latestList.length > 0" class="grid gap-4 grid-media-card mx-3 mb-3" tabindex="0">
|
||||||
v-if="latestList.length > 0"
|
<PosterCard v-for="data in latestList" :key="data.id" :media="data" />
|
||||||
class="grid gap-4 grid-media-card mx-3 mb-3"
|
</div>
|
||||||
tabindex="0"
|
</VCard>
|
||||||
>
|
</template>
|
||||||
<PosterCard
|
</VHover>
|
||||||
v-for="data in latestList"
|
|
||||||
:key="data.id"
|
|
||||||
:media="data"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.grid-media-card {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user