Compare commits
433 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
741876dcaa | ||
|
|
5c6f32a7db | ||
|
|
80b24cbfbc | ||
|
|
8afed9768d | ||
|
|
1f4dacff02 | ||
|
|
a046c0ec45 | ||
|
|
82d0fd2b11 | ||
|
|
e2fb55d910 | ||
|
|
7754c41d34 | ||
|
|
eea30c3a0d | ||
|
|
bfe41a0642 | ||
|
|
4ba0151c42 | ||
|
|
98bdfb160e | ||
|
|
6327649501 | ||
|
|
6937f5e1b1 | ||
|
|
e3ce4196fe | ||
|
|
bb67a051c2 | ||
|
|
812dd1f184 | ||
|
|
37d6612434 | ||
|
|
9cbafdfab8 | ||
|
|
1c4d806e58 | ||
|
|
aba2ee29dd | ||
|
|
51deb29145 | ||
|
|
1f7a677db3 | ||
|
|
0fb0652919 | ||
|
|
39c7e723ba | ||
|
|
a9ddf159cc | ||
|
|
22b93e1ae3 | ||
|
|
93b83048cf | ||
|
|
1c18f3a4f2 | ||
|
|
b5a01a7a42 | ||
|
|
caf211c34e | ||
|
|
8b79c70be7 | ||
|
|
6a4a218152 | ||
|
|
6bc420d57f | ||
|
|
db0325a59c | ||
|
|
eab2f0df20 | ||
|
|
2d1fbff2c5 | ||
|
|
75c3ac71ae | ||
|
|
61ffd222cc | ||
|
|
3499327984 | ||
|
|
c90ed003f7 | ||
|
|
bd9169bcd1 | ||
|
|
0c46ab7d5a | ||
|
|
3bc464011a | ||
|
|
74fe67fe4d | ||
|
|
48fcce54dc | ||
|
|
b91be6bb2f | ||
|
|
181ad39e18 | ||
|
|
4af6e5e91f | ||
|
|
d67c6acfa2 | ||
|
|
f4633a5832 | ||
|
|
36841f6f8f | ||
|
|
86b4df871a | ||
|
|
03ad8cc9e8 | ||
|
|
de39ffa260 | ||
|
|
becccb8368 | ||
|
|
1b75bb2cec | ||
|
|
2a9f9b725e | ||
|
|
5f15e84065 | ||
|
|
deeb5f9d62 | ||
|
|
b715198a02 | ||
|
|
005b1a9715 | ||
|
|
d120bb794c | ||
|
|
e625d56c65 | ||
|
|
2d1d19e457 | ||
|
|
c5d0a7fd74 | ||
|
|
c72fcbd10d | ||
|
|
5f388c8b09 | ||
|
|
b02c3c8e5c | ||
|
|
1d9e0eb3a3 | ||
|
|
0a5b553bb8 | ||
|
|
865d57b4d3 | ||
|
|
8e40c38730 | ||
|
|
ddee496c73 | ||
|
|
9c4d12d18b | ||
|
|
6efa0e307e | ||
|
|
f0ac2d739d | ||
|
|
1cb78b4ccd | ||
|
|
fc6f41a549 | ||
|
|
db86d075f0 | ||
|
|
3db4e12bb2 | ||
|
|
f4a7372b4f | ||
|
|
877d2f77bd | ||
|
|
02334489ed | ||
|
|
cd714d954f | ||
|
|
9e4655070c | ||
|
|
f84d69feb7 | ||
|
|
3c91ad2f59 | ||
|
|
873848b9a7 | ||
|
|
f906a172dd | ||
|
|
6b9c74dcea | ||
|
|
539cf9ada4 | ||
|
|
a69d2dfd71 | ||
|
|
3f9b9a6903 | ||
|
|
9949a16f34 | ||
|
|
7e30cf40a9 | ||
|
|
fba0df8cb9 | ||
|
|
7a97005524 | ||
|
|
ee8b57da91 | ||
|
|
e63f19a00d | ||
|
|
deabf23475 | ||
|
|
0b3fc938ae | ||
|
|
bdd0cdbe55 | ||
|
|
ae261cb684 | ||
|
|
0036a895e9 | ||
|
|
f317d15580 | ||
|
|
76a487854b | ||
|
|
b3f616ddc6 | ||
|
|
9b19cbefc8 | ||
|
|
a4ba6b947b | ||
|
|
fb510ff180 | ||
|
|
2710cbc85a | ||
|
|
b82f17bcf1 | ||
|
|
f9c33394a9 | ||
|
|
0a15a6eb64 | ||
|
|
45777c01ee | ||
|
|
b331cc55ce | ||
|
|
74ef5a8083 | ||
|
|
8dd82aacf2 | ||
|
|
35d130a01b | ||
|
|
15319bf586 | ||
|
|
832cae635e | ||
|
|
1c83752f56 | ||
|
|
7973457417 | ||
|
|
8083e94ecd | ||
|
|
b3485af14c | ||
|
|
01eaef2bf9 | ||
|
|
3e241cf8bc | ||
|
|
2df4dc0535 | ||
|
|
135a1e3d52 | ||
|
|
4366fdd4a6 | ||
|
|
a47d3f10f9 | ||
|
|
004c9eadd5 | ||
|
|
b483a5f4e8 | ||
|
|
06e0f4234f | ||
|
|
e9a5c0ae69 | ||
|
|
0c17702f65 | ||
|
|
ddf682d66a | ||
|
|
018c5f857b | ||
|
|
b5d89ff082 | ||
|
|
54046a4717 | ||
|
|
505773043b | ||
|
|
e9bb811244 | ||
|
|
6ef6ea1479 | ||
|
|
93bd4002db | ||
|
|
b9ec829747 | ||
|
|
f307327af3 | ||
|
|
936be9928d | ||
|
|
b639369846 | ||
|
|
5577e4cf62 | ||
|
|
63206fea2e | ||
|
|
40727dac2d | ||
|
|
d703909177 | ||
|
|
fc61060b7f | ||
|
|
73e21e77ec | ||
|
|
6be05819b0 | ||
|
|
0e116ad1b9 | ||
|
|
016c232ef2 | ||
|
|
9856419292 | ||
|
|
cf3a204eac | ||
|
|
dc3e364b90 | ||
|
|
d22ef17b95 | ||
|
|
4126692c5a | ||
|
|
d22f1c97ae | ||
|
|
735023330a | ||
|
|
6301cb287e | ||
|
|
85ebb0242a | ||
|
|
81a670d608 | ||
|
|
a547e5c34b | ||
|
|
cf6b6dd4dd | ||
|
|
574464c1ea | ||
|
|
816dfa4e3b | ||
|
|
9d7e52c25e | ||
|
|
d41b6ca459 | ||
|
|
4d1b5209e7 | ||
|
|
7da21f23aa | ||
|
|
40a9caceb8 | ||
|
|
7e4f21ff33 | ||
|
|
cd6f5090d7 | ||
|
|
1efd0a3d5b | ||
|
|
4434d7b8c9 | ||
|
|
24e184eace | ||
|
|
8ccd9cfd85 | ||
|
|
cb2c23dc96 | ||
|
|
29912cac8d | ||
|
|
6376a81c4a | ||
|
|
aff4b2f9b7 | ||
|
|
153fe8fcd0 | ||
|
|
95d8b3d1a6 | ||
|
|
19ce869763 | ||
|
|
e6b6d3ca27 | ||
|
|
8e7be239ee | ||
|
|
4bd97f9d81 | ||
|
|
49d182eabc | ||
|
|
9411a29adf | ||
|
|
61bb96e1fe | ||
|
|
6a6100a814 | ||
|
|
40fcf9d0cc | ||
|
|
65946c55d1 | ||
|
|
e2b4df3dcf | ||
|
|
04fee167b9 | ||
|
|
243c273084 | ||
|
|
b43cf4dd5d | ||
|
|
cf9c38fdd5 | ||
|
|
6e4e6df08f | ||
|
|
7b5630223d | ||
|
|
3d985decbc | ||
|
|
dbe23eaac7 | ||
|
|
e38df0f319 | ||
|
|
c2ac66fdbf | ||
|
|
5ad25ff14d | ||
|
|
04e1b527b5 | ||
|
|
09210f98e9 | ||
|
|
bfe228a367 | ||
|
|
a01978196d | ||
|
|
f795481895 | ||
|
|
83e199c1ea | ||
|
|
8734e7fc1b | ||
|
|
b48e4adacd | ||
|
|
a45e2b120e | ||
|
|
52b6f103a5 | ||
|
|
927f4a366c | ||
|
|
b28347d191 | ||
|
|
df057ebe4d | ||
|
|
aa7b4a0e94 | ||
|
|
ca9d44f55f | ||
|
|
247631fd68 | ||
|
|
3357928e80 | ||
|
|
fc263d79a8 | ||
|
|
ee10616acf | ||
|
|
30c3ad6c90 | ||
|
|
5ad6d6d904 | ||
|
|
e2c7fc0af0 | ||
|
|
172fb06d8e | ||
|
|
634522d27b | ||
|
|
03b14a0fb5 | ||
|
|
ec54ec2607 | ||
|
|
340bb08f2a | ||
|
|
022487a877 | ||
|
|
6ec1bbe1ae | ||
|
|
9d55f8ab24 | ||
|
|
fc61f3fca1 | ||
|
|
cca3368d8f | ||
|
|
57f6547b91 | ||
|
|
200b22cf0c | ||
|
|
e9b8f3138c | ||
|
|
dd9663451e | ||
|
|
78e0e7dba1 | ||
|
|
b94fb70e02 | ||
|
|
e94c149cd1 | ||
|
|
94ba3c4514 | ||
|
|
c129a37ccf | ||
|
|
6608a4266b | ||
|
|
809bfbb42a | ||
|
|
676ff8789b | ||
|
|
3b1a9bd0c4 | ||
|
|
202b9dc3bc | ||
|
|
ce96deb224 | ||
|
|
14afe59eeb | ||
|
|
790a8bdb9a | ||
|
|
8bd0f7a589 | ||
|
|
235eb82c45 | ||
|
|
f043447e4f | ||
|
|
e92a74a088 | ||
|
|
799a385ff9 | ||
|
|
2c74dc0ccd | ||
|
|
c191b12514 | ||
|
|
2c9e593af0 | ||
|
|
f1dbab7d55 | ||
|
|
ea77d7e76d | ||
|
|
64d8e3b1e1 | ||
|
|
bd4975d180 | ||
|
|
2a916a099c | ||
|
|
bc084922f7 | ||
|
|
42f755b755 | ||
|
|
7f2c629305 | ||
|
|
6136095e0f | ||
|
|
0a34e07cc5 | ||
|
|
71c6f4483f | ||
|
|
731a74905c | ||
|
|
8b0e47103c | ||
|
|
4da24e27a4 | ||
|
|
169f1b327b | ||
|
|
360f9afb54 | ||
|
|
0e45a59860 | ||
|
|
cfc2e407a4 | ||
|
|
a467fdb43f | ||
|
|
474db2be0d | ||
|
|
e946037c57 | ||
|
|
b2e1fe314f | ||
|
|
81fb44da80 | ||
|
|
0e8da35b0a | ||
|
|
4d2cf73330 | ||
|
|
de2ce12163 | ||
|
|
5df89f2ce4 | ||
|
|
045c0b4c0c | ||
|
|
8b4ffa0795 | ||
|
|
14359a37ae | ||
|
|
f4b2ed4f7d | ||
|
|
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 |
@@ -1 +1,2 @@
|
||||
VITE_API_BASE_URL=http://localhost:3001/api/v1/
|
||||
VITE_API_BASE_URL=/api/v1/
|
||||
VITE_PUBLIC_VAPID_KEY=BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
VITE_API_BASE_URL=api/v1/
|
||||
VITE_PUBLIC_VAPID_KEY=BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM
|
||||
|
||||
16
.github/workflows/build.yml
vendored
@@ -1,12 +1,12 @@
|
||||
name: Build Moviepilot-Frontend
|
||||
name: Build Moviepilot-Frontend v2
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- v2
|
||||
paths:
|
||||
- package.json
|
||||
- 'package.json'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -42,13 +42,21 @@ jobs:
|
||||
echo "$frontend_version" > dist/version.txt
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Delete Release
|
||||
uses: dev-drprasad/delete-tag-and-release@v1.1
|
||||
with:
|
||||
tag_name: ${{ env.frontend_version }}
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ env.frontend_version }}
|
||||
name: ${{ env.frontend_version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
files: |
|
||||
dist.zip
|
||||
env:
|
||||
|
||||
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="manifest.json" crossorigin="use-credentials" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
@@ -30,14 +29,13 @@
|
||||
<meta name="HandheldFriendly" content="True" />
|
||||
<meta name="MobileOptimized" content="320" />
|
||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||
<link rel="preload" href="index.js" as="script">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loading-bg">
|
||||
<div class="loading-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">
|
||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "1.9.2-4",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
@@ -19,7 +19,6 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytebase/vue-kbar": "^0.1.8",
|
||||
"@fullcalendar/core": "^6.1.8",
|
||||
"@fullcalendar/daygrid": "^6.1.8",
|
||||
"@fullcalendar/interaction": "^6.1.7",
|
||||
@@ -37,12 +36,12 @@
|
||||
"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",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"vue": "^3.3.2",
|
||||
"vue-router": "^4.2.0",
|
||||
"vue-toast-notification": "^3",
|
||||
@@ -92,6 +91,7 @@
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-pages": "^0.32.1",
|
||||
"vite-plugin-pwa": "^0.20.0",
|
||||
"vite-plugin-vue-layouts": "^0.11.0",
|
||||
"vite-plugin-vuetify": "2.0.3",
|
||||
"vue-shepherd": "^3.0.0",
|
||||
@@ -101,4 +101,4 @@
|
||||
"resolutions": {
|
||||
"postcss": "8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ body {
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden auto;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
min-block-size: calc(100% + env(safe-area-inset-top));
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
@@ -20,8 +19,8 @@ html {
|
||||
|
||||
.loading-logo {
|
||||
position: absolute;
|
||||
inset-block-start: 40%;
|
||||
inset-inline-start: calc(50% - 50px);
|
||||
inset-block-start: 35%;
|
||||
inset-inline-start: calc(50% - 5rem);
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -83,4 +82,4 @@ html {
|
||||
opacity: 1;
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"name": "MoviePilot",
|
||||
"short_name": "MoviePilot",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -14,7 +14,10 @@ function onClick() {
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
progress: Number,
|
||||
text: String
|
||||
})
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
progress: Number,
|
||||
text: String,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<div 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" />
|
||||
<span>{{ props.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -114,10 +114,8 @@ function changeTheme(theme: string) {
|
||||
currentThemeName.value = nextTheme
|
||||
// 保存主题到服务端
|
||||
try {
|
||||
api.post('/user/config/theme', nextTheme, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
api.post('/user/config/Layout', {
|
||||
theme: nextTheme
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('保存主题到服务端失败')
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
/* 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函数的问题
|
||||
*/
|
||||
export function fixArrayAt() {
|
||||
if (!Array.prototype.at) {
|
||||
Array.prototype.at = function(index: number) {
|
||||
if (index >= 0) {
|
||||
return this[index]
|
||||
} else {
|
||||
return this[this.length + index]
|
||||
}
|
||||
}
|
||||
;(function fixArrayAt() {
|
||||
if (!Array.prototype.at) {
|
||||
Array.prototype.at = function (index: number) {
|
||||
if (index >= 0) {
|
||||
return this[index]
|
||||
} else {
|
||||
return this[this.length + index]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -8,8 +8,7 @@ dayjs.extend(relativeTime)
|
||||
dayjs.locale(ZH_CN)
|
||||
|
||||
export function avatarText(value: string) {
|
||||
if (!value)
|
||||
return ''
|
||||
if (!value) return ''
|
||||
const nameArray = value.split(' ')
|
||||
|
||||
return nameArray.map(word => word.charAt(0).toUpperCase()).join('')
|
||||
@@ -19,7 +18,9 @@ export function avatarText(value: string) {
|
||||
export function kFormatter(num: number) {
|
||||
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 {Intl.DateTimeFormatOptions} formatting Intl object to format with
|
||||
*/
|
||||
export function formatDate(value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) {
|
||||
if (!value)
|
||||
return value
|
||||
export function formatDate(
|
||||
value: string,
|
||||
formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' },
|
||||
) {
|
||||
if (!value) return value
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
|
||||
}
|
||||
@@ -46,32 +49,36 @@ export function formatDateToMonthShort(value: string, toTimeForCurrentDay = true
|
||||
const date = new Date(value)
|
||||
let formatting: Record<string, string> = { month: 'short', day: 'numeric' }
|
||||
|
||||
if (toTimeForCurrentDay && isToday(date))
|
||||
formatting = { hour: 'numeric', minute: 'numeric' }
|
||||
if (toTimeForCurrentDay && isToday(date)) formatting = { hour: 'numeric', minute: 'numeric' }
|
||||
|
||||
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
|
||||
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
|
||||
export function formatFileSize(bytes: number) {
|
||||
if (bytes < 0)
|
||||
throw new Error('字节数不能为负数。')
|
||||
export function formatFileSize(bytes: number, decimals = 2, prefix = false) {
|
||||
// 负数标记
|
||||
let negative = false
|
||||
let size = bytes
|
||||
if (bytes < 0) {
|
||||
negative = true
|
||||
size = Math.abs(bytes)
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = bytes
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`
|
||||
if (negative) return `-${size.toFixed(decimals)} ${units[unitIndex]}`
|
||||
else
|
||||
return prefix ? `+${size.toFixed(decimals)} ${units[unitIndex]}` : `${size.toFixed(decimals)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
// 将时间秒格式化为时分秒
|
||||
@@ -82,22 +89,18 @@ export function formatSeconds(seconds: number) {
|
||||
|
||||
let formattedTime = ''
|
||||
|
||||
if (hours > 0)
|
||||
formattedTime += `${hours}小时`
|
||||
if (hours > 0) formattedTime += `${hours}小时`
|
||||
|
||||
if (minutes > 0)
|
||||
formattedTime += `${minutes}分`
|
||||
if (minutes > 0) formattedTime += `${minutes}分`
|
||||
|
||||
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0)
|
||||
formattedTime += `${remainingSeconds}秒`
|
||||
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0) formattedTime += `${remainingSeconds}秒`
|
||||
|
||||
return formattedTime
|
||||
}
|
||||
|
||||
// YYYY-MM-DD 转化为Date
|
||||
export function parseDate(dateString: string): Date | null {
|
||||
if (!dateString)
|
||||
return null
|
||||
if (!dateString) return null
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
|
||||
return new Date(year, month - 1, day)
|
||||
@@ -105,8 +108,7 @@ export function parseDate(dateString: string): Date | null {
|
||||
|
||||
// 文件大小格式化
|
||||
export function formatBytes(bytes: number, decimals = 2) {
|
||||
if (bytes === 0)
|
||||
return '0 bytes'
|
||||
if (bytes === 0) return '0 bytes'
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
@@ -119,11 +121,9 @@ export function formatBytes(bytes: number, decimals = 2) {
|
||||
|
||||
// 格式化剧集列表
|
||||
export function formatEp(nums: number[]): string {
|
||||
if (!nums.length)
|
||||
return ''
|
||||
if (!nums.length) return ''
|
||||
|
||||
if (nums.length === 1)
|
||||
return nums[0].toString()
|
||||
if (nums.length === 1) return nums[0].toString()
|
||||
|
||||
// 将数组升序排序
|
||||
nums.sort((a, b) => a - b)
|
||||
@@ -134,44 +134,22 @@ export function formatEp(nums: number[]): string {
|
||||
for (let i = 1; i < nums.length; i++) {
|
||||
if (nums[i] === end + 1) {
|
||||
end = nums[i]
|
||||
}
|
||||
else {
|
||||
if (start === end)
|
||||
formattedRanges.push(start.toString())
|
||||
|
||||
else
|
||||
formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
||||
} else {
|
||||
if (start === end) formattedRanges.push(start.toString())
|
||||
else formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
||||
|
||||
start = end = nums[i]
|
||||
}
|
||||
}
|
||||
|
||||
if (start === end)
|
||||
formattedRanges.push(start.toString())
|
||||
|
||||
else
|
||||
formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
||||
if (start === end) formattedRanges.push(start.toString())
|
||||
else formattedRanges.push(`${start.toString()}-${end.toString()}`)
|
||||
|
||||
return formattedRanges.join('、')
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd hh:mm:ss转换为时间差,如:1小时前,1天前
|
||||
export function formatDateDifference(dateString: string): string {
|
||||
// const timeDifference = dayjs().millisecond() - dayjs(dateString).millisecond()
|
||||
// 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 ''
|
||||
if (!dateString) return ''
|
||||
return dayjs(dateString).fromNow()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// 👉 IsEmpty
|
||||
export function isEmpty(value: unknown): boolean {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return true
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
|
||||
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中
|
||||
export function isContained(subArray: any[], mainArray: any[]): boolean {
|
||||
return subArray.every(element => mainArray.includes(element))
|
||||
@@ -112,8 +44,7 @@ export function isIntersected(array1: any[], array2: any[]): boolean {
|
||||
|
||||
export function isNullOrEmptyObject(obj: any): boolean {
|
||||
// 首先判断是否为 null 或 undefined
|
||||
if (obj === null || obj === undefined)
|
||||
return true
|
||||
if (obj === null || obj === undefined) return true
|
||||
|
||||
// 然后判断是否为空对象
|
||||
return !!(typeof obj === 'object' && Object.keys(obj).length === 0)
|
||||
@@ -127,3 +58,10 @@ export function checkPrefersColorSchemeIsDark(): boolean {
|
||||
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] : ''
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
export async function getClipboardContent() {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return await navigator.clipboard.readText()
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
const input = document.createElement('textarea')
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
@@ -18,13 +17,37 @@ export async function getClipboardContent() {
|
||||
export async function copyToClipboard(content: string) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(content)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
const input = document.createElement('textarea')
|
||||
input.value = content
|
||||
document.body.appendChild(input)
|
||||
// 阻止事件冒泡到其他元素,确保 focusin 事件只在 textarea 元素上处理,不会影响其他元素
|
||||
input.addEventListener('focusin', e => e.stopPropagation())
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 判断是否为PWA
|
||||
export const isPWA = async (): Promise<boolean> => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
return registrations.length > 0
|
||||
}
|
||||
return (window.navigator as any).standalone === true
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ function handleNavScroll(evt: Event) {
|
||||
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
|
||||
<div class="d-flex" v-html="logo" />
|
||||
|
||||
<h1 class="font-weight-bold leading-normal text-2xl">
|
||||
MOVIEPILOT
|
||||
<h1 class="font-weight-bold leading-normal text-xl">
|
||||
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>
|
||||
</h1>
|
||||
</RouterLink>
|
||||
</slot>
|
||||
|
||||
@@ -33,7 +33,10 @@ defineProps<{
|
||||
.nav-link a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0 3.125rem 3.125rem 0 !important;
|
||||
cursor: pointer;
|
||||
margin-inline-end: 1.125em;
|
||||
padding-inline: 1.375rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,3 +18,12 @@ defineProps<{
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-vertical-nav {
|
||||
.nav-section-title {
|
||||
padding-left: 1.375rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
7
src/@layouts/types.d.ts
vendored
@@ -120,6 +120,13 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
|
||||
disable?: boolean
|
||||
}
|
||||
|
||||
export interface NavMenu extends NavLink {
|
||||
header: string
|
||||
description?: string
|
||||
admin?: boolean
|
||||
footer?: boolean
|
||||
}
|
||||
|
||||
// 👉 Vertical nav group
|
||||
export interface NavGroup extends Partial<AclProperties> {
|
||||
title: string
|
||||
|
||||
@@ -10,6 +10,8 @@ import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
|
||||
|
||||
import modeCssUrl from 'ace-builds/src-noconflict/mode-css?url'
|
||||
|
||||
import modeIniUrl from 'ace-builds/src-noconflict/mode-ini?url'
|
||||
|
||||
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
|
||||
|
||||
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
|
||||
@@ -38,6 +40,8 @@ import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
|
||||
|
||||
import snippertsCssUrl from 'ace-builds/src-noconflict/snippets/css?url'
|
||||
|
||||
import snippertsIniUrl from 'ace-builds/src-noconflict/snippets/ini?url'
|
||||
|
||||
import 'ace-builds/src-noconflict/ext-language_tools'
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||
@@ -45,6 +49,7 @@ ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
|
||||
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
|
||||
ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
|
||||
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
|
||||
ace.config.setModuleUrl('ace/mode/ini', modeIniUrl)
|
||||
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
|
||||
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
|
||||
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
|
||||
@@ -59,5 +64,6 @@ ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
|
||||
|
||||
ace.require('ace/ext/language_tools')
|
||||
|
||||
64
src/api/constants.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export const storageOptions = [
|
||||
{
|
||||
title: '本地',
|
||||
value: 'local',
|
||||
},
|
||||
{
|
||||
title: '阿里云盘',
|
||||
value: 'alipan',
|
||||
},
|
||||
{
|
||||
title: '115网盘',
|
||||
value: 'u115',
|
||||
},
|
||||
{
|
||||
title: 'Rclone网盘',
|
||||
value: 'rclone',
|
||||
},
|
||||
]
|
||||
|
||||
export const innerFilterRules = [
|
||||
{ title: '特效字幕', value: ' SPECSUB ' },
|
||||
{ title: '中文字幕', value: ' CNSUB ' },
|
||||
{ title: '国语配音', value: ' CNVOI ' },
|
||||
{ title: '官种', value: ' GZ ' },
|
||||
{ title: '排除: 国语配音', value: ' !CNVOI ' },
|
||||
{ title: '粤语配音', value: ' HKVOI ' },
|
||||
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
|
||||
{ title: '促销: 免费', value: ' FREE ' },
|
||||
{ title: '分辨率: 4K', value: ' 4K ' },
|
||||
{ title: '分辨率: 1080P', value: ' 1080P ' },
|
||||
{ title: '分辨率: 720P', value: ' 720P ' },
|
||||
{ title: '排除: 720P', value: ' !720P ' },
|
||||
{ title: '质量: 蓝光原盘', value: ' BLU ' },
|
||||
{ title: '排除: 蓝光原盘', value: ' !BLU ' },
|
||||
{ title: '质量: BLURAY', value: ' BLURAY ' },
|
||||
{ title: '排除: BLURAY', value: ' !BLURAY ' },
|
||||
{ title: '质量: UHD', value: ' UHD ' },
|
||||
{ title: '排除: UHD', value: ' !UHD ' },
|
||||
{ title: '质量: REMUX', value: ' REMUX ' },
|
||||
{ title: '排除: REMUX', value: ' !REMUX ' },
|
||||
{ title: '质量: WEB-DL', value: ' WEBDL ' },
|
||||
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
|
||||
{ title: '质量: 60fps', value: ' 60FPS ' },
|
||||
{ title: '排除: 60fps', value: ' !60FPS ' },
|
||||
{ title: '编码: H265', value: ' H265 ' },
|
||||
{ title: '排除: H265', value: ' !H265 ' },
|
||||
{ title: '编码: H264', value: ' H264 ' },
|
||||
{ title: '排除: H264', value: ' !H264 ' },
|
||||
{ title: '效果: 杜比视界', value: ' DOLBY ' },
|
||||
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
|
||||
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
|
||||
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
|
||||
{ title: '效果: HDR', value: ' HDR ' },
|
||||
{ title: '排除: HDR', value: ' !HDR ' },
|
||||
{ title: '效果: SDR', value: ' SDR ' },
|
||||
{ title: '排除: SDR', value: ' !SDR ' },
|
||||
{ title: '效果: 3D', value: ' 3D ' },
|
||||
{ title: '排除: 3D', value: ' !3D ' },
|
||||
]
|
||||
|
||||
export const storageDict = storageOptions.reduce((dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
}, {} as Record<string, string>)
|
||||
@@ -27,14 +27,23 @@ api.interceptors.response.use(
|
||||
return Promise.reject(new Error(error))
|
||||
} else if (error.response.status === 403) {
|
||||
// 清除登录状态信息
|
||||
store.dispatch('auth/clearToken')
|
||||
|
||||
store.dispatch('auth/logout')
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(error))
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
export async function fetchGlobalSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/global')
|
||||
return result.data || {}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch global settings', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
334
src/api/types.ts
@@ -1,5 +1,6 @@
|
||||
// 订阅
|
||||
export interface Subscribe {
|
||||
// 订阅ID
|
||||
id: number
|
||||
// 订阅名称
|
||||
name: string
|
||||
@@ -58,17 +59,87 @@ export interface Subscribe {
|
||||
// 当前优先级
|
||||
current_priority: number
|
||||
// 保存目录
|
||||
save_path: string
|
||||
save_path?: string
|
||||
// 时间
|
||||
date: string
|
||||
// 编辑框设置项
|
||||
show_edit_dialog: boolean
|
||||
// 编辑框打开状态
|
||||
page_open?: boolean
|
||||
// 自定义识别词
|
||||
custom_words?: string
|
||||
// 自定义媒体类别
|
||||
media_category?: string
|
||||
// 过滤规则组
|
||||
filter_groups?: string[]
|
||||
}
|
||||
|
||||
// 订阅分享
|
||||
export interface SubscribeShare {
|
||||
// 分享ID
|
||||
id?: number
|
||||
// 订阅ID
|
||||
subscribe_id?: number
|
||||
// 分享标题
|
||||
share_title?: string
|
||||
// 分享说明
|
||||
share_comment?: string
|
||||
// 分享人
|
||||
share_user?: string
|
||||
// 订阅名称
|
||||
name?: string
|
||||
// 订阅年份
|
||||
year?: string
|
||||
// 订阅类型 电影/电视剧
|
||||
type?: string
|
||||
// 搜索关键字
|
||||
keyword?: string
|
||||
// TMDB ID
|
||||
tmdbid?: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
poster?: string
|
||||
// 背景图
|
||||
backdrop?: string
|
||||
// 评分
|
||||
vote?: number
|
||||
// 描述
|
||||
description?: string
|
||||
// 过滤规则
|
||||
filter?: string
|
||||
// 包含
|
||||
include?: string
|
||||
// 排除
|
||||
exclude?: string
|
||||
// 质量
|
||||
quality?: string
|
||||
// 分辨率
|
||||
resolution?: string
|
||||
// 特效
|
||||
effect?: string
|
||||
// 总集数
|
||||
total_episode?: number
|
||||
// 时间
|
||||
date?: string
|
||||
// 自定义识别词
|
||||
custom_words?: string
|
||||
// 自定义媒体类别
|
||||
media_category?: string
|
||||
// 复用次数
|
||||
count?: number
|
||||
}
|
||||
|
||||
// 历史记录
|
||||
export interface TransferHistory {
|
||||
// ID
|
||||
id: number
|
||||
// 源存储
|
||||
src_storage?: string
|
||||
// 目标存储
|
||||
dest_storage?: string
|
||||
// 源目录
|
||||
src?: string
|
||||
// 目的目录
|
||||
@@ -364,6 +435,48 @@ export interface SiteStatistic {
|
||||
note?: string
|
||||
}
|
||||
|
||||
// 站点用户数据
|
||||
export interface SiteUserData {
|
||||
// 站点域名
|
||||
domain?: string
|
||||
// 用户名
|
||||
username?: string
|
||||
// 用户ID
|
||||
userid?: number
|
||||
// 用户等级
|
||||
user_level?: string
|
||||
// 加入时间
|
||||
join_at?: string
|
||||
// 积分
|
||||
bonus?: number // 默认为 0.0
|
||||
// 上传量
|
||||
upload?: number // 默认为 0
|
||||
// 下载量
|
||||
download?: number // 默认为 0
|
||||
// 分享率
|
||||
ratio?: number // 默认为 0
|
||||
// 做种数
|
||||
seeding?: number // 默认为 0
|
||||
// 下载数
|
||||
leeching?: number // 默认为 0
|
||||
// 做种体积
|
||||
seeding_size?: number // 默认为 0
|
||||
// 下载体积
|
||||
leeching_size?: number // 默认为 0
|
||||
// 做种人数, 种子大小
|
||||
seeding_info?: any[] // 默认为空数组
|
||||
// 未读消息
|
||||
message_unread?: number // 默认为 0
|
||||
// 未读消息内容
|
||||
message_unread_contents?: any[] // 默认为空数组
|
||||
// 错误信息
|
||||
err_msg?: string | null // 默认为 null
|
||||
// 更新日期
|
||||
updated_day?: string
|
||||
// 更新时间
|
||||
updated_time?: string
|
||||
}
|
||||
|
||||
// 正在下载
|
||||
export interface DownloadingInfo {
|
||||
// HASH
|
||||
@@ -392,6 +505,8 @@ export interface DownloadingInfo {
|
||||
userid?: string
|
||||
// 下载用户名称
|
||||
username?: string
|
||||
// 剩余时间
|
||||
left_time?: string
|
||||
}
|
||||
|
||||
// 缺失剧集信息
|
||||
@@ -445,6 +560,8 @@ export interface Plugin {
|
||||
history?: { [key: string]: string }
|
||||
// 添加时间
|
||||
add_time?: number
|
||||
// 页面打开状态
|
||||
page_open?: boolean
|
||||
}
|
||||
|
||||
// 渲染结构
|
||||
@@ -638,6 +755,10 @@ export interface User {
|
||||
avatar: string
|
||||
// 是否开启双重验证
|
||||
is_otp: boolean
|
||||
// 用户权限 json
|
||||
permissions: { [key: string]: any }
|
||||
// 用户个性化设置 json
|
||||
settings: { [key: string]: string | null }
|
||||
}
|
||||
|
||||
// 存储空间
|
||||
@@ -716,6 +837,7 @@ export interface NotificationSwitch {
|
||||
slack: boolean
|
||||
synologychat: boolean
|
||||
vocechat: boolean
|
||||
webpush: boolean
|
||||
}
|
||||
|
||||
// 文件浏览接口
|
||||
@@ -736,22 +858,34 @@ export interface EndPoints {
|
||||
|
||||
// 文件浏览项目
|
||||
export interface FileItem {
|
||||
// 存储
|
||||
storage: string
|
||||
// 类型 dir/file
|
||||
type: string
|
||||
// 文件名
|
||||
name: string
|
||||
// 文件名不含扩展名
|
||||
basename: string
|
||||
basename?: string
|
||||
// 文件路径
|
||||
path: string
|
||||
// 文件扩展名
|
||||
extension: string
|
||||
extension?: string
|
||||
// 文件大小
|
||||
size: number
|
||||
size?: number
|
||||
// 文件子元素
|
||||
children: FileItem[]
|
||||
children?: FileItem[]
|
||||
// 文件创建时间
|
||||
modify_time: number
|
||||
modify_time?: number
|
||||
// 文件ID
|
||||
fileid?: string
|
||||
// 上级文件ID
|
||||
parent_fileid?: string
|
||||
// 缩略图
|
||||
thumbnail?: string
|
||||
// pickcode
|
||||
pickcode?: string
|
||||
// drive_id
|
||||
drive_id?: string
|
||||
}
|
||||
|
||||
// 媒体服务器播放条目
|
||||
@@ -828,22 +962,174 @@ export interface SystemNotification {
|
||||
date: string
|
||||
}
|
||||
|
||||
// 下载目录/媒体库目录
|
||||
export interface MediaDirectory {
|
||||
// 类型 download/library
|
||||
type?: string
|
||||
// 别名
|
||||
name?: string
|
||||
// 路径
|
||||
path?: string
|
||||
// 媒体类型 电影/电视剧
|
||||
media_type?: string
|
||||
// 媒体类别 动画电影/国产剧
|
||||
category?: string
|
||||
// 刮削媒体信息
|
||||
scrape?: boolean
|
||||
// 自动二级分类,未指定类别时自动分类
|
||||
auto_category?: boolean
|
||||
// 优先级
|
||||
priority?: number
|
||||
// 下载器配置
|
||||
export interface DownloaderConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 qbittorrent/transmission
|
||||
type: string
|
||||
// 是否默认
|
||||
default: boolean
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 通知配置
|
||||
export interface NotificationConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 telegram/wechat/vocechat/synologychat
|
||||
type: string
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
// 场景开关
|
||||
switchs?: string[]
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 通知场景开关配置
|
||||
export interface NotificationSwitchConf {
|
||||
// 场景名称
|
||||
type: string
|
||||
// 通知范围 all/user/admin
|
||||
action: string
|
||||
}
|
||||
|
||||
// 存储配置
|
||||
export interface StorageConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 local/alipan/u115/rclone
|
||||
type: string
|
||||
// 配置
|
||||
config?: { [key: string]: any }
|
||||
}
|
||||
|
||||
// 媒体服务器配置
|
||||
export interface MediaServerConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 emby/jellyfin/plex
|
||||
type: string
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
// 同步媒体体库列表
|
||||
sync_libraries?: string[]
|
||||
}
|
||||
|
||||
// 文件整理目录配置
|
||||
export interface TransferDirectoryConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 优先级
|
||||
priority: number
|
||||
// 存储
|
||||
storage: string
|
||||
// 下载目录
|
||||
download_path?: string
|
||||
// 适用媒体类型
|
||||
media_type?: string
|
||||
// 适用媒体类别
|
||||
media_category?: string
|
||||
// 下载类型子目录
|
||||
download_type_folder?: boolean
|
||||
// 下载类别子目录
|
||||
download_category_folder?: boolean
|
||||
// 监控方式 downloader/monitor,None为不监控
|
||||
monitor_type?: string
|
||||
// 监控模式 fast/compatibility
|
||||
monitor_mode?: string
|
||||
// 整理方式 move/copy/link/softlink
|
||||
transfer_type?: string
|
||||
// 文件覆盖模式 always/size/never/latest
|
||||
overwrite_mode?: string
|
||||
// 整理到媒体库目录
|
||||
library_path?: string
|
||||
// 媒体库目录存储
|
||||
library_storage?: string
|
||||
// 智能重命名
|
||||
renaming?: boolean
|
||||
// 刮削
|
||||
scraping?: boolean
|
||||
// 媒体库类型子目录
|
||||
library_type_folder?: boolean
|
||||
// 媒体库类别子目录
|
||||
library_category_folder?: boolean
|
||||
// 是否发送通知
|
||||
notify?: boolean
|
||||
}
|
||||
|
||||
// 自定义规则项
|
||||
export interface CustomRule {
|
||||
// 规则ID
|
||||
id: string
|
||||
// 名称
|
||||
name: string
|
||||
// 包含
|
||||
include?: string
|
||||
// 排除
|
||||
exclude?: string
|
||||
// 大小范围
|
||||
size_range?: string
|
||||
// 最少做种人数
|
||||
seeders?: string
|
||||
// 发布时间
|
||||
publish_time?: string
|
||||
}
|
||||
|
||||
// 过滤规则组
|
||||
export interface FilterRuleGroup {
|
||||
// 名称
|
||||
name: string
|
||||
// 规则串
|
||||
rule_string?: string
|
||||
// 适用类媒体类型 None-全部 电影/电视剧
|
||||
media_type?: string
|
||||
// # 适用媒体类别 None-全部 对应二级分类
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface SubscribeDownloadFileInfo {
|
||||
// 种子名称
|
||||
torrent_title?: string
|
||||
// 站点名称
|
||||
site_name?: string
|
||||
// 下载器
|
||||
downloader?: string
|
||||
// hash
|
||||
hash?: string
|
||||
// 文件路径
|
||||
file_path?: string
|
||||
}
|
||||
|
||||
export interface SubscribeLibraryFileInfo {
|
||||
// 存储
|
||||
storage?: string
|
||||
// 文件路径
|
||||
file_path?: string
|
||||
}
|
||||
|
||||
export interface SubscribeEpisodeInfo {
|
||||
// 标题
|
||||
title?: string
|
||||
// 描述
|
||||
description?: string
|
||||
// 背景图
|
||||
backdrop?: string
|
||||
// 下载文件信息
|
||||
download?: SubscribeDownloadFileInfo[]
|
||||
// 媒体库文件信息
|
||||
library?: SubscribeLibraryFileInfo[]
|
||||
}
|
||||
|
||||
export interface SubscrbieInfo {
|
||||
// 订阅信息
|
||||
subscribe: Subscribe
|
||||
// 集信息 {集号: {download: 文件路径,library: 文件路径, backdrop: url, title: 标题, description: 描述}}
|
||||
episodes: Record<number, SubscribeEpisodeInfo>
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 119 KiB |
BIN
src/assets/images/logos/emby.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src/assets/images/logos/jellyfin.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
src/assets/images/logos/plex.png
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
src/assets/images/logos/qbittorrent.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
src/assets/images/logos/safari.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/images/logos/slack.webp
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src/assets/images/logos/synologychat.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/images/logos/transmission.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/images/logos/vocechat.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
src/assets/images/misc/alipan.webp
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
src/assets/images/misc/rclone.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/images/misc/u115.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
44
src/assets/images/svg/filter-group.svg
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Слой_1" x="0px" y="0px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve">
|
||||
<linearGradient id="SVGID_1__48343" gradientUnits="userSpaceOnUse" x1="39" y1="23.25" x2="39" y2="33.0008" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#6DC7FF"/>
|
||||
<stop offset="1" style="stop-color:#E6ABFF"/>
|
||||
</linearGradient>
|
||||
<circle style="fill:url(#SVGID_1__48343);" cx="39" cy="28" r="4"/>
|
||||
<linearGradient id="SVGID_2__48343" gradientUnits="userSpaceOnUse" x1="32" y1="6.75" x2="32" y2="58.039" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#1A6DFF"/>
|
||||
<stop offset="1" style="stop-color:#C822FF"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_2__48343);" d="M58,13c0-2.757-2.243-5-5-5H19c-2.757,0-5,2.243-5,5v33H6v5c0,2.757,2.243,5,5,5h34 c2.757,0,5-2.243,5-5V18h8V13z M11,54c-1.654,0-3-1.346-3-3v-3h32v3c0,1.125,0.374,2.164,1.002,3H11z M48,51c0,1.654-1.346,3-3,3 s-3-1.346-3-3v-5H16V13c0-1.654,1.346-3,3-3h30.026C48.391,10.838,48,11.87,48,13V51z M56,16h-6v-3c0-1.654,1.346-3,3-3s3,1.346,3,3 V16z"/>
|
||||
<linearGradient id="SVGID_3__48343" gradientUnits="userSpaceOnUse" x1="39" y1="6.75" x2="39" y2="58.039" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#1A6DFF"/>
|
||||
<stop offset="1" style="stop-color:#C822FF"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_3__48343);" d="M39,23c-2.757,0-5,2.243-5,5s2.243,5,5,5s5-2.243,5-5S41.757,23,39,23z M39,31 c-1.654,0-3-1.346-3-3s1.346-3,3-3s3,1.346,3,3S40.654,31,39,31z"/>
|
||||
<linearGradient id="SVGID_4__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#1A6DFF"/>
|
||||
<stop offset="1" style="stop-color:#C822FF"/>
|
||||
</linearGradient>
|
||||
<rect x="20" y="23" style="fill:url(#SVGID_4__48343);" width="10" height="2"/>
|
||||
<linearGradient id="SVGID_5__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#1A6DFF"/>
|
||||
<stop offset="1" style="stop-color:#C822FF"/>
|
||||
</linearGradient>
|
||||
<rect x="20" y="27" style="fill:url(#SVGID_5__48343);" width="10" height="2"/>
|
||||
<linearGradient id="SVGID_6__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#1A6DFF"/>
|
||||
<stop offset="1" style="stop-color:#C822FF"/>
|
||||
</linearGradient>
|
||||
<rect x="20" y="31" style="fill:url(#SVGID_6__48343);" width="10" height="2"/>
|
||||
<linearGradient id="SVGID_7__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#1A6DFF"/>
|
||||
<stop offset="1" style="stop-color:#C822FF"/>
|
||||
</linearGradient>
|
||||
<rect x="20" y="35" style="fill:url(#SVGID_7__48343);" width="10" height="2"/>
|
||||
<linearGradient id="SVGID_8__48343" gradientUnits="userSpaceOnUse" x1="39" y1="6.75" x2="39" y2="58.039" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#1A6DFF"/>
|
||||
<stop offset="1" style="stop-color:#C822FF"/>
|
||||
</linearGradient>
|
||||
<rect x="34" y="35" style="fill:url(#SVGID_8__48343);" width="10" height="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
24
src/assets/images/svg/filter.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Слой_1" x="0px" y="0px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve">
|
||||
<linearGradient id="SVGID_1__52535" gradientUnits="userSpaceOnUse" x1="21.9994" y1="11.6667" x2="21.9994" y2="18.5839" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#6DC7FF"/>
|
||||
<stop offset="1" style="stop-color:#E6ABFF"/>
|
||||
</linearGradient>
|
||||
<circle style="fill:url(#SVGID_1__52535);" cx="21.999" cy="14.998" r="3"/>
|
||||
<linearGradient id="SVGID_2__52535" gradientUnits="userSpaceOnUse" x1="35.9994" y1="4.1667" x2="35.9994" y2="15.8334" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#6DC7FF"/>
|
||||
<stop offset="1" style="stop-color:#E6ABFF"/>
|
||||
</linearGradient>
|
||||
<circle style="fill:url(#SVGID_2__52535);" cx="35.999" cy="9.998" r="4"/>
|
||||
<linearGradient id="SVGID_3__52535" gradientUnits="userSpaceOnUse" x1="32" y1="20.7501" x2="32" y2="58.7632" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#1A6DFF"/>
|
||||
<stop offset="1" style="stop-color:#C822FF"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_3__52535);" d="M47.003,21H16.996C15.344,21,14,22.344,14,23.995V25v0.998v1.261 c0,0.717,0.257,1.41,0.722,1.95l10.556,12.315C25.743,42.068,26,42.763,26,43.479v6.964c0,0.652,0.32,1.264,0.854,1.634l8.016,5.569 c0.341,0.236,0.737,0.356,1.136,0.356c0.316,0,0.634-0.076,0.926-0.229C37.591,57.428,38,56.751,38,56.007V43.479 c0-0.716,0.257-1.409,0.722-1.953L49.277,29.21C49.743,28.668,50,27.975,50,27.259v-1.258V25v-1.005C50,22.344,48.655,21,47.003,21z M37.204,40.225c-0.447,0.521-0.762,1.129-0.963,1.775H33v2h3l0.001,2H33v2h3.003l0.002,2H34v2h2.007l0.003,4.002L28,50.442v-6.964 c0-1.193-0.428-2.35-1.205-3.255L17.176,29h29.648L37.204,40.225z M48,26.001C48,26.552,47.552,27,47,27H17.002 C16.449,27,16,26.551,16,25.998V25v-1.005C16,23.446,16.447,23,16.996,23h30.007C47.553,23,48,23.446,48,23.995V25V26.001z"/>
|
||||
<linearGradient id="SVGID_4__52535" gradientUnits="userSpaceOnUse" x1="41.9994" y1="17.3333" x2="41.9994" y2="21.3333" spreadMethod="reflect">
|
||||
<stop offset="0" style="stop-color:#6DC7FF"/>
|
||||
<stop offset="1" style="stop-color:#E6ABFF"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_4__52535);" d="M44.999,21c0-2-1.343-3-3-3s-3,1-3,3H44.999z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,19 +1,27 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import axios from 'axios'
|
||||
import FileList from './filebrowser/FileList.vue'
|
||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import type { EndPoints } from '@/api/types'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
storages: String,
|
||||
storage: String,
|
||||
path: String,
|
||||
storages: Array as PropType<StorageConf[]>,
|
||||
tree: Boolean,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: Object as PropType<Axios>,
|
||||
axios: {
|
||||
type: Object as PropType<Axios>,
|
||||
required: true,
|
||||
},
|
||||
axiosconfig: Object,
|
||||
item: {
|
||||
type: Object as PropType<FileItem>,
|
||||
required: true,
|
||||
},
|
||||
itemstack: {
|
||||
type: Array as PropType<FileItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
@@ -25,27 +33,109 @@ const availableStorages = [
|
||||
code: 'local',
|
||||
icon: 'mdi-folder-multiple-outline',
|
||||
},
|
||||
{
|
||||
name: '阿里云盘',
|
||||
code: 'alipan',
|
||||
icon: 'mdi-cloud-outline',
|
||||
},
|
||||
{
|
||||
name: '115网盘',
|
||||
code: 'u115',
|
||||
icon: 'mdi-cloud-outline',
|
||||
},
|
||||
{
|
||||
name: 'Rclone网盘',
|
||||
code: 'rclone',
|
||||
icon: 'mdi-cloud-outline',
|
||||
},
|
||||
]
|
||||
|
||||
const fileIcons = {
|
||||
// 压缩包
|
||||
zip: 'mdi-folder-zip-outline',
|
||||
rar: 'mdi-folder-zip-outline',
|
||||
bak: 'mdi-folder-zip-outline',
|
||||
tar: 'mdi-folder-zip-outline',
|
||||
gz: 'mdi-folder-zip-outline',
|
||||
bz2: 'mdi-folder-zip-outline',
|
||||
// 开发
|
||||
htm: 'mdi-language-html5',
|
||||
html: 'mdi-language-html5',
|
||||
vue: 'mdi-vuejs',
|
||||
js: 'mdi-nodejs',
|
||||
ts: 'mdi-language-typescript',
|
||||
json: 'mdi-file-document-outline',
|
||||
css: 'mdi-language-css3',
|
||||
scss: 'mdi-language-css3',
|
||||
less: 'mdi-language-css3',
|
||||
php: 'mdi-language-php',
|
||||
py: 'mdi-language-python',
|
||||
java: 'mdi-language-java',
|
||||
go: 'mdi-language-go',
|
||||
c: 'mdi-language-c',
|
||||
cpp: 'mdi-language-cpp',
|
||||
h: 'mdi-language-c',
|
||||
cs: 'mdi-language-csharp',
|
||||
sql: 'mdi-database',
|
||||
sh: 'mdi-language-bash',
|
||||
bat: 'mdi-language-bash',
|
||||
ps1: 'mdi-language-powershell',
|
||||
// markdown
|
||||
md: 'mdi-language-markdown-outline',
|
||||
pdf: 'mdi-file-pdf',
|
||||
png: 'mdi-file-image',
|
||||
jpg: 'mdi-file-image',
|
||||
jpeg: 'mdi-file-image',
|
||||
markdown: 'mdi-language-markdown-outline',
|
||||
// 图片
|
||||
png: 'mdi-file-png-box',
|
||||
jpg: 'mdi-file-jpg-box',
|
||||
jpeg: 'mdi-file-jpg-box',
|
||||
gif: 'mdi-file-gif-box',
|
||||
bmp: 'mdi-file-image-box',
|
||||
webp: 'mdi-file-image-box',
|
||||
ico: 'mdi-file-image-box',
|
||||
svg: 'mdi-file-image-box',
|
||||
// 视频
|
||||
mp4: 'mdi-filmstrip',
|
||||
mkv: 'mdi-filmstrip',
|
||||
avi: 'mdi-filmstrip',
|
||||
wmv: 'mdi-filmstrip',
|
||||
mov: 'mdi-filmstrip',
|
||||
flv: 'mdi-filmstrip',
|
||||
rmvb: 'mdi-filmstrip',
|
||||
// 文档
|
||||
txt: 'mdi-file-document-outline',
|
||||
env: 'mdi-file-cog-outline',
|
||||
yml: 'mdi-file-cog-outline',
|
||||
yaml: 'mdi-file-cog-outline',
|
||||
conf: 'mdi-file-cog-outline',
|
||||
log: 'mdi-file-document-outline',
|
||||
csv: 'mdi-file-delimited',
|
||||
// office
|
||||
xls: 'mdi-file-excel',
|
||||
xlsx: 'mdi-file-excel',
|
||||
doc: 'mdi-file-word',
|
||||
docx: 'mdi-file-word',
|
||||
ppt: 'mdi-file-powerpoint',
|
||||
pptx: 'mdi-file-powerpoint',
|
||||
pdf: 'mdi-file-pdf',
|
||||
// 音频
|
||||
mp2: 'mdi-music',
|
||||
mp3: 'mdi-music',
|
||||
m4a: 'mdi-music',
|
||||
wma: 'mdi-music',
|
||||
aac: 'mdi-music',
|
||||
ogg: 'mdi-music',
|
||||
flac: 'mdi-music',
|
||||
wav: 'mdi-music',
|
||||
// 字体
|
||||
ttf: 'mdi-format-font',
|
||||
otf: 'mdi-format-font',
|
||||
woff: 'mdi-format-font',
|
||||
woff2: 'mdi-format-font',
|
||||
eot: 'mdi-format-font',
|
||||
// 字幕
|
||||
srt: 'mdi-subtitles-outline',
|
||||
ass: 'mdi-subtitles-outline',
|
||||
sub: 'mdi-subtitles-outline',
|
||||
// 其他
|
||||
other: 'mdi-file-outline',
|
||||
}
|
||||
|
||||
@@ -57,30 +147,28 @@ const activeStorage = ref('local')
|
||||
const refreshPending = ref(false)
|
||||
// 排序
|
||||
const sort = ref('name')
|
||||
// axios实例
|
||||
const axiosInstance = ref<Axios>()
|
||||
|
||||
// 计算属性
|
||||
const storagesArray = computed(() => {
|
||||
const storageCodes = props.storages?.split(',')
|
||||
const storageCodes = props.storages?.map(item => item.type)
|
||||
return availableStorages.filter(item => storageCodes?.includes(item.code))
|
||||
})
|
||||
|
||||
// 方法
|
||||
function loadingChanged(loading: number) {
|
||||
if (loading)
|
||||
loading++
|
||||
else if (loading > 0)
|
||||
loading--
|
||||
if (loading) loading++
|
||||
else if (loading > 0) loading--
|
||||
}
|
||||
|
||||
function storageChanged(storage: string) {
|
||||
// 存储切换
|
||||
async function storageChanged(storage: string) {
|
||||
activeStorage.value = storage
|
||||
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
|
||||
}
|
||||
|
||||
// 路径变化
|
||||
function pathChanged(_path: string) {
|
||||
emit('pathchanged', _path)
|
||||
function pathChanged(item: FileItem) {
|
||||
emit('pathchanged', item)
|
||||
}
|
||||
|
||||
// 排序变化
|
||||
@@ -88,34 +176,29 @@ function sortChanged(s: string) {
|
||||
sort.value = s
|
||||
refreshPending.value = true
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
activeStorage.value = props.storage ?? 'local'
|
||||
axiosInstance.value = props.axios ?? axios.create(props.axiosconfig)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="mx-auto" :loading="loading > 0 || !path">
|
||||
<div v-if="path">
|
||||
<VCard class="mx-auto" :loading="loading > 0">
|
||||
<div v-if="activeStorage && item">
|
||||
<FileToolbar
|
||||
:path="path"
|
||||
:item="item"
|
||||
:itemstack="itemstack"
|
||||
:storages="storagesArray"
|
||||
:storage="activeStorage"
|
||||
:endpoints="endpoints"
|
||||
:axios="axiosInstance"
|
||||
:axios="axios"
|
||||
@storagechanged="storageChanged"
|
||||
@pathchanged="pathChanged"
|
||||
@foldercreated="refreshPending = true"
|
||||
@sortchanged="sortChanged"
|
||||
/>
|
||||
<FileList
|
||||
:path="path"
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axiosInstance"
|
||||
:axios="axios"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
@pathchanged="pathChanged"
|
||||
|
||||
@@ -73,9 +73,3 @@ const getImgUrl = computed(() => {
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.text-shadow {
|
||||
text-shadow: 1px 1px #777;
|
||||
}
|
||||
</style>
|
||||
|
||||
193
src/components/cards/CustomRuleCard.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts" setup>
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import filter_svg from '@images/svg/filter.svg'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 单条规则
|
||||
rule: {
|
||||
type: Object as PropType<CustomRule>,
|
||||
required: true,
|
||||
},
|
||||
// 所有规则
|
||||
rules: {
|
||||
type: Array as PropType<CustomRule[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const ruleInfoDialog = ref(false)
|
||||
|
||||
// 规则详情
|
||||
const ruleInfo = ref<CustomRule>({
|
||||
id: '',
|
||||
name: '',
|
||||
include: '',
|
||||
exclude: '',
|
||||
size_range: '',
|
||||
seeders: '',
|
||||
publish_time: '',
|
||||
})
|
||||
|
||||
// 打开详情弹窗
|
||||
function openRuleInfoDialog() {
|
||||
// 深复制
|
||||
ruleInfo.value = cloneDeep(props.rule)
|
||||
ruleInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveRuleInfo() {
|
||||
// 有空值
|
||||
if (!ruleInfo.value.id || !ruleInfo.value.name) {
|
||||
if (!ruleInfo.value.id && !ruleInfo.value.name) {
|
||||
$toast.error('规则ID和规则名称不能为空')
|
||||
}
|
||||
return
|
||||
}
|
||||
// 检查ID是否在内置的规则中
|
||||
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
|
||||
$toast.error('当前规则ID已被内置规则占用')
|
||||
return
|
||||
}
|
||||
// 检查规则名称是否在内置的规则中
|
||||
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
|
||||
$toast.error('当前规则名称已被内置规则占用')
|
||||
return
|
||||
}
|
||||
// ID已存在
|
||||
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
|
||||
$toast.error(`规则ID【${ruleInfo.value.id}】已存在`)
|
||||
return
|
||||
}
|
||||
// 规则名称已存在
|
||||
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
|
||||
$toast.error(`规则名称【${ruleInfo.value.name}】已存在`)
|
||||
return
|
||||
}
|
||||
// 保存数据
|
||||
ruleInfoDialog.value = false
|
||||
emit('change', ruleInfo.value, props.rule.id)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openRuleInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<DialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.rule.id }}</h5>
|
||||
<div class="text-body-1 mb-3">{{ props.rule.name }}</div>
|
||||
</div>
|
||||
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.rule.id} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="ruleInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.id"
|
||||
label="规则ID"
|
||||
placeholder="必填;不可与其他规则ID重名"
|
||||
hint="字符与数字组合,不能含空格"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.name"
|
||||
label="规则名称"
|
||||
placeholder="必填;不可与其他规则名称重名"
|
||||
hint="使用别名便于区分规则"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.include"
|
||||
placeholder="关键字/正则表达式"
|
||||
label="包含"
|
||||
hint="必须包含的关键字或正则表达式,多个值使用|分隔"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.exclude"
|
||||
placeholder="关键字/正则表达式"
|
||||
label="排除"
|
||||
hint="不能包含的关键字或正则表达式,多个值使用|分隔"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.size_range"
|
||||
placeholder="0/1-10"
|
||||
label="资源体积(MB)"
|
||||
hint="最小资源文件体积或文件体积范围"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.seeders"
|
||||
placeholder="0/1-10"
|
||||
label="做种人数"
|
||||
hint="最小做种人数或做种人数范围"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.publish_time"
|
||||
placeholder="0"
|
||||
label="发布时间(分钟)"
|
||||
hint="距离资源发布的最小时间间隔"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveRuleInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaDirectory } from '@/api/types'
|
||||
import { VTextField } from 'vuetify/lib/components/index.mjs'
|
||||
import type { TransferDirectoryConf } from '@/api/types'
|
||||
import { VDivider, VSpacer, VTextField } from 'vuetify/lib/components/index.mjs'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
type: String, // download/library
|
||||
directory: {
|
||||
type: Object as PropType<MediaDirectory>,
|
||||
type: Object as PropType<TransferDirectoryConf>,
|
||||
required: true, // 必填参数
|
||||
},
|
||||
categories: {
|
||||
@@ -17,8 +20,14 @@ const props = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 路径
|
||||
const path = ref<string>('')
|
||||
// 下载路径
|
||||
const downloadPath = ref<string>('')
|
||||
|
||||
// 媒体库路径
|
||||
const libraryPath = ref<string>('')
|
||||
|
||||
// 卡版是否折叠状态
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
// 类型下拉字典
|
||||
const typeItems = [
|
||||
@@ -27,6 +36,100 @@ const typeItems = [
|
||||
{ title: '电视剧', value: '电视剧' },
|
||||
]
|
||||
|
||||
// 存储下拉字典
|
||||
const storageItems = [
|
||||
{ title: '本地', value: 'local' },
|
||||
{ title: '阿里云盘', value: 'alipan' },
|
||||
{ title: '115网盘', value: 'u115' },
|
||||
{ title: 'Rclone网盘', value: 'rclone' },
|
||||
]
|
||||
|
||||
// 自动整理方式下拉字典
|
||||
const transferSourceItems = [
|
||||
{ title: '不自动整理', value: '' },
|
||||
{ title: '下载器监控', value: 'downloader' },
|
||||
{ title: '目录监控', value: 'monitor' },
|
||||
]
|
||||
|
||||
// 监控模式下拉字典
|
||||
const MonitorModeItems = [
|
||||
{ title: '性能模式', value: 'fast' },
|
||||
{ title: '兼容模式', value: 'compatibility' },
|
||||
]
|
||||
|
||||
// 整理方式下拉字典
|
||||
const transferTypeItems = ref<{ title: string; value: string }[]>([])
|
||||
|
||||
// 调用API查询支持的整理方式
|
||||
async function loadTransferTypeItems() {
|
||||
// 参数不全时不查询
|
||||
if (!props.directory.library_storage || !props.directory.storage) return
|
||||
try {
|
||||
// 下载器储存整理方法
|
||||
const storage_res = await api.get(`storage/transtype/${props.directory.storage}`)
|
||||
const storage_transtype = (storage_res as any).transtype
|
||||
// 媒体库储存整理方法
|
||||
const library_storage_res = await api.get(`storage/transtype/${props.directory.library_storage}`)
|
||||
const library_storage_transtype = (library_storage_res as any).transtype
|
||||
// 为空终止
|
||||
if (!library_storage_transtype || !storage_transtype) return
|
||||
// 取并集
|
||||
const transtype: { [key: string]: string } = {}
|
||||
Object.keys(storage_transtype).forEach(key => {
|
||||
if (key in library_storage_transtype) {
|
||||
transtype[key] = storage_transtype[key]
|
||||
}
|
||||
})
|
||||
// 非空时设置整理方式下拉字典
|
||||
if (transtype && Object.keys(transtype).length > 0) {
|
||||
transferTypeItems.value = Object.keys(transtype).map(key => ({
|
||||
title: transtype[key],
|
||||
value: key,
|
||||
}))
|
||||
// 如果整理方式下拉字典不为空,且当前值不在新的transferTypeItems里,则设置整理方式为第一个
|
||||
if (
|
||||
transferTypeItems.value.length > 0 &&
|
||||
!transferTypeItems.value.find(item => item.value === props.directory.transfer_type)
|
||||
) {
|
||||
nextTick(() => {
|
||||
props.directory.transfer_type = transferTypeItems.value[0].value
|
||||
})
|
||||
}
|
||||
// 如果整理方式下拉字典为空,清空整理方式
|
||||
if (transferTypeItems.value.length === 0) {
|
||||
props.directory.transfer_type = ''
|
||||
}
|
||||
} else {
|
||||
// 无可用整理方式,清除已选值
|
||||
transferTypeItems.value = []
|
||||
props.directory.transfer_type = ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 整理方式无数据提示
|
||||
const computedNoDataText = computed(() => {
|
||||
if (!props.directory.library_storage && !props.directory.storage) {
|
||||
return '无可用整理方式!请先选择下载器储存与媒体库储存!'
|
||||
} else if (!props.directory.library_storage) {
|
||||
return '无可用整理方式!请先选择媒体库储存!'
|
||||
} else if (!props.directory.storage) {
|
||||
return '无可用整理方式!请先选择下载器储存!'
|
||||
} else {
|
||||
return '选择的存储没有支持的整理方法!'
|
||||
}
|
||||
})
|
||||
|
||||
// 覆盖模式下拉字典
|
||||
const overwriteModeItems = [
|
||||
{ title: '从不', value: 'never' },
|
||||
{ title: '总是', value: 'always' },
|
||||
{ title: '按文件大小', value: 'size' },
|
||||
{ title: '仅保留最新版本', value: 'latest' },
|
||||
]
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'changed', 'update:modelValue'])
|
||||
|
||||
@@ -35,10 +138,22 @@ function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 路径更新
|
||||
function updatePath(value: string) {
|
||||
path.value = value
|
||||
emit('update:modelValue', value)
|
||||
// 下载路径更新
|
||||
function updateDownloadPath(value: string) {
|
||||
downloadPath.value = value
|
||||
emit('update:modelValue', {
|
||||
download: downloadPath.value,
|
||||
library: libraryPath.value,
|
||||
})
|
||||
}
|
||||
|
||||
// 媒体库路径更新
|
||||
function updateLibraryPath(value: string) {
|
||||
libraryPath.value = value
|
||||
emit('update:modelValue', {
|
||||
download: downloadPath.value,
|
||||
library: libraryPath.value,
|
||||
})
|
||||
}
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
@@ -47,6 +162,32 @@ const getCategories = computed(() => {
|
||||
if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value
|
||||
return default_value.concat(props.categories[props.directory.media_type ?? ''])
|
||||
})
|
||||
|
||||
// 监听 下载储存与媒体库储存 变化,重新加载整理方式下拉字典
|
||||
watch(
|
||||
[() => props.directory.library_storage, () => props.directory.storage],
|
||||
([newLibraryStorage, newStorage], [oldLibraryStorage, oldStorage]) => {
|
||||
if (newLibraryStorage !== oldLibraryStorage || newStorage !== oldStorage) {
|
||||
loadTransferTypeItems()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 媒体类别和类型变更非空时,将按类型分类和按类别分类置为false
|
||||
watch(
|
||||
[() => props.directory.media_type, () => props.directory.media_category],
|
||||
([newMediaType, newMediaCategory], [oldMediaType, oldMediaCategory]) => {
|
||||
if (newMediaType && newMediaType !== oldMediaType) {
|
||||
props.directory.download_type_folder = false
|
||||
props.directory.library_type_folder = false
|
||||
}
|
||||
if (newMediaCategory && newMediaCategory !== oldMediaCategory) {
|
||||
props.directory.download_category_folder = false
|
||||
props.directory.library_category_folder = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,40 +206,127 @@ const getCategories = computed(() => {
|
||||
</IconBtn>
|
||||
</span>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VCardText v-if="!isCollapsed">
|
||||
<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">
|
||||
<VCol cols="6">
|
||||
<VSelect
|
||||
v-model="props.directory.media_type"
|
||||
variant="underlined"
|
||||
:items="typeItems"
|
||||
label="媒体类型"
|
||||
@update:modelValue="props.directory.category = ''"
|
||||
@update:modelValue="props.directory.media_category = ''"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol>
|
||||
<VSelect v-model="props.directory.category" variant="underlined" :items="getCategories" label="媒体类别" />
|
||||
<VCol cols="6">
|
||||
<VSelect
|
||||
v-model="props.directory.media_category"
|
||||
variant="underlined"
|
||||
:items="getCategories"
|
||||
label="媒体类别"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
<VSelect v-model="props.directory.storage" variant="underlined" :items="storageItems" label="下载存储" />
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
<VPathField @update:modelValue="updateDownloadPath" :storage="props.directory.storage">
|
||||
<template #activator="{ menuprops }">
|
||||
<VTextField
|
||||
v-model="props.directory.download_path"
|
||||
v-bind="menuprops"
|
||||
variant="underlined"
|
||||
label="下载目录"
|
||||
/>
|
||||
</template>
|
||||
</VPathField>
|
||||
</VCol>
|
||||
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
|
||||
<VSwitch v-model="props.directory.download_type_folder" label="按类型分类"></VSwitch>
|
||||
</VCol>
|
||||
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
|
||||
<VSwitch v-model="props.directory.download_category_folder" label="按类别分类"></VSwitch>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VDivider v-if="$props.directory.monitor_type" class="my-3 bg-primary" />
|
||||
<VRow>
|
||||
<VCol v-if="!props.directory.category || props.directory.category === ''">
|
||||
<VSwitch v-model="props.directory.auto_category" label="自动分类"></VSwitch>
|
||||
<VCol>
|
||||
<VSelect
|
||||
v-model="props.directory.monitor_type"
|
||||
variant="underlined"
|
||||
:items="transferSourceItems"
|
||||
label="自动整理"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="type === 'library'">
|
||||
<VSwitch v-model="props.directory.scrape" label="刮削元数据"></VSwitch>
|
||||
</VRow>
|
||||
<VRow v-if="$props.directory.monitor_type">
|
||||
<VCol cols="12" v-if="$props.directory.monitor_type == 'monitor'">
|
||||
<VSelect
|
||||
v-model="props.directory.monitor_mode"
|
||||
variant="underlined"
|
||||
:items="MonitorModeItems"
|
||||
label="监控模式"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
<VSelect
|
||||
v-model="props.directory.library_storage"
|
||||
variant="underlined"
|
||||
:items="storageItems"
|
||||
label="媒体库存储"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
<VPathField @update:modelValue="updateLibraryPath" :storage="props.directory.library_storage">
|
||||
<template #activator="{ menuprops }">
|
||||
<VTextField
|
||||
v-model="props.directory.library_path"
|
||||
v-bind="menuprops"
|
||||
variant="underlined"
|
||||
label="媒体库目录"
|
||||
/>
|
||||
</template>
|
||||
</VPathField>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
<VSelect
|
||||
v-model="props.directory.transfer_type"
|
||||
variant="underlined"
|
||||
:items="transferTypeItems"
|
||||
label="整理方式"
|
||||
:no-data-text="computedNoDataText"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
<VSelect
|
||||
v-model="props.directory.overwrite_mode"
|
||||
variant="underlined"
|
||||
:items="overwriteModeItems"
|
||||
label="覆盖模式"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
|
||||
<VSwitch v-model="props.directory.library_type_folder" label="按类型分类"></VSwitch>
|
||||
</VCol>
|
||||
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
|
||||
<VSwitch v-model="props.directory.library_category_folder" label="按类别分类"></VSwitch>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="props.directory.renaming" label="智能重命名"></VSwitch>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="props.directory.scraping" label="刮削元数据"></VSwitch>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="props.directory.notify" label="发送通知"></VSwitch>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="text-center py-0">
|
||||
<VSpacer />
|
||||
<VBtn :icon="isCollapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'" @click.stop="isCollapsed = !isCollapsed" />
|
||||
<VSpacer />
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
319
src/components/cards/DownloaderCard.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import qbittorrent_image from '@images/logos/qbittorrent.png'
|
||||
import transmission_image from '@images/logos/transmission.png'
|
||||
import {cloneDeep} from "lodash";
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
// 单个下载器
|
||||
downloader: {
|
||||
type: Object as PropType<DownloaderConf>,
|
||||
required: true,
|
||||
},
|
||||
// 是否允许刷新数据
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 所有下载器
|
||||
downloaders: {
|
||||
type: Array as PropType<DownloaderConf[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'done', 'change'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// timeout定时器
|
||||
let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||
|
||||
// 上传速率
|
||||
const upload_rate = ref(0)
|
||||
|
||||
// 下载速度
|
||||
const download_rate = ref(0)
|
||||
|
||||
// 下载器详情弹窗
|
||||
const downloaderInfoDialog = ref(false)
|
||||
|
||||
// 下载器详情
|
||||
const downloaderInfo = ref<DownloaderConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
default: false,
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 调用API查询下载器数据
|
||||
async function loadDownloaderInfo() {
|
||||
if (!props.allowRefresh) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: DownloaderInfo = await api.get('dashboard/downloader', {
|
||||
params: {
|
||||
name: props.downloader.name,
|
||||
},
|
||||
})
|
||||
|
||||
if (res) {
|
||||
upload_rate.value = res.upload_speed
|
||||
download_rate.value = res.download_speed
|
||||
// 定时查询
|
||||
clearTimeout(timeoutTimer)
|
||||
if (props.downloader.enabled) {
|
||||
timeoutTimer = setTimeout(loadDownloaderInfo, 3000)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
function openDownloaderInfoDialog() {
|
||||
// 深复制
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
downloaderInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveDownloaderInfo() {
|
||||
// 为空不保存,跳出警告框
|
||||
if (!downloaderInfo.value.name) {
|
||||
$toast.error('名称不能为空,请输入后再确定')
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
|
||||
$toast.error(`【${downloaderInfo.value.name}】已存在,请替换为其他名称`)
|
||||
return
|
||||
}
|
||||
// 默认下载器去重
|
||||
if (downloaderInfo.value.default) {
|
||||
props.downloaders.forEach(item => {
|
||||
if (item.default && item !== props.downloader) {
|
||||
item.default = false
|
||||
$toast.info(`【${item.name}】存在默认下载器,已替换成【${downloaderInfo.value.name}】`)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 执行保存
|
||||
downloaderInfoDialog.value = false
|
||||
emit('change', downloaderInfo.value, props.downloader.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.downloader.type) {
|
||||
case 'qbittorrent':
|
||||
return qbittorrent_image
|
||||
case 'transmission':
|
||||
return transmission_image
|
||||
default:
|
||||
return qbittorrent_image
|
||||
}
|
||||
})
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.downloader.enabled) {
|
||||
await loadDownloaderInfo()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openDownloaderInfoDialog">
|
||||
<DialogCloseBtn @click="onClose" />
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VCardText class="flex justify-space-between align-center gap-4">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="flex items-center">
|
||||
<VBadge
|
||||
v-if="props.downloader.default && props.downloader.enabled"
|
||||
dot
|
||||
inline
|
||||
color="success"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="text-h6">{{ downloader.name }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
|
||||
<span class="me-2">{{ `↑ ${formatFileSize(upload_rate, 1)}/s ` }}</span>
|
||||
<span>{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.enabled" label="启用下载器" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.default" label="默认下载器" :disabled="!downloaderInfo.enabled" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
label="名称"
|
||||
placeholder="必填;不可与其他名称重名"
|
||||
hint="下载器的别名"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
label="地址"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
label="用户名"
|
||||
placeholder="admin"
|
||||
hint="登录使用的用户名"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
label="密码"
|
||||
hint="登录使用的密码"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.category"
|
||||
label="自动分类管理"
|
||||
hint="由下载器自动管理分类和下载目录"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.sequentail"
|
||||
label="顺序下载"
|
||||
hint="按顺序依次下载文件"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.force_resume"
|
||||
label="强制继续"
|
||||
hint="强制继续、强制上传模式"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.first_last_piece"
|
||||
label="优先首尾文件"
|
||||
hint="优先下载首尾文件块"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="downloaderInfo.type == 'transmission'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
label="名称"
|
||||
placeholder="必填;不可与其他名称重名"
|
||||
hint="下载器的别名"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
label="地址"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
label="用户名"
|
||||
placeholder="admin"
|
||||
hint="登录使用的用户名"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
label="密码"
|
||||
hint="登录使用的密码"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveDownloaderInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
|
||||
确定
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -17,16 +18,21 @@ function getPercentage() {
|
||||
|
||||
// 速度
|
||||
function getSpeedText() {
|
||||
return `↑ ${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${props.info?.left_time}`
|
||||
return `${formatFileSize(props.info?.size || 0)} ↑ ${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${
|
||||
props.info?.left_time
|
||||
}`
|
||||
}
|
||||
|
||||
// 下载状态
|
||||
const isDownloading = ref(props.info?.state === 'downloading')
|
||||
|
||||
// 监听props.info?.state的变化
|
||||
watch(() => props.info?.state, (newValue) => {
|
||||
isDownloading.value = newValue === 'downloading'
|
||||
})
|
||||
watch(
|
||||
() => props.info?.state,
|
||||
newValue => {
|
||||
isDownloading.value = newValue === 'downloading'
|
||||
},
|
||||
)
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
@@ -45,14 +51,10 @@ function getTextClass() {
|
||||
async function toggleDownload() {
|
||||
const operation = isDownloading.value ? 'stop' : 'start'
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
`download/${operation}/${props.info?.hash}`,
|
||||
)
|
||||
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`)
|
||||
|
||||
if (result.success)
|
||||
isDownloading.value = !isDownloading.value
|
||||
}
|
||||
catch (error) {
|
||||
if (result.success) isDownloading.value = !isDownloading.value
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -62,67 +64,42 @@ async function deleteDownload() {
|
||||
try {
|
||||
await api.delete(`download/${props.info?.hash}`)
|
||||
cardState.value = false
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
v-if="cardState"
|
||||
:key="props.info?.hash"
|
||||
>
|
||||
<VCard v-if="cardState" :key="props.info?.hash">
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.info?.media.image"
|
||||
aspect-ratio="2/3"
|
||||
cover
|
||||
class="brightness-50"
|
||||
@load="imageLoadHandler"
|
||||
/>
|
||||
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover class="brightness-50" @load="imageLoadHandler" />
|
||||
</template>
|
||||
|
||||
<VCardTitle
|
||||
class="break-words whitespace-normal"
|
||||
:class="getTextClass()"
|
||||
>
|
||||
<VCardTitle class="break-words whitespace-normal" :class="getTextClass()">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{ props.info?.media.episode ? `${props.info?.media.season} ${props.info?.media.episode}` : props.info?.season_episode }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
|
||||
<VCardSubtitle
|
||||
class="break-words whitespace-normal"
|
||||
:class="getTextClass()"
|
||||
>
|
||||
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
|
||||
<VCardText
|
||||
class="text-subtitle-1 pt-3 pb-1"
|
||||
:class="getTextClass()"
|
||||
>
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()">
|
||||
{{ getSpeedText() }}
|
||||
</VCardText>
|
||||
|
||||
<VCardText
|
||||
v-if="getPercentage() > 0"
|
||||
:class="getTextClass()"
|
||||
>
|
||||
<VCardText v-if="getPercentage() > 0" :class="getTextClass()">
|
||||
<VProgressLinear :model-value="getPercentage()" />
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn
|
||||
:icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`"
|
||||
@click="toggleDownload"
|
||||
/>
|
||||
<VBtn
|
||||
color="error"
|
||||
icon="mdi-trash-can-outline"
|
||||
@click="deleteDownload"
|
||||
/>
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
pri: String,
|
||||
maxpri: String,
|
||||
rules: Array as PropType<string[]>,
|
||||
width: String,
|
||||
height: String,
|
||||
custom_rules: Array as PropType<CustomRule[]>,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -22,50 +24,24 @@ function filtersChanged(value: string[]) {
|
||||
}
|
||||
|
||||
// 过滤规则下拉框
|
||||
const selectFilterOptions = ref<{ [key: string]: string }[]>([
|
||||
{ title: '特效字幕', value: ' SPECSUB ' },
|
||||
{ title: '中文字幕', value: ' CNSUB ' },
|
||||
{ title: '国语配音', value: ' CNVOI ' },
|
||||
{ title: '官种', value: ' GZ ' },
|
||||
{ title: '排除: 国语配音', value: ' !CNVOI ' },
|
||||
{ title: '粤语配音', value: ' HKVOI ' },
|
||||
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
|
||||
{ title: '促销: 免费', value: ' FREE ' },
|
||||
{ title: '分辨率: 4K', value: ' 4K ' },
|
||||
{ title: '分辨率: 1080P', value: ' 1080P ' },
|
||||
{ title: '分辨率: 720P', value: ' 720P ' },
|
||||
{ title: '排除: 720P', value: ' !720P ' },
|
||||
{ title: '质量: 蓝光原盘', value: ' BLU ' },
|
||||
{ title: '排除: 蓝光原盘', value: ' !BLU ' },
|
||||
{ title: '质量: BLURAY', value: ' BLURAY ' },
|
||||
{ title: '排除: BLURAY', value: ' !BLURAY ' },
|
||||
{ title: '质量: UHD', value: ' UHD ' },
|
||||
{ title: '排除: UHD', value: ' !UHD ' },
|
||||
{ title: '质量: REMUX', value: ' REMUX ' },
|
||||
{ title: '排除: REMUX', value: ' !REMUX ' },
|
||||
{ title: '质量: WEB-DL', value: ' WEBDL ' },
|
||||
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
|
||||
{ title: '质量: 60fps', value: ' 60FPS ' },
|
||||
{ title: '排除: 60fps', value: ' !60FPS ' },
|
||||
{ title: '编码: H265', value: ' H265 ' },
|
||||
{ title: '排除: H265', value: ' !H265 ' },
|
||||
{ title: '编码: H264', value: ' H264 ' },
|
||||
{ title: '排除: H264', value: ' !H264 ' },
|
||||
{ title: '效果: 杜比视界', value: ' DOLBY ' },
|
||||
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
|
||||
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
|
||||
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
|
||||
{ title: '效果: HDR', value: ' HDR ' },
|
||||
{ title: '排除: HDR', value: ' !HDR ' },
|
||||
{ title: '效果: SDR', value: ' SDR ' },
|
||||
{ title: '排除: SDR', value: ' !SDR ' },
|
||||
{ title: '效果: 3D', value: ' 3D ' },
|
||||
{ title: '排除: 3D', value: ' !3D ' },
|
||||
])
|
||||
const selectFilterOptions = ref<{ [key: string]: string }[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
selectFilterOptions.value = cloneDeep(innerFilterRules)
|
||||
if (props.custom_rules) {
|
||||
console.log(props.custom_rules)
|
||||
props.custom_rules.map(rule => {
|
||||
selectFilterOptions.value.push({
|
||||
title: rule.name,
|
||||
value: rule.id,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height">
|
||||
<VCard variant="tonal">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
@@ -83,6 +59,7 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
|
||||
chips
|
||||
label=""
|
||||
multiple
|
||||
clearable
|
||||
@update:modelValue="filtersChanged"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
302
src/components/cards/FilterRuleGroupCard.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import filter_group_svg from '@images/svg/filter-group.svg'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 单个规则组
|
||||
group: {
|
||||
type: Object as PropType<FilterRuleGroup>,
|
||||
required: true,
|
||||
},
|
||||
// 所有规则组
|
||||
groups: {
|
||||
type: Array as PropType<FilterRuleGroup[]>,
|
||||
required: true,
|
||||
},
|
||||
// 媒体类型字典
|
||||
categories: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
},
|
||||
// 自定义规则列表
|
||||
custom_rules: Array as PropType<CustomRule[]>,
|
||||
})
|
||||
|
||||
// 规则卡片类型
|
||||
interface FilterCard {
|
||||
// 优先级
|
||||
pri: string
|
||||
// 已选规则
|
||||
rules: string[]
|
||||
}
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const groupInfoDialog = ref(false)
|
||||
|
||||
// 规则详情
|
||||
const groupInfo = ref<FilterRuleGroup>({
|
||||
name: props.group?.name,
|
||||
rule_string: props.group?.rule_string,
|
||||
media_type: props.group?.media_type,
|
||||
category: props.group?.category,
|
||||
})
|
||||
|
||||
// 媒体类型字典
|
||||
const mediaTypeItems = [
|
||||
{ title: '通用', value: '' },
|
||||
{ title: '电影', value: '电影' },
|
||||
{ title: '电视剧', value: '电视剧' },
|
||||
]
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: '全部', value: '' }]
|
||||
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type ?? ''])
|
||||
return default_value
|
||||
return default_value.concat(props.categories[groupInfo.value.media_type ?? ''])
|
||||
})
|
||||
|
||||
// 规则组规则卡片列表
|
||||
const filterRuleCards = ref<FilterCard[]>([])
|
||||
|
||||
// 导入代码弹窗
|
||||
const importCodeDialog = ref(false)
|
||||
|
||||
// 导入的代码
|
||||
const importCodeString = ref('')
|
||||
|
||||
// 更新规则卡片的值
|
||||
function updateFilterCardValue(pri: string, rules: string[]) {
|
||||
const card = filterRuleCards.value.find(card => card.pri === pri)
|
||||
if (card) card.rules = rules
|
||||
}
|
||||
|
||||
// 移除卡片
|
||||
function filterCardClose(pri: string) {
|
||||
filterRuleCards.value = filterRuleCards.value
|
||||
.filter(card => card.pri !== pri)
|
||||
.map((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
return card
|
||||
})
|
||||
}
|
||||
|
||||
// 分享规则
|
||||
function shareRules() {
|
||||
// 有值才处理
|
||||
if (filterRuleCards.value.length === 0) return
|
||||
|
||||
// 将卡片规则接装为字符串
|
||||
const value = filterRuleCards.value
|
||||
.filter(card => card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
|
||||
// 复制到剪贴板
|
||||
try {
|
||||
copyToClipboard(value)
|
||||
$toast.success('优先级规则已复制到剪贴板')
|
||||
} catch (error) {
|
||||
$toast.error('优先级规则复制失败!')
|
||||
}
|
||||
}
|
||||
|
||||
// 导入规则
|
||||
async function importRules() {
|
||||
importCodeString.value = ''
|
||||
importCodeDialog.value = true
|
||||
}
|
||||
|
||||
// 监听导入代码变化
|
||||
watchEffect(() => {
|
||||
if (!importCodeString.value) return
|
||||
// 导入代码需要以空格开头和结束,没有则拼接
|
||||
if (!importCodeString.value.startsWith(' ')) importCodeString.value = ` ${importCodeString.value}`
|
||||
if (!importCodeString.value.endsWith(' ')) importCodeString.value = `${importCodeString.value} `
|
||||
// 将导入的代码转换为规则卡片
|
||||
const groups = importCodeString.value.split('>')
|
||||
filterRuleCards.value = groups.map((group: string, index: number) => {
|
||||
return {
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&'),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 增加卡片
|
||||
function addFilterCard() {
|
||||
// 优先级
|
||||
const pri = (filterRuleCards.value.length + 1).toString()
|
||||
|
||||
// 新卡片
|
||||
const newCard: FilterCard = { pri, rules: [] }
|
||||
|
||||
// 添加到列表
|
||||
filterRuleCards.value.push(newCard)
|
||||
}
|
||||
|
||||
// 根据列表的拖动顺序更新优先级
|
||||
function dragOrderEnd() {
|
||||
filterRuleCards.value.map((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
return card
|
||||
})
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
function opengroupInfoDialog() {
|
||||
// 深复制
|
||||
groupInfo.value = cloneDeep(props.group)
|
||||
if (props.group.rule_string) {
|
||||
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => {
|
||||
return {
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&'),
|
||||
}
|
||||
})
|
||||
}
|
||||
groupInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveGroupInfo() {
|
||||
// 为空
|
||||
if (!groupInfo.value.name) {
|
||||
$toast.error('规则组名称不能为空')
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
|
||||
$toast.error(`规则组名称【${groupInfo.value.name}】已存在,请替换`)
|
||||
return
|
||||
}
|
||||
// 保存
|
||||
groupInfoDialog.value = false
|
||||
// 更新到 groupInfo的rule_string
|
||||
groupInfo.value.rule_string = filterRuleCards.value
|
||||
.filter(card => card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
emit('change', groupInfo.value, props.group.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="opengroupInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<DialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
|
||||
<div class="text-body-1 mb-3">
|
||||
<span v-if="!props.group.category">{{ props.group.media_type || '通用' }}</span>
|
||||
<span v-else>{{ props.group.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
|
||||
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="groupInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="groupInfo.name"
|
||||
label="规则组名称"
|
||||
placeholder="必填;不可与其他规则组重名"
|
||||
hint="自定义规则组名称"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VSelect
|
||||
v-model="groupInfo.media_type"
|
||||
label="适用媒体类型"
|
||||
:items="mediaTypeItems"
|
||||
hint="选择规则组适用的媒体类型"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VSelect
|
||||
v-model="groupInfo.category"
|
||||
:items="getCategories"
|
||||
label="适用媒体类别"
|
||||
hint="选择规则组适用的媒体类别"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<draggable
|
||||
v-model="filterRuleCards"
|
||||
handle=".cursor-move"
|
||||
item-key="pri"
|
||||
tag="div"
|
||||
@end="dragOrderEnd"
|
||||
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<FilterRuleCard
|
||||
:pri="element.pri"
|
||||
:maxpri="filterRuleCards.length.toString()"
|
||||
:rules="element.rules"
|
||||
:custom_rules="props.custom_rules"
|
||||
@changed="updateFilterCardValue"
|
||||
@close="filterCardClose(element.pri)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">请添加或导入规则</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" variant="tonal" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VBtn color="success" variant="tonal" @click="importRules">
|
||||
<VIcon icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn color="info" variant="tonal" @click="shareRules">
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveGroupInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<VDialog v-model="importCodeDialog" width="60rem" scrollable>
|
||||
<ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,36 +35,28 @@ function imageErrorHandler() {
|
||||
|
||||
// 默认图片
|
||||
function getDefaultImage() {
|
||||
if (props.media?.server === 'plex')
|
||||
return plex
|
||||
else if (props.media?.server === 'emby')
|
||||
return emby
|
||||
else if (props.media?.server === 'jellyfin')
|
||||
return jellyfin
|
||||
else
|
||||
return plex
|
||||
if (props.media?.server === 'plex') return plex
|
||||
else if (props.media?.server === 'emby') return emby
|
||||
else if (props.media?.server === 'jellyfin') return jellyfin
|
||||
else return plex
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
function goPlay() {
|
||||
if (props.media?.link)
|
||||
window.open(props.media?.link, '_blank')
|
||||
if (props.media?.link) window.open(props.media?.link, '_blank')
|
||||
}
|
||||
|
||||
// 生成图片代理路径
|
||||
function getImgUrl(url: string) {
|
||||
if (!url)
|
||||
return getDefaultImage()
|
||||
else
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
if (!url) return getDefaultImage()
|
||||
else return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
// 根据多张图片生成媒体库封面
|
||||
async function drawImages(imageList: string[]) {
|
||||
// 图片
|
||||
const IMAGES = imageList
|
||||
if (IMAGES.length === 0)
|
||||
return getDefaultImage()
|
||||
if (IMAGES.length === 0) return getDefaultImage()
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
for (let i = 0; i < IMAGES.length; i++)
|
||||
@@ -72,8 +64,7 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
// canvas
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas)
|
||||
return getDefaultImage()
|
||||
if (!canvas) return getDefaultImage()
|
||||
|
||||
// 画布参数
|
||||
const POSTER_WIDTH = (canvas.width - 32) / 4
|
||||
@@ -85,8 +76,7 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
// 获取画布上下文
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx)
|
||||
return getDefaultImage()
|
||||
if (!ctx) return getDefaultImage()
|
||||
|
||||
// 设置背景色为黑色
|
||||
ctx.fillStyle = '#000000'
|
||||
@@ -94,16 +84,14 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
// 绘制图片
|
||||
async function drawImageWithReflection(imgSrc: string, index: number) {
|
||||
if (!canvas)
|
||||
return
|
||||
if (!canvas) return
|
||||
|
||||
if (!ctx)
|
||||
return
|
||||
if (!ctx) return
|
||||
|
||||
const img = new Image()
|
||||
img.setAttribute('crossorigin', 'anonymous')
|
||||
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 y = MARGIN_HEIGHT
|
||||
@@ -125,12 +113,7 @@ async function drawImages(imageList: string[]) {
|
||||
REFLECTION_HEIGHT,
|
||||
)
|
||||
|
||||
const gradient = ctx.createLinearGradient(
|
||||
0,
|
||||
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
|
||||
0,
|
||||
REFLECTION_HEIGHT,
|
||||
)
|
||||
const gradient = ctx.createLinearGradient(0, REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT, 0, REFLECTION_HEIGHT)
|
||||
|
||||
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
|
||||
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)
|
||||
for (let i = 0; i < loopCount; i++)
|
||||
await drawImageWithReflection(IMAGES[i], i + 1)
|
||||
for (let i = 0; i < loopCount; i++) await drawImageWithReflection(IMAGES[i], i + 1)
|
||||
|
||||
// 转换为图片地址
|
||||
return canvas.toDataURL('image/png')
|
||||
@@ -152,17 +134,12 @@ async function drawImages(imageList: string[]) {
|
||||
onMounted(async () => {
|
||||
if (props.media?.image_list && props.media?.image_list.length > 0)
|
||||
imgUrl.value = await drawImages(props.media?.image_list || [])
|
||||
else
|
||||
imgUrl.value = getImgUrl(props.media?.image || '')
|
||||
else imgUrl.value = getImgUrl(props.media?.image || '')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover
|
||||
v-bind="props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
>
|
||||
<VHover v-bind="props" :height="props.height" :width="props.width">
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
@@ -175,13 +152,7 @@ onMounted(async () => {
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" class="w-full h-full hidden" />
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
@@ -190,7 +161,7 @@ onMounted(async () => {
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<h1 class="mb-1 text-white 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 }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
|
||||
@@ -19,6 +19,9 @@ const props = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
const store = useStore()
|
||||
|
||||
// 提示框
|
||||
@@ -366,17 +369,19 @@ onBeforeMount(() => {
|
||||
const getImgUrl: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
// 如果地址中包含douban则使用中转代理
|
||||
if (url.includes('doubanio.com'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}douban/img?imgurl=${encodeURIComponent(url)}`
|
||||
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
return url
|
||||
})
|
||||
|
||||
// 拼装季图片地址
|
||||
function getSeasonPoster(posterPath: string) {
|
||||
if (!posterPath) return ''
|
||||
return `https://image.tmdb.org/t/p/w500${posterPath}`
|
||||
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
||||
@@ -385,12 +390,18 @@ function formatAirDate(airDate: string) {
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
// 从yyyy-mm-dd中提取年份
|
||||
function getYear(airDate: string) {
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return date.getFullYear()
|
||||
}
|
||||
|
||||
// 移除订阅
|
||||
function onRemoveSubscribe() {
|
||||
subscribeEditDialog.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -405,7 +416,7 @@ function getYear(airDate: string) {
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering)"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
@@ -537,12 +548,7 @@ function getYear(airDate: string) {
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="
|
||||
() => {
|
||||
subscribeEditDialog = false
|
||||
handleCheckSubscribe()
|
||||
}
|
||||
"
|
||||
@remove="onRemoveSubscribe"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
349
src/components/cards/MediaServerCard.vue
Normal file
@@ -0,0 +1,349 @@
|
||||
<script setup lang="ts">
|
||||
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import emby_image from '@images/logos/emby.png'
|
||||
import jellyfin_image from '@images/logos/jellyfin.png'
|
||||
import plex_image from '@images/logos/plex.png'
|
||||
import api from '@/api'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
// 单个媒体服务器
|
||||
mediaserver: {
|
||||
type: Object as PropType<MediaServerConf>,
|
||||
required: true,
|
||||
},
|
||||
// 所有媒体服务器
|
||||
mediaservers: {
|
||||
type: Array as PropType<MediaServerConf[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'done', 'change'])
|
||||
|
||||
// 媒体统计数据
|
||||
const infoItems = ref([
|
||||
{
|
||||
avatar: 'mdi-movie-roll',
|
||||
title: '电影',
|
||||
amount: '0',
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-television-box',
|
||||
title: '电视剧',
|
||||
amount: '0',
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-account',
|
||||
title: '用户',
|
||||
amount: '0',
|
||||
},
|
||||
])
|
||||
|
||||
// 同步媒体库选项
|
||||
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
|
||||
{
|
||||
title: '全部',
|
||||
value: 'all',
|
||||
},
|
||||
])
|
||||
|
||||
// 媒体服务器详情弹窗
|
||||
const mediaServerInfoDialog = ref(false)
|
||||
|
||||
// 媒体服务器详情
|
||||
const mediaServerInfo = ref<MediaServerConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 打开详情弹窗
|
||||
function openMediaServerInfoDialog() {
|
||||
loadLibrary(props.mediaserver.name)
|
||||
// 深复制
|
||||
mediaServerInfo.value = cloneDeep(props.mediaserver)
|
||||
mediaServerInfoDialog.value = true
|
||||
if (!props.mediaserver.sync_libraries) {
|
||||
mediaServerInfo.value.sync_libraries = ['all']
|
||||
}
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveMediaServerInfo() {
|
||||
// 为空不保存,跳出警告框
|
||||
if (!mediaServerInfo.value.name) {
|
||||
$toast.error('名称不能为空,请输入后再确定')
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
|
||||
$toast.error(`【${mediaServerInfo.value.name}】已存在,请替换为其他名称`)
|
||||
return
|
||||
}
|
||||
// 执行保存
|
||||
mediaServerInfoDialog.value = false
|
||||
emit('change', mediaServerInfo.value, props.mediaserver.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.mediaserver.type) {
|
||||
case 'emby':
|
||||
return emby_image
|
||||
case 'jellyfin':
|
||||
return jellyfin_image
|
||||
default:
|
||||
return plex_image
|
||||
}
|
||||
})
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 调用API加载媒体统计数据
|
||||
async function loadMediaStatistic() {
|
||||
try {
|
||||
const res: MediaStatistic = await api.get('dashboard/statistic', {
|
||||
params: {
|
||||
name: props.mediaserver.name,
|
||||
},
|
||||
})
|
||||
|
||||
if (res) {
|
||||
infoItems.value = [
|
||||
{
|
||||
avatar: 'mdi-movie-roll',
|
||||
title: '电影',
|
||||
amount: res.movie_count.toLocaleString(),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-television-box',
|
||||
title: '电视剧',
|
||||
amount: res.tv_count.toLocaleString(),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-account',
|
||||
title: '用户',
|
||||
amount: res.user_count.toLocaleString(),
|
||||
},
|
||||
]
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API查询媒体库
|
||||
async function loadLibrary(server: string) {
|
||||
try {
|
||||
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
|
||||
if (result && result.length > 0) {
|
||||
librariesOptions.value = result.map(item => ({
|
||||
title: item.name,
|
||||
value: item.id?.toString(),
|
||||
}))
|
||||
} else {
|
||||
librariesOptions.value = []
|
||||
}
|
||||
librariesOptions.value.unshift({
|
||||
title: '全部',
|
||||
value: 'all',
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMediaStatistic()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openMediaServerInfoDialog">
|
||||
<DialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
|
||||
<div class="text-sm mt-5 flex flex-wrap">
|
||||
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
|
||||
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="mediaServerInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="mediaServerInfo.enabled" label="启用媒体服务器" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'emby'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
label="名称"
|
||||
placeholder="必填;不可与其他名称重名"
|
||||
hint="媒体服务器的别名"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
label="地址"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="跳转播放页面使用的地址,格式:http(s)://domain:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
label="API密钥"
|
||||
hint="Emby设置->高级->API密钥中生成的密钥"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'jellyfin'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
label="名称"
|
||||
placeholder="必填;不可与其他名称重名"
|
||||
hint="媒体服务器的别名"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
label="地址"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="跳转播放页面使用的地址,格式:http(s)://domain:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
label="API密钥"
|
||||
hint="Jellyfin设置->高级->API密钥中生成的密钥"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'plex'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
label="名称"
|
||||
placeholder="必填;不可与其他名称重名"
|
||||
hint="媒体服务器的别名"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
label="地址"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="跳转播放页面使用的地址,格式:http(s)://domain:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.token"
|
||||
label="X-Plex-Token"
|
||||
hint="浏览器F12->网络,从Plex请求URL中获取的X-Plex-Token"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
label="同步媒体库"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
hint="只有选中的媒体库才会被同步"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveMediaServerInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
|
||||
确定
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,8 +22,7 @@ async function imageLoaded() {
|
||||
|
||||
// 链接打开新窗口
|
||||
function openLink() {
|
||||
if (props.message?.link)
|
||||
window.open(props.message.link, '_blank')
|
||||
if (props.message?.link) window.open(props.message.link, '_blank')
|
||||
}
|
||||
|
||||
// 将note转换为json
|
||||
@@ -31,9 +30,8 @@ function noteToJson() {
|
||||
if (props.message?.note) {
|
||||
try {
|
||||
return JSON.parse(props.message.note)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
} catch (error) {
|
||||
return props.message.note
|
||||
}
|
||||
}
|
||||
return {}
|
||||
@@ -41,23 +39,14 @@ function noteToJson() {
|
||||
|
||||
// 将\n转换为html属性的换行符
|
||||
function replaceNewLine(value: string) {
|
||||
if (!value)
|
||||
return ''
|
||||
if (!value) return ''
|
||||
return value.replace(/\n/g, '<br/>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
variant="tonal"
|
||||
@click="openLink"
|
||||
>
|
||||
<div
|
||||
v-if="props.message?.image"
|
||||
class="relative text-center card-cover-blurred"
|
||||
>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink">
|
||||
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
|
||||
<VImg
|
||||
:src="props.message?.image"
|
||||
aspect-ratio="4/3"
|
||||
@@ -67,28 +56,25 @@ function replaceNewLine(value: string) {
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</div>
|
||||
<VCardTitle v-if="props.message?.title" class="whitespace-break-spaces">
|
||||
<div
|
||||
v-if="props.message?.title && !props.message?.image && !props.message?.note"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
>
|
||||
<p class="mb-0">{{ props.message?.title }}</p>
|
||||
</div>
|
||||
<VCardTitle v-else-if="props.message?.title">
|
||||
{{ props.message?.title }}
|
||||
</VCardTitle>
|
||||
<VAlert
|
||||
<div
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
variant="tonal"
|
||||
type="success"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
>
|
||||
<template #prepend />
|
||||
{{ props.message?.text }}
|
||||
</VAlert>
|
||||
<VCardText
|
||||
v-if="props.message?.text && props.message?.action === 1"
|
||||
v-html="replaceNewLine(props.message?.text)"
|
||||
/>
|
||||
<p class="mb-0">{{ props.message?.text }}</p>
|
||||
</div>
|
||||
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
|
||||
<VCardText v-if="props.message?.note">
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(value, key) in noteToJson()"
|
||||
:key="key"
|
||||
two-line
|
||||
>
|
||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold">
|
||||
{{ key + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
@@ -104,9 +90,11 @@ function replaceNewLine(value: string) {
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<div class="text-end">
|
||||
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
|
||||
<span class="text-sm italic me-2">{{ formatDateDifference(props.message?.reg_time || props.message?.date || '') }}</span>
|
||||
</div>
|
||||
</VCard>
|
||||
<div class="text-end">
|
||||
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
|
||||
<span class="text-sm italic me-2">{{
|
||||
formatDateDifference(props.message?.reg_time || props.message?.date || '')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
401
src/components/cards/NotificationChannelCard.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<script setup lang="ts">
|
||||
import { NotificationConf } from '@/api/types'
|
||||
import wechat_image from '@images/logos/wechat.png'
|
||||
import telegram_image from '@images/logos/telegram.webp'
|
||||
import vocechat_image from '@images/logos/vocechat.png'
|
||||
import synologychat_image from '@images/logos/synologychat.png'
|
||||
import slack_image from '@images/logos/slack.webp'
|
||||
import chrome_image from '@images/logos/chrome.png'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { cloneDeep } from "lodash"
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
// 单个通知
|
||||
notification: {
|
||||
type: Object as PropType<NotificationConf>,
|
||||
required: true,
|
||||
},
|
||||
// 所有通知
|
||||
notifications: {
|
||||
type: Array as PropType<NotificationConf[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'change', 'done'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 通知详情弹窗
|
||||
const notificationInfoDialog = ref(false)
|
||||
|
||||
// 通知详情
|
||||
const notificationInfo = ref<NotificationConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 各通知类型的名称字典
|
||||
const notificationTypeNames: { [key: string]: string } = {
|
||||
wechat: '企业微信',
|
||||
telegram: 'Telegram',
|
||||
vocechat: 'VoceChat',
|
||||
synologychat: 'Synology Chat',
|
||||
slack: 'Slack',
|
||||
webpush: 'WebPush',
|
||||
}
|
||||
|
||||
// 消息类型下拉字典
|
||||
const notificationTypes = [
|
||||
{ value: '资源下载', title: '资源下载' },
|
||||
{ value: '整理入库', title: '整理入库' },
|
||||
{ value: '订阅', title: '订阅' },
|
||||
{ value: '站点', title: '站点' },
|
||||
{ value: '媒体服务器', title: '媒体服务器' },
|
||||
{ value: '手动处理', title: '手动处理' },
|
||||
{ value: '插件', title: '插件' },
|
||||
{ value: '其它', title: '其它' },
|
||||
]
|
||||
|
||||
// 打开详情弹窗
|
||||
function openNotificationInfoDialog() {
|
||||
// 替换成深复制,避免修改时影响原数据
|
||||
notificationInfo.value = cloneDeep(props.notification)
|
||||
console.log(`当前卡片的通知信息:${JSON.stringify(notificationInfo.value)}`)
|
||||
notificationInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveNotificationInfo() {
|
||||
// 为空不保存,跳出警告框
|
||||
if (!notificationInfo.value.name) {
|
||||
$toast.error('名称不能为空,请输入后再确定')
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
|
||||
$toast.error(`通知渠道【${notificationInfo.value.name}】已存在,请替换`)
|
||||
return
|
||||
}
|
||||
notificationInfoDialog.value = false
|
||||
emit('change', notificationInfo.value, props.notification.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.notification.type) {
|
||||
case 'wechat':
|
||||
return wechat_image
|
||||
case 'telegram':
|
||||
return telegram_image
|
||||
case 'vocechat':
|
||||
return vocechat_image
|
||||
case 'synologychat':
|
||||
return synologychat_image
|
||||
case 'slack':
|
||||
return slack_image
|
||||
case 'webpush':
|
||||
return chrome_image
|
||||
default:
|
||||
return wechat_image
|
||||
}
|
||||
})
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openNotificationInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<DialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<div class="flex items-center">
|
||||
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
|
||||
<span class="text-h6">{{ props.notification.name }}</span>
|
||||
</div>
|
||||
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.notification.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="notificationInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="notificationInfo.enabled" label="启用通知" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="notificationInfo.switchs"
|
||||
:items="notificationTypes"
|
||||
label="消息类型"
|
||||
hint="开启通知的消息类型"
|
||||
multiple
|
||||
clearable
|
||||
chips
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'wechat'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
label="名称"
|
||||
placeholder="别名"
|
||||
hint="通知渠道的别名"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_CORPID"
|
||||
label="企业ID"
|
||||
hint="企业微信后台企业信息中的企业ID"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_ID"
|
||||
label="应用 AgentId"
|
||||
hint="企业微信自建应用的AgentId"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_SECRET"
|
||||
label="应用 Secret"
|
||||
hint="企业微信自建应用的Secret"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_PROXY"
|
||||
label="代理地址"
|
||||
hint="微信消息的转发代理地址,2022年6月20日后创建的自建应用才需要,不使用代理时需要保留默认值"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_TOKEN"
|
||||
label="Token"
|
||||
hint="微信企业自建应用->API接收消息配置中的Token"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
|
||||
label="EncodingAESKey"
|
||||
hint="微信企业自建应用->API接收消息配置中的EncodingAESKey"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
label="管理员白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="可使用管理菜单及命令的用户ID列表,多个ID使用,分隔"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'telegram'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
label="名称"
|
||||
placeholder="别名"
|
||||
hint="通知渠道的别名"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_TOKEN"
|
||||
label="Bot Token"
|
||||
hint="Telegram机器人token,格式:123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_CHAT_ID"
|
||||
label="Chat ID"
|
||||
hint="接受消息通知的用户、群组或频道Chat ID"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_USERS"
|
||||
label="用户白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="可使用Telegram机器人的用户ID清单,多个用户用,分隔,不填写则所有用户都能使用"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_ADMINS"
|
||||
label="管理员白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="可使用管理菜单及命令的用户ID列表,多个ID使用,分隔"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'slack'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
label="名称"
|
||||
placeholder="别名"
|
||||
hint="通知渠道的别名"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SLACK_OAUTH_TOKEN"
|
||||
label="Slack Bot User OAuth Token"
|
||||
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
hint="Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SLACK_APP_TOKEN"
|
||||
label="Slack App-Level Token"
|
||||
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
hint="Slack应用`OAuth & Permissions`页面中的`App-Level Token`"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SLACK_CHANNEL"
|
||||
label="频道名称"
|
||||
placeholder="全体"
|
||||
hint="消息发送频道,默认`全体`"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'synologychat'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
label="名称"
|
||||
placeholder="别名"
|
||||
hint="通知渠道的别名"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SYNOLOGYCHAT_WEBHOOK"
|
||||
label="机器人传入URL"
|
||||
hint="Synology Chat机器人传入URL"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SYNOLOGYCHAT_TOKEN"
|
||||
label="令牌"
|
||||
hint="Synology Chat机器人令牌"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'vocechat'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
label="名称"
|
||||
placeholder="别名"
|
||||
hint="通知渠道的别名"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.VOCECHAT_HOST"
|
||||
label="地址"
|
||||
hint="VoceChat服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.VOCECHAT_API_KEY"
|
||||
label="机器人密钥"
|
||||
hint="VoceChat机器人密钥"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.VOCECHAT_CHANNEL_ID"
|
||||
label="频道ID"
|
||||
placeholder="不包含#号"
|
||||
hint="VoceChat的频道ID,不包含#号"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'webpush'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
label="名称"
|
||||
placeholder="别名"
|
||||
hint="通知渠道的别名"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WEBPUSH_USERNAME"
|
||||
label="登录用户名"
|
||||
hint="只有对应的用户登录后才会推送消息"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveNotificationInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
|
||||
确定
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,9 @@ const personProps = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
// 当前人物
|
||||
const personInfo = ref(personProps.person)
|
||||
|
||||
@@ -17,22 +20,26 @@ const isImageLoaded = ref(false)
|
||||
|
||||
// 人物图片地址
|
||||
function getPersonImage() {
|
||||
let url = ''
|
||||
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}`
|
||||
url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/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
|
||||
url = personInfo.value?.avatar?.normal
|
||||
} else {
|
||||
return personInfo.value?.avatar
|
||||
url = personInfo.value?.avatar
|
||||
}
|
||||
} else if (personProps.person?.source === 'bangumi') {
|
||||
if (!personInfo.value?.images) return personIcon
|
||||
return personInfo.value?.images?.medium
|
||||
url = personInfo.value?.images?.medium
|
||||
} else {
|
||||
return personIcon
|
||||
}
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
}
|
||||
|
||||
// 人物姓名
|
||||
|
||||
@@ -150,55 +150,61 @@ const dropdownItems = ref([
|
||||
|
||||
<template>
|
||||
<VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
|
||||
<div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
|
||||
<div class="me-n3 absolute top-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" class="text-white" />
|
||||
<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>
|
||||
<VAvatar size="6rem">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
<div class="me-n3 absolute bottom-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<VCardTitle>
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="pb-2">
|
||||
<div>{{ props.plugin?.plugin_desc }}</div>
|
||||
<div>
|
||||
<VChip v-for="label in pluginLabels" variant="tonal" size="small" class="me-1 my-1" color="info" label>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
|
||||
></div>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="text-white px-2 py-1 text-shadow line-clamp-3">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex align-self-baseline pb-2 w-full align-end">
|
||||
<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-account" class="me-1" />
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
@@ -220,15 +226,3 @@ const dropdownItems = ref([
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</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>
|
||||
|
||||
@@ -10,7 +10,6 @@ import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import { isNullOrEmptyObject } from '@core/utils'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import store from '@/store'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
@@ -62,6 +61,9 @@ const pluginInfoDialog = ref(false)
|
||||
// 进度框文本
|
||||
const progressText = ref('正在更新插件...')
|
||||
|
||||
// 用户头像是否加载完成
|
||||
const isAvatarLoaded = ref(false)
|
||||
|
||||
// 插件数据页面配置项
|
||||
let pluginPageItems = ref([])
|
||||
|
||||
@@ -216,11 +218,19 @@ const iconPath: Ref<string> = computed(() => {
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
})
|
||||
|
||||
// 插件作者头像路径
|
||||
const authorPath: Ref<string> = computed(() => {
|
||||
// 网络图片则使用代理后返回
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
props.plugin?.author_url + '.png',
|
||||
)}`
|
||||
})
|
||||
|
||||
// 重置插件
|
||||
async function resetPlugin() {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认重置插件 ${props.plugin?.plugin_name} 的配置数据?`,
|
||||
content: `此操作将恢复插件 ${props.plugin?.plugin_name} 的默认设置,并清除所有相关数据,确定要继续吗?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
@@ -277,10 +287,9 @@ function visitAuthorPage() {
|
||||
|
||||
// 查看日志URL
|
||||
function openLoggerWindow() {
|
||||
const token = store.state.auth.token
|
||||
const url = `${
|
||||
import.meta.env.VITE_API_BASE_URL
|
||||
}system/logging?token=${token}&length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
@@ -365,70 +374,95 @@ const dropdownItems = ref([
|
||||
// 监听插件状态变化
|
||||
watch(
|
||||
() => props.plugin?.has_update,
|
||||
(newHasUpdate, oldHasUpdate) => {
|
||||
(newHasUpdate, _) => {
|
||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
||||
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
|
||||
},
|
||||
)
|
||||
|
||||
// 监听插件窗口状态变化
|
||||
watch(
|
||||
() => props.plugin?.page_open,
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) openPluginDetail()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 插件卡片 -->
|
||||
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
|
||||
<div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 left-1">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
<div class="me-n3 absolute top-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" class="text-white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<VAvatar size="6rem">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
<div class="me-n3 absolute bottom-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<VCardItem class="py-2">
|
||||
<VCardTitle class="flex items-center flex-row">
|
||||
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText class="pb-1">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-end align-self-baseline p-1 w-full align-end">
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
|
||||
/>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<VBadge v-if="props.plugin?.state" dot inline color="success" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="px-2 py-1 text-white text-shadow line-clamp-3">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span class="author-info">
|
||||
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
||||
<VIcon v-if="!isAvatarLoaded" icon="mdi-github" class="me-1" />
|
||||
</VImg>
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-fire" />
|
||||
<span class="text-sm ms-1">{{ props.count?.toLocaleString() }}</span>
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
</VCardText>
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 插件配置页面 -->
|
||||
@@ -492,4 +526,17 @@ watch(
|
||||
content: '';
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.author-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
border-radius: 50%;
|
||||
block-size: 24px;
|
||||
inline-size: 24px;
|
||||
margin-inline-end: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,30 +2,24 @@
|
||||
import type { PropType } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
|
||||
import SiteTorrentTable from '../table/SiteTorrentTable.vue'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
|
||||
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue'
|
||||
import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
|
||||
import api from '@/api'
|
||||
import type { Site, SiteStatistic } from '@/api/types'
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
import { VCardActions, VExpandTransition, VProgressLinear, VSpacer } from 'vuetify/lib/components/index.mjs'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const cardProps = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
width: String,
|
||||
height: String,
|
||||
data: Object as PropType<SiteUserData>,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update', 'remove'])
|
||||
|
||||
// 密码输入
|
||||
const isPasswordVisible = ref(false)
|
||||
|
||||
// 图标
|
||||
const siteIcon = ref<string>('')
|
||||
|
||||
@@ -38,9 +32,6 @@ const testButtonText = ref('测试')
|
||||
// 测试按钮可用性
|
||||
const testButtonDisable = ref(false)
|
||||
|
||||
// 更新按钮可用性
|
||||
const updateButtonDisable = ref(false)
|
||||
|
||||
// 更新站点Cookie UA弹窗
|
||||
const siteCookieDialog = ref(false)
|
||||
|
||||
@@ -50,18 +41,11 @@ const siteEditDialog = ref(false)
|
||||
// 资源浏览弹窗
|
||||
const resourceDialog = ref(false)
|
||||
|
||||
// 进度条
|
||||
const progressDialog = ref(false)
|
||||
// 用户数据弹窗
|
||||
const siteUserDataDialog = ref(false)
|
||||
|
||||
// 进度文本
|
||||
const progressText = ref('请稍候 ...')
|
||||
|
||||
// 用户名密码表单
|
||||
const userPwForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
code: '',
|
||||
})
|
||||
// 站点操作显示
|
||||
const siteActionShow = ref(false)
|
||||
|
||||
// 站点使用统计
|
||||
const siteStats = ref<SiteStatistic>({})
|
||||
@@ -113,34 +97,9 @@ async function handleResourceBrowse() {
|
||||
resourceDialog.value = true
|
||||
}
|
||||
|
||||
// 调用API,更新站点Cookie UA
|
||||
async function updateSiteCookie() {
|
||||
try {
|
||||
if (!userPwForm.value.username || !userPwForm.value.password) return
|
||||
|
||||
// 更新按钮状态
|
||||
siteCookieDialog.value = false
|
||||
updateButtonDisable.value = true
|
||||
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
|
||||
params: {
|
||||
username: userPwForm.value.username,
|
||||
password: userPwForm.value.password,
|
||||
code: userPwForm.value.code,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) $toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
|
||||
else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
|
||||
|
||||
progressDialog.value = false
|
||||
updateButtonDisable.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
// 打开站点用户数据弹窗
|
||||
async function handleSiteUserData() {
|
||||
siteUserDataDialog.value = true
|
||||
}
|
||||
|
||||
// 打开站点页面
|
||||
@@ -162,9 +121,10 @@ const statColor = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 监听resourceDialog,如果为false则重新查询站点使用统计
|
||||
watch(resourceDialog, value => {
|
||||
if (!value) getSiteStats()
|
||||
// 计算上传量和下载量的百分比
|
||||
const getPercentage = computed(() => {
|
||||
if (cardProps.data?.upload === 0) return 100
|
||||
return ((cardProps.data?.download ?? 0) / ((cardProps.data?.download ?? 0) + (cardProps.data?.upload ?? 0))) * 100
|
||||
})
|
||||
|
||||
// 保存站点
|
||||
@@ -173,6 +133,18 @@ function saveSite() {
|
||||
emit('update')
|
||||
}
|
||||
|
||||
// 更新站点Cookie UA后的回调
|
||||
function onSiteCookieUpdated() {
|
||||
siteCookieDialog.value = false
|
||||
getSiteStats()
|
||||
}
|
||||
|
||||
// 资源浏览弹窗关闭后的回调
|
||||
function onSiteResourceDone() {
|
||||
resourceDialog.value = false
|
||||
getSiteStats()
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
@@ -183,8 +155,6 @@ onMounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
:height="cardProps.height"
|
||||
:width="cardProps.width"
|
||||
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
|
||||
class="overflow-hidden"
|
||||
@click="siteEditDialog = true"
|
||||
@@ -194,7 +164,7 @@ onMounted(() => {
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardItem>
|
||||
<VCardItem style="padding-block-end: 0">
|
||||
<VCardTitle class="font-bold">
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
|
||||
</VCardTitle>
|
||||
@@ -202,10 +172,10 @@ onMounted(() => {
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText class="py-2">
|
||||
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
|
||||
<VCardText class="py-1">
|
||||
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
|
||||
@@ -213,9 +183,9 @@ onMounted(() => {
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
|
||||
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
|
||||
<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-apple-safari" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.filter" text="过滤">
|
||||
@@ -224,67 +194,61 @@ onMounted(() => {
|
||||
</template>
|
||||
</VTooltip>
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<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>
|
||||
<VBtn
|
||||
:icon="siteActionShow ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click.stop="siteActionShow = !siteActionShow"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
↑ {{ formatFileSize(cardProps.data?.upload || 0) }} / ↓ {{ formatFileSize(cardProps.data?.download || 0) }}
|
||||
</span>
|
||||
<VSpacer />
|
||||
</VCardActions>
|
||||
<VDivider class="mb-1" v-if="siteActionShow" />
|
||||
<VExpandTransition>
|
||||
<div v-show="siteActionShow" class="py-1 pe-12">
|
||||
<VBtn v-if="!cardProps.site?.public" @click.stop="handleSiteUpdate" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" />
|
||||
</template>
|
||||
更新
|
||||
</VBtn>
|
||||
<VBtn :disabled="testButtonDisable" @click.stop="testSite" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-link" />
|
||||
</template>
|
||||
{{ testButtonText }}
|
||||
</VBtn>
|
||||
<VBtn @click.stop="handleResourceBrowse" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-web" />
|
||||
</template>
|
||||
浏览
|
||||
</VBtn>
|
||||
<VBtn @click.stop="handleSiteUserData" variant="text">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-chart-bell-curve" />
|
||||
</template>
|
||||
数据
|
||||
</VBtn>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
|
||||
<span class="absolute top-1 right-8">
|
||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||
</span>
|
||||
<div class="w-full absolute bottom-0" v-if="(cardProps.data?.upload || cardProps.data?.download || 0) > 0">
|
||||
<VProgressLinear :model-value="getPercentage" bg-color="success" color="warning" bg-opacity="0.5" height="3" />
|
||||
</div>
|
||||
</VCard>
|
||||
<!-- 更新站点Cookie & UA弹窗 -->
|
||||
<VDialog v-model="siteCookieDialog" max-width="50rem">
|
||||
<!-- 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>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="updateSiteCookie" prepend-icon="mdi-refresh" class="px-5"> 开始更新 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<SiteCookieUpdateDialog
|
||||
v-if="siteCookieDialog"
|
||||
v-model="siteCookieDialog"
|
||||
:site="cardProps.site"
|
||||
@close="siteCookieDialog = false"
|
||||
@done="onSiteCookieUpdated"
|
||||
/>
|
||||
<!-- 站点编辑弹窗 -->
|
||||
<SiteAddEditDialog
|
||||
v-if="siteEditDialog"
|
||||
@@ -294,30 +258,19 @@ onMounted(() => {
|
||||
@remove="emit('remove')"
|
||||
@close="siteEditDialog = false"
|
||||
/>
|
||||
<!-- 站点数据弹窗 -->
|
||||
<SiteUserDataDialog
|
||||
v-if="siteUserDataDialog"
|
||||
v-model="siteUserDataDialog"
|
||||
:site="cardProps.site"
|
||||
@close="siteUserDataDialog = false"
|
||||
/>
|
||||
<!-- 站点资源弹窗 -->
|
||||
<VDialog
|
||||
<SiteResourceDialog
|
||||
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" />
|
||||
:site="cardProps.site"
|
||||
@close="onSiteResourceDone"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
147
src/components/cards/StorageCard.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { StorageConf } from '@/api/types'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
import storage_png from '@images/misc/storage.png'
|
||||
import alipan_png from '@images/misc/alipan.webp'
|
||||
import u115_png from '@images/misc/u115.png'
|
||||
import rclone_png from '@images/misc/rclone.png'
|
||||
import api from '@/api'
|
||||
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
|
||||
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
|
||||
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
storage: {
|
||||
type: Object as PropType<StorageConf>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done'])
|
||||
|
||||
// 提示信息
|
||||
const $toast = useToast()
|
||||
|
||||
// 存储总空间
|
||||
const total = ref(0)
|
||||
|
||||
// 存储可用空间
|
||||
const available = ref(0)
|
||||
|
||||
// 储存已用空间
|
||||
const used = computed(() => {
|
||||
return total.value - available.value
|
||||
})
|
||||
|
||||
// 阿里云盘认证对话框
|
||||
const aliyunAuthDialog = ref(false)
|
||||
// 115网盘认证对话框
|
||||
const u115AuthDialog = ref(false)
|
||||
// Rclone配置对话框
|
||||
const rcloneConfigDialog = ref(false)
|
||||
|
||||
// 打开存储对话框
|
||||
function openStorageDialog() {
|
||||
switch (props.storage.type) {
|
||||
case 'alipan':
|
||||
aliyunAuthDialog.value = true
|
||||
break
|
||||
case 'u115':
|
||||
u115AuthDialog.value = true
|
||||
break
|
||||
case 'rclone':
|
||||
rcloneConfigDialog.value = true
|
||||
break
|
||||
default:
|
||||
$toast.info('此存储类型无需配置参数,请直接配置目录!')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.storage.type) {
|
||||
case 'local':
|
||||
return storage_png
|
||||
case 'alipan':
|
||||
return alipan_png
|
||||
case 'u115':
|
||||
return u115_png
|
||||
case 'rclone':
|
||||
return rclone_png
|
||||
default:
|
||||
return storage_png
|
||||
}
|
||||
})
|
||||
|
||||
// 计算进度条颜色
|
||||
const progressColor = computed(() => {
|
||||
if (usage.value > 90) {
|
||||
return 'error'
|
||||
} else if (usage.value > 70) {
|
||||
return 'warning'
|
||||
} else {
|
||||
return 'success'
|
||||
}
|
||||
})
|
||||
|
||||
// 计算存储使用率
|
||||
const usage = computed(() => {
|
||||
return Math.round((used.value / (total.value || 1)) * 1000) / 10
|
||||
})
|
||||
|
||||
// 查询存储信息
|
||||
async function queryStorage() {
|
||||
try {
|
||||
const data: { total: number; available: number } = await api.get(`storage/usage/${props.storage.type}`)
|
||||
total.value = data.total
|
||||
available.value = data.available
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 完成配置后的处理
|
||||
function handleDone() {
|
||||
aliyunAuthDialog.value = false
|
||||
u115AuthDialog.value = false
|
||||
rcloneConfigDialog.value = false
|
||||
emit('done')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryStorage()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VCard variant="tonal" @click="openStorageDialog">
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
|
||||
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
|
||||
<div v-else>未配置</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-5" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
|
||||
</div>
|
||||
</VCard>
|
||||
<AliyunAuthDialog
|
||||
v-if="aliyunAuthDialog"
|
||||
v-model="aliyunAuthDialog"
|
||||
@close="aliyunAuthDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<U115AuthDialog v-if="u115AuthDialog" v-model="u115AuthDialog" @close="u115AuthDialog = false" @done="handleDone" />
|
||||
<RcloneConfigDialog
|
||||
v-if="rcloneConfigDialog"
|
||||
v-model="rcloneConfigDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="rcloneConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
|
||||
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'
|
||||
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
@@ -12,9 +14,15 @@ const props = defineProps({
|
||||
media: Object as PropType<Subscribe>,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save'])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -24,6 +32,12 @@ const imageLoaded = ref(false)
|
||||
// 订阅弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 订阅文件信息弹窗
|
||||
const subscribeFilesDialog = ref(false)
|
||||
|
||||
// 分享订阅弹窗
|
||||
const subscribeShareDialog = ref(false)
|
||||
|
||||
// 上一次更新时间
|
||||
const lastUpdateText = ref(props.media && props.media.last_update ? formatDateDifference(props.media.last_update) : '')
|
||||
|
||||
@@ -32,13 +46,6 @@ function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 根据 type 返回不同的图标
|
||||
function getIcon() {
|
||||
if (props.media?.type === '电影') return 'mdi-movie'
|
||||
else if (props.media?.type === '电视剧') return 'mdi-television-classic'
|
||||
else return 'mdi-help-circle'
|
||||
}
|
||||
|
||||
// 计算百分比
|
||||
function getPercentage() {
|
||||
if (props.media?.total_episode === 0) return 0
|
||||
@@ -48,16 +55,6 @@ function getPercentage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 计算文本颜色
|
||||
function getTextColor() {
|
||||
return imageLoaded.value ? 'white' : ''
|
||||
}
|
||||
|
||||
// 计算文本类
|
||||
function getTextClass() {
|
||||
return imageLoaded.value ? 'text-white' : ''
|
||||
}
|
||||
|
||||
// 删除订阅
|
||||
async function removeSubscribe() {
|
||||
try {
|
||||
@@ -84,11 +81,53 @@ async function searchSubscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
// 重置订阅
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 分享订阅
|
||||
async function shareSubscribe() {
|
||||
subscribeShareDialog.value = true
|
||||
}
|
||||
|
||||
// 编辑订阅响应
|
||||
async function editSubscribeDialog() {
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 查看文件详情
|
||||
async function viewSubscribeFiles() {
|
||||
subscribeFilesDialog.value = true
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -108,24 +147,44 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '查看详情',
|
||||
title: '详情',
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-open-in-new',
|
||||
click: () => {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
},
|
||||
prependIcon: 'mdi-information-outline',
|
||||
click: viewMediaDetail,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '取消订阅',
|
||||
title: '文件',
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
click: viewSubscribeFiles,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '重置',
|
||||
value: 5,
|
||||
props: {
|
||||
prependIcon: 'mdi-restore-alert',
|
||||
click: resetSubscribe,
|
||||
color: 'warning',
|
||||
},
|
||||
show: props.media?.type === '电视剧',
|
||||
},
|
||||
{
|
||||
title: '分享',
|
||||
value: 6,
|
||||
props: {
|
||||
prependIcon: 'mdi-share',
|
||||
click: shareSubscribe,
|
||||
color: 'success',
|
||||
},
|
||||
show: props.media?.type === '电视剧',
|
||||
},
|
||||
{
|
||||
title: '取消订阅',
|
||||
value: 7,
|
||||
props: {
|
||||
prependIcon: 'mdi-trash-can-outline',
|
||||
color: 'error',
|
||||
@@ -133,6 +192,44 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// 监听插件窗口状态变化
|
||||
watch(
|
||||
() => props.media?.page_open,
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) editSubscribeDialog()
|
||||
},
|
||||
)
|
||||
|
||||
// 计算backdrop图片地址
|
||||
const backdropUrl = computed(() => {
|
||||
const url = props.media?.backdrop || props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
})
|
||||
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
})
|
||||
|
||||
// 订阅编辑保存
|
||||
function onSubscribeEditSave() {
|
||||
subscribeEditDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// 订阅编辑取消
|
||||
function onSubscribeEditRemove() {
|
||||
subscribeEditDialog.value = false
|
||||
emit('remove')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -141,83 +238,97 @@ const dropdownItems = ref([
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col"
|
||||
class="flex flex-col rounded-lg"
|
||||
:class="{
|
||||
'outline-dashed outline-1': props.media?.best_version,
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
min-height="170"
|
||||
@click="editSubscribeDialog"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-2">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem
|
||||
v-if="item.show !== false"
|
||||
variant="plain"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.media?.backdrop || props.media?.poster"
|
||||
aspect-ratio="2/3"
|
||||
cover
|
||||
class="brightness-50"
|
||||
@load="imageLoadHandler"
|
||||
/>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</VImg>
|
||||
</template>
|
||||
<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>
|
||||
<VIcon icon="mdi-dots-vertical" :color="getTextColor()" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<div>
|
||||
<VCardText class="flex items-center">
|
||||
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<p class="clamp-text mb-0" :class="getTextClass()">
|
||||
{{ props.media?.description }}
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardText class="d-flex justify-space-between align-center flex-wrap">
|
||||
<div class="d-flex align-center">
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-clock"
|
||||
:color="getTextColor()"
|
||||
class="me-1"
|
||||
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap">
|
||||
<div class="flex align-center">
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<div v-if="props.media?.season" class="text-subtitle-2 me-4 text-white">
|
||||
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.username" icon="mdi-account" color="white" class="me-1" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear
|
||||
v-if="getPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
<span v-if="props.media?.season" class="text-subtitle-2 me-4" :class="getTextClass()"
|
||||
>{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}</span
|
||||
>
|
||||
<IconBtn v-if="props.media?.username" icon="mdi-account" :color="getTextColor()" class="me-1" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 me-4" :class="getTextClass()">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<VProgressLinear v-if="getPercentage() > 0" :model-value="getPercentage()" bg-color="success" color="success" />
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
@@ -226,18 +337,28 @@ const dropdownItems = ref([
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="props.media?.id"
|
||||
@remove="
|
||||
() => {
|
||||
emit('remove')
|
||||
subscribeEditDialog = false
|
||||
}
|
||||
"
|
||||
@save="
|
||||
() => {
|
||||
emit('save')
|
||||
subscribeEditDialog = false
|
||||
}
|
||||
"
|
||||
@remove="onSubscribeEditRemove"
|
||||
@save="onSubscribeEditSave"
|
||||
@close="subscribeEditDialog = false"
|
||||
/>
|
||||
|
||||
<!-- 订阅文件信息弹窗 -->
|
||||
<SubscribeFilesDialog
|
||||
v-if="subscribeFilesDialog"
|
||||
v-model="subscribeFilesDialog"
|
||||
:subid="props.media?.id"
|
||||
@close="subscribeFilesDialog = false"
|
||||
/>
|
||||
<!-- 分享订阅弹窗 -->
|
||||
<SubscribeShareDialog
|
||||
v-if="subscribeShareDialog"
|
||||
v-model="subscribeShareDialog"
|
||||
:sub="props.media"
|
||||
@close="subscribeShareDialog = false"
|
||||
/>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
180
src/components/cards/SubscribeShareCard.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<SubscribeShare>,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 订阅编辑弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 订阅ID
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 分享时间
|
||||
const dateText = ref(props.media && props.media?.date ? formatDateDifference(props.media.date) : '')
|
||||
|
||||
// 计算backdrop图片地址
|
||||
const backdropUrl = computed(() => {
|
||||
const url = props.media?.backdrop || props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
})
|
||||
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
})
|
||||
|
||||
// 查看媒体详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 复用订阅
|
||||
async function forkSubscribe() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
// 确认
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认添加来自 ${props.media?.share_user} 分享的订阅:${props.media?.share_title}?`,
|
||||
})
|
||||
if (!isConfirmed) return
|
||||
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
|
||||
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
|
||||
// 弹出订阅编辑弹窗
|
||||
subscribeId.value = result.data.id
|
||||
subscribeEditDialog.value = true
|
||||
} else {
|
||||
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}!`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col rounded-lg"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
min-height="170"
|
||||
@click="forkSubscribe"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</VImg>
|
||||
</template>
|
||||
<div>
|
||||
<VCardText class="flex items-center pb-1">
|
||||
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center pl-2 xl:pl-4">
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.share_title }}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.share_comment }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap">
|
||||
<div class="flex align-center">
|
||||
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
|
||||
<div class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.share_user }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="error" class="me-1" />
|
||||
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.count.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-calcdar" class="me-1" />
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="subscribeEditDialog = false"
|
||||
/>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -15,12 +14,6 @@ const props = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 更多来源界面
|
||||
const showMoreTorrents = ref(false)
|
||||
|
||||
@@ -33,11 +26,29 @@ const media = ref(props.torrent?.media_info)
|
||||
// 识别元数据
|
||||
const meta = ref(props.torrent?.meta_info)
|
||||
|
||||
// 当前下载项
|
||||
const downloadItem = ref(props.torrent)
|
||||
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<String[]>([])
|
||||
const downloaded = ref<string[]>([])
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
@@ -49,44 +60,12 @@ async function getSiteIcon() {
|
||||
}
|
||||
|
||||
// 询问并添加下载
|
||||
async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
|
||||
if (!_media || !_torrent || !_site) {
|
||||
_site = torrent.value?.site_name
|
||||
_media = media.value
|
||||
_torrent = torrent.value
|
||||
async function handleAddDownload(item: Context | null = null) {
|
||||
if (item && !isNullOrEmptyObject(item)) {
|
||||
downloadItem.value = item
|
||||
}
|
||||
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认下载【${_site}】${_torrent?.title} ?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
addDownload(_media, _torrent)
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('download/', {
|
||||
media_in: _media,
|
||||
torrent_in: _torrent,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
||||
downloaded.value.push(_torrent?.enclosure || '')
|
||||
} else {
|
||||
// 添加下载失败
|
||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
// 打开下载对话框
|
||||
addDownloadDialog.value = true
|
||||
}
|
||||
|
||||
// 打开种子详情页面
|
||||
@@ -114,127 +93,137 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
<template v-if="!showMoreTorrents" #image>
|
||||
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardItem class="py-1">
|
||||
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
|
||||
{{ media?.title }} {{ meta?.season_episode }}
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
</VCardTitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail()">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<div>
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
>
|
||||
<template v-if="!showMoreTorrents" #image>
|
||||
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="text-subtitle-2">
|
||||
{{ torrent?.title }}
|
||||
</VCardText>
|
||||
<VCardText>{{ torrent?.description }}</VCardText>
|
||||
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
|
||||
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ torrent?.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in torrent?.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
||||
{{ meta?.video_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
||||
{{ formatFileSize(torrent?.size) }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ meta?.resource_team }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</VChip>
|
||||
</VCardItem>
|
||||
<VCardActions>
|
||||
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
|
||||
<VCardItem class="py-1">
|
||||
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
|
||||
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
</VCardTitle>
|
||||
<template #append>
|
||||
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail()">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
更多来源
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
<VExpandTransition>
|
||||
<div v-show="showMoreTorrents">
|
||||
<VDivider />
|
||||
<VChipGroup class="p-3" column>
|
||||
<VChip
|
||||
v-for="(item, index) in props.more"
|
||||
:key="index"
|
||||
@click.stop="handleAddDownload(item.torrent_info?.site_name, item.media_info, item.torrent_info)"
|
||||
>
|
||||
<template #append>
|
||||
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
|
||||
<VBadge
|
||||
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||
:content="item.torrent_info?.volume_factor"
|
||||
inline
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
{{ item.torrent_info.site_name }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
</VCard>
|
||||
</VCardItem>
|
||||
<VCardText class="text-subtitle-2">
|
||||
{{ torrent?.title }}
|
||||
</VCardText>
|
||||
<VCardText>【{{ torrent?.site_name }}】{{ torrent?.description }}</VCardText>
|
||||
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
|
||||
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ torrent?.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in torrent?.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
||||
{{ meta?.video_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
||||
{{ formatFileSize(torrent?.size) }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ meta?.resource_team }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</VChip>
|
||||
</VCardItem>
|
||||
<VCardActions>
|
||||
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
|
||||
<template #append>
|
||||
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
|
||||
</template>
|
||||
更多来源
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
<VExpandTransition>
|
||||
<div v-show="showMoreTorrents">
|
||||
<VDivider />
|
||||
<VChipGroup class="p-3" column>
|
||||
<VChip v-for="(item, index) in props.more" :key="index" @click.stop="handleAddDownload(item)">
|
||||
<template #append>
|
||||
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
|
||||
<VBadge
|
||||
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||
:content="item.torrent_info?.volume_factor"
|
||||
inline
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
{{ item.torrent_info.site_name }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
</VCard>
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
:title="`${downloadItem?.media_info?.title_year || downloadItem?.meta_info?.name} ${
|
||||
downloadItem?.meta_info?.season_episode
|
||||
}`"
|
||||
:media="downloadItem?.media_info"
|
||||
:torrent="downloadItem?.torrent_info"
|
||||
@done="addDownloadSuccess"
|
||||
@error="addDownloadError"
|
||||
@close="addDownloadDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
torrent: Object as PropType<Context>,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 更多来源界面
|
||||
const showMoreTorrents = ref(false)
|
||||
|
||||
@@ -34,7 +26,10 @@ const meta = ref(props.torrent?.meta_info)
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<String[]>([])
|
||||
const downloaded = ref<string[]>([])
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
@@ -46,44 +41,21 @@ async function getSiteIcon() {
|
||||
}
|
||||
|
||||
// 询问并添加下载
|
||||
async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
|
||||
if (!_media || !_torrent || !_site) {
|
||||
_site = torrent.value?.site_name
|
||||
_media = media.value
|
||||
_torrent = torrent.value
|
||||
}
|
||||
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认下载【${_site}】${_torrent?.title} ?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
addDownload(_media, _torrent)
|
||||
async function handleAddDownload() {
|
||||
// 打开下载对话框
|
||||
addDownloadDialog.value = true
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('download/', {
|
||||
media_in: _media,
|
||||
torrent_in: _torrent,
|
||||
})
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
||||
downloaded.value.push(_torrent?.enclosure || '')
|
||||
} else {
|
||||
// 添加下载失败
|
||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 打开种子详情页面
|
||||
@@ -111,88 +83,101 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VListItem @click="handleAddDownload" :variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'">
|
||||
<template v-if="!showMoreTorrents" #prepend>
|
||||
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
|
||||
{{ torrent?.title }}
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
{{ torrent?.description }}
|
||||
</VListItemSubtitle>
|
||||
<div v-if="torrent?.labels" class="pt-2">
|
||||
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ torrent?.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in torrent?.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
||||
{{ meta?.video_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
||||
{{ formatFileSize(torrent?.size) }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ meta?.resource_team }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail()">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<div>
|
||||
<VListItem
|
||||
@click="handleAddDownload"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
>
|
||||
<template v-if="!showMoreTorrents" #prepend>
|
||||
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
|
||||
{{ torrent?.title }}
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle> 【{{ torrent?.site_name }}】{{ torrent?.description }} </VListItemSubtitle>
|
||||
<div v-if="torrent?.labels" class="pt-2">
|
||||
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ torrent?.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in torrent?.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
||||
{{ meta?.video_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
||||
{{ formatFileSize(torrent?.size) }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ meta?.resource_team }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail()">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
:title="`${media?.title_year || meta?.name} ${meta?.season_episode}`"
|
||||
:media="media"
|
||||
:torrent="torrent"
|
||||
@done="addDownloadSuccess"
|
||||
@error="addDownloadError"
|
||||
@close="addDownloadDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
192
src/components/cards/UserCard.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { Subscribe, User } from '@/api/types'
|
||||
import store from '@/store'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
||||
|
||||
// 定义输入变量
|
||||
const props = defineProps({
|
||||
// 用户信息
|
||||
user: {
|
||||
type: Object as PropType<User>,
|
||||
required: true,
|
||||
},
|
||||
// 所有用户
|
||||
users: {
|
||||
type: Array as PropType<User[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 当前用户的ID
|
||||
const currentLoginUserId = computed(() => store.state.auth.userID)
|
||||
|
||||
// 当前用户是否是管理员
|
||||
const currentUserIsSuperuser = computed(() => store.state.auth.superUser)
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save'])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 用户信息弹窗
|
||||
const userEditDialog = ref(false)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 用户电影订阅数量
|
||||
const movieSubscriptions = ref(0)
|
||||
|
||||
// 用户电视剧订阅数量
|
||||
const tvShowSubscriptions = ref(0)
|
||||
|
||||
// 按用户查询订阅数量
|
||||
async function fetchSubscriptions() {
|
||||
try {
|
||||
const result: Subscribe[] = await api.get(`subscribe/user/${props.user.name}`)
|
||||
if (result) {
|
||||
movieSubscriptions.value = result.filter(item => item.type === '电影').length
|
||||
tvShowSubscriptions.value = result.filter(item => item.type === '电视剧').length
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
async function removeUser() {
|
||||
if (props.user.id === currentLoginUserId.value) {
|
||||
$toast.error('不能删除当前登录用户!')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '注意',
|
||||
content: `删除用户 ${props.user?.name} 的所有数据,是否确认?`,
|
||||
})
|
||||
if (!isConfirmed) return
|
||||
const result: { [key: string]: any } = await api.delete(`user/id/${props.user.id}`)
|
||||
if (result.success) {
|
||||
$toast.success('用户删除成功')
|
||||
emit('remove')
|
||||
} else {
|
||||
$toast.error('用户删除失败!')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
function editUser() {
|
||||
userEditDialog.value = true
|
||||
}
|
||||
|
||||
// 用户重新完成时
|
||||
function onUserUpdate() {
|
||||
userEditDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSubscriptions()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VCard>
|
||||
<VCardText class="text-center pt-10 pb-3">
|
||||
<VAvatar variant="flat" size="100" rounded>
|
||||
<VImg :src="user.avatar || avatar1" alt="avatar" />
|
||||
</VAvatar>
|
||||
<h5 class="text-h5 mt-3">{{ user.name }}</h5>
|
||||
<VChip size="small" class="mt-3" :class="{ 'text-error': user.is_superuser }">
|
||||
{{ user.is_superuser ? '管理员' : '普通用户' }}
|
||||
</VChip>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-center gap-6 pb-5">
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
|
||||
<VIcon size="24" icon="mdi-movie-open-outline"></VIcon>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h6">{{ movieSubscriptions }}</div>
|
||||
<div class="text-sm text-no-wrap">电影订阅</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
|
||||
<VIcon size="24" icon="mdi-television"></VIcon>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h6">{{ tvShowSubscriptions }}</div>
|
||||
<div class="text-sm text-no-wrap">电视剧订阅</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="pb-6">
|
||||
<VDivider class="my-2">
|
||||
<h5 class="text-h6">详情</h5>
|
||||
</VDivider>
|
||||
<VList lines="one">
|
||||
<VListItem>
|
||||
<VListItemTitle class="text-sm">
|
||||
<span class="font-weight-medium">邮箱:</span><span class="text-body-1"> {{ user.email }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VListItemTitle class="text-sm">
|
||||
<span class="font-weight-medium">状态:</span
|
||||
><span class="text-body-1">
|
||||
<VChip size="small" :class="{ 'text-success': user.is_active }" variant="tonal">
|
||||
{{ user.is_active ? '激活' : '已停用' }}
|
||||
</VChip>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VListItemTitle class="text-sm">
|
||||
<span class="font-weight-medium">双重认证:</span
|
||||
><span class="text-body-1">
|
||||
<VChip size="small" :class="{ 'text-success': user.is_otp }" variant="tonal">
|
||||
{{ user.is_otp ? '已启用' : '未启用' }}
|
||||
</VChip>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<VCardText class="flex flex-row justify-center">
|
||||
<VBtn
|
||||
v-if="currentUserIsSuperuser"
|
||||
color="primary"
|
||||
class="me-4"
|
||||
@click="editUser"
|
||||
>
|
||||
编辑
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="currentUserIsSuperuser && props.user.id != currentLoginUserId"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
@click="removeUser"
|
||||
>
|
||||
删除
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 用户编辑弹窗 -->
|
||||
<UserAddEditDialog
|
||||
v-if="userEditDialog"
|
||||
v-model="userEditDialog"
|
||||
:username="props.user?.name"
|
||||
:usernames="props.users.map(item => item.name)"
|
||||
oper="edit"
|
||||
@save="onUserUpdate"
|
||||
@close="userEditDialog = false"
|
||||
/>
|
||||
</template>
|
||||
172
src/components/dialog/AddDownloadDialog.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { VCardTitle } from 'vuetify/lib/components/index.mjs'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
media: Object as PropType<MediaInfo>,
|
||||
torrent: Object as PropType<TorrentInfo>,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 选择的下载器
|
||||
const selectedDownloader = ref<string | null>(null)
|
||||
|
||||
// 选择的保存目录
|
||||
const selectedDirectory = ref<string | null>(null)
|
||||
|
||||
// 定义成功和失败事件
|
||||
const emit = defineEmits(['done', 'error', 'close'])
|
||||
|
||||
// 所有目录设置
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
|
||||
// 是否正在加载
|
||||
const loading = ref(false)
|
||||
|
||||
// 计算按钮图标
|
||||
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
|
||||
|
||||
// 计算按钮文字
|
||||
const buttonText = computed(() => (loading.value ? '下载中...' : '开始下载'))
|
||||
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取保存目录
|
||||
const targetDirectories = computed(() => {
|
||||
const downloadDirectories = directories.value.map(item => item.download_path)
|
||||
return [...new Set(downloadDirectories)]
|
||||
})
|
||||
|
||||
// 下载器
|
||||
const downloaders = ref<DownloaderConf[]>([])
|
||||
|
||||
// 调用API查询下载器设置
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Downloaders')
|
||||
downloaders.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 下载器可选项
|
||||
const downloaderOptions = computed(() => {
|
||||
return downloaders.value.map(item => ({
|
||||
title: item.name,
|
||||
value: item.name,
|
||||
}))
|
||||
})
|
||||
|
||||
// 添加下载
|
||||
async function addDownload() {
|
||||
startNProgress()
|
||||
loading.value = true
|
||||
try {
|
||||
let result: { [key: string]: any }
|
||||
|
||||
const payload: any = {
|
||||
torrent_in: props.torrent,
|
||||
downloader: selectedDownloader.value,
|
||||
save_path: selectedDirectory.value,
|
||||
}
|
||||
|
||||
if (props.media) {
|
||||
payload.media_in = props.media
|
||||
}
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
|
||||
result = await api.post(endpoint, payload)
|
||||
|
||||
if (result && result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(`${props.torrent?.site_name} ${props.torrent?.title} 下载成功!`)
|
||||
// 下载成功,返回链接
|
||||
emit('done', props.torrent?.enclosure)
|
||||
} else {
|
||||
// 添加下载失败
|
||||
$toast.error(`${props.torrent?.site_name} ${props.torrent?.title} 下载失败:${result?.message}!`)
|
||||
// 下载失败,返回错误原因
|
||||
emit('error', result?.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
loading.value = false
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDirectories()
|
||||
loadDownloaderSetting()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="40rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle v-if="title">下载 - {{ title }}</VCardTitle>
|
||||
<VCardTitle v-else>确认下载</VCardTitle>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" class="text-lg text-high-emphasis pb-0">
|
||||
<div><strong>站点:</strong>{{ props.torrent?.site_name }}</div>
|
||||
<div><strong>标题:</strong>{{ props.torrent?.title }}</div>
|
||||
<div><strong>描述:</strong>{{ props.torrent?.description }}</div>
|
||||
<div><strong>大小:</strong>{{ formatFileSize(props.torrent?.size || 0) }}</div>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="selectedDownloader"
|
||||
:items="downloaderOptions"
|
||||
label="下载器"
|
||||
variant="underlined"
|
||||
placeholder="默认"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="8">
|
||||
<VCombobox
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
label="保存目录"
|
||||
placeholder="自动"
|
||||
variant="underlined"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardText class="text-center">
|
||||
<VBtn
|
||||
variant="elevated"
|
||||
:disabled="loading"
|
||||
@click="addDownload"
|
||||
:prepend-icon="icon"
|
||||
class="px-5"
|
||||
size="large"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
106
src/components/dialog/AliyunAuthDialog.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<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('/storage/qrcode/alipan')
|
||||
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('/storage/check/alipan', {
|
||||
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>
|
||||
68
src/components/dialog/PluginMarketSettingDialog.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
})
|
||||
|
||||
const $toast = useToast()
|
||||
|
||||
// 插件仓库设置字符串
|
||||
const repoString = ref('')
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
// 查询已设置的插件仓库
|
||||
async function queryMarketRepoSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
|
||||
if (result && result.data && result.data.value) repoString.value = result.data.value
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
async function saveHandle() {
|
||||
try {
|
||||
// 用户名密码
|
||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoString.value)
|
||||
|
||||
if (result.success) {
|
||||
$toast.success('插件仓库保存成功')
|
||||
emit('save')
|
||||
} else $toast.error('插件仓库保存失败!')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryMarketRepoSetting()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog width="50rem" scrollable max-height="85vh">
|
||||
<VCard title="插件仓库设置" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText class="pt-2">
|
||||
<VTextarea
|
||||
v-model="repoString"
|
||||
placeholder="格式:https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/"
|
||||
hint="多个地址使用逗号分隔,仅支持Github仓库"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
|
||||
保存
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
66
src/components/dialog/RcloneConfigDialog.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
conf: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!props.conf.filepath) {
|
||||
props.conf.filepath = '/moviepilot/.config/rclone/rclone.conf'
|
||||
}
|
||||
|
||||
if (!props.conf.content) {
|
||||
props.conf.content = '# 请在此处填写rclone配置文件内容 \n# 请参考 https://rclone.org/docs/ \n# 存储节点名必须为:MP'
|
||||
}
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
await savaRcloneConfig()
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 保存rclone设置
|
||||
async function savaRcloneConfig() {
|
||||
try {
|
||||
await api.post(`storage/save/rclone`, props.conf)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog width="50rem" scrollable max-height="85vh">
|
||||
<VCard title="Rclone网盘配置" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="props.conf.filepath" label="rclone配置文件路径" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAceEditor
|
||||
v-model:value="props.conf.content"
|
||||
lang="ini"
|
||||
theme="monokai"
|
||||
style="block-size: 30rem"
|
||||
class="rounded"
|
||||
>
|
||||
</VAceEditor>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -1,23 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import store from '@/store'
|
||||
import api from '@/api'
|
||||
import { storageOptions } from '@/api/constants'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
import { MediaDirectory } from '@/api/types'
|
||||
import { FileItem, TransferDirectoryConf } from '@/api/types'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
path: String,
|
||||
target: String,
|
||||
logids: Array<number>,
|
||||
items: Array<FileItem>,
|
||||
target_storage: String,
|
||||
target_path: String,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.data?.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
@@ -29,9 +36,6 @@ const seasonItems = ref(
|
||||
})),
|
||||
)
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref('themoviedb')
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -50,11 +54,23 @@ const progressText = ref('请稍候 ...')
|
||||
// 整理进度
|
||||
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({
|
||||
fileitem: {},
|
||||
logid: 0,
|
||||
path: '',
|
||||
target: props.target ?? null,
|
||||
target_storage: props.target_storage ?? 'local',
|
||||
target_path: props.target_path ?? null,
|
||||
tmdbid: null,
|
||||
doubanid: null,
|
||||
season: null,
|
||||
@@ -66,29 +82,35 @@ const transferForm = reactive({
|
||||
episode_offset: null,
|
||||
min_filesize: 0,
|
||||
scrape: false,
|
||||
from_history: false,
|
||||
})
|
||||
|
||||
// 所有媒体库目录
|
||||
const libraryDirectories = ref<MediaDirectory[]>([])
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
|
||||
// 查询目录
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 目的目录下拉框
|
||||
const targetDirectories = computed(() => {
|
||||
const directories = libraryDirectories.value.map(item => item.path)
|
||||
return [...new Set(directories)]
|
||||
const libraryDirectories = directories.value.map(item => item.library_path)
|
||||
return [...new Set(libraryDirectories)]
|
||||
})
|
||||
|
||||
// 监听输入变化
|
||||
watchEffect(() => {
|
||||
transferForm.path = props.path ?? ''
|
||||
transferForm.target = props.target ?? null
|
||||
})
|
||||
|
||||
// 监听目的路径变化,自动查询目录的刮削配置
|
||||
// 监听目的路径变化,自动查询目录削配置
|
||||
watch(transferForm, async () => {
|
||||
if (transferForm.target) {
|
||||
const directory = libraryDirectories.value.find(item => item.path === transferForm.target)
|
||||
if (transferForm.target_path) {
|
||||
const directory = directories.value.find(item => item.library_path === transferForm.target_path)
|
||||
if (directory) {
|
||||
transferForm.scrape = directory.scrape ?? false
|
||||
transferForm.scrape = directory.scraping ?? false
|
||||
transferForm.transfer_type = directory.transfer_type ?? ''
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -96,12 +118,7 @@ watch(transferForm, async () => {
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
progressText.value = '请稍候 ...'
|
||||
|
||||
const token = store.state.auth.token
|
||||
|
||||
progressEventSource.value = new EventSource(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer?token=${token}`,
|
||||
)
|
||||
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
|
||||
progressEventSource.value.onmessage = event => {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
@@ -117,47 +134,25 @@ function stopLoadingProgress() {
|
||||
}
|
||||
|
||||
// 整理文件
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
async function transfer() {
|
||||
if (!props.logids && !props.path) return
|
||||
if (!props.logids && !props.items) return
|
||||
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
// 开始监听进度
|
||||
startLoadingProgress()
|
||||
|
||||
if (props.path) {
|
||||
// 文件整理
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(
|
||||
'transfer/manual',
|
||||
{},
|
||||
{
|
||||
params: transferForm,
|
||||
},
|
||||
)
|
||||
// 显示结果
|
||||
if (result.success) $toast.success(`${props.path} 整理完成!`)
|
||||
else $toast.error(`${props.path} 整理失败:${result.message}!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
// 文件整理
|
||||
if (props.items) {
|
||||
for (const item of props.items) {
|
||||
await handleTransfer(item)
|
||||
}
|
||||
} else if (props.logids) {
|
||||
// 日志整理
|
||||
}
|
||||
|
||||
// 日志整理
|
||||
if (props.logids) {
|
||||
for (const logid of props.logids) {
|
||||
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)
|
||||
}
|
||||
await handleTransferLog(logid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,55 +164,54 @@ async function transfer() {
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 调用API,加载当前系统环境设置
|
||||
async function loadSystemSettings() {
|
||||
// 整理文件
|
||||
async function handleTransfer(item: FileItem) {
|
||||
transferForm.fileitem = item
|
||||
transferForm.logid = 0
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env')
|
||||
if (result) mediaSource.value = result.data?.RECOGNIZE_SOURCE || 'themoviedb'
|
||||
const result: { [key: string]: any } = await api.post('transfer/manual', transferForm)
|
||||
if (!result.success) $toast.error(`文件 ${item.path} 整理失败:${result.message}!`)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询媒体库目录
|
||||
async function loadLibraryDirectories() {
|
||||
// 整理日志
|
||||
async function handleTransferLog(logid: number) {
|
||||
transferForm.logid = logid
|
||||
transferForm.fileitem = {}
|
||||
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)
|
||||
const result: { [key: string]: any } = await api.post('transfer/manual', transferForm)
|
||||
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSystemSettings()
|
||||
loadLibraryDirectories()
|
||||
loadDirectories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${props.path ? `整理 - ${props.path}` : `整理 - 共 ${props.logids?.length} 条记录`}`"
|
||||
class="rounded-t"
|
||||
>
|
||||
<VCard :title="dialogTitle" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VCombobox
|
||||
v-model="transferForm.target"
|
||||
:items="targetDirectories"
|
||||
label="目的路径"
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="transferForm.target_storage"
|
||||
:items="storageOptions"
|
||||
label="目的存储"
|
||||
placeholder="留空自动"
|
||||
hint="留空将自动匹配目标路径"
|
||||
hint="整理目的存储"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="transferForm.transfer_type"
|
||||
label="整理方式"
|
||||
@@ -227,9 +221,19 @@ onMounted(() => {
|
||||
{ title: '复制', value: 'copy' },
|
||||
{ title: '硬链接', value: 'link' },
|
||||
{ title: '软链接', value: 'softlink' },
|
||||
{ title: 'Rclone复制', value: 'rclone_copy' },
|
||||
{ title: 'Rclone移动', value: 'rclone_move' },
|
||||
]"
|
||||
hint="文件操作整理方式"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="12">
|
||||
<VCombobox
|
||||
v-model="transferForm.target_path"
|
||||
:items="targetDirectories"
|
||||
label="目的路径"
|
||||
placeholder="留空自动"
|
||||
hint="整理目的路径,留空将自动匹配"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -243,6 +247,8 @@ onMounted(() => {
|
||||
{ title: '电影', value: '电影' },
|
||||
{ title: '电视剧', value: '电视剧' },
|
||||
]"
|
||||
hint="文件的媒体类型"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -254,7 +260,8 @@ onMounted(() => {
|
||||
placeholder="留空自动识别"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
hint="点击图标按名称搜索,留空将自动重新识别"
|
||||
hint="按名称查询媒体编号,留空自动识别"
|
||||
persistent-hint
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
@@ -265,7 +272,8 @@ onMounted(() => {
|
||||
placeholder="留空自动识别"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
hint="点击图标按名称搜索,留空将自动重新识别"
|
||||
hint="按名称查询媒体编号,留空自动识别"
|
||||
persistent-hint
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -275,6 +283,8 @@ onMounted(() => {
|
||||
v-model.number="transferForm.season"
|
||||
label="季"
|
||||
:items="seasonItems"
|
||||
hint="指定季数"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -284,7 +294,8 @@ onMounted(() => {
|
||||
v-model="transferForm.episode_format"
|
||||
label="集数定位"
|
||||
placeholder="使用{ep}定位集数"
|
||||
hint="使用{ep}定位文件名中的集数部分,其余相同部分直接填写,不同部分使用{a}进行忽略,例如:{a}葬送的芙莉莲_Sousou no Frieren 第{ep}话{b}"
|
||||
hint="使用{ep}定位文件名中的集数部分以辅助识别"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -292,7 +303,8 @@ onMounted(() => {
|
||||
v-model="transferForm.episode_detail"
|
||||
label="指定集数"
|
||||
placeholder="起始集,终止集,如1或1,2"
|
||||
hint="直接指定集数或者范围,格式:起始集,终止集,如1或1,2"
|
||||
hint="指定集数或范围,如1或1,2"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -300,7 +312,8 @@ onMounted(() => {
|
||||
v-model="transferForm.episode_part"
|
||||
label="指定Part"
|
||||
placeholder="如part1"
|
||||
hint="指定集数的Part,如part1"
|
||||
hint="指定Part,如part1"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -308,7 +321,8 @@ onMounted(() => {
|
||||
v-model.number="transferForm.episode_offset"
|
||||
label="集数偏移"
|
||||
placeholder="如-10"
|
||||
hint="对集数进行偏移运算,如-10表示文件名中的集数减10为整理后集数"
|
||||
hint="集数偏移运算,如-10或EP*2"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -317,13 +331,27 @@ onMounted(() => {
|
||||
label="最小文件大小(MB)"
|
||||
:rules="[numberValidator]"
|
||||
placeholder="0"
|
||||
hint="最小文件大小,小于此大小的文件将被忽略不进行整理"
|
||||
hint="只整理大于最小文件大小的文件"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="transferForm.scrape" label="刮削元数据" hint="整理完成后自动刮削元数据" />
|
||||
<VSwitch
|
||||
v-model="transferForm.scrape"
|
||||
label="刮削元数据"
|
||||
hint="整理完成后自动刮削元数据"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="props.logids">
|
||||
<VSwitch
|
||||
v-model="transferForm.from_history"
|
||||
label="复用历史识别信息"
|
||||
hint="使用历史记录中已识别的媒体信息"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
|
||||
@@ -40,6 +40,12 @@ const siteForm = ref<Site>({
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 维护类型
|
||||
const siteType = ref('cookie')
|
||||
|
||||
// 是否限流
|
||||
const isLimit = ref(false)
|
||||
|
||||
// 状态下拉项
|
||||
const statusItems = [
|
||||
{ title: '启用', value: true },
|
||||
@@ -54,11 +60,6 @@ const priorityItems = ref(
|
||||
})),
|
||||
)
|
||||
|
||||
// 监控输入参数
|
||||
watchEffect(async () => {
|
||||
if (props.siteid) fetchSiteInfo()
|
||||
})
|
||||
|
||||
// 查询站点信息
|
||||
async function fetchSiteInfo() {
|
||||
try {
|
||||
@@ -111,6 +112,15 @@ async function deleteSiteInfo() {
|
||||
async function updateSiteInfo() {
|
||||
startNProgress()
|
||||
try {
|
||||
if (isLimit.value) {
|
||||
siteForm.value.limit_interval = siteForm.value.limit_interval || 0
|
||||
siteForm.value.limit_count = siteForm.value.limit_count || 0
|
||||
siteForm.value.limit_seconds = siteForm.value.limit_seconds || 0
|
||||
} else {
|
||||
siteForm.value.limit_interval = 0
|
||||
siteForm.value.limit_count = 0
|
||||
siteForm.value.limit_seconds = 0
|
||||
}
|
||||
const result: { [key: string]: any } = await api.put('site/', siteForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`${siteForm.value?.name} 更新成功!`)
|
||||
@@ -124,6 +134,15 @@ async function updateSiteInfo() {
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.oper !== 'add') {
|
||||
await fetchSiteInfo()
|
||||
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
|
||||
isLimit.value = true
|
||||
if (siteForm.value.apikey) siteType.value = 'api'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -143,6 +162,7 @@ async function updateSiteInfo() {
|
||||
label="站点地址"
|
||||
:rules="[requiredValidator]"
|
||||
hint="格式:http://www.example.com/"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
@@ -151,11 +171,18 @@ async function updateSiteInfo() {
|
||||
label="优先级"
|
||||
:items="priorityItems"
|
||||
:rules="[requiredValidator]"
|
||||
hint="站点资源下载优先级,优先级数字越小越优先下载"
|
||||
hint="优先级越小越优先"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<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>
|
||||
</VRow>
|
||||
<VRow>
|
||||
@@ -163,44 +190,83 @@ async function updateSiteInfo() {
|
||||
<VTextField
|
||||
v-model="siteForm.rss"
|
||||
label="RSS地址"
|
||||
hint="订阅模式为站点RSS时,将会使用此地址获取站点种子资源,该地址一般会自动获取,也可手动补充"
|
||||
hint="订阅模式为`站点RSS`时使用的订阅链接,如未自动获取需手动补充"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间,为空将使用默认值" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="siteForm.cookie"
|
||||
label="站点Cookie"
|
||||
hint="浏览器打开站点首页,打开开发人员工具,刷新页面后在网络选项中找到首页地址,在请求头中获取Cookie信息"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="siteForm.token"
|
||||
label="请求头(Authorization)"
|
||||
hint="在开发人员工具,网络请求头中获取Authorization,仅个别站点需要"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="siteForm.apikey" label="令牌(API Key)" hint="站点的访问API Key,仅个别站点需要" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="siteForm.ua"
|
||||
label="站点User-Agent"
|
||||
hint="在开发人员工具,网络请求头中获取User-Agent信息,需与站点Cookie配套使用"
|
||||
/>
|
||||
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
|
||||
<VTab selected-class="v-tab--selected">
|
||||
<div>
|
||||
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
|
||||
Cookie
|
||||
</div>
|
||||
</VTab>
|
||||
<VTab selected-class="v-tab--selected">
|
||||
<div>
|
||||
<VIcon size="20" start icon="mdi-api" value="api" />
|
||||
API
|
||||
</div>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
<VWindow v-model="siteType" class="my-3 disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="cookie">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="siteForm.cookie"
|
||||
label="站点Cookie"
|
||||
hint="站点请求头中的Cookie信息"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="siteForm.ua"
|
||||
label="站点User-Agent"
|
||||
hint="获取Cookie的浏览器对应的User-Agent"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="api">
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<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>
|
||||
</VRow>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch v-model="isLimit" label="限制站点访问频率" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="isLimit">
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="siteForm.limit_interval"
|
||||
label="单位周期(秒)"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定站点限流的单位周期,单位为秒,0为不限流"
|
||||
hint="限流控制的单位周期时长"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -208,7 +274,8 @@ async function updateSiteInfo() {
|
||||
v-model="siteForm.limit_count"
|
||||
label="周期内访问次数"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定单位周期内站点允许的访问次数,0为不限制"
|
||||
hint="单位周期内允许的访问次数"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -216,19 +283,21 @@ async function updateSiteInfo() {
|
||||
v-model="siteForm.limit_seconds"
|
||||
label="访问间隔(秒)"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定单位周期内每次站点访问需间隔时间,单位为秒,0为不限制"
|
||||
hint="每次访问需要间隔的最小时间"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="siteForm.proxy" label="代理" hint="站点是否需要代理访问,需要设置好代理服务器信息" />
|
||||
<VSwitch v-model="siteForm.proxy" label="使用代理访问" hint="使用代理服务器访问该站点" persistent-hint />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="siteForm.render"
|
||||
label="仿真"
|
||||
hint="站点是否需要使用浏览器模拟访问,开启可以一定程度上提升连通性,但会大大增加站点请求时间"
|
||||
label="浏览器仿真"
|
||||
hint="使用浏览器模拟真实访问该站点"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
114
src/components/dialog/SiteCookieUpdateDialog.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { Site } from '@/api/types'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const cardProps = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'done'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 用户名密码表单
|
||||
const userPwForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
code: '',
|
||||
})
|
||||
|
||||
// 密码输入
|
||||
const isPasswordVisible = ref(false)
|
||||
|
||||
// 更新按钮可用性
|
||||
const updateButtonDisable = ref(false)
|
||||
|
||||
// 进度条
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度文本
|
||||
const progressText = ref('请稍候 ...')
|
||||
|
||||
// 调用API,更新站点Cookie UA
|
||||
async function updateSiteCookie() {
|
||||
try {
|
||||
if (!userPwForm.value.username || !userPwForm.value.password) return
|
||||
|
||||
// 更新按钮状态
|
||||
updateButtonDisable.value = true
|
||||
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
|
||||
params: {
|
||||
username: userPwForm.value.username,
|
||||
password: userPwForm.value.password,
|
||||
code: userPwForm.value.code,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
|
||||
emit('done')
|
||||
} else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
|
||||
|
||||
progressDialog.value = false
|
||||
updateButtonDisable.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="50rem">
|
||||
<!-- Dialog Content -->
|
||||
<VCard title="更新站点Cookie & UA">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<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>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="elevated"
|
||||
@click="updateSiteCookie"
|
||||
:disabled="updateButtonDisable"
|
||||
prepend-icon="mdi-refresh"
|
||||
class="px-5"
|
||||
>
|
||||
开始更新
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
</VDialog>
|
||||
</template>
|
||||
222
src/components/dialog/SiteResourceDialog.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { Site } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
})
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 搜索
|
||||
const resourceSearch = ref('')
|
||||
|
||||
// 总条数
|
||||
const resourceTotalItems = ref(0)
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 种子元数据
|
||||
const torrent = ref<TorrentInfo>()
|
||||
|
||||
// 资源浏览表头
|
||||
const resourceHeaders = [
|
||||
{ title: '标题', key: 'title', sortable: false },
|
||||
{ title: '时间', key: 'pubdate', sortable: true },
|
||||
{ title: '大小', key: 'size', sortable: true },
|
||||
{ title: '做种', key: 'seeders', sortable: true },
|
||||
{ title: '下载', key: 'peers', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
window.open(page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile(enclosure: string) {
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`)
|
||||
resourceLoading.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
else return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_torrent: any) {
|
||||
torrent.value = _torrent
|
||||
addDownloadDialog.value = true
|
||||
}
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="80rem" scrollable z-index="1010" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`浏览 - ${props.site?.name}`">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText class="pt-2">
|
||||
<VDataTable
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
:items="resourceDataList"
|
||||
:items-length="resourceTotalItems"
|
||||
:search="resourceSearch"
|
||||
:loading="resourceLoading"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
hover
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-sm my-1">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</a>
|
||||
</template>
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.date_elapsed }}</div>
|
||||
<div class="text-sm">
|
||||
{{ item.pubdate }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.size="{ item }">
|
||||
<div class="text-nowrap whitespace-nowrap">
|
||||
{{ formatFileSize(item.size) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.seeders="{ item }">
|
||||
<div>{{ item.seeders }}</div>
|
||||
</template>
|
||||
<template #item.peers="{ item }">
|
||||
<div>{{ item.peers }}</div>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail(item.page_url || '')">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTable>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 添加下载对话框 -->
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
:torrent="torrent"
|
||||
@done="addDownloadSuccess"
|
||||
@error="addDownloadError"
|
||||
@close="addDownloadDialog = false"
|
||||
/>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
436
src/components/dialog/SiteUserDataDialog.vue
Normal file
@@ -0,0 +1,436 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Site, SiteUserData } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { VAvatar, VCardText, VIcon } from 'vuetify/lib/components/index.mjs'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
})
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const vuetifyTheme = useTheme()
|
||||
|
||||
const currentTheme = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
() => vuetifyTheme.current.value.colors,
|
||||
)
|
||||
|
||||
// 站点数据列表
|
||||
const siteDatas = ref<SiteUserData[]>([])
|
||||
|
||||
// 最新一天的数据,按时间倒序排序后取第一条记录
|
||||
const siteData = computed(() => siteDatas.value[0])
|
||||
|
||||
// 站点数据列表中的上传量、下载量数据生成图形使用的数据
|
||||
const historySeries = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: '上传量',
|
||||
data: siteDatas.value.map(item => Math.round((item.upload ?? 0) / 1024 / 1024 / 1024)),
|
||||
},
|
||||
{
|
||||
name: '下载量',
|
||||
data: siteDatas.value.map(item => Math.round((item.download ?? 0) / 1024 / 1024 / 1024)),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// 图形选项
|
||||
const historyChartOptions = computed(() => {
|
||||
return {
|
||||
chart: {
|
||||
type: 'area',
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true },
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
},
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
tooltip: {
|
||||
x: {
|
||||
format: 'dd MMM yyyy',
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
lines: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'GB',
|
||||
},
|
||||
lines: { show: true },
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
width: 3,
|
||||
lineCap: 'butt',
|
||||
curve: 'smooth',
|
||||
},
|
||||
colors: [currentTheme.value.success, currentTheme.value.warning],
|
||||
markers: {
|
||||
size: 0,
|
||||
style: 'hollow',
|
||||
},
|
||||
xaxis: {
|
||||
type: 'category',
|
||||
categories: siteDatas.value.map(item => item.updated_day),
|
||||
labels: {
|
||||
show: true,
|
||||
formatter: function (val: string) {
|
||||
return new Date(val).toLocaleDateString('zh-CN')
|
||||
},
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'GB',
|
||||
},
|
||||
labels: {
|
||||
formatter: function (val: number) {
|
||||
return val.toLocaleString()
|
||||
},
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0.7,
|
||||
stops: [0, 100],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// 做种分布列,seeding_info的格式为[[x, y], [x, y], ...],x为做种数,y为做种体积,做种体积需要转换为GB
|
||||
const seedingSeries = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: '体积',
|
||||
data: siteData.value?.seeding_info?.map(item => [item[0] ?? 0, Math.round((item[1] ?? 0) / 1024 / 1024 / 1024)]),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// 做种分布图形选项
|
||||
const seedingChartOptions = computed(() => {
|
||||
return {
|
||||
chart: {
|
||||
type: 'scatter',
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true },
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
x: {
|
||||
formatter: function (val: number) {
|
||||
return '数量:' + val.toLocaleString()
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
lines: { show: true },
|
||||
},
|
||||
yaxis: {
|
||||
lines: { show: true },
|
||||
},
|
||||
},
|
||||
colors: [currentTheme.value.primary],
|
||||
xaxis: {
|
||||
type: 'numeric',
|
||||
labels: {
|
||||
show: true,
|
||||
formatter: function (val: number) {
|
||||
return Math.round(val).toLocaleString()
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: '数量',
|
||||
},
|
||||
tickAmount: 10,
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'GB',
|
||||
},
|
||||
labels: {
|
||||
formatter: function (val: number) {
|
||||
return val.toLocaleString() + ' GB'
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// 根据传入属性,计算列表数据中第一条与第二条的差值,如果没有第二条则差值为全部
|
||||
const diffData: { [key: string]: any } = computed(() => {
|
||||
if (siteDatas.value.length < 2) {
|
||||
return siteData.value
|
||||
}
|
||||
const first = siteDatas.value[siteDatas.value.length - 1]
|
||||
const second = siteDatas.value[siteDatas.value.length - 2]
|
||||
return {
|
||||
bonus: (first.bonus ?? 0) - (second.bonus ?? 0),
|
||||
ratio: (first.ratio ?? 0) - (second.ratio ?? 0),
|
||||
upload: (first.upload ?? 0) - (second.upload ?? 0),
|
||||
download: (first.download ?? 0) - (second.download ?? 0),
|
||||
seeding: (first.seeding ?? 0) - (second.seeding ?? 0),
|
||||
seeding_size: (first.seeding_size ?? 0) - (second.seeding_size ?? 0),
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化差值
|
||||
function getDiffString(diff: number | undefined, format: boolean = true) {
|
||||
if (diff === undefined) {
|
||||
return '0'
|
||||
}
|
||||
if (format) {
|
||||
return diff >= 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()
|
||||
}
|
||||
return diff >= 0 ? `+${diff}` : diff
|
||||
}
|
||||
|
||||
// 根据差值的正负,返回不同的样式
|
||||
function getDiffClass(diff: number | undefined) {
|
||||
if (diff === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (diff == 0) {
|
||||
return ''
|
||||
}
|
||||
return diff > 0 ? 'text-success' : 'text-error'
|
||||
}
|
||||
|
||||
// 查询站点用户数据
|
||||
async function fetchSiteUserData() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`site/userdata/${props.site?.id}`)
|
||||
if (result.success) {
|
||||
siteDatas.value = result.data.sort((a: { updated_day: any }, b: { updated_day: any }) =>
|
||||
(a.updated_day || '').localeCompare(b.updated_day || ''),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await fetchSiteUserData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`数据 - ${props.site?.name}`" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText>
|
||||
<VRow class="match-height">
|
||||
<!-- 用户信息 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<span class="text-base">用户等级</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.user_level || '无' }}
|
||||
</h5>
|
||||
</div>
|
||||
<VAvatar variant="tonal" size="42" rounded>
|
||||
<VIcon icon="mdi-account"></VIcon>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 积分 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<span class="text-base">积分</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.bonus?.toLocaleString() }}
|
||||
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.bonus)">
|
||||
({{ getDiffString(diffData?.bonus) }})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<VAvatar variant="tonal" size="42" rounded>
|
||||
<VIcon icon="mdi-scoreboard"></VIcon>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 分享率 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<span class="text-base">分享率</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.ratio }}
|
||||
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.ratio)">
|
||||
({{ getDiffString(diffData?.ratio) }})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<VAvatar variant="tonal" size="42" rounded>
|
||||
<VIcon icon="mdi-percent"></VIcon>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 总上传量 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<span class="text-base">总上传量</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ formatFileSize(siteData?.upload || 0) }}
|
||||
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.upload)">
|
||||
({{ formatFileSize(diffData?.upload || 0, 2, true) }})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<VAvatar variant="tonal" size="42" rounded>
|
||||
<VIcon icon="mdi-upload"></VIcon>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 总下载量 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<span class="text-base">总下载量</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ formatFileSize(siteData?.download || 0) }}
|
||||
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.download)">
|
||||
({{ formatFileSize(diffData?.download || 0, 2, true) }})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<VAvatar variant="tonal" size="42" rounded>
|
||||
<VIcon icon="mdi-download"></VIcon>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 总做种数 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<span class="text-base">总做种数</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.seeding?.toLocaleString() }}
|
||||
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.seeding)">
|
||||
({{ getDiffString(diffData?.seeding) }})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<VAvatar variant="tonal" size="42" rounded>
|
||||
<VIcon icon="mdi-seed"></VIcon>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 总做种体积 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<span class="text-base">总做种体积</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ formatFileSize(siteData?.seeding_size || 0) }}
|
||||
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.seeding_size)">
|
||||
({{ formatFileSize(diffData?.seeding_size || 0, 2, true) }})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<VAvatar variant="tonal" size="42" rounded>
|
||||
<VIcon icon="mdi-database"></VIcon>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 加入时间 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<span class="text-base">加入时间</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.join_at?.split(' ')[0] }}
|
||||
</h5>
|
||||
</div>
|
||||
<VAvatar variant="tonal" size="42" rounded>
|
||||
<VIcon icon="mdi-calendar"></VIcon>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VCard title="历史流量">
|
||||
<VCardText>
|
||||
<VueApexCharts type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VCard title="做种分布">
|
||||
<VCardText>
|
||||
<VueApexCharts type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -2,9 +2,10 @@
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { MediaDirectory, Site, Subscribe } from '@/api/types'
|
||||
import type { FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { VTextarea, VTextField } from 'vuetify/lib/components/index.mjs'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -22,38 +23,33 @@ const props = defineProps({
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save', 'close'])
|
||||
|
||||
const activeTab = ref('basic')
|
||||
|
||||
// 站点数据列表
|
||||
const siteList = ref<Site[]>([])
|
||||
|
||||
// 下载目录列表
|
||||
const downloadDirectories = ref<MediaDirectory[]>([])
|
||||
const downloadDirectories = ref<TransferDirectoryConf[]>([])
|
||||
|
||||
// 站点选择下载框
|
||||
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
|
||||
|
||||
// 所有规则组列表
|
||||
const filterRuleGroups = ref<FilterRuleGroup[]>([])
|
||||
|
||||
// 订阅编辑表单
|
||||
const subscribeForm = ref<Subscribe>({
|
||||
id: props.subid ?? 0,
|
||||
keyword: '',
|
||||
quality: '',
|
||||
resolution: '',
|
||||
effect: '',
|
||||
include: '',
|
||||
exclude: '',
|
||||
total_episode: 0,
|
||||
start_episode: 0,
|
||||
best_version: 0,
|
||||
search_imdbid: 0,
|
||||
sites: [],
|
||||
type: '',
|
||||
name: '',
|
||||
year: '',
|
||||
type: '',
|
||||
tmdbid: 0,
|
||||
state: '',
|
||||
last_update: '',
|
||||
username: '',
|
||||
sites: [],
|
||||
best_version: undefined,
|
||||
current_priority: 0,
|
||||
save_path: '',
|
||||
date: '',
|
||||
show_edit_dialog: false,
|
||||
})
|
||||
@@ -61,6 +57,24 @@ const subscribeForm = ref<Subscribe>({
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 加载规则组
|
||||
async function queryFilterRuleGroups() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||
filterRuleGroups.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤规则组选择项
|
||||
const filterRuleGroupOptions = computed(() => {
|
||||
return filterRuleGroups.value.map(item => ({
|
||||
title: item.name,
|
||||
value: item.name,
|
||||
}))
|
||||
})
|
||||
|
||||
// 调用API修改订阅
|
||||
async function updateSubscribeInfo() {
|
||||
try {
|
||||
@@ -162,6 +176,7 @@ async function removeSubscribe() {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(`订阅 ${subscribeForm.value.name} 已取消!`)
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
}
|
||||
@@ -185,7 +200,7 @@ async function loadDownloadDirectories() {
|
||||
// 保存目录下拉框
|
||||
const targetDirectories = computed(() => {
|
||||
// 去重后的下载目录
|
||||
const directories = downloadDirectories.value.map(item => item.path)
|
||||
const directories = downloadDirectories.value.map(item => item.download_path)
|
||||
return [...new Set(directories)]
|
||||
})
|
||||
|
||||
@@ -274,6 +289,7 @@ const effectOptions = ref([
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
queryFilterRuleGroups()
|
||||
loadDownloadDirectories()
|
||||
getSiteList()
|
||||
if (props.subid) getSubscribeInfo()
|
||||
@@ -291,106 +307,188 @@ onMounted(() => {
|
||||
}`"
|
||||
class="rounded-t"
|
||||
>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VTextField
|
||||
v-if="!props.default"
|
||||
v-model="subscribeForm.keyword"
|
||||
label="搜索关键词"
|
||||
hint="设定搜索关键词后,将使用此关键词搜索站点资源,否则自动使用themoviedb中的名称搜索"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
|
||||
<VTextField
|
||||
v-model="subscribeForm.total_episode"
|
||||
label="总集数"
|
||||
:rules="[numberValidator]"
|
||||
hint="手动设定总集数"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
|
||||
<VTextField
|
||||
v-model="subscribeForm.start_episode"
|
||||
label="开始集数"
|
||||
:rules="[numberValidator]"
|
||||
hint="只下载此集数及之后的集"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect v-model="subscribeForm.quality" label="质量" :items="qualityOptions" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect v-model="subscribeForm.resolution" label="分辨率" :items="resolutionOptions" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect v-model="subscribeForm.effect" label="特效" :items="effectOptions" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="subscribeForm.include"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="支持正则表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="subscribeForm.exclude"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="支持正则表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="subscribeForm.sites"
|
||||
:items="selectSitesOptions"
|
||||
chips
|
||||
label="订阅站点"
|
||||
multiple
|
||||
hint="只订阅选中的订阅站点,不选则订阅所有可订阅站点"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCombobox
|
||||
v-model="subscribeForm.save_path"
|
||||
:items="targetDirectories"
|
||||
label="保存路径"
|
||||
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.best_version"
|
||||
label="洗版"
|
||||
hint="开启后不管媒体库是否存在,均会根据洗版优先级进行过滤下载,直到下载到了最高优先级的资源为止"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.search_imdbid"
|
||||
label="使用 ImdbID 搜索"
|
||||
hint="开启后将使用 ImdbID 搜索资源,搜索结果更精确,但不是所有站点都支持"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="props.default" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.show_edit_dialog"
|
||||
label="订阅时编辑更多规则"
|
||||
hint="开启后将在添加订阅后弹出编辑订阅的对话框,方便用户编辑订阅规则"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VTabs v-model="activeTab" show-arrows>
|
||||
<VTab value="basic">
|
||||
<div>基础</div>
|
||||
</VTab>
|
||||
<VTab value="advance">
|
||||
<div>进阶</div>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="basic">
|
||||
<div>
|
||||
<VRow v-if="!props.default">
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="subscribeForm.keyword"
|
||||
label="搜索关键词"
|
||||
hint="指定搜索站点时使用的关键词"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="subscribeForm.total_episode"
|
||||
label="总集数"
|
||||
:rules="[numberValidator]"
|
||||
hint="剧集总集数"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="subscribeForm.start_episode"
|
||||
label="开始集数"
|
||||
:rules="[numberValidator]"
|
||||
hint="开始订阅集数"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="subscribeForm.quality"
|
||||
label="质量"
|
||||
:items="qualityOptions"
|
||||
hint="订阅资源质量"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="subscribeForm.resolution"
|
||||
label="分辨率"
|
||||
:items="resolutionOptions"
|
||||
hint="订阅资源分辨率"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="subscribeForm.effect"
|
||||
label="特效"
|
||||
:items="effectOptions"
|
||||
hint="订阅资源特效"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="subscribeForm.sites"
|
||||
:items="selectSitesOptions"
|
||||
chips
|
||||
label="订阅站点"
|
||||
multiple
|
||||
clearable
|
||||
hint="订阅的站点范围,不选使用系统设置"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.best_version"
|
||||
label="洗版"
|
||||
hint="根据洗版优先级进行洗版订阅"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.search_imdbid"
|
||||
label="使用 ImdbID 搜索"
|
||||
hint="开使用 ImdbID 精确搜索资源"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="props.default" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.show_edit_dialog"
|
||||
label="订阅时编辑更多规则"
|
||||
hint="添加订阅时显示此编辑订阅对话框"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="advance">
|
||||
<div>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="subscribeForm.include"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="包含规则,支持正则表达式"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="subscribeForm.exclude"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="排除规则,支持正则表达式"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="subscribeForm.filter_groups"
|
||||
:items="filterRuleGroupOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
label="优先级规则组"
|
||||
hint="按选定的过滤规则组对订阅进行过滤"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="!props.default">
|
||||
<VTextField
|
||||
v-model="subscribeForm.media_category"
|
||||
label="自定义类别"
|
||||
hint="指定类别名称,留空自动识别"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VCombobox
|
||||
v-model="subscribeForm.save_path"
|
||||
:items="targetDirectories"
|
||||
label="自定义保存路径"
|
||||
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="!props.default">
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="subscribeForm.custom_words"
|
||||
label="自定义识别词"
|
||||
hint="只对该订阅使用的识别词"
|
||||
persistent-hint
|
||||
placeholder="屏蔽词
|
||||
被替换词 => 替换词
|
||||
前定位词 <> 后定位词 >> 集偏移量(EP)
|
||||
被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)
|
||||
其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别,其中s、e为季数和集数(可选)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
|
||||
301
src/components/dialog/SubscribeFilesDialog.vue
Normal file
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { SubscrbieInfo } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
//定义输入参数
|
||||
const props = defineProps({
|
||||
subid: Number,
|
||||
})
|
||||
|
||||
const activeTab = ref('download')
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 订阅文件信息
|
||||
const subScribeInfo = ref<SubscrbieInfo>()
|
||||
|
||||
// 下载文件表头
|
||||
const downloadHeaders = [
|
||||
{ title: '集', key: 'episode_number', sortable: true },
|
||||
{ title: '种子', key: 'torrent_title', sortable: true },
|
||||
{ title: '文件', key: 'file_path', sortable: true },
|
||||
]
|
||||
|
||||
// 媒体库文件表头
|
||||
const libraryHeaders = [
|
||||
{ title: '集', key: 'episode_number', sortable: true },
|
||||
{ title: '文件', key: 'file_path', sortable: true },
|
||||
]
|
||||
|
||||
// 调用API查询订阅文件信息
|
||||
async function loadSubscribeFilesInfo() {
|
||||
try {
|
||||
subScribeInfo.value = await api.get(`subscribe/files/${props.subid}`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算下载文件列表
|
||||
const downloadInfos = computed(() => {
|
||||
return Object.keys(subScribeInfo.value?.episodes ?? {}).map((key: any) => {
|
||||
const item = subScribeInfo.value?.episodes[key]
|
||||
return {
|
||||
episode_number: key,
|
||||
title: item?.title,
|
||||
download: item?.download ?? [],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 总集数
|
||||
const totalCount = computed(() => {
|
||||
return Object.keys(subScribeInfo.value?.episodes ?? {}).length
|
||||
})
|
||||
|
||||
// 计算媒体库文件列表
|
||||
const libraryInfos = computed(() => {
|
||||
return Object.keys(subScribeInfo.value?.episodes ?? {}).map((key: any) => {
|
||||
const item = subScribeInfo.value?.episodes[key]
|
||||
return {
|
||||
episode_number: key,
|
||||
title: item?.title,
|
||||
library: item?.library ?? [],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadSubscribeFilesInfo()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="rounded-t">
|
||||
<VCardItem class="my-2">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<div class="media-page">
|
||||
<div class="media-header">
|
||||
<div class="media-poster">
|
||||
<VImg
|
||||
:src="subScribeInfo?.subscribe?.poster"
|
||||
cover
|
||||
class="object-cover aspect-w-2 aspect-h-3 ring-1 ring-gray-500"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="media-title">
|
||||
<h1 class="d-flex flex-column flex-lg-row align-baseline justify-center justify-lg-start">
|
||||
<div class="align-self-center align-self-lg-end">
|
||||
{{ subScribeInfo?.subscribe?.name }}
|
||||
</div>
|
||||
<div v-if="subScribeInfo?.subscribe?.season" class="text-lg align-self-center align-self-lg-end ms-3">
|
||||
第 {{ subScribeInfo?.subscribe?.season }} 季
|
||||
</div>
|
||||
</h1>
|
||||
<div>{{ subScribeInfo?.subscribe?.year }}</div>
|
||||
<div class="media-overview">
|
||||
<div class="media-overview-left">
|
||||
<p>{{ subScribeInfo?.subscribe?.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-7">
|
||||
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill">
|
||||
<VTab value="download" selected-class="v-slide-group-item--active v-tab--selected">
|
||||
<div>
|
||||
<VIcon size="20" start icon="mdi-download" />
|
||||
下载文件
|
||||
</div>
|
||||
</VTab>
|
||||
<VTab value="library" selected-class="v-slide-group-item--active v-tab--selected">
|
||||
<div>
|
||||
<VIcon size="20" start icon="mdi-filmstrip-box-multiple" />
|
||||
媒体库文件
|
||||
</div>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="download">
|
||||
<transition name="fade-slide" appear>
|
||||
<VDataTable
|
||||
items-per-page="50"
|
||||
:headers="downloadHeaders"
|
||||
:items="downloadInfos"
|
||||
:items-length="totalCount"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
hover
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
>
|
||||
<template #item.episode_number="{ item }">
|
||||
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
|
||||
</template>
|
||||
<template #item.torrent_title="{ item }">
|
||||
<div class="text-xs" v-for="file in item.download">
|
||||
【{{ file.site_name }}】{{ file.torrent_title }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.file_path="{ item }">
|
||||
<div class="text-xs" v-for="file in item.download">{{ file.file_path }}</div>
|
||||
</template>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTable>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="library">
|
||||
<transition name="fade-slide" appear>
|
||||
<VDataTable
|
||||
items-per-page="50"
|
||||
:headers="libraryHeaders"
|
||||
:items="libraryInfos"
|
||||
:items-length="totalCount"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
hover
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
>
|
||||
<template #item.episode_number="{ item }">
|
||||
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
|
||||
</template>
|
||||
<template #item.file_path="{ item }">
|
||||
<div class="text-xs" v-for="file in item.library">{{ file.file_path }}</div>
|
||||
</template>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTable>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.vue-media-back {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(var(--v-theme-background), 0) 50%,
|
||||
rgba(var(--v-theme-background), 1) 100%
|
||||
),
|
||||
linear-gradient(90deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%),
|
||||
linear-gradient(270deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%);
|
||||
box-shadow: 0 0 0 2px rgb(var(--v-theme-background));
|
||||
margin-block-start: calc(-70px - env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.media-page {
|
||||
position: relative;
|
||||
background-position: 50%;
|
||||
background-size: cover;
|
||||
margin-block-start: calc(-4rem - env(safe-area-inset-top));
|
||||
padding-block-start: calc(4rem + env(safe-area-inset-top));
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.media-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-block-start: 1rem;
|
||||
}
|
||||
|
||||
@media (width >= 1280px) {
|
||||
.media-header {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.media-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 1rem 1rem;
|
||||
}
|
||||
|
||||
@media (width >= 1024px) {
|
||||
.media-overview {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.media-poster {
|
||||
overflow: hidden;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
inline-size: 8rem;
|
||||
|
||||
--tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, 10%), 0 1px 2px -1px rgba(0, 0, 0, 10%);
|
||||
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
|
||||
}
|
||||
|
||||
@media (width >= 1280px) {
|
||||
.media-poster {
|
||||
inline-size: 13rem;
|
||||
margin-inline-end: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 768px) {
|
||||
.media-poster {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
inline-size: 11rem;
|
||||
|
||||
--tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, 25%);
|
||||
--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);
|
||||
}
|
||||
}
|
||||
|
||||
.media-title {
|
||||
display: flex;
|
||||
flex: 1 1 0%;
|
||||
flex-direction: column;
|
||||
margin-block-start: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (width >= 1280px) {
|
||||
.media-title {
|
||||
margin-block-start: 0;
|
||||
margin-inline-end: 1rem;
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
.media-title > h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
@media (width >= 1280px) {
|
||||
.media-title > h1 {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -145,65 +145,69 @@ const dropdownItems = ref([
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- <VList lines="two" v-if="historyList.length > 0"> -->
|
||||
<VList lines="two">
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-hidden" @load="loadHistory">
|
||||
<template #loading>
|
||||
<LoadingBanner />
|
||||
</template>
|
||||
<template #empty />
|
||||
<template v-for="(item, i) in historyList" :key="i">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded shadow ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }} <span class="text-sm">第 {{ item.season }} 季</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
<template v-if="historyList.length > 0">
|
||||
<template v-for="(item, i) in historyList" :key="i">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded shadow ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }} <span class="text-sm">第 {{ item.season }} 季</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</template>
|
||||
</VInfiniteScroll>
|
||||
</VList>
|
||||
<VCardText v-if="historyList.length === 0" class="text-center"> 没有已完成的订阅 </VCardText>
|
||||
</VCard>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
|
||||
92
src/components/dialog/SubscribeShareDialog.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { Subscribe, SubscribeShare } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
sub: Object as PropType<Subscribe>,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 订阅编辑表单
|
||||
const shareForm = ref<SubscribeShare>({
|
||||
subscribe_id: props.sub?.id ?? 0,
|
||||
})
|
||||
|
||||
// 分享订阅
|
||||
async function doShare() {
|
||||
if (!shareForm.value.share_title || !shareForm.value.share_comment || !shareForm.value.share_user) return
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
$toast.success(`${props.sub?.name} 分享成功!`)
|
||||
// 通知父组件刷新
|
||||
emit('close')
|
||||
} else {
|
||||
$toast.error(`${props.sub?.name} 分享失败:${result.message}!`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`分享订阅 - ${props.sub?.name} ${props.sub?.season ? `第 ${props.sub?.season} 季` : ''}`"
|
||||
class="rounded-t"
|
||||
>
|
||||
<VCardText>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VForm @submit.prevent="() => {}" class="pt-2">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="shareForm.share_title"
|
||||
label="标题"
|
||||
hint="给分享取一个便于识别的名称"
|
||||
:rules="[requiredValidator]"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="shareForm.share_comment"
|
||||
label="说明"
|
||||
:rules="[requiredValidator]"
|
||||
hint="关于该订阅的说明"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="shareForm.share_user"
|
||||
label="分享用户"
|
||||
:rules="[requiredValidator]"
|
||||
hint="分享人的昵称"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="doShare" prepend-icon="mdi-share" class="px-5"> 确认分享 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
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('/storage/qrcode/u115')
|
||||
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('/storage/check/u115')
|
||||
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>
|
||||
423
src/components/dialog/UserAddEditDialog.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import type { User } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import store from '@/store'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
const refInputEl = ref<HTMLElement>()
|
||||
const isNewPasswordVisible = ref(false)
|
||||
const isConfirmPasswordVisible = ref(false)
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
username: String,
|
||||
usernames: Array,
|
||||
oper: String,
|
||||
})
|
||||
|
||||
// 当前登录用户名称
|
||||
const currentLoginUser = store.state.auth.userName
|
||||
|
||||
// 用户名
|
||||
const userName = ref('')
|
||||
|
||||
// 当前头像缓存
|
||||
const currentAvatar = ref(avatar1)
|
||||
|
||||
// 用户名缓存
|
||||
const currentUserName = ref('')
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
// 创建新用户按钮运行状态
|
||||
const isAdding = ref(false)
|
||||
|
||||
// 更新用户消息按钮运行状态
|
||||
const isUpdating = ref(false)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 状态下拉项
|
||||
const statusItems = [
|
||||
{ title: '激活', value: 1 },
|
||||
{ title: '已停用', value: 0 },
|
||||
]
|
||||
|
||||
// 用户编辑表单数据
|
||||
const userForm = ref<User>({
|
||||
id: 0,
|
||||
name: props.username ?? '',
|
||||
password: '',
|
||||
email: '',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
avatar: avatar1,
|
||||
is_otp: false,
|
||||
permissions: {},
|
||||
settings: {
|
||||
wechat_userid: null,
|
||||
telegram_userid: null,
|
||||
slack_userid: null,
|
||||
vocechat_userid: null,
|
||||
synologychat_userid: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 更新头像
|
||||
function changeAvatar(file: Event) {
|
||||
const fileReader = new FileReader()
|
||||
const { files } = file.target as HTMLInputElement
|
||||
if (files && files.length > 0) {
|
||||
const selectedFile = files[0]
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
const maxSize = 800 * 1024
|
||||
// 检查文件是否为图片
|
||||
if (!allowedTypes.includes(selectedFile.type)) {
|
||||
$toast.error('上传的文件不符合要求,请重新选择头像')
|
||||
return
|
||||
}
|
||||
// 检查文件大小
|
||||
if (selectedFile.size > maxSize) {
|
||||
$toast.error('文件大小不得大于800KB')
|
||||
return
|
||||
}
|
||||
fileReader.readAsDataURL(selectedFile)
|
||||
fileReader.onload = () => {
|
||||
if (typeof fileReader.result === 'string') {
|
||||
currentAvatar.value = fileReader.result
|
||||
$toast.success('新头像上传成功,待保存后生效!')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重置默认头像
|
||||
function resetDefaultAvatar() {
|
||||
currentAvatar.value = avatar1
|
||||
$toast.success('已重置为默认头像,待保存后生效!')
|
||||
}
|
||||
|
||||
// 还原当前头像
|
||||
function restoreCurrentAvatar() {
|
||||
currentAvatar.value = userForm.value.avatar
|
||||
$toast.success('已还原当前使用头像!')
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
userForm.value = await api.get(`user/${props.username}`)
|
||||
if (userForm.value) {
|
||||
userForm.value.avatar = userForm.value.avatar || avatar1
|
||||
currentAvatar.value = userForm.value.avatar
|
||||
currentUserName.value = userForm.value.name
|
||||
userName.value = userForm.value.name
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API 新增用户
|
||||
async function addUser() {
|
||||
if (isAdding.value) {
|
||||
$toast.error(`正在创建【${userForm.value.name}】用户,请稍后`)
|
||||
return
|
||||
}
|
||||
if (!currentUserName.value) {
|
||||
$toast.error('用户名不能为空')
|
||||
return
|
||||
} else userForm.value.name = currentUserName.value
|
||||
// 重名检查
|
||||
if (props.usernames && props.usernames.includes(userForm.value.name)) {
|
||||
$toast.error('用户名已存在')
|
||||
return
|
||||
}
|
||||
if (!userForm.value?.name || !newPassword.value) return
|
||||
if (newPassword.value || confirmPassword.value) {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
$toast.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
userForm.value.password = newPassword.value
|
||||
}
|
||||
isAdding.value = true
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post('user/', userForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`用户【${userForm.value.name}】创建成功`)
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`创建用户失败:${result.message}`)
|
||||
// 清除用户名
|
||||
userForm.value.name = ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
isAdding.value = false
|
||||
}
|
||||
|
||||
// 调用API更新用户信息
|
||||
async function updateUser() {
|
||||
if (isUpdating.value) {
|
||||
$toast.error(`正在更新【${userForm.value.name}】用户,请稍后`)
|
||||
return
|
||||
}
|
||||
if (!currentUserName.value) {
|
||||
$toast.error('用户名不能为空')
|
||||
return
|
||||
}
|
||||
if (newPassword.value || confirmPassword.value) {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
$toast.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
userForm.value.password = newPassword.value
|
||||
}
|
||||
const oldUserName = userForm.value.name
|
||||
userForm.value.name = currentUserName.value
|
||||
const oldAvatar = userForm.value.avatar
|
||||
userForm.value.avatar = currentAvatar.value
|
||||
isUpdating.value = true
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put('user/', userForm.value)
|
||||
if (result.success) {
|
||||
if (oldUserName !== currentUserName.value) {
|
||||
$toast.success(`【${oldUserName}】更名【${currentUserName.value}】, 更新成功!`)
|
||||
// 如果是当前登录用户,更新当前用户名称显示
|
||||
if (isCurrentUser.value) store.commit('auth/setUserName', currentUserName.value)
|
||||
} else {
|
||||
$toast.success(`【${userForm.value?.name}】更新成功!`)
|
||||
}
|
||||
// 更新本地头像显示
|
||||
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
|
||||
store.commit('auth/setAvatar', currentAvatar.value)
|
||||
}
|
||||
emit('save')
|
||||
} else {
|
||||
if (oldUserName !== currentUserName.value) {
|
||||
$toast.error(`【${oldUserName}】更名【${currentUserName.value}】, 更新失败:${result.message}`)
|
||||
currentUserName.value = oldUserName
|
||||
} else {
|
||||
$toast.error(`【${userForm.value?.name}】更新失败:${result.message}`)
|
||||
}
|
||||
}
|
||||
//失败缓存值还原
|
||||
currentUserName.value = userForm.value.name
|
||||
userForm.value.name = oldUserName
|
||||
currentAvatar.value = userForm.value.avatar
|
||||
userForm.value.avatar = oldAvatar
|
||||
userForm.value.password = ''
|
||||
} catch (error) {
|
||||
$toast.error(`【${userForm.value?.name}】更新失败!`)
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
isUpdating.value = false
|
||||
}
|
||||
|
||||
// 用户状态转换,true/false转换为1/0
|
||||
const userStatus = computed({
|
||||
get: () => (userForm.value.is_active ? 1 : 0),
|
||||
set: (value: number) => {
|
||||
userForm.value.is_active = value === 1
|
||||
},
|
||||
})
|
||||
|
||||
// 计算是否有用户管理权限
|
||||
const canControl = computed(() => {
|
||||
// 新增用户时,有权限
|
||||
if (props.oper === 'add') {
|
||||
return true
|
||||
} else {
|
||||
// 调用isCurrentUser函数判断是否为当前用户
|
||||
return !isCurrentUser.value
|
||||
}
|
||||
})
|
||||
|
||||
// 检查是否为当前用户
|
||||
const isCurrentUser = computed(() => {
|
||||
return props.username === currentLoginUser
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.oper !== 'add') {
|
||||
fetchUserInfo()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${props.oper === 'add' ? '新增' : '编辑'}用户${props.oper !== 'add' ? ` - ${userName}` : ''}`"
|
||||
class="rounded-t"
|
||||
>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText class="d-flex">
|
||||
<!-- 👉 Avatar -->
|
||||
<VAvatar rounded="lg" size="100" class="me-6" :image="currentAvatar" />
|
||||
|
||||
<!-- 👉 Upload Photo -->
|
||||
<form class="d-flex flex-column justify-center gap-5">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<VBtn color="primary" @click="refInputEl?.click()">
|
||||
<VIcon icon="mdi-cloud-upload-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
|
||||
</VBtn>
|
||||
|
||||
<input ref="refInputEl" type="file" name="file" accept=".jpeg,.png,.jpg,GIF" hidden @input="changeAvatar" />
|
||||
|
||||
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
type="reset"
|
||||
:color="props.oper === 'add' ? 'info' : 'error'"
|
||||
variant="tonal"
|
||||
@click="resetDefaultAvatar"
|
||||
>
|
||||
<VIcon icon="mdi-image-sync-outline" />
|
||||
<span v-if="display.mdAndUp.value" class="ms-2">默认</span>
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-0">允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。</p>
|
||||
</form>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}" class="mt-3">
|
||||
<VDivider class="my-10">
|
||||
<span>用户基础设置</span>
|
||||
</VDivider>
|
||||
<VRow>
|
||||
<VCol md="6" cols="12">
|
||||
<VTextField
|
||||
v-model="currentUserName"
|
||||
density="comfortable"
|
||||
:readonly="props.oper !== 'add'"
|
||||
label="用户名"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="userForm.email" density="comfortable" clearable label="邮箱" type="email" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="newPassword"
|
||||
density="comfortable"
|
||||
:type="isNewPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
clearable
|
||||
label="密码"
|
||||
autocomplete=""
|
||||
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<!-- 👉 confirm password -->
|
||||
<VTextField
|
||||
v-model="confirmPassword"
|
||||
density="comfortable"
|
||||
:type="isConfirmPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
clearable
|
||||
label="确认密码"
|
||||
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="canControl">
|
||||
<VSelect
|
||||
v-model="userStatus"
|
||||
:items="statusItems"
|
||||
item-text="title"
|
||||
item-value="value"
|
||||
label="状态"
|
||||
dense
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VDivider class="my-10">
|
||||
<span>消息账号绑定</span>
|
||||
</VDivider>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="userForm.settings.wechat_userid" density="comfortable" clearable label="微信用户" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.telegram_userid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
label="Telegram用户"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="userForm.settings.slack_userid" density="comfortable" clearable label="Slack用户" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.vocechat_userid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
label="VoceChat用户"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.synologychat_userid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
label="SynologyChat用户"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="props.oper === 'add'"
|
||||
:disabled="isAdding"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
@click="addUser"
|
||||
prepend-icon="mdi-plus"
|
||||
class="px-5"
|
||||
>
|
||||
<span v-if="isAdding">创建中...</span>
|
||||
<span v-else>创建</span>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else
|
||||
:disabled="isUpdating"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
@click="updateUser"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
<span v-if="isUpdating">更新中...</span>
|
||||
<span v-else>更新</span>
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -1,37 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import type { Axios, AxiosRequestConfig } from 'axios'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import axios from 'axios'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
import type { Context, EndPoints, FileItem } from '@/api/types'
|
||||
import store from '@/store'
|
||||
import api from '@/api'
|
||||
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
icons: Object,
|
||||
storage: String,
|
||||
path: String,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: Object as PropType<Axios>,
|
||||
axios: {
|
||||
type: Object as PropType<Axios>,
|
||||
required: true,
|
||||
},
|
||||
refreshpending: Boolean,
|
||||
item: {
|
||||
type: Object as PropType<FileItem>,
|
||||
required: true,
|
||||
},
|
||||
sort: String,
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 是否选择模式
|
||||
const selectMode = ref(false)
|
||||
|
||||
// 是否正在加载
|
||||
const loading = ref(true)
|
||||
|
||||
// 重命名loading
|
||||
const renameLoading = ref(false)
|
||||
|
||||
// 识别进度条
|
||||
const progressDialog = ref(false)
|
||||
|
||||
@@ -41,15 +61,6 @@ const progressText = ref('请稍候 ...')
|
||||
// 识别进度
|
||||
const progressValue = ref(0)
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 存储空间类型
|
||||
const storage = ref(inProps.storage ?? '')
|
||||
|
||||
// axios实例
|
||||
const axiosInstance = ref<Axios>(inProps.axios ?? axios)
|
||||
|
||||
// 内容列表
|
||||
const items = ref<FileItem[]>([])
|
||||
|
||||
@@ -65,170 +76,386 @@ const transferPopper = ref(false)
|
||||
// 新名称
|
||||
const newName = ref('')
|
||||
|
||||
// 当前名称
|
||||
// 处理目录内所有文件
|
||||
const renameAll = ref(false)
|
||||
|
||||
// 当前操作项
|
||||
const currentItem = ref<FileItem>()
|
||||
|
||||
// 选中的项目
|
||||
const selected = ref<FileItem[]>([])
|
||||
|
||||
// 识别结果
|
||||
const nameTestResult = ref<Context>()
|
||||
|
||||
// 识别结果对话框
|
||||
const nameTestDialog = ref(false)
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref<{ [key: string]: any }[]>([])
|
||||
|
||||
// 加载进度SSE
|
||||
const progressEventSource = ref<EventSource>()
|
||||
|
||||
// 目录过滤
|
||||
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)))
|
||||
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.name.includes(filter.value)))
|
||||
|
||||
// 文件过滤
|
||||
const files = computed(() => items.value.filter(item => item.type === 'file' && item.basename.includes(filter.value)))
|
||||
|
||||
// 是否目录
|
||||
const isDir = computed(() => inProps.path?.endsWith('/'))
|
||||
const files = computed(() => items.value.filter(item => item.type === 'file' && item.name.includes(filter.value)))
|
||||
|
||||
// 是否文件
|
||||
const isFile = computed(() => !isDir.value)
|
||||
const isFile = computed(() => inProps.item.type == 'file')
|
||||
|
||||
// 是否目录
|
||||
const isDir = computed(() => !isFile.value)
|
||||
|
||||
// 需要整理的文件项
|
||||
const transferItems = ref<FileItem[]>([])
|
||||
|
||||
// 当前图片地址
|
||||
const currentImgLink = ref('')
|
||||
|
||||
// 大小控制
|
||||
const scrollStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
|
||||
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
const ext = inProps.path?.split('.').pop()?.toLowerCase()
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
|
||||
const ext = inProps.item.path?.split('.').pop()?.toLowerCase()
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].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
|
||||
emit('loading', true)
|
||||
// 参数
|
||||
const url = inProps.endpoints?.list.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(inProps.path || ''))
|
||||
.replace(/{sort}/g, inProps.sort || 'name')
|
||||
|
||||
const config = {
|
||||
// 参数
|
||||
const url = inProps.endpoints?.list.url.replace(/{sort}/g, inProps.sort || 'name')
|
||||
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
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)
|
||||
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
|
||||
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({
|
||||
title: '确认',
|
||||
content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.basename}?`,
|
||||
content: `是否确认删除选中的 ${selected.value.length} 个项目?`,
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
emit('loading', true)
|
||||
const url = inProps.endpoints?.delete.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(item.path))
|
||||
if (!confirmed) return
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: inProps.endpoints?.delete.method || 'post',
|
||||
}
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
progressValue.value = 0
|
||||
|
||||
await axiosInstance.value.request(config)
|
||||
emit('filedeleted')
|
||||
emit('loading', false)
|
||||
// 重新加载
|
||||
load()
|
||||
}
|
||||
// 删除选中的项目
|
||||
selected.value.every(async item => {
|
||||
progressText.value = `正在删除 ${item.name} ...`
|
||||
await deleteItem(item, false)
|
||||
})
|
||||
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
|
||||
// 重新加载
|
||||
list_files()
|
||||
}
|
||||
|
||||
// 切换路径
|
||||
function changePath(_path: string) {
|
||||
emit('pathchanged', _path)
|
||||
function changePath(item: FileItem) {
|
||||
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) {
|
||||
if (!path) return
|
||||
const token = store.state.auth.token
|
||||
const url_path = inProps.endpoints?.download.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(path))
|
||||
const url = `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
|
||||
async function download(item: FileItem) {
|
||||
const url = inProps.endpoints?.download.url
|
||||
// 下载文件
|
||||
window.open(url, '_blank')
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
method: inProps.endpoints?.download.method || 'post',
|
||||
data: item,
|
||||
responseType: 'blob',
|
||||
}
|
||||
// 加载数据
|
||||
const result: Blob = await inProps.axios.request(config)
|
||||
if (result) {
|
||||
const downloadUrl = URL.createObjectURL(result)
|
||||
window.open(downloadUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示图片
|
||||
function getImgLink(path: string) {
|
||||
if (!path) return ''
|
||||
const token = store.state.auth.token
|
||||
const url_path = inProps.endpoints?.image.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(path))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
|
||||
// 获取图片地址
|
||||
async function getImgLink(item: FileItem) {
|
||||
let url = inProps.endpoints?.image.url
|
||||
// 下载文件
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
method: inProps.endpoints?.image.method || 'post',
|
||||
data: item,
|
||||
responseType: 'blob',
|
||||
}
|
||||
// 加载二进制数据
|
||||
const result: Blob = await inProps.axios.request(config)
|
||||
if (result) {
|
||||
// 创建图片地址
|
||||
currentImgLink.value = URL.createObjectURL(result)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果当前是图片且是文件,则获取图片地址
|
||||
watch(
|
||||
() => inProps.item,
|
||||
async () => {
|
||||
if (isImage.value && isFile.value) {
|
||||
await getImgLink(inProps.item)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 显示重命名弹窗
|
||||
function showRenmae(item: FileItem) {
|
||||
currentItem.value = item
|
||||
newName.value = item.name
|
||||
renameAll.value = false
|
||||
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() {
|
||||
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,
|
||||
method: inProps.endpoints?.mkdir.method || 'post',
|
||||
// 关闭弹窗
|
||||
renamePopper.value = false
|
||||
|
||||
// 显示进度条
|
||||
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
|
||||
await inProps.axios?.request(config)
|
||||
let url = inProps.endpoints?.rename.url.replace(/{newname}/g, encodeURIComponent(newName.value))
|
||||
if (renameAll.value) {
|
||||
url += '&recursive=true'
|
||||
}
|
||||
|
||||
renamePopper.value = false
|
||||
newName.value = ''
|
||||
emit('loading', false)
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
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')
|
||||
}
|
||||
|
||||
// 显示整理对话框
|
||||
function showTransfer(item: FileItem) {
|
||||
currentItem.value = item
|
||||
transferItems.value = [item]
|
||||
transferPopper.value = true
|
||||
}
|
||||
|
||||
// 显示批量整理对话框
|
||||
function showBatchTransfer() {
|
||||
transferItems.value = selected.value
|
||||
transferPopper.value = true
|
||||
}
|
||||
|
||||
// 整理完成
|
||||
function transferDone() {
|
||||
transferPopper.value = false
|
||||
list_files()
|
||||
}
|
||||
|
||||
// 将文件修改时间(timestape)转换为本地时间
|
||||
function formatTime(timestape: number) {
|
||||
return new Date(timestape * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
// 监听path变化
|
||||
watch(
|
||||
() => inProps.path,
|
||||
async () => {
|
||||
items.value = []
|
||||
nameTestResult.value = undefined
|
||||
nameTestDialog.value = false
|
||||
await load()
|
||||
},
|
||||
)
|
||||
|
||||
// 监听refreshPending变化
|
||||
watch(
|
||||
() => inProps.refreshpending,
|
||||
async () => {
|
||||
if (inProps.refreshpending) {
|
||||
await load()
|
||||
await list_files()
|
||||
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识别
|
||||
async function recognize(path: string) {
|
||||
try {
|
||||
@@ -251,75 +478,66 @@ async function recognize(path: string) {
|
||||
}
|
||||
|
||||
// 调用API刮削
|
||||
async function scrape(path: string) {
|
||||
async function scrape(item: FileItem, confirm: boolean = true) {
|
||||
try {
|
||||
if (confirm) {
|
||||
// 确认
|
||||
const confirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认刮削 ${item.path}?`,
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在刮削 ${path} ...`
|
||||
const result: { [key: string]: any } = await api.get('media/scrape', {
|
||||
params: {
|
||||
path,
|
||||
},
|
||||
})
|
||||
progressText.value = `正在刮削 ${item.path} ...`
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.storage}`, item)
|
||||
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
if (!result.success) $toast.error(result.message)
|
||||
else $toast.success(`${path}削刮完成!`)
|
||||
else $toast.success(`${item.path} 削刮完成!`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
title: '识别',
|
||||
value: 1,
|
||||
props: {
|
||||
prependIcon: 'mdi-text-recognition',
|
||||
click: (_item: FileItem) => {
|
||||
recognize(_item.path || '')
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '刮削',
|
||||
value: 2,
|
||||
props: {
|
||||
prependIcon: 'mdi-auto-fix',
|
||||
click: (_item: FileItem) => {
|
||||
scrape(_item.path || '')
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '重命名',
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-rename',
|
||||
click: showRenmae,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '整理',
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-folder-arrow-right',
|
||||
click: showTransfer,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '删除',
|
||||
value: 5,
|
||||
props: {
|
||||
prependIcon: 'mdi-delete-outline',
|
||||
color: 'error',
|
||||
click: deleteItem,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// 批量刮削
|
||||
async function batchScrape() {
|
||||
// 确认
|
||||
const confirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认刮削选中的 ${selected.value.length} 项?`,
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
selected.value.map(item => {
|
||||
scrape(item, false)
|
||||
})
|
||||
}
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
progressText.value = '请稍候 ...'
|
||||
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`)
|
||||
progressEventSource.value.onmessage = event => {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
progressText.value = progress.text
|
||||
progressValue.value = progress.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停止监听加载进度
|
||||
function stopLoadingProgress() {
|
||||
progressEventSource.value?.close()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
list_files()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -339,99 +557,131 @@ onMounted(() => {
|
||||
rounded="0"
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
|
||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
|
||||
<VIcon color="primary" v-else>mdi-select</VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
|
||||
<VIcon color="primary"> mdi-text-recognition </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
|
||||
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
|
||||
<VIcon color="primary"> mdi-download </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="load">
|
||||
<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>
|
||||
<VCardText v-if="loading" class="text-center flex flex-col items-center">
|
||||
<VProgressCircular size="48" indeterminate color="primary" />
|
||||
</VCardText>
|
||||
<VCardText v-if="!path" class="grow d-flex justify-center align-center grey--text"> 选择目录或文件 </VCardText>
|
||||
<VCardText v-else-if="isFile && !isImage" class="text-center break-all">
|
||||
<strong>{{ items[0]?.name }}</strong
|
||||
><br />
|
||||
大小:{{ formatBytes(items[0]?.size || 0) }}<br />
|
||||
修改时间:{{ formatTime(items[0]?.modify_time || 0) }}
|
||||
<!-- 文件详情 -->
|
||||
<VCardText v-else-if="isFile && !isImage && items.length > 0" class="text-center break-all">
|
||||
<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 v-else-if="isFile && isImage" class="grow d-flex justify-center align-center">
|
||||
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
|
||||
<!-- 图片 -->
|
||||
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
|
||||
<VImg :src="currentImgLink" max-width="100%" max-height="100%" />
|
||||
</VCardText>
|
||||
<!-- 目录和文件列表 -->
|
||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||
<VList subheader>
|
||||
<VVirtualScroll class="virtual-scroll-div" :items="[...dirs, ...files]">
|
||||
<VVirtualScroll :items="[...dirs, ...files]" :style="scrollStyle">
|
||||
<template #default="{ item }">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="changePath(item.path)">
|
||||
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
v-if="inProps.icons && item.extension"
|
||||
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
|
||||
/>
|
||||
<VIcon v-else icon="mdi-folder-outline" />
|
||||
<VListItemAction v-if="selectMode">
|
||||
<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>
|
||||
<VListItemTitle v-text="item.name" />
|
||||
<VListItemSubtitle v-if="item.size">
|
||||
{{ formatBytes(item.size) }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<IconBtn class="d-sm-none">
|
||||
<IconBtn v-if="display.smAndDown.value && !selectMode">
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
<template v-for="(menu, i) in dropdownItems" :key="i">
|
||||
<VListItem
|
||||
v-if="menu.show"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-if="hover.isHovering" class="flex">
|
||||
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
|
||||
<VTooltip text="识别">
|
||||
<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" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="刮削">
|
||||
<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" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="重命名">
|
||||
<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" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="整理">
|
||||
<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" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="删除">
|
||||
<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" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
@@ -453,13 +703,25 @@ onMounted(() => {
|
||||
<!-- 重命名弹窗 -->
|
||||
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="50rem">
|
||||
<VCard title="重命名">
|
||||
<DialogCloseBtn @click="renamePopper = false" />
|
||||
<VDivider />
|
||||
<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>
|
||||
<VCardActions>
|
||||
<VBtn depressed @click="renamePopper = false"> 取消 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn :disabled="!newName" depressed variant="tonal" @click="rename"> 重命名 </VBtn>
|
||||
<VBtn color="success" variant="elevated" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
|
||||
自动识别名称
|
||||
</VBtn>
|
||||
<VBtn :disabled="!newName" variant="elevated" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
确定
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
@@ -467,13 +729,9 @@ onMounted(() => {
|
||||
<ReorganizeDialog
|
||||
v-if="transferPopper"
|
||||
v-model="transferPopper"
|
||||
:path="currentItem?.path"
|
||||
@done="
|
||||
() => {
|
||||
transferPopper = false
|
||||
load()
|
||||
}
|
||||
"
|
||||
:items="transferItems"
|
||||
:target_storage="inProps.storage"
|
||||
@done="transferDone"
|
||||
@close="transferPopper = false"
|
||||
/>
|
||||
<!-- 进度框 -->
|
||||
@@ -497,14 +755,4 @@ onMounted(() => {
|
||||
.v-toolbar {
|
||||
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>
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import type { EndPoints } from '@/api/types'
|
||||
import type { Axios, AxiosRequestConfig } from 'axios'
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
storages: Array as PropType<any[]>,
|
||||
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>,
|
||||
axios: Object as PropType<Axios>,
|
||||
axios: {
|
||||
type: Object as PropType<Axios>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
@@ -25,10 +39,8 @@ const sort = ref('name')
|
||||
|
||||
// 调整排序方式
|
||||
function changeSort() {
|
||||
if (sort.value === 'name')
|
||||
sort.value = 'time'
|
||||
else
|
||||
sort.value = 'name'
|
||||
if (sort.value === 'name') sort.value = 'time'
|
||||
else sort.value = 'name'
|
||||
|
||||
emit('sortchanged', sort.value)
|
||||
}
|
||||
@@ -36,18 +48,20 @@ function changeSort() {
|
||||
// 计算PATH面包屑
|
||||
const pathSegments = computed(() => {
|
||||
let path_str = ''
|
||||
const isFolder = inProps.path?.endsWith('/')
|
||||
const segments = inProps.path?.split('/').filter(item => item)
|
||||
|
||||
return segments?.map((item, index) => {
|
||||
path_str += item + ((index < segments.length - 1 || isFolder) ? '/' : '')
|
||||
return {
|
||||
name: item,
|
||||
path: path_str,
|
||||
}
|
||||
}) ?? []
|
||||
const isFolder = inProps.item.path?.endsWith('/')
|
||||
const segments = inProps.item.path?.split('/').filter(item => item)
|
||||
return (
|
||||
segments?.map((item, index) => {
|
||||
path_str += item + (index < segments.length - 1 || isFolder ? '/' : '')
|
||||
return {
|
||||
name: item,
|
||||
path: path_str,
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
})
|
||||
|
||||
// 当前存储
|
||||
const storageObject = computed(() => {
|
||||
return inProps.storages?.find(item => item.code === inProps.storage)
|
||||
})
|
||||
@@ -56,36 +70,34 @@ const storageObject = computed(() => {
|
||||
function changeStorage(code: string) {
|
||||
if (inProps.storage !== code) {
|
||||
emit('storagechanged', code)
|
||||
emit('pathchanged', '')
|
||||
}
|
||||
}
|
||||
|
||||
// 路径变化
|
||||
function changePath(_path: string) {
|
||||
emit('pathchanged', _path)
|
||||
function changePath(item: FileItem) {
|
||||
emit('pathchanged', item)
|
||||
}
|
||||
|
||||
// 返回上一级
|
||||
function goUp() {
|
||||
const segments = pathSegments.value ?? []
|
||||
const path = segments?.length === 1 ? '/' : segments[segments.length - 2].path
|
||||
changePath(path)
|
||||
const fileitem = inProps.itemstack[segments.length - 1]
|
||||
changePath(fileitem)
|
||||
}
|
||||
|
||||
// 创建目录
|
||||
async function mkdir() {
|
||||
emit('loading', true)
|
||||
const url = inProps.endpoints?.mkdir.url
|
||||
.replace(/{storage}/g, inProps.storage)
|
||||
.replace(/{path}/g, encodeURIComponent(inProps.path + newFolderName.value))
|
||||
const url = inProps.endpoints?.mkdir.url.replace(/{name}/g, newFolderName.value)
|
||||
|
||||
const config = {
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
method: inProps.endpoints?.mkdir.method || 'post',
|
||||
data: inProps.item,
|
||||
}
|
||||
|
||||
// 调API
|
||||
await inProps.axios?.request(config)
|
||||
await inProps.axios.request(config)
|
||||
|
||||
newFolderPopper.value = false
|
||||
newFolderName.value = ''
|
||||
@@ -97,10 +109,8 @@ async function mkdir() {
|
||||
|
||||
// 计算排序图标
|
||||
const sortIcon = computed(() => {
|
||||
if (sort.value === 'time')
|
||||
return 'mdi-sort-clock-ascending-outline'
|
||||
else
|
||||
return 'mdi-sort-alphabetical-ascending'
|
||||
if (sort.value === 'time') return 'mdi-sort-clock-ascending-outline'
|
||||
else return 'mdi-sort-alphabetical-ascending'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -127,16 +137,17 @@ const sortIcon = computed(() => {
|
||||
</VListItem>
|
||||
</VList>
|
||||
</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" />
|
||||
{{ storageObject?.name }}
|
||||
</VBtn>
|
||||
<template v-for="(segment, index) in pathSegments" :key="index">
|
||||
<VBtn
|
||||
v-if="display.mdAndUp.value"
|
||||
variant="text"
|
||||
:input-value="index === pathSegments.length - 1"
|
||||
class="px-1 d-none d-md-block"
|
||||
@click="changePath(segment.path)"
|
||||
class="px-1"
|
||||
@click="changePath(inProps.itemstack[index + 1])"
|
||||
>
|
||||
<VIcon icon=" mdi-chevron-right" />
|
||||
{{ segment.name }}
|
||||
@@ -158,10 +169,7 @@ const sortIcon = computed(() => {
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VDialog
|
||||
v-model="newFolderPopper"
|
||||
max-width="50rem"
|
||||
>
|
||||
<VDialog v-model="newFolderPopper" max-width="50rem">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props">
|
||||
<VTooltip text="新建文件夹">
|
||||
@@ -172,20 +180,14 @@ const sortIcon = computed(() => {
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VCard title="新建文件夹">
|
||||
<DialogCloseBtn @click="newFolderPopper = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField v-model="newFolderName" label="名称" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<div class="flex-grow-1" />
|
||||
<VBtn depressed @click="newFolderPopper = false">
|
||||
取消
|
||||
</VBtn>
|
||||
<VBtn
|
||||
:disabled="!newFolderName"
|
||||
depressed
|
||||
variant="tonal"
|
||||
@click="mkdir"
|
||||
>
|
||||
<VBtn :disabled="!newFolderName" variant="elevated" @click="mkdir" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
新建
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -10,6 +10,10 @@ const props = defineProps({
|
||||
default: '/',
|
||||
required: true,
|
||||
},
|
||||
storage: {
|
||||
type: String,
|
||||
default: 'local',
|
||||
},
|
||||
})
|
||||
|
||||
// update:modelValue 事件
|
||||
@@ -27,19 +31,19 @@ const treeItems = ref<FileItem[]>([
|
||||
name: '/',
|
||||
path: props.root,
|
||||
children: [],
|
||||
type: '',
|
||||
type: 'dir',
|
||||
basename: props.root,
|
||||
extension: '',
|
||||
size: 0,
|
||||
modify_time: 0,
|
||||
storage: props.storage,
|
||||
},
|
||||
])
|
||||
|
||||
// 拉取子目录
|
||||
async function fetchDirs(item: any) {
|
||||
return api
|
||||
.get('/filebrowser/listdir?path=' + item.path)
|
||||
.post('/storage/list', item)
|
||||
.then((data: any) => {
|
||||
// 只添加目录到子目录
|
||||
data = data.filter((i: any) => i.type === 'dir')
|
||||
item.children.push(...data)
|
||||
})
|
||||
.catch(err => console.warn(err))
|
||||
@@ -59,9 +63,24 @@ watch(activedDirs, newVal => {
|
||||
emit('update:modelValue', selectedPath)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchDirs(treeItems.value[0])
|
||||
})
|
||||
// 监听存储变化
|
||||
watch(
|
||||
() => props.storage,
|
||||
async newVal => {
|
||||
treeItems.value = [
|
||||
{
|
||||
name: '/',
|
||||
path: props.root,
|
||||
children: [],
|
||||
type: 'dir',
|
||||
basename: props.root,
|
||||
storage: newVal,
|
||||
},
|
||||
]
|
||||
openedDirs.value = []
|
||||
activedDirs.value = []
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -12,12 +12,18 @@ 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'])
|
||||
@@ -32,10 +38,10 @@ onUnmounted(() => {
|
||||
<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'" />
|
||||
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" />
|
||||
<AnalyticsCpu v-else-if="config?.id === 'cpu'" />
|
||||
<AnalyticsMemory v-else-if="config?.id === 'memory'" />
|
||||
<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'" />
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 元素
|
||||
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)
|
||||
run_to_left_px = slideview_content.value.scrollWidth - slideview_content.value.clientWidth
|
||||
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
const card_index = card_current - card_max
|
||||
run_to_left_px = card_index * card_width
|
||||
if (run_to_left_px <= 0)
|
||||
run_to_left_px = 0
|
||||
if (run_to_left_px <= 0) run_to_left_px = 0
|
||||
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
|
||||
}
|
||||
slideview_content.value.scrollTo({
|
||||
@@ -46,7 +48,7 @@ function slideNext(next: boolean) {
|
||||
function countMaxNumber() {
|
||||
slide_card_length = slideview_content.value.children.length
|
||||
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_max = Math.trunc(slideview_content.value.clientWidth / card_width)
|
||||
countDisabled()
|
||||
@@ -55,16 +57,18 @@ function countMaxNumber() {
|
||||
// 修改分页切换按钮状态
|
||||
function countDisabled() {
|
||||
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)
|
||||
if (slide_card_length * card_width <= slideview_content.value.clientWidth)
|
||||
disabled.value = 3
|
||||
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)
|
||||
card_current =
|
||||
slideview_content.value.scrollLeft === 0
|
||||
? 0
|
||||
: Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
|
||||
if (slide_card_length * card_width <= slideview_content.value.clientWidth) disabled.value = 3
|
||||
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
|
||||
|
||||
else
|
||||
disabled.value = 1
|
||||
else disabled.value = 1
|
||||
}
|
||||
|
||||
// 组件加载完成
|
||||
@@ -91,7 +95,7 @@ onActivated(() => {
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</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
|
||||
class="rounded-circle"
|
||||
variant="text"
|
||||
@@ -122,9 +126,8 @@ onActivated(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slideview_content {
|
||||
overflow: scroll hidden !important;
|
||||
-ms-overflow-style: none !important;
|
||||
overflow-x: scroll !important;
|
||||
overflow-y: hidden !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
scrollbar-width: none !important;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
// 输入参数
|
||||
const props = inject('rankingPropsKey')
|
||||
|
||||
const props: any = inject('rankingPropsKey')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ms-1"
|
||||
>
|
||||
<RouterLink
|
||||
:to="props.linkurl ? props.linkurl : ''"
|
||||
class="slider-title"
|
||||
>
|
||||
<span>{{ props.title }}</span>
|
||||
<VIcon
|
||||
icon="mdi-arrow-right-circle-outline"
|
||||
class="ms-1"
|
||||
/>
|
||||
<div class="ms-1">
|
||||
<RouterLink :to="props?.linkurl ? props?.linkurl : ''" class="slider-title">
|
||||
<span>{{ props?.title }}</span>
|
||||
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Number,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 搜索
|
||||
const resourceSearch = ref('')
|
||||
|
||||
// 总条数
|
||||
const resourceTotalItems = ref(0)
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 资源浏览表头
|
||||
const resourceHeaders = [
|
||||
{ title: '标题', key: 'title', sortable: false },
|
||||
{ title: '时间', key: 'pubdate', sortable: true },
|
||||
{ title: '大小', key: 'size', sortable: true },
|
||||
{ title: '做种', key: 'seeders', sortable: true },
|
||||
{ title: '下载', key: 'peers', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
window.open(page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile(enclosure: string) {
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site}`)
|
||||
resourceLoading.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
else return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_torrent: any) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认下载【${_torrent.site_name}】${_torrent?.title} ?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('download/add', _torrent)
|
||||
|
||||
if (result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
||||
} else {
|
||||
// 添加下载失败
|
||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDataTable
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
:items="resourceDataList"
|
||||
:items-length="resourceTotalItems"
|
||||
:search="resourceSearch"
|
||||
:loading="resourceLoading"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
hover
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-sm my-1">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</a>
|
||||
</template>
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.date_elapsed }}</div>
|
||||
<div class="text-sm">
|
||||
{{ item.pubdate }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.size="{ item }">
|
||||
<div class="text-nowrap whitespace-nowrap">
|
||||
{{ formatFileSize(item.size) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.seeders="{ item }">
|
||||
<div>{{ item.seeders }}</div>
|
||||
</template>
|
||||
<template #item.peers="{ item }">
|
||||
<div>{{ item.peers }}</div>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail(item.page_url || '')">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTable>
|
||||
</template>
|
||||
@@ -2,8 +2,6 @@
|
||||
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
|
||||
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
||||
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
|
||||
|
||||
// Components
|
||||
import Footer from '@/layouts/components/Footer.vue'
|
||||
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
|
||||
import UserNofification from '@/layouts/components/UserNotification.vue'
|
||||
@@ -11,9 +9,49 @@ import SearchBar from '@/layouts/components/SearchBar.vue'
|
||||
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import store from '@/store'
|
||||
import { SystemNavMenus } from '@/router/menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 从Vuex Store中获取superuser信息
|
||||
const superUser = store.state.auth.superUser
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode')
|
||||
|
||||
// 是否超级用户
|
||||
let superUser = store.state.auth.superUser
|
||||
|
||||
// 开始菜单项
|
||||
const startMenus = ref<NavMenu[]>([])
|
||||
|
||||
// 发现菜单项
|
||||
const discoveryMenus = ref<NavMenu[]>([])
|
||||
|
||||
// 订阅菜单项
|
||||
const subscribeMenus = ref<NavMenu[]>([])
|
||||
|
||||
// 整理菜单项
|
||||
const organizeMenus = ref<NavMenu[]>([])
|
||||
|
||||
// 系统菜单项
|
||||
const systemMenus = ref<NavMenu[]>([])
|
||||
|
||||
// 根据分类获取菜单列表
|
||||
const getMenuList = (header: string) => {
|
||||
return SystemNavMenus.filter((item: NavMenu) => item.header === header && (superUser || !item.admin))
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
function goBack() {
|
||||
history.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 获取菜单列表
|
||||
startMenus.value = getMenuList('开始')
|
||||
discoveryMenus.value = getMenuList('发现')
|
||||
subscribeMenus.value = getMenuList('订阅')
|
||||
organizeMenus.value = getMenuList('整理')
|
||||
systemMenus.value = getMenuList('系统')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,154 +60,67 @@ const superUser = store.state.auth.superUser
|
||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||||
<div class="d-flex h-100 align-center mx-1">
|
||||
<!-- 👉 Vertical Nav Toggle -->
|
||||
<IconBtn class="ms-n2 d-lg-none" @click="toggleVerticalOverlayNavActive(true)">
|
||||
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
|
||||
<VIcon icon="mdi-menu" />
|
||||
</IconBtn>
|
||||
|
||||
<!-- 👉 Back Button -->
|
||||
<IconBtn v-if="appMode && display.lgAndUp" class="ms-n2" @click="goBack">
|
||||
<VIcon icon="mdi-arrow-left" size="32" />
|
||||
</IconBtn>
|
||||
<!-- 👉 Search Bar -->
|
||||
<SearchBar />
|
||||
|
||||
<!-- 👉 Spacer -->
|
||||
<VSpacer />
|
||||
|
||||
<!-- 👉 Shortcuts -->
|
||||
<ShortcutBar v-if="superUser" />
|
||||
|
||||
<!-- 👉 Theme -->
|
||||
<NavbarThemeSwitcher />
|
||||
|
||||
<!-- 👉 Notification -->
|
||||
<UserNofification />
|
||||
|
||||
<!-- 👉 UserProfile -->
|
||||
<UserProfile />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #vertical-nav-content>
|
||||
<VerticalNavLink
|
||||
:item="{
|
||||
title: '仪表板',
|
||||
icon: 'mdi-home-outline',
|
||||
to: '/dashboard',
|
||||
}"
|
||||
/>
|
||||
|
||||
<VerticalNavLink v-for="item in startMenus" :item="item" />
|
||||
<!-- 👉 发现 -->
|
||||
<VerticalNavSectionTitle
|
||||
v-if="discoveryMenus.length > 0"
|
||||
:item="{
|
||||
heading: '发现',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink
|
||||
:item="{
|
||||
title: '推荐',
|
||||
icon: 'mdi-table-star',
|
||||
to: '/ranking',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink
|
||||
:item="{
|
||||
title: '资源搜索',
|
||||
icon: 'mdi-magnify',
|
||||
to: '/resource',
|
||||
}"
|
||||
/>
|
||||
|
||||
<VerticalNavLink v-for="item in discoveryMenus" :item="item" />
|
||||
<!-- 👉 订阅 -->
|
||||
<VerticalNavSectionTitle
|
||||
v-if="subscribeMenus.length > 0"
|
||||
:item="{
|
||||
heading: '订阅',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink
|
||||
:item="{
|
||||
title: '电影',
|
||||
icon: 'mdi-movie-check-outline',
|
||||
to: '/subscribe-movie?tab=mysub',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink
|
||||
:item="{
|
||||
title: '电视剧',
|
||||
icon: 'mdi-television-classic',
|
||||
to: '/subscribe-tv?tab=mysub',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink
|
||||
:item="{
|
||||
title: '日历',
|
||||
icon: 'mdi-calendar',
|
||||
to: '/calendar',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink v-for="item in subscribeMenus" :item="item" />
|
||||
<!-- 👉 整理 -->
|
||||
<VerticalNavSectionTitle
|
||||
v-if="organizeMenus.length > 0"
|
||||
:item="{
|
||||
heading: '整理',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink
|
||||
: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',
|
||||
}"
|
||||
/>
|
||||
|
||||
<VerticalNavLink v-for="item in organizeMenus" :item="item" />
|
||||
<!-- 👉 系统 -->
|
||||
<VerticalNavSectionTitle
|
||||
v-if="superUser"
|
||||
v-if="systemMenus.length > 0"
|
||||
:item="{
|
||||
heading: '系统',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink
|
||||
v-if="superUser"
|
||||
:item="{
|
||||
title: '插件',
|
||||
icon: 'mdi-apps',
|
||||
to: '/plugins?tab=installed',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink
|
||||
v-if="superUser"
|
||||
:item="{
|
||||
title: '站点管理',
|
||||
icon: 'mdi-web',
|
||||
to: '/site',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink
|
||||
v-if="superUser"
|
||||
:item="{
|
||||
title: '设定',
|
||||
icon: 'mdi-cog',
|
||||
to: '/setting?tab=account',
|
||||
}"
|
||||
/>
|
||||
<VerticalNavLink v-for="item in systemMenus" :item="item" />
|
||||
</template>
|
||||
|
||||
<template #after-vertical-nav-items />
|
||||
|
||||
<!-- 👉 Pages -->
|
||||
<slot />
|
||||
|
||||
<!-- 👉 Footer -->
|
||||
<template #footer>
|
||||
<Footer />
|
||||
@@ -1,3 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && 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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -1,109 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
import * as Mousetrap from 'mousetrap'
|
||||
import SearchBarView from '@/views/system/SearchBarView.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 搜索词
|
||||
const searchWord = ref(null)
|
||||
const display = useDisplay()
|
||||
|
||||
// 搜索弹窗
|
||||
const searchDialog = ref(false)
|
||||
|
||||
// ref
|
||||
const searchWordInput = ref<HTMLElement | null>(null)
|
||||
|
||||
// 当前的搜索类型 media/person
|
||||
const searchType = ref('media')
|
||||
|
||||
// 搜索提示词列表
|
||||
const searchHintList = ref<string[]>([])
|
||||
|
||||
// Search
|
||||
function search() {
|
||||
if (!searchWord.value) return
|
||||
if (!searchHintList.value.includes(searchWord.value)) searchHintList.value.push(searchWord.value)
|
||||
searchDialog.value = false
|
||||
router.push({
|
||||
path: '/browse/media/search',
|
||||
query: {
|
||||
title: searchWord.value,
|
||||
type: searchType.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 切换搜索类型
|
||||
function switchSearchType() {
|
||||
searchType.value = searchType.value === 'media' ? 'person' : 'media'
|
||||
}
|
||||
// 注册快捷键
|
||||
Mousetrap.bind(['command+k', 'ctrl+k'], openSearchDialog)
|
||||
|
||||
// 打开搜索弹窗
|
||||
function openSearchDialog() {
|
||||
searchDialog.value = true
|
||||
nextTick(() => {
|
||||
searchWordInput.value?.focus()
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// 检测操作系统是否是Mac
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
}
|
||||
// 计算属性:根据操作系统显示不同的按键提示
|
||||
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<VCombobox
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
:items="searchHintList"
|
||||
:prepend-inner-icon="searchType == 'person' ? 'mdi-account' : 'mdi-movie'"
|
||||
:label="searchType == 'person' ? '搜索演员' : '搜索电影、电视剧'"
|
||||
@keydown.enter="search"
|
||||
@click:prepend-inner="switchSearchType"
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="tonal" @click="search"> 搜索 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
<!-- 👉 Search Icon -->
|
||||
<IconBtn class="d-md-none" @click="openSearchDialog">
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</IconBtn>
|
||||
<!-- 👉 Search Textfield -->
|
||||
<span class="w-full me-3">
|
||||
<VCombobox
|
||||
key="search_navbar"
|
||||
v-model="searchWord"
|
||||
:items="searchHintList"
|
||||
class="d-none d-md-block text-disabled search-box"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
:prepend-inner-icon="searchType == 'person' ? 'mdi-account' : 'mdi-movie'"
|
||||
:label="searchType == 'person' ? '搜索演员' : '搜索电影、电视剧'"
|
||||
append-inner-icon="mdi-magnify"
|
||||
single-line
|
||||
hide-details
|
||||
flat
|
||||
rounded
|
||||
@click:append-inner="search"
|
||||
@click:prepend-inner="switchSearchType"
|
||||
@keydown.enter="search"
|
||||
/>
|
||||
</span>
|
||||
<div class="d-flex align-center cursor-pointer ms-lg-n2" style="user-select: none">
|
||||
<IconBtn @click="openSearchDialog">
|
||||
<VIcon icon="ri-search-line" />
|
||||
</IconBtn>
|
||||
<span v-if="display.lgAndUp.value" class="flex align-center text-disabled ms-2" @click="openSearchDialog">
|
||||
<span class="me-3">搜索</span>
|
||||
<span class="meta-key">{{ metaKey }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 搜索弹窗 -->
|
||||
<SearchBarView v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.search-box div.v-input__control div[role='textbox'] {
|
||||
border: 1px solid rgb(var(--v-theme-background));
|
||||
<style type="scss" scoped>
|
||||
.meta-key {
|
||||
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>
|
||||
|
||||
@@ -5,9 +5,9 @@ import LoggingView from '@/views/system/LoggingView.vue'
|
||||
import RuleTestView from '@/views/system/RuleTestView.vue'
|
||||
import ModuleTestView from '@/views/system/ModuleTestView.vue'
|
||||
import MessageView from '@/views/system/MessageView.vue'
|
||||
import store from '@/store'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { getQueryValue } from '@/@core/utils'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -54,8 +54,7 @@ function scrollMessageToEnd() {
|
||||
|
||||
// 拼接全部日志url
|
||||
function allLoggingUrl() {
|
||||
const token = store.state.auth.token
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
@@ -75,6 +74,29 @@ async function sendMessage() {
|
||||
|
||||
onMounted(() => {
|
||||
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>
|
||||
|
||||
@@ -121,8 +143,8 @@ onMounted(() => {
|
||||
<VAvatar size="48" variant="tonal">
|
||||
<VIcon icon="mdi-filter-cog-outline" />
|
||||
</VAvatar>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">优先级</h6>
|
||||
<span class="text-sm">优先级规则测试</span>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">规则</h6>
|
||||
<span class="text-sm">规则测试</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -182,6 +204,7 @@ onMounted(() => {
|
||||
<VDialog v-if="netTestDialog" v-model="netTestDialog" max-width="35rem" max-height="85vh" scrollable>
|
||||
<VCard title="网络测试">
|
||||
<DialogCloseBtn @click="netTestDialog = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<NetTestView />
|
||||
</VCardText>
|
||||
@@ -210,6 +233,7 @@ onMounted(() => {
|
||||
</a>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<LoggingView />
|
||||
</VCardText>
|
||||
@@ -217,7 +241,7 @@ onMounted(() => {
|
||||
</VDialog>
|
||||
<!-- 规则测试弹窗 -->
|
||||
<VDialog v-if="ruleTestDialog" v-model="ruleTestDialog" max-width="50rem" scrollable>
|
||||
<VCard title="优先级测试">
|
||||
<VCard title="规则测试">
|
||||
<DialogCloseBtn @click="ruleTestDialog = false" />
|
||||
<VCardText>
|
||||
<RuleTestView />
|
||||
@@ -228,6 +252,7 @@ onMounted(() => {
|
||||
<VDialog v-if="systemTestDialog" v-model="systemTestDialog" max-width="35rem" max-height="85vh" scrollable>
|
||||
<VCard title="系统健康检查">
|
||||
<DialogCloseBtn @click="systemTestDialog = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<ModuleTestView />
|
||||
</VCardText>
|
||||
@@ -243,23 +268,20 @@ onMounted(() => {
|
||||
>
|
||||
<VCard title="消息中心">
|
||||
<DialogCloseBtn @click="messageDialog = false" />
|
||||
<VDivider />
|
||||
<VCardText ref="chatContainer">
|
||||
<MessageView @scroll="scrollMessageToEnd" />
|
||||
</VCardText>
|
||||
|
||||
<VCardItem>
|
||||
<VTextField
|
||||
v-model="user_message"
|
||||
variant="solo"
|
||||
placeholder="输入消息或命令"
|
||||
outlined
|
||||
hide-details
|
||||
single-line
|
||||
clearable
|
||||
density="compact"
|
||||
:disabled="sendButtonDisabled"
|
||||
@keydown.enter="sendMessage"
|
||||
>
|
||||
<template #append>
|
||||
<template #append-inner>
|
||||
<VBtn color="primary" :disabled="sendButtonDisabled" @click="sendMessage"> 发送 </VBtn>
|
||||
</template>
|
||||
</VTextField>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import store from '@/store'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import { SystemNotification } from '@/api/types'
|
||||
|
||||
@@ -17,9 +16,9 @@ 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}`)
|
||||
// 延迟 3 秒启动 SSE,避免相关认证信息尚未写入 Cookie 导致 403
|
||||
setTimeout(() => {
|
||||
eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message`)
|
||||
eventSource.addEventListener('message', event => {
|
||||
if (event.data) {
|
||||
const noti: SystemNotification = JSON.parse(event.data)
|
||||
@@ -28,7 +27,7 @@ function startSSEMessager() {
|
||||
// TODO 在顶部显示消息汽泡
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 页面加载时,加载当前用户数据
|
||||
|
||||
@@ -22,9 +22,7 @@ const progressDialog = ref(false)
|
||||
// 执行注销操作
|
||||
function logout() {
|
||||
// 清除登录状态信息
|
||||
store.dispatch('auth/clearToken')
|
||||
// 主动登出时清除路由标记
|
||||
store.state.auth.originalPath = null
|
||||
store.dispatch('auth/logout')
|
||||
// 重定向到登录页面或其他适当的页面
|
||||
router.push('/login')
|
||||
}
|
||||
@@ -59,16 +57,15 @@ async function restart() {
|
||||
}
|
||||
|
||||
// 从Vuex Store中获取信息
|
||||
const superUser = store.state.auth.superUser
|
||||
const userName = store.state.auth.userName
|
||||
const avatar = store.state.auth.avatar
|
||||
const superUser = computed(() => store.state.auth.superUser)
|
||||
const userName = computed(() => store.state.auth.userName)
|
||||
const avatar = computed(() => store.state.auth.avatar || avatar1)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VAvatar class="cursor-pointer ms-3" color="primary" variant="tonal">
|
||||
<VImg :src="avatar ?? avatar1" />
|
||||
<VImg :src="avatar" />
|
||||
|
||||
<!-- SECTION Menu -->
|
||||
<VMenu activator="parent" width="230" location="bottom end" offset="14px">
|
||||
<VList>
|
||||
<!-- 👉 User Avatar & Name -->
|
||||
@@ -76,7 +73,7 @@ const avatar = store.state.auth.avatar
|
||||
<template #prepend>
|
||||
<VListItemAction start>
|
||||
<VAvatar color="primary" variant="tonal">
|
||||
<VImg :src="avatar ?? avatar1" />
|
||||
<VImg :src="avatar" />
|
||||
</VAvatar>
|
||||
</VListItemAction>
|
||||
</template>
|
||||
@@ -86,26 +83,27 @@ const avatar = store.state.auth.avatar
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<!-- 👉 Profile -->
|
||||
<VListItem v-if="superUser" link @click="router.push('/setting?tab=account')">
|
||||
<VListItem link @click="router.push('/profile')">
|
||||
<template #prepend>
|
||||
<VIcon class="me-2" icon="mdi-account-outline" size="22" />
|
||||
</template>
|
||||
<VListItemTitle>设定</VListItemTitle>
|
||||
<VListItemTitle>个人信息</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 FAQ -->
|
||||
<VListItem href="https://github.com/jxxghp/MoviePilot/blob/main/README.md" target="_blank">
|
||||
<VListItem href="https://wiki.movie-pilot.org" target="_blank">
|
||||
<template #prepend>
|
||||
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
|
||||
</template>
|
||||
<VListItemTitle>帮助</VListItemTitle>
|
||||
<VListItemTitle>帮助文档</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- Divider -->
|
||||
<VDivider class="my-2" />
|
||||
<VDivider v-if="superUser" class="my-2" />
|
||||
|
||||
<!-- 👉 restart -->
|
||||
<VListItem v-if="superUser" @click="restart">
|
||||
@@ -115,9 +113,6 @@ const avatar = store.state.auth.avatar
|
||||
<VListItemTitle>重启</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- Divider -->
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<!-- 👉 Logout -->
|
||||
<VListItem @click="logout">
|
||||
<VBtn color="error" block>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import DefaultLayoutWithVerticalNav from './components/DefaultLayoutWithVerticalNav.vue'
|
||||
import DefaultLayout from './components/DefaultLayout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayoutWithVerticalNav>
|
||||
<DefaultLayout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
|
||||
</router-view>
|
||||
</DefaultLayoutWithVerticalNav>
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
141
src/main.ts
@@ -1,23 +1,21 @@
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { createApp } from 'vue'
|
||||
import '@/@core/utils/compatibility'
|
||||
import '@/@iconify/icons-bundle'
|
||||
import ToastPlugin from 'vue-toast-notification'
|
||||
import VuetifyUseDialog from 'vuetify-use-dialog'
|
||||
import './ace-config'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { removeEl } from './@core/utils/dom'
|
||||
import '@/plugins/webfontloader'
|
||||
import App from '@/App.vue'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import { loadFonts } from '@/plugins/webfontloader'
|
||||
import router from '@/router'
|
||||
import store from '@/store'
|
||||
import '@core/scss/template/index.scss'
|
||||
import '@layouts/styles/index.scss'
|
||||
import '@styles/styles.scss'
|
||||
import 'vue-toast-notification/dist/theme-bootstrap.css'
|
||||
import { createApp } from 'vue'
|
||||
import { removeEl } from './@core/utils/dom'
|
||||
import { fetchGlobalSettings } from './api'
|
||||
import { isPWA } from './@core/utils/navigator'
|
||||
import './ace-config'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import 'vue3-perfect-scrollbar/style.css'
|
||||
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'
|
||||
@@ -27,61 +25,74 @@ 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 { fixArrayAt } from '@/@core/utils/compatibility'
|
||||
|
||||
// 修复低版本Safari等浏览器数组不支持at函数的问题
|
||||
fixArrayAt()
|
||||
|
||||
// 加载字体
|
||||
loadFonts()
|
||||
import '@core/scss/template/index.scss'
|
||||
import '@layouts/styles/index.scss'
|
||||
import '@styles/styles.scss'
|
||||
import 'vue-toast-notification/dist/theme-bootstrap.css'
|
||||
import 'vue3-perfect-scrollbar/style.css'
|
||||
|
||||
// 创建Vue实例
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册全局组件
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
.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)
|
||||
async function initializeApp() {
|
||||
try {
|
||||
// 是否为PWA
|
||||
const pwaMode = await isPWA()
|
||||
app.provide('pwaMode', pwaMode)
|
||||
// 全局设置
|
||||
const globalSettings = await fetchGlobalSettings()
|
||||
app.provide('globalSettings', globalSettings)
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize app', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册插件
|
||||
app
|
||||
.use(vuetify)
|
||||
.use(router)
|
||||
.use(store)
|
||||
.use(ToastPlugin, {
|
||||
position: 'bottom-right',
|
||||
})
|
||||
.use(VuetifyUseDialog, {
|
||||
confirmDialog: {
|
||||
dialogProps: {
|
||||
maxWidth: '40rem',
|
||||
// 注册全局组件
|
||||
initializeApp().then(() => {
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
.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
|
||||
.use(vuetify)
|
||||
.use(router)
|
||||
.use(store)
|
||||
.use(ToastPlugin, {
|
||||
position: 'bottom-right',
|
||||
})
|
||||
.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: '取消',
|
||||
},
|
||||
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(VueApexCharts)
|
||||
.mount('#app')
|
||||
.$nextTick(() => removeEl('#loading-bg'))
|
||||
})
|
||||
.use(PerfectScrollbarPlugin)
|
||||
.use(VueApexCharts)
|
||||
.mount('#app')
|
||||
.$nextTick(() => removeEl('#loading-bg'))
|
||||
})
|
||||
|
||||
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) && !item.footer)
|
||||
}
|
||||
|
||||
// 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="3" lg="2" 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>
|
||||
@@ -31,7 +31,7 @@ function getApiPath(paths: string[] | string) {
|
||||
<div v-if="title" class="mt-3 md:flex md:items-center md:justify-between">
|
||||
<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"
|
||||
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>
|
||||
|
||||
@@ -5,6 +5,11 @@ 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'
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 从Vuex Store中获取superuser信息
|
||||
const superUser = store.state.auth.superUser
|
||||
@@ -12,6 +17,9 @@ const superUser = store.state.auth.superUser
|
||||
// 是否拉升高度
|
||||
const isElevated = ref(true)
|
||||
|
||||
// 是否发送请求的总开关
|
||||
const isRequest = ref(true)
|
||||
|
||||
// 计算属性,控制是否拉升高度
|
||||
const elevatedConf = controlledComputed(
|
||||
() => isElevated.value,
|
||||
@@ -182,25 +190,21 @@ function sortDashboardConfigs() {
|
||||
// 设置项目
|
||||
async function saveDashboardConfig() {
|
||||
// 启用配置
|
||||
const data = JSON.stringify(enableConfig.value)
|
||||
localStorage.setItem('MP_DASHBOARD', data)
|
||||
const enableString = JSON.stringify(enableConfig.value)
|
||||
localStorage.setItem('MP_DASHBOARD', enableString)
|
||||
|
||||
// 顺序配置,从dashboardConfigs中提取
|
||||
const order = JSON.stringify(dashboardConfigs.value.map(item => ({ id: item.id, key: item.key })))
|
||||
localStorage.setItem('MP_DASHBOARD_ORDER', order)
|
||||
const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))
|
||||
const orderString = JSON.stringify(orderObj)
|
||||
localStorage.setItem('MP_DASHBOARD_ORDER', orderString)
|
||||
|
||||
// 是否拉升高度
|
||||
localStorage.setItem('MP_DASHBOARD_ELEVATED', isElevated.value.toString())
|
||||
|
||||
// 保存到服务端
|
||||
try {
|
||||
await api.post('/user/config/Dashboard', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
await api.post('/user/config/DashboardOrder', order, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
await api.post('/user/config/Dashboard', enableConfig.value)
|
||||
await api.post('/user/config/DashboardOrder', orderObj)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -262,7 +266,8 @@ async function getPluginDashboard(id: string, key: string) {
|
||||
if (
|
||||
res.attrs?.refresh &&
|
||||
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
|
||||
enableConfig.value[pluginDashboardId]
|
||||
enableConfig.value[pluginDashboardId] &&
|
||||
isRequest.value
|
||||
) {
|
||||
// 清除之前的定时器
|
||||
if (refreshTimers.value[pluginDashboardId]) {
|
||||
@@ -291,6 +296,14 @@ onBeforeMount(async () => {
|
||||
await loadDashboardConfig()
|
||||
getPluginDashboardMeta()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
isRequest.value = true
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
isRequest.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -307,6 +320,7 @@ onBeforeMount(async () => {
|
||||
<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>
|
||||
@@ -314,7 +328,16 @@ onBeforeMount(async () => {
|
||||
</draggable>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<VFab icon="mdi-view-dashboard-edit" location="bottom" size="x-large" fixed app appear @click="dialog = true" />
|
||||
<VFab
|
||||
icon="mdi-view-dashboard-edit"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="dialog = true"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
|
||||
<!-- 弹窗,根据配置生成选项 -->
|
||||
<VDialog v-model="dialog" max-width="35rem" scrollable>
|
||||
|
||||
@@ -1,7 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
|
||||
import router from '@/router'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const activeTab = ref(route.query.tab)
|
||||
|
||||
// 下载器
|
||||
const downloaders = ref<DownloaderConf[]>([])
|
||||
|
||||
// 获取启用的下载器
|
||||
const enabledDownloaders = computed(() => downloaders.value.filter(item => item.enabled))
|
||||
|
||||
// 调用API查询下载器设置
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Downloaders')
|
||||
if (result.data?.value && result.data.value.length > 0) {
|
||||
downloaders.value = result.data?.value ?? []
|
||||
if (!activeTab.value) activeTab.value = downloaders.value[0].name
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
function jumpTab(tab: string) {
|
||||
router.push('/subscribe/movie?tab=' + tab)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDownloaderSetting()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DownloadingListView />
|
||||
<div v-if="enabledDownloaders.length > 0">
|
||||
<VTabs v-model="activeTab">
|
||||
<VTab v-for="item in enabledDownloaders" :value="item.name" @to="jumpTab(item.name)">
|
||||
<span class="mx-5">{{ item.name }}</span>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindowItem v-for="item in enabledDownloaders" :value="item.name">
|
||||
<transition name="fade-slide" appear>
|
||||
<DownloadingListView :name="item.name" />
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-else
|
||||
error-code="404"
|
||||
error-title="没有下载器"
|
||||
error-description="请先在设置中正确配置并启用下载器。"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import router from '@/router'
|
||||
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()
|
||||
|
||||
@@ -30,11 +31,9 @@ const isPasswordVisible = ref(false)
|
||||
// 错误信息
|
||||
const errorMessage = ref('')
|
||||
|
||||
// 背景图片
|
||||
const backgroundImageUrl = ref('')
|
||||
|
||||
// 背景图片加载状态
|
||||
const isImageLoaded = ref(false)
|
||||
// 背景图片 URL 和预加载 URL
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
|
||||
// 是否开启双重验证
|
||||
const isOTP = ref(false)
|
||||
@@ -42,17 +41,18 @@ const isOTP = ref(false)
|
||||
// 用户名称输入框
|
||||
const usernameInput = ref()
|
||||
|
||||
// Interval定时器
|
||||
let intervalTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImage() {
|
||||
api
|
||||
.get('/login/wallpaper')
|
||||
.then((response: any) => {
|
||||
backgroundImageUrl.value = response.message
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.log(error)
|
||||
})
|
||||
try {
|
||||
backgroundImages.value = await api.get('/login/wallpapers')
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询是否开启双重验证
|
||||
const fetchOTP = debounce(async () => {
|
||||
const userid = usernameInput.value?.value
|
||||
@@ -72,9 +72,9 @@ const fetchOTP = debounce(async () => {
|
||||
|
||||
// 获取用户主题配置
|
||||
async function fetchThemeConfig() {
|
||||
const response = await api.get('/user/config/theme')
|
||||
const response = await api.get('/user/config/Layout')
|
||||
if (response && response.data && response.data.value) {
|
||||
return response.data.value
|
||||
return response.data.value?.theme
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -89,11 +89,39 @@ async function setTheme() {
|
||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||
}
|
||||
|
||||
async function afterLogin() {
|
||||
// 订阅推送通知
|
||||
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 afterLogin(superuser: boolean) {
|
||||
// 生效主题配置
|
||||
await setTheme()
|
||||
// 跳转到首页或回原始页面
|
||||
router.push(store.state.auth.originalPath ?? '/')
|
||||
// 订阅推送通知
|
||||
if (superuser) await subscribeForPushNotifications()
|
||||
}
|
||||
|
||||
// 登录获取token事件
|
||||
@@ -122,19 +150,28 @@ function login() {
|
||||
.then((response: any) => {
|
||||
// 获取token
|
||||
const token = response.access_token
|
||||
const superuser = response.super_user
|
||||
const username = response.user_name
|
||||
const superUser = response.super_user
|
||||
const userID = response.user_id
|
||||
const userName = response.user_name
|
||||
const avatar = response.avatar
|
||||
const level = response.level
|
||||
const remember = form.value.remember
|
||||
const permissions = response.permissions
|
||||
|
||||
// 更新token和remember状态到Vuex Store
|
||||
store.dispatch('auth/updateToken', token)
|
||||
store.dispatch('auth/updateRemember', form.value.remember)
|
||||
store.dispatch('auth/updateSuperUser', superuser)
|
||||
store.dispatch('auth/updateUserName', username)
|
||||
store.dispatch('auth/updateAvatar', avatar)
|
||||
store.dispatch('auth/login', {
|
||||
token,
|
||||
remember,
|
||||
superUser,
|
||||
userID,
|
||||
userName,
|
||||
avatar,
|
||||
level,
|
||||
permissions,
|
||||
})
|
||||
|
||||
// 登录后处理
|
||||
afterLogin()
|
||||
afterLogin(superUser)
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// 登录失败,显示错误提示
|
||||
@@ -146,8 +183,15 @@ function login() {
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化背景图片轮循
|
||||
function startBackgroundRotation() {
|
||||
intervalTimer = setInterval(() => {
|
||||
activeImageIndex.value = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
}, 5000) // 每5秒切换一次图片
|
||||
}
|
||||
|
||||
// 自动登录
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// 从Vuex Store中获取token和remember状态
|
||||
const token = store.state.auth.token
|
||||
const remember = store.state.auth.remember
|
||||
@@ -157,36 +201,45 @@ onMounted(() => {
|
||||
router.push('/')
|
||||
} else {
|
||||
// 获取背景图片
|
||||
fetchBackgroundImage()
|
||||
await fetchBackgroundImage()
|
||||
if (backgroundImages.value.length > 1) {
|
||||
startBackgroundRotation()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalTimer) clearInterval(intervalTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VImg
|
||||
aspect-ratio="4/3"
|
||||
:src="backgroundImageUrl"
|
||||
class="w-full h-full overflow-hidden"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
>
|
||||
<div class="auth-wrapper d-flex align-center justify-center pa-4">
|
||||
<VCard
|
||||
class="auth-card pa-7 w-full h-full"
|
||||
:class="isImageLoaded ? 'backdrop-blur-xl bg-white/50' : ''"
|
||||
max-width="25rem"
|
||||
:theme="isImageLoaded ? 'light' : ''"
|
||||
<!-- 当前背景图片 -->
|
||||
<div class="relative flex min-h-screen flex-col bg-gray-900 items-center justify-center">
|
||||
<div>
|
||||
<div
|
||||
v-for="(imageUrl, index) in backgroundImages"
|
||||
class="absolute-top-shift absolute inset-0 bg-cover bg-center transition-opacity duration-300 ease-in"
|
||||
:class="{ 'opacity-100': index === activeImageIndex, 'opacity-0': index !== activeImageIndex }"
|
||||
>
|
||||
<VCardItem class="justify-center mb-7">
|
||||
<VImg :src="imageUrl" class="absolute inset-0 transition-opacity duration-1000" cover position="center top" />
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background-image: linear-gradient(rgba(45, 55, 72, 47%) 0%, rgb(26, 32, 46) 100%)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 登录表单 -->
|
||||
<div class="auth-wrapper d-flex align-center justify-center">
|
||||
<VCard class="auth-card px-7 py-3 w-full h-full rounded-lg opacity-85" 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>
|
||||
<VCardTitle class="font-weight-bold text-2xl text-uppercase"> MoviePilot </VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VForm ref="refForm" @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
@@ -231,7 +284,7 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</VImg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -240,4 +293,13 @@ onMounted(() => {
|
||||
.v-card-item__prepend {
|
||||
padding-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
.absolute-top-shift {
|
||||
inset-block-start: calc(-4rem - env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.auth-wrapper {
|
||||
overflow: hidden;
|
||||
block-size: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
9
src/pages/profile.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import UserProfileView from '@/views/user/UserProfileView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UserProfileView />
|
||||
</div>
|
||||
</template>
|
||||