Compare commits
494 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b446afb6d8 | ||
|
|
8580af36d1 | ||
|
|
95ca092117 | ||
|
|
ba200cae5c | ||
|
|
87c73e0253 | ||
|
|
d4d7f635f5 | ||
|
|
729db1510e | ||
|
|
8a12ecf918 | ||
|
|
cacc2602df | ||
|
|
8c6cfa7fc5 | ||
|
|
0113f28d8c | ||
|
|
d870b788bc | ||
|
|
19a3213be0 | ||
|
|
f5c8a463fa | ||
|
|
ff3b5b4232 | ||
|
|
6da0aae362 | ||
|
|
abbce2644a | ||
|
|
1c5773444e | ||
|
|
1674f15d7c | ||
|
|
c6981e9955 | ||
|
|
96d3426d0c | ||
|
|
c88b2abcce | ||
|
|
42fe928155 | ||
|
|
4cc455b948 | ||
|
|
bce073ebe0 | ||
|
|
c27167097e | ||
|
|
44d23480a3 | ||
|
|
01796b3dc5 | ||
|
|
dcf0924c73 | ||
|
|
cc8d5cf931 | ||
|
|
6083887675 | ||
|
|
beb0506b0c | ||
|
|
0f906f791a | ||
|
|
7614696e61 | ||
|
|
4235d3687c | ||
|
|
2960e7cfde | ||
|
|
e0ebc35178 | ||
|
|
07c9442ac8 | ||
|
|
ccc820e8d2 | ||
|
|
68bb568400 | ||
|
|
13cd214e6d | ||
|
|
311880bcd3 | ||
|
|
088ebbe0bb | ||
|
|
de3523056a | ||
|
|
cf139a938e | ||
|
|
be2f4d0170 | ||
|
|
79493665c1 | ||
|
|
106062da82 | ||
|
|
50e54e943d | ||
|
|
6b811f2250 | ||
|
|
fa7f2a6c7c | ||
|
|
e362f3cbdd | ||
|
|
f4c4d7495f | ||
|
|
5b850d9464 | ||
|
|
d7f74a3a8a | ||
|
|
91dbf065db | ||
|
|
1759e666ba | ||
|
|
65230f1ae8 | ||
|
|
508cf5d08f | ||
|
|
0e9ddc9da2 | ||
|
|
48e6fc4466 | ||
|
|
30a4c55050 | ||
|
|
dee5d9d213 | ||
|
|
c5e2b1349f | ||
|
|
0e005c3c7e | ||
|
|
348ae6b313 | ||
|
|
122ecc82fd | ||
|
|
88fad5b764 | ||
|
|
f01971ee3a | ||
|
|
5e8489c620 | ||
|
|
6900042cf7 | ||
|
|
75862c026a | ||
|
|
bbe3368c69 | ||
|
|
587f06eb9f | ||
|
|
7114c63e8f | ||
|
|
2a6f9e3cc0 | ||
|
|
00d37d7bda | ||
|
|
546af84dab | ||
|
|
5953496d84 | ||
|
|
0fda7c70de | ||
|
|
48546e1999 | ||
|
|
06355ff91d | ||
|
|
523f8c4cc8 | ||
|
|
73f6e7482f | ||
|
|
81ab3f9da8 | ||
|
|
d520645a8b | ||
|
|
af67fddce0 | ||
|
|
6d89dad8de | ||
|
|
f3ab2a8eff | ||
|
|
74c980c7a5 | ||
|
|
52fc2557ec | ||
|
|
34124418f8 | ||
|
|
e2d36da299 | ||
|
|
9965428bae | ||
|
|
e62a0b5a8d | ||
|
|
3c926f7485 | ||
|
|
de3f4e6374 | ||
|
|
2e22f6ae86 | ||
|
|
99665c7d79 | ||
|
|
a4a00586c7 | ||
|
|
cf59a07d4b | ||
|
|
8a362d0740 | ||
|
|
b49385af29 | ||
|
|
b227412c96 | ||
|
|
b3c8faab70 | ||
|
|
9a480dd803 | ||
|
|
847fd13982 | ||
|
|
7fa4f4a2f0 | ||
|
|
4207a70716 | ||
|
|
c97247b92b | ||
|
|
e9bed7ff8a | ||
|
|
f25a619f13 | ||
|
|
2065b05143 | ||
|
|
eec1f2d7b3 | ||
|
|
17a343392c | ||
|
|
a2b2e8cd94 | ||
|
|
9703b2dbee | ||
|
|
310a501380 | ||
|
|
30bf895ae1 | ||
|
|
4f9dce70d3 | ||
|
|
f495e13667 | ||
|
|
f293681588 | ||
|
|
2f1a356e65 | ||
|
|
5909d2423c | ||
|
|
42f7df8f4a | ||
|
|
abaa40d819 | ||
|
|
0d05a104c4 | ||
|
|
e8708f8de7 | ||
|
|
7918b21b5b | ||
|
|
088db67089 | ||
|
|
62e0d8e9dc | ||
|
|
96d655155a | ||
|
|
a475085d7b | ||
|
|
58fdb77b37 | ||
|
|
8a25c6578d | ||
|
|
ef62bd6e98 | ||
|
|
876a46607b | ||
|
|
107f70abde | ||
|
|
090b9d735d | ||
|
|
dbeea6afcc | ||
|
|
2931f5df46 | ||
|
|
e14c81d178 | ||
|
|
a9403c9c34 | ||
|
|
dc4914e3ca | ||
|
|
f3dbc4afad | ||
|
|
e3e22aebd9 | ||
|
|
0ca2f20b24 | ||
|
|
14279c773d | ||
|
|
8372f63eb6 | ||
|
|
b7b62d7922 | ||
|
|
162cce1f50 | ||
|
|
aa49c6ccbc | ||
|
|
a40e52079f | ||
|
|
c29e329548 | ||
|
|
e2d26f6a25 | ||
|
|
1752256868 | ||
|
|
23d7f0dcc1 | ||
|
|
288aeed178 | ||
|
|
9a9a618136 | ||
|
|
723eb319e1 | ||
|
|
96684a8d13 | ||
|
|
fc9fe5e21e | ||
|
|
24b763d808 | ||
|
|
f761cdff00 | ||
|
|
b785769138 | ||
|
|
6d1febd70a | ||
|
|
bdbaf503ca | ||
|
|
f9e74cf436 | ||
|
|
e043669a10 | ||
|
|
78d8fdba9d | ||
|
|
5c0f0386a6 | ||
|
|
30b39283b6 | ||
|
|
de84c39d2f | ||
|
|
65152e7e37 | ||
|
|
ba343ce5fa | ||
|
|
60495668a6 | ||
|
|
f2ac624dbb | ||
|
|
6238849d3f | ||
|
|
82cb903c1f | ||
|
|
5e5eb95b55 | ||
|
|
74e6f8b03e | ||
|
|
a2bf0d2b16 | ||
|
|
7532d39978 | ||
|
|
5cc9bf7418 | ||
|
|
20bdb940cd | ||
|
|
e9b214cff8 | ||
|
|
54f5fb2877 | ||
|
|
e86cb9e1cc | ||
|
|
3f258b9016 | ||
|
|
b54e144d0e | ||
|
|
7b20a7b775 | ||
|
|
df66b3e917 | ||
|
|
a919622d08 | ||
|
|
2a9ce950b7 | ||
|
|
48c12b765d | ||
|
|
1120055eed | ||
|
|
c66b6649e2 | ||
|
|
8479099926 | ||
|
|
cab65be1c9 | ||
|
|
6689e976c2 | ||
|
|
712dfa3fe1 | ||
|
|
346121f3c2 | ||
|
|
61c073ad6c | ||
|
|
4b3733bc19 | ||
|
|
b29c6bd83f | ||
|
|
b40fc4bd30 | ||
|
|
a225ba6075 | ||
|
|
303fe39c01 | ||
|
|
d343cbcf71 | ||
|
|
0eef8c5174 | ||
|
|
46fe257585 | ||
|
|
f69a57863e | ||
|
|
8876aadcfa | ||
|
|
485e9691a0 | ||
|
|
a0e7283ae6 | ||
|
|
b44c0647f1 | ||
|
|
7e60ab9064 | ||
|
|
f05c1f42b5 | ||
|
|
672bbb4265 | ||
|
|
10c1041b06 | ||
|
|
59c73facfe | ||
|
|
ba7d4cd392 | ||
|
|
d76a50c216 | ||
|
|
617223777b | ||
|
|
6ef047050d | ||
|
|
942ecc4c04 | ||
|
|
e72f9a8374 | ||
|
|
9cf782eb5b | ||
|
|
660338688a | ||
|
|
2d50bd7536 | ||
|
|
b02a4f1347 | ||
|
|
1748fdea34 | ||
|
|
6bbaf43671 | ||
|
|
4a66aaadad | ||
|
|
e2e239f6d9 | ||
|
|
fe22403e66 | ||
|
|
3313c71805 | ||
|
|
1e60e83514 | ||
|
|
9c893abcdf | ||
|
|
ead891ca2f | ||
|
|
8713e3cc86 | ||
|
|
3cc83d10d3 | ||
|
|
192ded374a | ||
|
|
13997c7e74 | ||
|
|
71b0dd4cc2 | ||
|
|
a58a0cdffe | ||
|
|
6aeb040db4 | ||
|
|
fef20e361e | ||
|
|
a63a07701d | ||
|
|
5dd56f2db3 | ||
|
|
275b095574 | ||
|
|
05eae71fba | ||
|
|
777b3c9445 | ||
|
|
a214168b1e | ||
|
|
9d55d02557 | ||
|
|
16c084ba80 | ||
|
|
b0f4ccc186 | ||
|
|
96d0606b4d | ||
|
|
450b9ec28a | ||
|
|
2ccf03fc1b | ||
|
|
38dfb3af07 | ||
|
|
ae4c59bfdb | ||
|
|
c9f4fdbee8 | ||
|
|
d21f461dda | ||
|
|
28a5a83315 | ||
|
|
11d11b88bf | ||
|
|
ff7658b5ba | ||
|
|
351faf2891 | ||
|
|
7d66229bad | ||
|
|
2b08be1e7d | ||
|
|
8255cfd479 | ||
|
|
f356bb4407 | ||
|
|
07e60291a2 | ||
|
|
2dbe8e6685 | ||
|
|
40f36b2afd | ||
|
|
d4260d5103 | ||
|
|
45f68bc936 | ||
|
|
9469074837 | ||
|
|
193807bb6f | ||
|
|
d4548db5b9 | ||
|
|
29aaea6fe6 | ||
|
|
369cc6438f | ||
|
|
d80b39c77b | ||
|
|
626725a8ca | ||
|
|
8be96358ae | ||
|
|
f2bfbfa3c5 | ||
|
|
7c9ffd6abc | ||
|
|
b370354287 | ||
|
|
145d71e283 | ||
|
|
eeea82d815 | ||
|
|
babd267bc4 | ||
|
|
e136c931ac | ||
|
|
ae00602345 | ||
|
|
5382108ee7 | ||
|
|
514063d3fb | ||
|
|
b08f396fec | ||
|
|
d37a7f06f1 | ||
|
|
ad7bca3aae | ||
|
|
4fb70ba80e | ||
|
|
1225b2eb9e | ||
|
|
24b2f103b9 | ||
|
|
0d304b58ca | ||
|
|
f419dbd794 | ||
|
|
7854cc81a8 | ||
|
|
9ad1bd29bd | ||
|
|
b88d4f0ecb | ||
|
|
44168b62d2 | ||
|
|
1dab013436 | ||
|
|
64a4a7aff5 | ||
|
|
e43b545c89 | ||
|
|
69fcde250e | ||
|
|
63d6290166 | ||
|
|
c1d759f3f3 | ||
|
|
3a782bc69c | ||
|
|
bea752879c | ||
|
|
a48fcb3819 | ||
|
|
68a07bc952 | ||
|
|
828dba09b0 | ||
|
|
0d2189e9e8 | ||
|
|
f0f0ab81e4 | ||
|
|
64b5fa7038 | ||
|
|
1d04c9b9c9 | ||
|
|
dee719ac25 | ||
|
|
ea676876f1 | ||
|
|
c1a4d5d81e | ||
|
|
95d88804e4 | ||
|
|
1fa072790f | ||
|
|
fe19c1183c | ||
|
|
be40f55bd9 | ||
|
|
30a10eaf6d | ||
|
|
3bc0c86df4 | ||
|
|
03c8726e6e | ||
|
|
de47491ded | ||
|
|
c691cdaa0e | ||
|
|
53efdc2802 | ||
|
|
9644076463 | ||
|
|
cb4e88f8aa | ||
|
|
adc16fc58d | ||
|
|
d6860a3e24 | ||
|
|
7e6116de45 | ||
|
|
1688a2ca25 | ||
|
|
fe57acfce0 | ||
|
|
1ae49b28b1 | ||
|
|
ef4e9c8b40 | ||
|
|
5da0758e89 | ||
|
|
816cab252d | ||
|
|
843f638835 | ||
|
|
e4684b2e12 | ||
|
|
c17365b6c9 | ||
|
|
01835c0ac5 | ||
|
|
e5749bd6ef | ||
|
|
689e58737b | ||
|
|
38da061cf1 | ||
|
|
e79940e52e | ||
|
|
88dd6068b6 | ||
|
|
7dd10f9c96 | ||
|
|
94aaf83107 | ||
|
|
e84fc5f424 | ||
|
|
f342b08179 | ||
|
|
0fcad02f3b | ||
|
|
43d2406ee9 | ||
|
|
78e2d05730 | ||
|
|
425bf808ed | ||
|
|
6d2916dc9f | ||
|
|
2281e4224b | ||
|
|
95282f9883 | ||
|
|
b470f182c9 | ||
|
|
0bba1068af | ||
|
|
947a7d8296 | ||
|
|
bd36cbf888 | ||
|
|
d8fa47bff7 | ||
|
|
1132beea5e | ||
|
|
2e3314e6c3 | ||
|
|
daa8f857f8 | ||
|
|
6d14271fe8 | ||
|
|
9284d48f67 | ||
|
|
c5d1c5a468 | ||
|
|
b98512789f | ||
|
|
6b8ed8d527 | ||
|
|
ec4500dcef | ||
|
|
288e63ce68 | ||
|
|
b3885584bb | ||
|
|
968b24be1e | ||
|
|
5a23c1783a | ||
|
|
ddeeb5a7c3 | ||
|
|
0b9bbcc7b8 | ||
|
|
022c8b4515 | ||
|
|
be04991928 | ||
|
|
34770567a5 | ||
|
|
6154fc2157 | ||
|
|
e77dcdd3d4 | ||
|
|
58a3532c1b | ||
|
|
116a5eeb43 | ||
|
|
decd50cb40 | ||
|
|
355563244c | ||
|
|
51aad628b5 | ||
|
|
7dd7a2cf34 | ||
|
|
4c0ff7c7f2 | ||
|
|
8aba3cbe00 | ||
|
|
e21c3ec507 | ||
|
|
fdbb0b2ca8 | ||
|
|
180195ab7d | ||
|
|
8add4e6b46 | ||
|
|
3d622d2efe | ||
|
|
bb7ed7b963 | ||
|
|
d541ea41ad | ||
|
|
7c7ebc9eb7 | ||
|
|
22275c3b12 | ||
|
|
8744a34e8e | ||
|
|
e98836fd0e | ||
|
|
feb62196a2 | ||
|
|
9fd29a2958 | ||
|
|
546c82ca40 | ||
|
|
f132dc38f4 | ||
|
|
58c70b8ca6 | ||
|
|
147f55eefe | ||
|
|
229b7b0c12 | ||
|
|
4b7b5ff8a4 | ||
|
|
4906bde746 | ||
|
|
a87a1a8988 | ||
|
|
e05f45e681 | ||
|
|
b4acacea81 | ||
|
|
fa9645b05b | ||
|
|
1ed4052814 | ||
|
|
7dc814461f | ||
|
|
9154ec0e8c | ||
|
|
3a2ea60583 | ||
|
|
b36bff3a1e | ||
|
|
b3d8cbf280 | ||
|
|
38fb02d112 | ||
|
|
2597f893cd | ||
|
|
ebdd036654 | ||
|
|
5032f0e6a9 | ||
|
|
ad963d718d | ||
|
|
69d314bce3 | ||
|
|
4a7425a947 | ||
|
|
c172ac0d5c | ||
|
|
01a66493a8 | ||
|
|
188f8b3faa | ||
|
|
ebcf5fad71 | ||
|
|
d1a656db82 | ||
|
|
4f6a11fd7c | ||
|
|
1d09a946bb | ||
|
|
6c4eb7edbd | ||
|
|
4f9f669ac6 | ||
|
|
f9e0e78473 | ||
|
|
b004facfca | ||
|
|
fb6ee2910f | ||
|
|
3fedc9b730 | ||
|
|
b260427312 | ||
|
|
dd1447e93c | ||
|
|
dbcc213562 | ||
|
|
1c019cd5c8 | ||
|
|
e37bde77a1 | ||
|
|
57bf0d2021 | ||
|
|
88b00f7069 | ||
|
|
7b08cbb2f7 | ||
|
|
97c0ec184d | ||
|
|
d18c845088 | ||
|
|
a64d97774d | ||
|
|
2ddc51aa4f | ||
|
|
28afe2a922 | ||
|
|
c2e97bf191 | ||
|
|
c922752a1f | ||
|
|
08f36a74ca | ||
|
|
d7809dd00c | ||
|
|
27582004da | ||
|
|
3d6a176cde | ||
|
|
4a2073a038 | ||
|
|
c8a65ecbe4 | ||
|
|
3750d5cba0 | ||
|
|
55b383780e | ||
|
|
6aec0ddf88 | ||
|
|
7c8e94d1df | ||
|
|
5ecbf626c8 | ||
|
|
584f580e3b | ||
|
|
280de47dac | ||
|
|
c7c05f5897 | ||
|
|
bb86180582 | ||
|
|
aff228edd3 | ||
|
|
f65ae6d703 | ||
|
|
0fccc06883 | ||
|
|
8652966645 | ||
|
|
6d84eb9f09 | ||
|
|
1a3dccac29 | ||
|
|
fa8de34fc5 | ||
|
|
10cfd6be80 | ||
|
|
a390b36e7c | ||
|
|
d6b5994e22 | ||
|
|
08611a97e7 | ||
|
|
35bbb44ce3 | ||
|
|
8ff879661a | ||
|
|
a8f01f099d | ||
|
|
040ab1096b |
3
.gitignore
vendored
@@ -13,6 +13,7 @@ dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
package-lock.json
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
@@ -34,3 +35,5 @@ dev-dist
|
||||
# iconify dist files
|
||||
src/@iconify/*.js
|
||||
public/plugin_icon/**
|
||||
docs-lock/
|
||||
.trae/
|
||||
|
||||
@@ -16,13 +16,17 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
|
||||
|
||||
## 3. 核心概念
|
||||
|
||||
每个插件需要提供三个标准组件:
|
||||
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
|
||||
|
||||
| 组件名称 | 文件名 | 用途 |
|
||||
|---------|-------|------|
|
||||
| Page | Page.vue | 插件详情页面 |
|
||||
| Config | Config.vue | 插件配置页面 |
|
||||
| Dashboard | Dashboard.vue | 仪表板组件 |
|
||||
| 组件名称 | 暴露名 | 文件名 | 用途 |
|
||||
|---------|--------|--------|------|
|
||||
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
|
||||
| Config | `./Config` | Config.vue | 插件配置页面 |
|
||||
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
|
||||
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
|
||||
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
|
||||
|
||||
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage` → `Page`;`nav_key` 为 `main` 时仅尝试 `AppPage` → `Page`。
|
||||
|
||||
## 4. 快速开始
|
||||
|
||||
@@ -56,6 +60,8 @@ export default defineConfig({
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
@@ -245,17 +251,110 @@ const props = defineProps({
|
||||
|
||||
<template>
|
||||
<div class="dashboard-widget">
|
||||
<!-- 仪表板内容 -->
|
||||
<v-card>
|
||||
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- 组件内容 -->
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-hover>
|
||||
<!-- 仪表板内容 -->
|
||||
<template #default="{ isHovering, props: hoverProps }">
|
||||
<v-card v-bind="hoverProps">
|
||||
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- 组件内容 -->
|
||||
</v-card-text>
|
||||
<!-- 只在悬停时显示拖拽图标 -->
|
||||
<div v-show="isHovering" class="absolute right-5 top-5">
|
||||
<v-icon class="cursor-move">mdi-drag</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-hover>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.4 AppPage 组件(侧栏全页)
|
||||
|
||||
用于主应用左侧导航中的独立页面(路由 `#/plugin-app/:pluginId/:navKey?`),占据默认布局下的主内容区;与 `Page` 不同,不嵌在插件管理弹窗中。
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
navKey: { type: String, default: 'main' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="text-h6 mb-2">侧栏全页示例({{ pluginId }} / {{ navKey }})</div>
|
||||
<v-btn size="small" @click="emit('action')">通知主应用</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 后端:注册侧栏入口
|
||||
|
||||
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
|
||||
| `title` | 侧栏显示标题 |
|
||||
| `icon` | MDI 图标名,如 `mdi-rss` |
|
||||
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
|
||||
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
|
||||
| `order` | 可选:同组内排序,数值越小越靠前 |
|
||||
|
||||
```python
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "示例订阅页",
|
||||
"icon": "mdi-rss",
|
||||
"section": "subscribe",
|
||||
"permission": "subscribe",
|
||||
"order": 10,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### 同一插件多个全页界面(多 `nav_key`)
|
||||
|
||||
在 `get_sidebar_nav` 中**返回多条**记录,每条使用不同的 `nav_key` / `title` / `section` 等,侧栏与「更多」中会出现多个入口,路由形如 `#/plugin-app/<插件ID>/<nav_key>`。
|
||||
|
||||
前端加载远程组件的顺序为:
|
||||
|
||||
| `nav_key` | 依次尝试的联邦暴露名 |
|
||||
|-----------|----------------------|
|
||||
| `main` 或省略 | `./AppPage` → `./Page` |
|
||||
| 其它(如 `settings`、`my_tool`) | `./AppPage{PascalCase}` → `./AppPage` → `./Page` |
|
||||
|
||||
`PascalCase` 规则:按 `-`、`_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings`;`my_tool` → `./AppPageMyTool`。
|
||||
|
||||
**两种实现方式(二选一或混用):**
|
||||
|
||||
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
|
||||
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`。
|
||||
|
||||
`vite.config` 多暴露示例:
|
||||
|
||||
```typescript
|
||||
exposes: {
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 构建和部署
|
||||
|
||||
### 构建项目
|
||||
|
||||
5
env.d.ts
vendored
@@ -4,8 +4,13 @@ declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
action?: string
|
||||
subject?: string
|
||||
keepAlive?: boolean
|
||||
keepAliveKey?: string
|
||||
layoutWrapperClasses?: string
|
||||
navActiveLink?: RouteLocationRaw
|
||||
requiresAuth?: boolean
|
||||
subType?: string
|
||||
hideFooter?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MoviePilot 插件远程组件示例
|
||||
|
||||
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例实现了三个标准组件:Page(详情页面)、Config(配置页面)和Dashboard(仪表板组件)。
|
||||
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例包含 Page、Config、Dashboard、AppPage,以及可选的 `AppPageSettings`(`nav_key=settings` 时由主应用优先加载,用于演示「一插件多全页界面」)。
|
||||
|
||||
## 1. 开发环境准备
|
||||
|
||||
@@ -28,7 +28,9 @@ plugin-component/
|
||||
│ ├── components/
|
||||
│ │ ├── Page.vue # 插件详情页面组件
|
||||
│ │ ├── Config.vue # 插件配置页面组件
|
||||
│ │ └── Dashboard.vue # 插件仪表板组件
|
||||
│ │ ├── Dashboard.vue # 插件仪表板组件
|
||||
│ │ ├── AppPage.vue # 侧栏全页(主内容区,nav_key=main)
|
||||
│ │ └── AppPageSettings.vue # 可选第二全页(nav_key=settings)
|
||||
│ ├── App.vue # 本地开发入口组件
|
||||
│ └── main.js # 本地开发入口文件
|
||||
├── vite.config.js # Vite和模块联邦配置
|
||||
|
||||
32
examples/plugin-component/src/components/AppPage.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 侧栏全页:在主应用 #/plugin-app/:pluginId/:navKey 中渲染,占据主内容区。
|
||||
* 需在插件后端实现 get_sidebar_nav 才会出现在侧栏。
|
||||
*/
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
navKey: {
|
||||
type: String,
|
||||
default: 'main',
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-app-page pa-4">
|
||||
<div class="text-h6 mb-2">AppPage(侧栏全页)</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
pluginId: {{ pluginId }} · navKey: {{ navKey }}
|
||||
</div>
|
||||
<v-btn size="small" variant="tonal" @click="emit('action')">action</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
17
examples/plugin-component/src/components/AppPageSettings.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 示例:nav_key=settings 时主应用会优先加载 AppPageSettings,再回退 AppPage。
|
||||
*/
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
navKey: { type: String, default: 'settings' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="text-subtitle-1">Settings 子界面(AppPageSettings)</div>
|
||||
<div class="text-caption text-medium-emphasis">navKey={{ navKey }} · pluginId={{ pluginId }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,6 +12,8 @@ export default defineConfig({
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
|
||||
402
index.html
@@ -1,11 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" style="
|
||||
overflow: hidden auto;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
<html lang="zh-CN" data-launch-loading="true" style="
|
||||
overflow: hidden;
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
--initial-loader-bg: #0E1116;
|
||||
--initial-loader-color: #9155FD;
|
||||
--initial-loader-height: 100svh;
|
||||
--initial-loader-width: 100vw;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
">
|
||||
|
||||
<head>
|
||||
@@ -13,7 +16,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 核心viewport设置 - 针对PWA优化 -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" />
|
||||
|
||||
<!-- 防止缩放和选择,提供原生应用体验 -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||
@@ -34,7 +37,7 @@
|
||||
|
||||
<!-- iOS Safari PWA 优化 -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
|
||||
|
||||
<!-- iOS Safari 全屏模式 -->
|
||||
@@ -91,57 +94,99 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
|
||||
<!-- 预加载关键资源 -->
|
||||
<link rel="preload" href="/logo.png" as="image" />
|
||||
<link rel="modulepreload" href="/src/main.ts" />
|
||||
|
||||
<!-- 内联关键CSS -->
|
||||
<style>
|
||||
/* 关键路径CSS - 从loader.css内联 */
|
||||
html,
|
||||
body {
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"],
|
||||
html[data-launch-loading="true"] body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] body {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] #app {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99999;
|
||||
display: block;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
block-size: 100vh;
|
||||
inline-size: 100vw;
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
overflow: hidden;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
block-size: var(--initial-loader-height, 100svh);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
transition: opacity 0.12s ease-out, transform 0.12s ease-out;
|
||||
padding:
|
||||
calc(env(safe-area-inset-top, 0px) + 24px)
|
||||
24px
|
||||
calc(env(safe-area-inset-bottom, 0px) + 48px);
|
||||
}
|
||||
|
||||
.loading-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
position: absolute;
|
||||
inset-block-start: 35%;
|
||||
inset-inline-start: calc(50% - 5rem);
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: min(160px, 36vw);
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 添加logo完成动画 - 放大虚化效果 */
|
||||
.loading-complete .loading-logo {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
.loading-logo img {
|
||||
display: block;
|
||||
block-size: auto;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
/* 添加加载背景消失动画 - 放大虚化效果 */
|
||||
.loading-complete {
|
||||
filter: blur(15px);
|
||||
.loading-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: clamp(72px, 14vh, 120px);
|
||||
}
|
||||
|
||||
.loading-complete .loading-shell,
|
||||
.loading-complete #loading-timeout {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
transform: translate3d(0, 6px, 0);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
block-size: 55px;
|
||||
inline-size: 55px;
|
||||
inset-block-start: 80%;
|
||||
inset-inline-start: calc(50% - 27.5px);
|
||||
block-size: 46px;
|
||||
inline-size: 46px;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
/* 完成时隐藏加载动画 */
|
||||
.loading-complete .loading {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -195,9 +240,38 @@
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
/* 超时通知样式 */
|
||||
#loading-timeout {
|
||||
position: absolute;
|
||||
z-index: 2500;
|
||||
display: none;
|
||||
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
max-inline-size: calc(100% - 32px);
|
||||
white-space: normal;
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#timeout-btn {
|
||||
color: var(--initial-loader-color, #9155FD);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
margin-inline-start: 8px;
|
||||
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 初始化脚本 -->
|
||||
<script>
|
||||
// 检测系统主题是否为深色模式
|
||||
function checkPrefersColorSchemeIsDark() {
|
||||
@@ -208,25 +282,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 主题色彩初始化
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
|
||||
// 检查主题设置
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const isAutoTheme = savedTheme === 'auto'
|
||||
|
||||
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
|
||||
if (isAutoTheme || !loaderColor) {
|
||||
loaderColor = checkPrefersColorSchemeIsDark() ? '#0E1116' : '#FFFFFF'
|
||||
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
|
||||
const launchThemeBackgrounds = {
|
||||
light: '#F4F5FA',
|
||||
dark: '#0E1116',
|
||||
purple: '#28243D',
|
||||
transparent: '#1C1C1C',
|
||||
default: '#F4F5FA',
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const resolvedLaunchTheme = savedTheme === 'auto'
|
||||
? (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
|
||||
: savedTheme
|
||||
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
|| launchThemeBackgrounds[resolvedLaunchTheme]
|
||||
|| launchThemeBackgrounds.light
|
||||
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
if (!primaryColor) {
|
||||
primaryColor = '#9155FD'
|
||||
}
|
||||
|
||||
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去。
|
||||
function syncInitialViewport(force) {
|
||||
const viewport = window.visualViewport
|
||||
const nextHeight = Math.round(viewport?.height || window.innerHeight || document.documentElement.clientHeight || 0)
|
||||
const nextWidth = Math.round(viewport?.width || window.innerWidth || document.documentElement.clientWidth || 0)
|
||||
const currentHeight = parseInt(
|
||||
document.documentElement.style.getPropertyValue('--initial-loader-height') || '0',
|
||||
10,
|
||||
)
|
||||
|
||||
if (!nextHeight || !nextWidth) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && currentHeight && Math.abs(nextHeight - currentHeight) < 120) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty('--initial-loader-height', `${nextHeight}px`)
|
||||
document.documentElement.style.setProperty('--initial-loader-width', `${nextWidth}px`)
|
||||
}
|
||||
|
||||
// 应用主题色彩
|
||||
document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme)
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
document.documentElement.style.backgroundColor = loaderColor
|
||||
syncInitialViewport(true)
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.style.backgroundColor = loaderColor
|
||||
})
|
||||
|
||||
window.addEventListener('orientationchange', () => {
|
||||
window.setTimeout(() => syncInitialViewport(true), 160)
|
||||
})
|
||||
|
||||
// 状态栏适配
|
||||
if (window.navigator.standalone) {
|
||||
@@ -246,121 +360,95 @@
|
||||
updateSafeArea()
|
||||
window.addEventListener('resize', updateSafeArea)
|
||||
window.addEventListener('orientationchange', updateSafeArea)
|
||||
|
||||
// 清除缓存处理逻辑
|
||||
window.clearAndReload = async function() {
|
||||
try {
|
||||
// 1. 清除所有缓存
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)))
|
||||
console.log('[VersionChecker] 已清除所有缓存')
|
||||
}
|
||||
// 2. 注销 Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map(registration => registration.unregister()))
|
||||
console.log('[VersionChecker] 已注销所有 Service Worker')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VersionChecker] 清除缓存时出错:', e)
|
||||
} finally {
|
||||
// 3. 重载页面
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
window.location.replace(url.pathname + url.search + url.hash)
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(function() {
|
||||
const timeoutEl = document.getElementById('loading-timeout');
|
||||
if (timeoutEl) {
|
||||
// 适配多语言
|
||||
const lang = navigator.language || 'zh-CN';
|
||||
const messages = {
|
||||
'zh-CN': {
|
||||
text: '页面加载似乎遇到了阻碍,请尝试',
|
||||
btn: '清除缓存'
|
||||
},
|
||||
'zh-TW': {
|
||||
text: '頁面載入似乎遇到了阻礙,請嘗試',
|
||||
btn: '清除快取'
|
||||
},
|
||||
'en-US': {
|
||||
text: 'Page loading seems to be blocked, please try',
|
||||
btn: 'Clear Cache'
|
||||
}
|
||||
};
|
||||
|
||||
// 默认匹配前缀,如 en-GB 匹配 en-US 的逻辑
|
||||
let msg = messages['zh-CN'];
|
||||
if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) {
|
||||
msg = messages['zh-TW'];
|
||||
} else if (lang.startsWith('en')) {
|
||||
msg = messages['en-US'];
|
||||
}
|
||||
|
||||
const textNode = document.createTextNode(msg.text + ' ');
|
||||
const btnLink = document.createElement('a');
|
||||
btnLink.href = 'javascript:void(0)';
|
||||
btnLink.id = 'timeout-btn';
|
||||
btnLink.onclick = window.clearAndReload;
|
||||
btnLink.textContent = msg.btn;
|
||||
|
||||
timeoutEl.innerHTML = '';
|
||||
timeoutEl.appendChild(textNode);
|
||||
timeoutEl.appendChild(btnLink);
|
||||
timeoutEl.style.display = 'block';
|
||||
}
|
||||
}, 15000); // 15秒后显示超时提示
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<svg width="160px" height="160px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
|
||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||
<rect x="0" y="0" width="192" height="192" style="fill: none" />
|
||||
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
|
||||
<path
|
||||
d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
|
||||
style="fill: url(#_Linear1)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
|
||||
style="fill: url(#_Linear2)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
|
||||
style="fill: url(#_Linear3)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
|
||||
style="fill: rgb(165, 118, 255)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
|
||||
style="fill: url(#_Linear4)" />
|
||||
</g>
|
||||
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
|
||||
<path
|
||||
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
|
||||
style="fill: rgb(104, 0, 197)" />
|
||||
<clipPath id="_clip5">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z" />
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip5)">
|
||||
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
|
||||
<path
|
||||
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
|
||||
style="fill: url(#_Linear6)" />
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
|
||||
style="fill: url(#_Radial7)" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)">
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)">
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)">
|
||||
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="_Radial7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
<div class="loading-shell">
|
||||
<div class="loading-main">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-footer">
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 超时提示 - 默认隐藏 -->
|
||||
<div id="loading-timeout"></div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
14
package.json
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.7.7",
|
||||
"version": "2.13.1-1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"prebuild": "npm run build:icons",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5050",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
@@ -51,11 +52,13 @@
|
||||
"http-proxy-middleware": "^3.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.83.4",
|
||||
"tailwindcss": "^ 3.4.17",
|
||||
"vue": "^3.5.13",
|
||||
@@ -69,7 +72,11 @@
|
||||
"webfontloader": "^1.6.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/line-md": "^1.2.13",
|
||||
"@iconify-json/lucide": "^1.2.85",
|
||||
"@iconify-json/material-symbols": "^1.2.51",
|
||||
"@iconify-json/mdi": "^1.1.52",
|
||||
"@iconify-json/tabler": "^1.2.23",
|
||||
"@iconify/tools": "^4.0.4",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
@@ -77,9 +84,12 @@
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/markdown-it-link-attributes": "^3.0.5",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^20.1.4",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 102 KiB |
@@ -49,7 +49,7 @@ http {
|
||||
root html;
|
||||
}
|
||||
|
||||
location ~ ^/api/v1/system/(message|progress/) {
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
|
||||
# SSE MIME类型设置
|
||||
default_type text/event-stream;
|
||||
|
||||
|
||||
1
shims.d.ts
vendored
@@ -12,3 +12,4 @@ declare module 'vue-prism-component' {
|
||||
export default component
|
||||
}
|
||||
declare module 'vue-shepherd';
|
||||
declare module 'colorthief';
|
||||
|
||||
@@ -15,7 +15,7 @@ function onClick() {
|
||||
|
||||
<template>
|
||||
<IconBtn
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'"
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
<VIcon icon="mdi-close" />
|
||||
|
||||
@@ -22,6 +22,7 @@ code {
|
||||
|
||||
%blurry-bg {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
|
||||
|
||||
@media (width >= 1280px) and (hover: hover) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use "sass:map";
|
||||
@use "vuetify/lib/styles/settings" as vuetify_settings;
|
||||
@use "vuetify/lib/styles/settings/_index.sass" as vuetify_settings;
|
||||
@use "@styles/variables/_vuetify.scss" as vuetify;
|
||||
|
||||
@mixin themed($property, $light-value, $dark-value) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.auth-wrapper {
|
||||
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
min-block-size: 100%;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
.auth-footer-mask {
|
||||
|
||||
@@ -23,6 +23,13 @@ export function kFormatter(num: number) {
|
||||
: Math.abs(num).toFixed(0).replace(regex, ',')
|
||||
}
|
||||
|
||||
// 格式化下载量显示,超过1000显示为x.xk格式
|
||||
export function formatDownloadCount(num: number): string {
|
||||
if (!num || num < 1000) return num?.toLocaleString() || '0'
|
||||
|
||||
return `${(num / 1000).toFixed(1)}k`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format and return date in Humanize format
|
||||
* Intl docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import ColorThief from 'colorthief'
|
||||
|
||||
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
||||
const DOMINANT_COLOR_CACHE_LIMIT = 100
|
||||
const colorThief = new ColorThief()
|
||||
const dominantColorCache = new Map<string, Promise<string>>()
|
||||
|
||||
interface DominantColorOptions {
|
||||
fallback?: string
|
||||
quality?: number
|
||||
}
|
||||
|
||||
// 将 RGB 转换为十六进制
|
||||
function rgbStringToHex(rgbArray: number[]): string {
|
||||
if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format')
|
||||
@@ -14,11 +24,46 @@ function rgbStringToHex(rgbArray: number[]): string {
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
function getImageCacheKey(image: HTMLImageElement) {
|
||||
return image.currentSrc || image.src || ''
|
||||
}
|
||||
|
||||
function rememberDominantColor(key: string, colorPromise: Promise<string>) {
|
||||
if (!key) return colorPromise
|
||||
|
||||
if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) {
|
||||
const firstKey = dominantColorCache.keys().next().value
|
||||
if (firstKey) dominantColorCache.delete(firstKey)
|
||||
}
|
||||
|
||||
dominantColorCache.set(key, colorPromise)
|
||||
return colorPromise
|
||||
}
|
||||
|
||||
// 提取主要颜色
|
||||
export async function getDominantColor(image: HTMLImageElement): Promise<string> {
|
||||
const colorThief = new ColorThief()
|
||||
const dominantColor = colorThief.getColor(image)
|
||||
return rgbStringToHex(dominantColor)
|
||||
export async function getDominantColor(
|
||||
image: HTMLImageElement | undefined | null,
|
||||
options: DominantColorOptions = {},
|
||||
): Promise<string> {
|
||||
const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR
|
||||
|
||||
if (!image) return fallback
|
||||
|
||||
const cacheKey = getImageCacheKey(image)
|
||||
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
|
||||
if (cachedColor) return cachedColor
|
||||
|
||||
const colorPromise = Promise.resolve()
|
||||
.then(() => {
|
||||
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
|
||||
return rgbStringToHex(dominantColor)
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Failed to extract dominant color:', error)
|
||||
return fallback
|
||||
})
|
||||
|
||||
return rememberDominantColor(cacheKey, colorPromise)
|
||||
}
|
||||
|
||||
// 预加载图片
|
||||
|
||||
@@ -35,6 +35,19 @@ export function urlBase64ToUint8Array(base64String: string) {
|
||||
return outputArray
|
||||
}
|
||||
|
||||
// Uint8Array 转 Base64URL
|
||||
export function bufferToBase64Url(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '')
|
||||
}
|
||||
|
||||
// Base64URL 转 Uint8Array
|
||||
export function base64UrlToUint8Array(base64Url: string): Uint8Array {
|
||||
return Uint8Array.from(atob(base64Url.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
}
|
||||
|
||||
// 判断是否为PWA
|
||||
export const isPWA = async (): Promise<boolean> => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createRequire } from 'node:module'
|
||||
|
||||
// Get current directory
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectSrcDir = join(__dirname, '..')
|
||||
|
||||
// Create require function for importing JSON files in ESM
|
||||
const require = createRequire(import.meta.url)
|
||||
@@ -86,33 +87,12 @@ const sources: BundleScriptConfig = {
|
||||
],
|
||||
|
||||
icons: [
|
||||
// 'mdi:home',
|
||||
// 'mdi:account',
|
||||
// 'mdi:login',
|
||||
// 'mdi:logout',
|
||||
// 'octicon:book-24',
|
||||
// 'octicon:code-square-24',
|
||||
'lucide:sparkles',
|
||||
'material-symbols:passkey',
|
||||
'line-md:loading-twotone-loop',
|
||||
],
|
||||
|
||||
json: [
|
||||
// Custom JSON file
|
||||
// 'json/gg.json',
|
||||
|
||||
// Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename)
|
||||
require.resolve('@iconify-json/mdi/icons.json'),
|
||||
|
||||
// Custom file with only few icons
|
||||
// {
|
||||
// filename: require.resolve('@iconify-json/line-md/icons.json'),
|
||||
// icons: [
|
||||
// 'home-twotone-alt',
|
||||
// 'github',
|
||||
// 'document-list',
|
||||
// 'document-code',
|
||||
// 'image-twotone',
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
json: [],
|
||||
}
|
||||
|
||||
// Iconify component (this changes import statement in generated file)
|
||||
@@ -130,6 +110,15 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(async function () {
|
||||
const scannedIcons = await collectUsedIcons(projectSrcDir)
|
||||
|
||||
if (sources.icons) {
|
||||
sources.icons.push(...scannedIcons)
|
||||
sources.icons = Array.from(new Set(sources.icons)).sort()
|
||||
} else {
|
||||
sources.icons = scannedIcons
|
||||
}
|
||||
|
||||
let bundle = commonJS
|
||||
? `const { addCollection } = require('${component}');\n\n`
|
||||
: `import { addCollection } from '${component}';\n\n`
|
||||
@@ -154,7 +143,13 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
// Sort icons by prefix
|
||||
const organizedList = organizeIconsList(sources.icons)
|
||||
for (const prefix in organizedList) {
|
||||
const filename = require.resolve(`@iconify/json/json/${prefix}.json`)
|
||||
let filename
|
||||
try {
|
||||
filename = require.resolve(`@iconify-json/${prefix}/icons.json`)
|
||||
}
|
||||
catch (err) {
|
||||
filename = require.resolve(`@iconify/json/json/${prefix}.json`)
|
||||
}
|
||||
|
||||
sourcesJSON.push({
|
||||
filename,
|
||||
@@ -269,8 +264,60 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
console.log(`Saved ${target} (${bundle.length} bytes)`)
|
||||
})().catch((err) => {
|
||||
console.error(err)
|
||||
// 构建图标失败时必须终止构建,避免继续发布上一次遗留的超大 icons-bundle。
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
async function collectUsedIcons(rootDir: string): Promise<string[]> {
|
||||
const icons = new Set<string>()
|
||||
const files = await walkDirectory(rootDir)
|
||||
const sourceFiles = files.filter(file => /\.(vue|ts|js|tsx|jsx)$/.test(file))
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
if (file.includes('/@iconify/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = await fs.readFile(file, 'utf8')
|
||||
|
||||
for (const match of content.matchAll(/\b(lucide|material-symbols|line-md|tabler):([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`${match[1]}:${match[2]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi:([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\btabler-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`tabler:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(icons).sort()
|
||||
}
|
||||
|
||||
async function walkDirectory(dir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkDirectory(fullPath)))
|
||||
continue
|
||||
}
|
||||
|
||||
files.push(fullPath)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove metadata from icon set
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,11 @@ export default defineComponent({
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
const isDialogOpen = ref(false)
|
||||
const wasScrolledBeforeDialog = ref(false)
|
||||
let dialogObserver: MutationObserver | null = null
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
}
|
||||
|
||||
// 监听弹窗状态变化
|
||||
const checkDialogState = () => {
|
||||
@@ -32,21 +37,25 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
})
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
// 初始检查弹窗状态
|
||||
checkDialogState()
|
||||
|
||||
// 监听 DOM 变化以检测弹窗状态
|
||||
const observer = new MutationObserver(checkDialogState)
|
||||
observer.observe(document.documentElement, {
|
||||
dialogObserver = new MutationObserver(checkDialogState)
|
||||
dialogObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
dialogObserver?.disconnect()
|
||||
dialogObserver = null
|
||||
})
|
||||
|
||||
return () => {
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
@@ -142,7 +151,7 @@ export default defineComponent({
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
block-size: 100%;
|
||||
min-block-size: 100%;
|
||||
|
||||
.layout-content-wrapper {
|
||||
display: flex;
|
||||
@@ -224,7 +233,9 @@ export default defineComponent({
|
||||
|
||||
.layout-page-content {
|
||||
// display: flex;
|
||||
overflow: hidden;
|
||||
// 使用 clip 替代 hidden,避免 Chrome 144+ 滚动锁定问题
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
|
||||
.page-content-container {
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -8,12 +8,13 @@ html {
|
||||
background: rgb(var(--v-theme-background));
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: rgb(var(--v-theme-background));
|
||||
overscroll-behavior-y: contain;
|
||||
// Chrome 144+ 兼容性:覆盖 Vuetify 的内联 overflow: hidden 样式
|
||||
overflow: visible !important;
|
||||
|
||||
--webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@@ -36,7 +37,9 @@ body,
|
||||
.layout-page-content {
|
||||
@include mixins.boxed-content(true);
|
||||
|
||||
overflow: hidden;
|
||||
// Chrome 144+ 兼容性:使用 clip 替代 hidden,避免滚动锁定问题
|
||||
// overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动
|
||||
overflow: clip;
|
||||
flex-grow: 1;
|
||||
|
||||
// TODO: Use grid gutter variable here;
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom))
|
||||
min-block-size: 100%;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ValidationRule } from 'vuetify/types/services/validation'
|
||||
type ValidationRule = (value: any) => string | boolean
|
||||
|
||||
// 必输校验
|
||||
export const requiredValidator: ValidationRule = (value: any) => {
|
||||
|
||||
243
src/App.vue
@@ -11,14 +11,28 @@ import { preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
||||
function syncRootLaunchPalette() {
|
||||
const { background, primary } = globalTheme.current.value.colors
|
||||
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', background)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primary)
|
||||
document.documentElement.style.backgroundColor = background
|
||||
document.body.style.backgroundColor = background
|
||||
}
|
||||
|
||||
// 生效语言
|
||||
const localeValue = getBrowserLocale()
|
||||
setI18nLanguage(localeValue as SupportedLocale)
|
||||
@@ -26,6 +40,7 @@ setI18nLanguage(localeValue as SupportedLocale)
|
||||
// 检查是否登录
|
||||
const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
const route = useRoute()
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -37,49 +52,60 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
)
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
let authenticatedStateTimer: number | null = null
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
interface Window {
|
||||
Apex: any
|
||||
}
|
||||
function getStoredNumber(key: string, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseFloat(localStorage.getItem(key) || '')
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
|
||||
// 配置 ApexCharts 全局选项
|
||||
function configureApexCharts() {
|
||||
if (typeof window !== 'undefined' && window.Apex) {
|
||||
try {
|
||||
// 获取当前主题
|
||||
const currentTheme = globalTheme.name.value
|
||||
const isDark = currentTheme === 'dark' || currentTheme === 'transparent'
|
||||
function applyTransparentBackgroundSettings() {
|
||||
document.documentElement.style.setProperty(
|
||||
'--transparent-background-poster-opacity',
|
||||
(1 - getStoredNumber('transparency-background-poster-opacity', 0, 0, 1)).toString(),
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--transparent-background-blur',
|
||||
`${getStoredNumber('transparency-background-blur', 16, 0, 30)}px`,
|
||||
)
|
||||
}
|
||||
|
||||
// 数据标签
|
||||
window.Apex.dataLabels = {
|
||||
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||
// 如果有小数点,保留两位小数,否则保留整数
|
||||
const data = w.config.series[seriesIndex]
|
||||
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||
},
|
||||
}
|
||||
// 图例
|
||||
window.Apex.legend = {
|
||||
labels: {
|
||||
useSeriesColors: true,
|
||||
},
|
||||
}
|
||||
// 标题
|
||||
window.Apex.title = {
|
||||
style: {
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||
},
|
||||
}
|
||||
// 鼠标悬浮提示
|
||||
window.Apex.tooltip = {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
applyTransparentBackgroundSettings()
|
||||
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
if (heartbeatInterval) {
|
||||
stopHeartbeat()
|
||||
}
|
||||
|
||||
// 开始心跳任务
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
try {
|
||||
if (isLogin.value) {
|
||||
await api.get('dashboard/cpu')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ApexCharts 全局配置失败:', error)
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatInterval) {
|
||||
window.clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,14 +113,16 @@ function configureApexCharts() {
|
||||
function updateHtmlThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
document.body.setAttribute('data-theme', themeName)
|
||||
syncRootLaunchPalette()
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImages() {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = new AbortController()
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`, {
|
||||
signal: controller.signal,
|
||||
signal: backgroundRequestController.signal,
|
||||
})
|
||||
activeImageIndex.value = 0
|
||||
} catch (e) {
|
||||
@@ -136,12 +164,56 @@ function startBackgroundRotation() {
|
||||
}
|
||||
}
|
||||
|
||||
function stopBackgroundLoading() {
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = null
|
||||
|
||||
if (backgroundRetryTimer) {
|
||||
window.clearTimeout(backgroundRetryTimer)
|
||||
backgroundRetryTimer = null
|
||||
}
|
||||
|
||||
removeBackgroundTimer('background-rotation')
|
||||
}
|
||||
|
||||
async function initializeAuthenticatedState() {
|
||||
if (!isLogin.value) return
|
||||
|
||||
try {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', true)
|
||||
await globalSettingsStore.initialize()
|
||||
await globalSettingsStore.loadUserSettings()
|
||||
} finally {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAuthenticatedStateInitialization() {
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
}
|
||||
|
||||
// 登录后会立刻发生路由切换,稍后再拉取设置可避开导航中止请求。
|
||||
authenticatedStateTimer = window.setTimeout(() => {
|
||||
authenticatedStateTimer = null
|
||||
initializeAuthenticatedState()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
// 添加logo动画效果并延迟移除加载界面
|
||||
function animateAndRemoveLoader() {
|
||||
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
||||
if (loadingBg) {
|
||||
removeEl('#loading-bg')
|
||||
document.documentElement.style.removeProperty('background')
|
||||
// 只收掉启动内容,背景层保持实色直到节点被移除,避免底部 safe area 先透出页面内容。
|
||||
loadingBg.classList.add('loading-complete')
|
||||
window.setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
|
||||
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
|
||||
document.documentElement.removeAttribute('data-launch-loading')
|
||||
document.documentElement.style.removeProperty('overflow')
|
||||
document.body.style.removeProperty('overflow')
|
||||
}, 120)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +222,6 @@ async function removeLoadingWithStateCheck() {
|
||||
try {
|
||||
// 设置各个组件的加载状态
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', true)
|
||||
globalLoadingStateManager.setLoadingState('global-settings', true)
|
||||
globalLoadingStateManager.setLoadingState('background-images', true)
|
||||
|
||||
// 静默检查PWA状态恢复
|
||||
const pwaController = (window as any).pwaStateController
|
||||
@@ -160,18 +230,7 @@ async function removeLoadingWithStateCheck() {
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
// 并行加载关键资源
|
||||
await Promise.all([
|
||||
globalSettingsStore.initialize().then(() => {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
globalLoadingStateManager.setLoadingState('background-images', false)
|
||||
resolve(void 0)
|
||||
}, 50)
|
||||
}),
|
||||
])
|
||||
await initializeAuthenticatedState()
|
||||
|
||||
// 等待所有加载完成
|
||||
await globalLoadingStateManager.waitForAllComplete()
|
||||
@@ -180,7 +239,9 @@ async function removeLoadingWithStateCheck() {
|
||||
animateAndRemoveLoader()
|
||||
|
||||
// 检查未读消息
|
||||
checkAndEmitUnreadMessages()
|
||||
if (isLogin.value) {
|
||||
checkAndEmitUnreadMessages()
|
||||
}
|
||||
} catch (error) {
|
||||
// 即使出错也要移除加载界面
|
||||
globalLoadingStateManager.reset()
|
||||
@@ -199,7 +260,8 @@ async function loadBackgroundImages(retryCount = 0) {
|
||||
if (retryCount < maxRetries) {
|
||||
const baseDelay = isAbortError ? 1000 : 3000
|
||||
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
|
||||
setTimeout(() => {
|
||||
backgroundRetryTimer = window.setTimeout(() => {
|
||||
backgroundRetryTimer = null
|
||||
loadBackgroundImages(retryCount + 1)
|
||||
}, retryDelay)
|
||||
}
|
||||
@@ -207,8 +269,16 @@ async function loadBackgroundImages(retryCount = 0) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 移除URL中的时间戳参数
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.has('_t')) {
|
||||
url.searchParams.delete('_t')
|
||||
const newUrl = url.pathname + url.search + url.hash
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
}
|
||||
|
||||
// 配置 ApexCharts
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(globalTheme.name.value)
|
||||
|
||||
// 初始化data-theme属性
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
@@ -223,29 +293,68 @@ onMounted(async () => {
|
||||
// 更新HTML主题属性
|
||||
updateHtmlThemeAttribute(newTheme)
|
||||
// 重新配置ApexCharts以适应新主题
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(newTheme)
|
||||
},
|
||||
)
|
||||
|
||||
// 加载背景图片
|
||||
loadBackgroundImages()
|
||||
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
|
||||
watch(
|
||||
shouldLoadBackgroundImages,
|
||||
shouldLoad => {
|
||||
stopBackgroundLoading()
|
||||
if (shouldLoad) {
|
||||
loadBackgroundImages()
|
||||
} else if (!isTransparentTheme.value) {
|
||||
backgroundImages.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 使用优化后的加载界面移除逻辑
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
})
|
||||
// 启动心跳
|
||||
if (isLogin.value) {
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
// 登录状态可能在当前单页会话中变化,这里按需补齐登录后初始化和心跳。
|
||||
watch(isLogin, loggedIn => {
|
||||
if (loggedIn) {
|
||||
startHeartbeat()
|
||||
scheduleAuthenticatedStateInitialization()
|
||||
} else {
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
stopHeartbeat()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清除背景轮换定时器
|
||||
removeBackgroundTimer('background-rotation')
|
||||
stopBackgroundLoading()
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-wrapper">
|
||||
<!-- 透明主题背景 -->
|
||||
<div v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)" class="background-container">
|
||||
<div
|
||||
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
|
||||
class="background-container"
|
||||
:class="{ 'is-transparent-theme': isTransparentTheme && isLogin }"
|
||||
>
|
||||
<div
|
||||
v-for="(imageUrl, index) in backgroundImages"
|
||||
:key="`bg-${index}-${loginStateKey}`"
|
||||
@@ -259,6 +368,8 @@ onUnmounted(() => {
|
||||
<!-- 页面内容 -->
|
||||
<VApp>
|
||||
<RouterView />
|
||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||
<SharedDialogHost />
|
||||
<!-- PWA安装提示 -->
|
||||
<PWAInstallPrompt />
|
||||
</VApp>
|
||||
@@ -310,11 +421,15 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.background-container.is-transparent-theme .background-image.active {
|
||||
opacity: var(--transparent-background-poster-opacity, 1);
|
||||
}
|
||||
|
||||
/* 全局磨砂层 */
|
||||
.global-blur-layer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(var(--transparent-background-blur, 16px));
|
||||
background-color: rgba(128, 128, 128, 30%);
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -14,6 +14,10 @@ import modeIniUrl from 'ace-builds/src-noconflict/mode-ini?url'
|
||||
|
||||
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
|
||||
|
||||
import themeGithubDarkUrl from 'ace-builds/src-noconflict/theme-github_dark?url'
|
||||
|
||||
import themeGithubLightDefaultUrl from 'ace-builds/src-noconflict/theme-github_light_default?url'
|
||||
|
||||
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
|
||||
|
||||
import themeMonokaiUrl from 'ace-builds/src-noconflict/theme-monokai?url'
|
||||
@@ -44,6 +48,488 @@ import snippertsIniUrl from 'ace-builds/src-noconflict/snippets/ini?url'
|
||||
|
||||
import 'ace-builds/src-noconflict/ext-language_tools'
|
||||
|
||||
const aceModule = ace as typeof ace & {
|
||||
define?: (moduleName: string, deps: string[], payload: (...args: any[]) => void) => void
|
||||
}
|
||||
|
||||
function registerJinja2Mode() {
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_highlight_rules',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const Jinja2HighlightRules = function (this: any) {
|
||||
const tags =
|
||||
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
|
||||
const filters =
|
||||
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
|
||||
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
|
||||
const tests =
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
'keyword.control.jinja2': tags,
|
||||
'keyword.operator.jinja2': operators,
|
||||
'support.function.jinja2': [filters, functions, tests].join('|'),
|
||||
'constant.language.jinja2': 'false|False|none|None|null|true|True',
|
||||
},
|
||||
'identifier',
|
||||
)
|
||||
|
||||
const jinjaExpressionRules = [
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
push: 'jinja2-qstring',
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
push: 'jinja2-qqstring',
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\|)(\\s*)(${filters})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['support.function.jinja2', 'text', 'paren.lparen'],
|
||||
regex: `\\b(${functions})(\\s*)(\\()`,
|
||||
},
|
||||
{
|
||||
token: 'variable.language.jinja2',
|
||||
regex: `\\b(?:${contextVariables})\\b`,
|
||||
},
|
||||
{
|
||||
token: keywordMapper,
|
||||
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.assignment.jinja2',
|
||||
regex: /=|~/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.comparison.jinja2',
|
||||
regex: /==|!=|<=|>=|<|>/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.arithmetic.jinja2',
|
||||
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.other.jinja2',
|
||||
regex: /\.{2}|\||:/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator.jinja2',
|
||||
regex: /[.,;?]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[\[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
]
|
||||
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /\{#-?/,
|
||||
push: 'jinja2-comment',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /\{\{-?/,
|
||||
push: 'jinja2-expression',
|
||||
},
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /\{%-?/,
|
||||
push: 'jinja2-statement',
|
||||
},
|
||||
],
|
||||
'jinja2-comment': [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /-?#\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment.block.jinja2',
|
||||
},
|
||||
],
|
||||
'jinja2-expression': [
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /-?\}\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaExpressionRules,
|
||||
],
|
||||
'jinja2-statement': [
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /-?%\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaExpressionRules,
|
||||
],
|
||||
'jinja2-qqstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\"ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
'jinja2-qstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\'ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(Jinja2HighlightRules, TextHighlightRules)
|
||||
exports.Jinja2HighlightRules = Jinja2HighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const Jinja2HighlightRules = require('./jinja2_highlight_rules').Jinja2HighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = Jinja2HighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.$id = 'ace/mode/jinja2'
|
||||
this.blockComment = { start: '{#', end: '#}' }
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.('ace/snippets/jinja2', ['require', 'exports', 'module'], (_require: any, exports: any) => {
|
||||
exports.snippetText =
|
||||
'snippet if\n\t{% if ${1:condition} %}\n\t\t${0}\n\t{% endif %}\n' +
|
||||
'snippet for\n\t{% for ${1:item} in ${2:items} %}\n\t\t${0}\n\t{% endfor %}\n' +
|
||||
'snippet var\n\t{{ ${1:name} }}\n'
|
||||
exports.scope = 'jinja2'
|
||||
})
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_json_highlight_rules',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const Jinja2JsonHighlightRules = function (this: any) {
|
||||
const tags =
|
||||
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
|
||||
const filters =
|
||||
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
|
||||
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
|
||||
const tests =
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
'keyword.control.jinja2': tags,
|
||||
'keyword.operator.jinja2': operators,
|
||||
'support.function.jinja2': [filters, functions, tests].join('|'),
|
||||
'constant.language.jinja2': 'false|False|none|None|null|true|True',
|
||||
},
|
||||
'identifier',
|
||||
)
|
||||
|
||||
const jinjaRules = [
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
push: 'jinja2-json-qstring',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\|)(\\s*)(${filters})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['support.function.jinja2', 'text', 'paren.lparen'],
|
||||
regex: `\\b(${functions})(\\s*)(\\()`,
|
||||
},
|
||||
{
|
||||
token: 'variable.language.jinja2',
|
||||
regex: `\\b(?:${contextVariables})\\b`,
|
||||
},
|
||||
{
|
||||
token: keywordMapper,
|
||||
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.assignment.jinja2',
|
||||
regex: /=|~/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.comparison.jinja2',
|
||||
regex: /==|!=|<=|>=|<|>/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.arithmetic.jinja2',
|
||||
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.other.jinja2',
|
||||
regex: /\.{2}|\||:/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator.jinja2',
|
||||
regex: /[.,;?]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[\[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
]
|
||||
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'variable',
|
||||
regex: /"(?:(?:\\.)|(?:[^"\\]))*?"\s*(?=:)/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
push: 'json-string',
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /0[xX][0-9a-fA-F]+\b/,
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?\d+(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b/,
|
||||
},
|
||||
{
|
||||
token: 'constant.language.boolean',
|
||||
regex: /(?:true|false|null)\b/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /['](?:(?:\\.)|(?:[^'\\]))*?[']/,
|
||||
},
|
||||
{
|
||||
token: 'comment',
|
||||
regex: /\/\/.*$/,
|
||||
},
|
||||
{
|
||||
token: 'comment.start',
|
||||
regex: /\/\*/,
|
||||
push: 'comment',
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator',
|
||||
regex: /[:,]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
],
|
||||
'json-string': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
|
||||
},
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /\{#-?/,
|
||||
push: 'jinja2-json-comment',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /\{\{-?/,
|
||||
push: 'jinja2-json-expression',
|
||||
},
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /\{%-?/,
|
||||
push: 'jinja2-json-statement',
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: /"|$/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
comment: [
|
||||
{
|
||||
token: 'comment.end',
|
||||
regex: /\*\//,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment',
|
||||
},
|
||||
],
|
||||
'jinja2-json-comment': [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /-?#\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment.block.jinja2',
|
||||
},
|
||||
],
|
||||
'jinja2-json-expression': [
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /-?\}\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaRules,
|
||||
],
|
||||
'jinja2-json-statement': [
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /-?%\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaRules,
|
||||
],
|
||||
'jinja2-json-qstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\'ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(Jinja2JsonHighlightRules, TextHighlightRules)
|
||||
exports.Jinja2JsonHighlightRules = Jinja2JsonHighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_json',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_json_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const Jinja2JsonHighlightRules = require('./jinja2_json_highlight_rules').Jinja2JsonHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = Jinja2JsonHighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.lineCommentStart = '//'
|
||||
this.blockComment = { start: '/*', end: '*/' }
|
||||
this.$id = 'ace/mode/jinja2_json'
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
|
||||
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
|
||||
@@ -51,6 +537,8 @@ ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
|
||||
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
|
||||
ace.config.setModuleUrl('ace/mode/ini', modeIniUrl)
|
||||
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
|
||||
ace.config.setModuleUrl('ace/theme/github_dark', themeGithubDarkUrl)
|
||||
ace.config.setModuleUrl('ace/theme/github_light_default', themeGithubLightDefaultUrl)
|
||||
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
|
||||
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
|
||||
ace.config.setModuleUrl('ace/mode/base', workerBaseUrl)
|
||||
@@ -61,9 +549,10 @@ ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
|
||||
ace.config.setModuleUrl('ace/mode/css_worker', workerCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/yaml', snippetsYamlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
|
||||
|
||||
registerJinja2Mode()
|
||||
ace.require('ace/ext/language_tools')
|
||||
|
||||
@@ -52,6 +52,10 @@ export const downloaderOptions = [
|
||||
value: 'transmission',
|
||||
title: i18n.global.t('setting.system.transmission'),
|
||||
},
|
||||
{
|
||||
value: 'rtorrent',
|
||||
title: i18n.global.t('setting.system.rtorrent'),
|
||||
},
|
||||
]
|
||||
|
||||
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
|
||||
@@ -64,6 +68,10 @@ export const mediaServerOptions = [
|
||||
value: 'emby',
|
||||
title: i18n.global.t('setting.system.emby'),
|
||||
},
|
||||
{
|
||||
value: 'zspace',
|
||||
title: i18n.global.t('setting.system.zspace'),
|
||||
},
|
||||
{
|
||||
value: 'jellyfin',
|
||||
title: i18n.global.t('setting.system.jellyfin'),
|
||||
@@ -76,6 +84,10 @@ export const mediaServerOptions = [
|
||||
value: 'trimemedia',
|
||||
title: i18n.global.t('setting.system.trimeMedia'),
|
||||
},
|
||||
{
|
||||
value: 'ugreen',
|
||||
title: i18n.global.t('setting.system.ugreen'),
|
||||
},
|
||||
]
|
||||
|
||||
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
|
||||
@@ -274,6 +286,10 @@ export const notificationSwitchOptions = [
|
||||
title: i18n.global.t('notificationSwitch.plugin'),
|
||||
value: '插件',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('notificationSwitch.agent'),
|
||||
value: '智能体',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('notificationSwitch.other'),
|
||||
value: '其它',
|
||||
|
||||
139
src/api/types.ts
@@ -46,6 +46,8 @@ export interface Subscribe {
|
||||
start_episode?: number
|
||||
// 缺失集数
|
||||
lack_episode?: number
|
||||
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
|
||||
completed_episode?: number
|
||||
// 附加信息
|
||||
note?: string
|
||||
// 状态:N-新建 R-订阅中 P-待定 S-暂停
|
||||
@@ -58,10 +60,14 @@ export interface Subscribe {
|
||||
sites: number[]
|
||||
// 是否洗版,数字或者boolean
|
||||
best_version: any
|
||||
// 是否只洗全集整包,数字或者boolean
|
||||
best_version_full?: any
|
||||
// 使用 imdbid 搜索
|
||||
search_imdbid?: any
|
||||
// 当前优先级
|
||||
current_priority: number
|
||||
// 洗版时已下载剧集的优先级状态
|
||||
episode_priority?: Record<string, number>
|
||||
// 保存目录
|
||||
save_path?: string
|
||||
// 时间
|
||||
@@ -314,6 +320,8 @@ export interface MediaInfo {
|
||||
production_countries?: any[]
|
||||
// 语种
|
||||
spoken_languages?: string[]
|
||||
// 数字/实体发行日期
|
||||
release_dates?: MediaRelease[]
|
||||
// 状态
|
||||
status?: string
|
||||
// 标签
|
||||
@@ -368,6 +376,18 @@ export interface TmdbSeason {
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
// 发行信息
|
||||
export interface MediaRelease {
|
||||
// 发行日期
|
||||
date: string
|
||||
// 发行地区
|
||||
iso_code: string
|
||||
// 备注
|
||||
note?: string
|
||||
// 发行类型
|
||||
type: number
|
||||
}
|
||||
|
||||
// TMDB集信息
|
||||
export interface TmdbEpisode {
|
||||
// 上映日期
|
||||
@@ -630,6 +650,12 @@ export interface Plugin {
|
||||
has_page?: boolean
|
||||
// 是否有新版本
|
||||
has_update?: boolean
|
||||
// 主系统版本是否兼容
|
||||
system_version_compatible?: boolean
|
||||
// 主系统版本兼容提示
|
||||
system_version_message?: string
|
||||
// 主系统版本限定范围
|
||||
system_version?: string
|
||||
// 是否本地插件
|
||||
is_local?: boolean
|
||||
// 插件仓库地址
|
||||
@@ -642,6 +668,17 @@ export interface Plugin {
|
||||
page_open?: boolean
|
||||
}
|
||||
|
||||
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
|
||||
export interface PluginSidebarNavItem {
|
||||
plugin_id: string
|
||||
nav_key: string
|
||||
title: string
|
||||
icon: string
|
||||
section: 'start' | 'discovery' | 'subscribe' | 'organize' | 'system'
|
||||
permission?: 'subscribe' | 'discovery' | 'search' | 'manage' | 'admin' | null
|
||||
order: number
|
||||
}
|
||||
|
||||
// 渲染结构
|
||||
export interface RenderProps {
|
||||
component: string
|
||||
@@ -847,6 +884,16 @@ export interface User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 通行密钥
|
||||
export interface PassKey {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
aaguid?: string
|
||||
transports?: string
|
||||
}
|
||||
|
||||
// 存储空间
|
||||
export interface Storage {
|
||||
// 总空间
|
||||
@@ -861,8 +908,8 @@ export interface MediaStatistic {
|
||||
movie_count: number
|
||||
// 电视剧总数
|
||||
tv_count: number
|
||||
// 电视剧总集数
|
||||
episode_count: number
|
||||
// 电视剧总集数,未获取时为 null
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
}
|
||||
@@ -992,6 +1039,8 @@ export interface MediaServerPlayItem {
|
||||
percent?: number
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
// 图片是否需要Cookies
|
||||
use_cookies?: boolean
|
||||
}
|
||||
|
||||
// 媒体服务器媒体库
|
||||
@@ -1014,6 +1063,8 @@ export interface MediaServerLibrary {
|
||||
link?: string
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
// 图片是否需要Cookies
|
||||
use_cookies?: boolean
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
@@ -1066,6 +1117,8 @@ export interface DownloaderConf {
|
||||
config: { [key: string]: any }
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
// 路径映射
|
||||
path_mapping?: Array<[storagePath: string, downloadPath: string]>
|
||||
}
|
||||
|
||||
// 通知配置
|
||||
@@ -1104,7 +1157,7 @@ export interface StorageConf {
|
||||
export interface MediaServerConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 emby/jellyfin/plex
|
||||
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen
|
||||
type: string
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
@@ -1269,7 +1322,63 @@ export interface TransferForm {
|
||||
// 媒体库类别子目录
|
||||
library_category_folder?: boolean
|
||||
// 剧集组编号
|
||||
episode_group?: string
|
||||
episode_group?: string | null
|
||||
// 预览模式
|
||||
preview?: boolean
|
||||
}
|
||||
|
||||
// 手动整理请求
|
||||
export interface ManualTransferPayload extends Omit<TransferForm, 'fileitem'> {
|
||||
// 文件项
|
||||
fileitem?: FileItem
|
||||
// 多选文件批量请求
|
||||
fileitems?: FileItem[]
|
||||
}
|
||||
|
||||
// 手动整理预览统计
|
||||
export interface ManualTransferPreviewSummary {
|
||||
// 总数
|
||||
total: number
|
||||
// 成功数
|
||||
success: number
|
||||
// 失败数
|
||||
failed: number
|
||||
}
|
||||
|
||||
// 手动整理预览项
|
||||
export interface ManualTransferPreviewItem {
|
||||
// 原始路径
|
||||
source?: string
|
||||
// 目标路径
|
||||
target?: string
|
||||
// 目标目录
|
||||
target_dir?: string
|
||||
// 是否成功
|
||||
success?: boolean
|
||||
// 提示信息
|
||||
message?: string
|
||||
// 媒体类型
|
||||
type?: string
|
||||
// 媒体标题
|
||||
title?: string
|
||||
// 季
|
||||
season?: number | string
|
||||
// 开始集
|
||||
episode?: number | string
|
||||
// 结束集
|
||||
episode_end?: number | string
|
||||
// Part
|
||||
part?: string
|
||||
}
|
||||
|
||||
// 手动整理预览数据
|
||||
export interface ManualTransferPreviewData {
|
||||
// 统计信息
|
||||
summary: ManualTransferPreviewSummary
|
||||
// 预览结果
|
||||
items: ManualTransferPreviewItem[]
|
||||
// 额外消息
|
||||
message?: string
|
||||
}
|
||||
|
||||
// 整理队列
|
||||
@@ -1409,3 +1518,25 @@ export interface SubscribeShareStatistics {
|
||||
// 总复用人次
|
||||
total_reuse_count?: number
|
||||
}
|
||||
|
||||
// 通用API响应
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 分类规则
|
||||
export interface CategoryRule {
|
||||
genre_ids?: string
|
||||
original_language?: string
|
||||
production_countries?: string
|
||||
origin_country?: string
|
||||
release_year?: string
|
||||
}
|
||||
|
||||
// 分类配置
|
||||
export interface CategoryConfig {
|
||||
movie?: { [key: string]: CategoryRule }
|
||||
tv?: { [key: string]: CategoryRule }
|
||||
}
|
||||
|
||||
BIN
src/assets/images/logos/clawbot.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
src/assets/images/logos/discord.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/images/logos/feishu.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/assets/images/logos/qq.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/images/logos/rtorrent.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/assets/images/logos/ugreen.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/images/logos/zspace.webp
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
@@ -4,6 +4,14 @@ import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// LocalStorage keys
|
||||
const SORT_KEY = 'fileBrowser.sort'
|
||||
const SHOW_TREE_KEY = 'fileBrowser.showDirTree'
|
||||
const NAV_WIDTH_KEY = 'fileBrowser.navigatorWidth'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -11,7 +19,7 @@ const props = defineProps({
|
||||
tree: Boolean,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
axiosconfig: Object,
|
||||
@@ -23,10 +31,17 @@ const props = defineProps({
|
||||
type: Array as PropType<FileItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['pathchanged'])
|
||||
const route = useRoute()
|
||||
const { appMode } = usePWA()
|
||||
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
|
||||
|
||||
const fileIcons = {
|
||||
// 压缩包
|
||||
@@ -117,24 +132,47 @@ const fileIcons = {
|
||||
other: 'mdi-file-outline',
|
||||
}
|
||||
|
||||
function openNewFolderDialog() {
|
||||
toolbarRef.value?.openNewFolderDialog()
|
||||
}
|
||||
|
||||
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
|
||||
|
||||
useDynamicButton({
|
||||
icon: 'mdi-folder-plus-outline',
|
||||
onClick: openNewFolderDialog,
|
||||
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
|
||||
})
|
||||
|
||||
// 加载次数
|
||||
const loading = ref(0)
|
||||
// 当前存储
|
||||
const activeStorage = ref('local')
|
||||
|
||||
// 刷新
|
||||
const refreshPending = ref(false)
|
||||
// 排序
|
||||
const sort = ref('name')
|
||||
// 排序 - 从localStorage恢复
|
||||
const sort = ref(localStorage.getItem(SORT_KEY) || 'name')
|
||||
|
||||
// 是否显示目录树
|
||||
const showDirTree = ref(false)
|
||||
// 是否显示目录树 - 从localStorage恢复
|
||||
const showDirTree = ref(localStorage.getItem(SHOW_TREE_KEY) === 'true')
|
||||
|
||||
// 拖动分隔条相关
|
||||
const navigatorWidth = ref(280) // 初始宽度
|
||||
// 拖动分隔条相关 - 从localStorage恢复宽度
|
||||
const navigatorWidth = ref(parseInt(localStorage.getItem(NAV_WIDTH_KEY) || '280'))
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartWidth = ref(0)
|
||||
|
||||
watch(sort, (val) => {
|
||||
localStorage.setItem(SORT_KEY, val)
|
||||
})
|
||||
|
||||
watch(showDirTree, (val) => {
|
||||
localStorage.setItem(SHOW_TREE_KEY, String(val))
|
||||
})
|
||||
|
||||
watch(navigatorWidth, (val) => {
|
||||
localStorage.setItem(NAV_WIDTH_KEY, String(val))
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const storagesArray = computed(() => {
|
||||
return props.storages?.map(item => ({
|
||||
@@ -144,15 +182,15 @@ const storagesArray = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
// 方法
|
||||
function loadingChanged(loading: number) {
|
||||
if (loading) loading++
|
||||
else if (loading > 0) loading--
|
||||
function loadingChanged(isLoading: number) {
|
||||
if (isLoading) loading.value++
|
||||
else if (loading.value > 0) loading.value--
|
||||
}
|
||||
|
||||
// 存储切换
|
||||
async function storageChanged(storage: string) {
|
||||
activeStorage.value = storage
|
||||
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
|
||||
}
|
||||
|
||||
@@ -235,14 +273,16 @@ function stopDrag() {
|
||||
|
||||
<template>
|
||||
<div class="mx-auto" :loading="loading > 0">
|
||||
<div v-if="activeStorage && item">
|
||||
<div v-if="item">
|
||||
<FileToolbar
|
||||
ref="toolbarRef"
|
||||
:sort="sort"
|
||||
:item="item"
|
||||
:itemstack="itemstack"
|
||||
:storages="storagesArray"
|
||||
:storage="activeStorage"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:show-new-folder-button="!showFloatingNewFolderAction"
|
||||
@storagechanged="storageChanged"
|
||||
@pathchanged="pathChanged"
|
||||
@foldercreated="refreshPending = true"
|
||||
@@ -251,7 +291,7 @@ function stopDrag() {
|
||||
<div class="flex">
|
||||
<FileNavigator
|
||||
v-if="showDirTree"
|
||||
:storage="activeStorage"
|
||||
:storage="item.storage"
|
||||
:currentPath="item.path"
|
||||
:items="fileListItems"
|
||||
:endpoints="endpoints"
|
||||
@@ -266,13 +306,13 @@ function stopDrag() {
|
||||
</div>
|
||||
<FileList
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
:showTree="showDirTree"
|
||||
:active="active"
|
||||
:style="{ flex: 1 }"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@@ -285,6 +325,18 @@ function stopDrag() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body" v-if="!appMode && showFloatingNewFolderAction">
|
||||
<div class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-folder-plus-outline"
|
||||
color="primary"
|
||||
appear
|
||||
class="compact-fab compact-fab--primary"
|
||||
@click="openNewFolderDialog"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -10,12 +11,18 @@ const props = defineProps({
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 图片加载失败响应
|
||||
function imageErrorHandler() {
|
||||
imageLoadError.value = true
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
@@ -26,7 +33,13 @@ async function goPlay() {
|
||||
// 计算图片地址
|
||||
const getImgUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const use_cookies = props.media?.use_cookies
|
||||
if (use_cookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return url
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -45,7 +58,7 @@ const getImgUrl = computed(() => {
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler">
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import type { CustomRule } from '@/api/types'
|
||||
import filter_svg from '@images/svg/filter.svg'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const CustomRuleInfoDialog = defineAsyncComponent(() => import('@/components/dialog/CustomRuleInfoDialog.vue'))
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -24,204 +21,52 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const ruleInfoDialog = ref(false)
|
||||
|
||||
// 规则详情
|
||||
const ruleInfo = ref<CustomRule>({
|
||||
id: '',
|
||||
name: '',
|
||||
include: '',
|
||||
exclude: '',
|
||||
size_range: '',
|
||||
seeders: '',
|
||||
publish_time: '',
|
||||
})
|
||||
|
||||
// 打开详情弹窗
|
||||
/** 打开共享自定义规则配置弹窗。 */
|
||||
function openRuleInfoDialog() {
|
||||
// 深复制
|
||||
ruleInfo.value = cloneDeep(props.rule)
|
||||
ruleInfoDialog.value = true
|
||||
openSharedDialog(
|
||||
CustomRuleInfoDialog,
|
||||
{
|
||||
rule: props.rule,
|
||||
rules: props.rules,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
done: () => emit('done'),
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveRuleInfo() {
|
||||
// 有空值
|
||||
if (!ruleInfo.value.id || !ruleInfo.value.name) {
|
||||
if (!ruleInfo.value.id && !ruleInfo.value.name) {
|
||||
$toast.error(t('customRule.error.emptyIdName'))
|
||||
}
|
||||
return
|
||||
}
|
||||
// 检查ID是否在内置的规则中
|
||||
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
|
||||
$toast.error(t('customRule.error.idOccupied'))
|
||||
return
|
||||
}
|
||||
// 检查规则名称是否在内置的规则中
|
||||
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
|
||||
$toast.error(t('customRule.error.nameOccupied'))
|
||||
return
|
||||
}
|
||||
// ID已存在
|
||||
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
|
||||
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
|
||||
return
|
||||
}
|
||||
// 规则名称已存在
|
||||
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
|
||||
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
|
||||
return
|
||||
}
|
||||
// 保存数据
|
||||
ruleInfoDialog.value = false
|
||||
emit('change', ruleInfo.value, props.rule.id)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 验证规则ID输入
|
||||
function validateRuleId() {
|
||||
// 只允许英文和数字,不允许空格
|
||||
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
/** 关闭自定义规则卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openRuleInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.rule.name }}</h5>
|
||||
<div class="text-body-1 mb-3">{{ props.rule.id }}</div>
|
||||
</div>
|
||||
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog
|
||||
v-if="ruleInfoDialog"
|
||||
v-model="ruleInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-filter-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="ruleInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.id"
|
||||
:label="t('customRule.field.ruleId')"
|
||||
:placeholder="t('customRule.placeholder.ruleId')"
|
||||
:hint="t('customRule.hint.ruleId')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
@input="validateRuleId"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.name"
|
||||
:label="t('customRule.field.ruleName')"
|
||||
:placeholder="t('customRule.placeholder.ruleName')"
|
||||
:hint="t('customRule.hint.ruleName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.include"
|
||||
:label="t('customRule.field.include')"
|
||||
:placeholder="t('customRule.placeholder.include')"
|
||||
:hint="t('customRule.hint.include')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-plus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.exclude"
|
||||
:label="t('customRule.field.exclude')"
|
||||
:placeholder="t('customRule.placeholder.exclude')"
|
||||
:hint="t('customRule.hint.exclude')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-minus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.size_range"
|
||||
:label="t('customRule.field.sizeRange')"
|
||||
:placeholder="t('customRule.placeholder.sizeRange')"
|
||||
:hint="t('customRule.hint.sizeRange')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-harddisk"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.seeders"
|
||||
:label="t('customRule.field.seeders')"
|
||||
:placeholder="t('customRule.placeholder.seeders')"
|
||||
:hint="t('customRule.hint.seeders')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.publish_time"
|
||||
:label="t('customRule.field.publishTime')"
|
||||
:placeholder="t('customRule.placeholder.publishTime')"
|
||||
:hint="t('customRule.hint.publishTime')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-calendar-clock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">{{
|
||||
t('customRule.action.confirm')
|
||||
}}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@click="openRuleInfoDialog"
|
||||
>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
|
||||
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg ref="imageRef" :src="filter_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -5,8 +5,20 @@ import { nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storageRemoteDict } from '@/api/constants'
|
||||
|
||||
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
|
||||
const STORAGE_ACCENT_COLOR_MAP = {
|
||||
local: '#FFB400',
|
||||
alipan: '#00A7F2',
|
||||
u115: '#17B26A',
|
||||
rclone: '#6675FF',
|
||||
alist: '#12B8D7',
|
||||
smb: '#3B82F6',
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const downloadAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
|
||||
const libraryAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -63,6 +75,47 @@ const transferSourceItems = computed(() => [
|
||||
{ title: t('directory.manualTransfer'), value: 'manual' },
|
||||
])
|
||||
|
||||
function hasKnownStorageType(storageType?: string): storageType is keyof typeof STORAGE_ACCENT_COLOR_MAP {
|
||||
return !!storageType && Object.prototype.hasOwnProperty.call(STORAGE_ACCENT_COLOR_MAP, storageType)
|
||||
}
|
||||
|
||||
function hexToRgbString(hexColor: string) {
|
||||
const normalizedColor = hexColor.replace('#', '')
|
||||
const colorValue = Number.parseInt(normalizedColor, 16)
|
||||
|
||||
if (Number.isNaN(colorValue) || normalizedColor.length !== 6) return DEFAULT_DIRECTORY_ACCENT_RGB
|
||||
|
||||
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
|
||||
}
|
||||
|
||||
function getCustomStoragePaletteColor(storageType?: string) {
|
||||
const customStorageIndex = Math.max(Number(storageType?.match(/\d+$/)?.[0] ?? 1) - 1, 0)
|
||||
const customStorageColors = ['#F97316', '#8B5CF6', '#06B6D4', '#84CC16', '#EC4899', '#14B8A6']
|
||||
|
||||
return customStorageColors[customStorageIndex % customStorageColors.length]
|
||||
}
|
||||
|
||||
function getStorageAccentColor(storageType?: string) {
|
||||
if (hasKnownStorageType(storageType)) return STORAGE_ACCENT_COLOR_MAP[storageType]
|
||||
|
||||
// 自定义存储没有固定品牌图标,使用离散调色板,保证连续 custom1/custom2 也能明显区分。
|
||||
return getCustomStoragePaletteColor(storageType)
|
||||
}
|
||||
|
||||
// 目录卡片用下载存储和媒体库存储两端的图标主色生成轻渐变,体现整理链路的两个存储端点。
|
||||
const directoryAccentStyle = computed(() => ({
|
||||
'--app-card-accent-rgb': downloadAccentRgb.value,
|
||||
'--app-card-accent-end-rgb': libraryAccentRgb.value,
|
||||
}))
|
||||
|
||||
function updateDirectoryAccentColors() {
|
||||
const downloadStorage = props.directory.storage
|
||||
const libraryStorage = props.directory.library_storage || props.directory.storage
|
||||
|
||||
downloadAccentRgb.value = hexToRgbString(getStorageAccentColor(downloadStorage))
|
||||
libraryAccentRgb.value = hexToRgbString(getStorageAccentColor(libraryStorage))
|
||||
}
|
||||
|
||||
// 监控模式下拉字典
|
||||
const MonitorModeItems = computed(() => [
|
||||
{ title: t('directory.performanceMode'), value: 'fast' },
|
||||
@@ -168,6 +221,15 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 存储类型切换后主动重新提取图标色,避免图片缓存导致 load 事件不触发。
|
||||
watch(
|
||||
[() => props.directory.storage, () => props.directory.library_storage],
|
||||
() => {
|
||||
updateDirectoryAccentColors()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 媒体类别和类型变更非空时,将按类型分类和按类别分类置为false
|
||||
watch(
|
||||
[() => props.directory.media_type, () => props.directory.media_category],
|
||||
@@ -195,7 +257,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height">
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="directoryAccentStyle"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VTextField
|
||||
@@ -204,8 +272,8 @@ watch(
|
||||
:label="t('directory.alias')"
|
||||
class="me-20 text-high-emphasis font-weight-bold"
|
||||
/>
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import qbittorrent_image from '@images/logos/qbittorrent.png'
|
||||
import transmission_image from '@images/logos/transmission.png'
|
||||
import custom_image from '@images/logos/downloader.png'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import type { DownloaderConf, DownloaderInfo } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { downloaderDict } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const DownloaderInfoDialog = defineAsyncComponent(() => import('@/components/dialog/DownloaderInfoDialog.vue'))
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
const { useConditionalDataRefresh } = useBackgroundOptimization()
|
||||
const { useConditionalDataRefresh } = useBackground()
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -42,34 +38,18 @@ const props = defineProps({
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'done', 'change'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 上传速率
|
||||
const upload_rate = ref(0)
|
||||
|
||||
// 下载速度
|
||||
const download_rate = ref(0)
|
||||
|
||||
// 下载器详情弹窗
|
||||
const downloaderInfoDialog = ref(false)
|
||||
|
||||
// 下载器详情
|
||||
const downloaderInfo = ref<DownloaderConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
default: false,
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 下载器是否应该刷新数据的计算属性
|
||||
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
|
||||
|
||||
// 调用API查询下载器数据
|
||||
/** 调用 API 查询下载器实时速率数据。 */
|
||||
async function loadDownloaderInfo() {
|
||||
if (!shouldRefresh.value) {
|
||||
// 当下载器被禁用时,重置速率数据
|
||||
upload_rate.value = 0
|
||||
download_rate.value = 0
|
||||
return
|
||||
@@ -90,53 +70,37 @@ async function loadDownloaderInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
/** 打开共享下载器配置弹窗。 */
|
||||
function openDownloaderInfoDialog() {
|
||||
// 深复制
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
downloaderInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveDownloaderInfo() {
|
||||
// 为空不保存,跳出警告框
|
||||
if (!downloaderInfo.value.name) {
|
||||
$toast.error(t('downloader.nameRequired'))
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
|
||||
$toast.error(t('downloader.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
// 默认下载器去重
|
||||
if (downloaderInfo.value.default) {
|
||||
props.downloaders.forEach(item => {
|
||||
if (item.default && item !== props.downloader) {
|
||||
item.default = false
|
||||
$toast.info(t('downloader.defaultChanged'))
|
||||
}
|
||||
})
|
||||
}
|
||||
// 执行保存
|
||||
downloaderInfoDialog.value = false
|
||||
emit('change', downloaderInfo.value, props.downloader.name)
|
||||
emit('done')
|
||||
openSharedDialog(
|
||||
DownloaderInfoDialog,
|
||||
{
|
||||
downloader: props.downloader,
|
||||
downloaders: props.downloaders,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
done: () => emit('done'),
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.downloader.type) {
|
||||
case 'qbittorrent':
|
||||
return qbittorrent_image
|
||||
return getLogoUrl('qbittorrent')
|
||||
case 'transmission':
|
||||
return transmission_image
|
||||
return getLogoUrl('transmission')
|
||||
case 'rtorrent':
|
||||
return getLogoUrl('rtorrent')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('downloader')
|
||||
}
|
||||
})
|
||||
|
||||
// 按钮点击
|
||||
/** 关闭下载器卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
@@ -145,244 +109,55 @@ function onClose() {
|
||||
const { stop: stopRefresh } = useConditionalDataRefresh(
|
||||
`downloader-${props.downloader.name}`,
|
||||
loadDownloaderInfo,
|
||||
shouldRefresh, // 响应式条件:只有当allowRefresh为true且downloader启用时才运行
|
||||
3000, // 3秒间隔
|
||||
true, // 立即执行一次
|
||||
shouldRefresh,
|
||||
3000,
|
||||
true,
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRefresh()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
variant="tonal"
|
||||
@click="openDownloaderInfoDialog"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VCardText class="flex justify-space-between align-center gap-4">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="flex items-center">
|
||||
<VBadge
|
||||
v-if="props.downloader.default && props.downloader.enabled"
|
||||
dot
|
||||
inline
|
||||
color="success"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="text-h6">{{ downloader.name }}</span>
|
||||
</div>
|
||||
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="mt-1 flex flex-wrap text-sm">
|
||||
<span class="me-2">{{ `↑ ${formatFileSize(upload_rate, 1)}/s ` }}</span>
|
||||
<span>{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
</div>
|
||||
<div v-else-if="!downloaderDict[downloader.type]" class="mt-1 flex flex-wrap text-sm">
|
||||
<span class="me-2">自定义下载器</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VHover>
|
||||
|
||||
<VDialog
|
||||
v-if="downloaderInfoDialog"
|
||||
v-model="downloaderInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
<template>
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@click="openDownloaderInfoDialog"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('common.config') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.default"
|
||||
:label="t('downloader.default')"
|
||||
:disabled="!downloaderInfo.enabled"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port"
|
||||
:hint="t('downloader.host')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.category"
|
||||
:label="t('downloader.category')"
|
||||
:hint="t('downloader.category')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.sequentail"
|
||||
:label="t('downloader.sequentail')"
|
||||
:hint="t('downloader.sequentail')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.force_resume"
|
||||
:label="t('downloader.force_resume')"
|
||||
:hint="t('downloader.force_resume')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.first_last_piece"
|
||||
:label="t('downloader.first_last_piece')"
|
||||
:hint="t('downloader.first_last_piece')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'transmission'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port"
|
||||
:hint="t('downloader.host')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.type"
|
||||
:label="t('downloader.type')"
|
||||
:hint="t('downloader.customTypeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:hint="t('downloader.nameRequired')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VCardText class="app-card-summary app-card-summary--double-action">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title-row">
|
||||
<VBadge
|
||||
v-if="props.downloader.default && props.downloader.enabled"
|
||||
dot
|
||||
inline
|
||||
color="success"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
|
||||
</div>
|
||||
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="app-card-summary__meta text-sm">
|
||||
<span class="app-card-summary__meta-item">{{ `↑ ${formatFileSize(upload_rate, 1)}/s` }}</span>
|
||||
<span class="app-card-summary__meta-item">{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
</div>
|
||||
<div v-else-if="!downloaderDict[downloader.type]" class="app-card-summary__subtitle text-sm">
|
||||
{{ t('setting.system.custom') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
@@ -28,32 +28,31 @@ function filtersChanged(value: string[]) {
|
||||
}
|
||||
|
||||
// 过滤规则下拉框
|
||||
const selectFilterOptions = ref<{ [key: string]: string }[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
selectFilterOptions.value = cloneDeep(innerFilterRules)
|
||||
if (props.custom_rules) {
|
||||
console.log(props.custom_rules)
|
||||
props.custom_rules.map(rule => {
|
||||
selectFilterOptions.value.push({
|
||||
title: rule.name,
|
||||
value: rule.id,
|
||||
})
|
||||
// 同时包含内置规则与用户自定义规则;使用 computed 而非 onMounted 一次性赋值,
|
||||
// 是为了在父组件异步加载完 custom_rules 或后续新增/删除规则时,
|
||||
// 选项与已选 chip 的显示名(title)能跟随刷新,避免回退到原始 ID(如 "zhong")。
|
||||
const selectFilterOptions = computed<{ [key: string]: string }[]>(() => {
|
||||
const options = cloneDeep(innerFilterRules)
|
||||
props.custom_rules?.forEach(rule => {
|
||||
options.push({
|
||||
title: rule.name,
|
||||
value: rule.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
return options
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
|
||||
<VCardTitle class="pr-8">{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VAutocomplete
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import type { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import filter_group_svg from '@images/svg/filter-group.svg'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const FilterRuleGroupInfoDialog = defineAsyncComponent(() => import('@/components/dialog/FilterRuleGroupInfoDialog.vue'))
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -37,285 +32,57 @@ const props = defineProps({
|
||||
custom_rules: Array as PropType<CustomRule[]>,
|
||||
})
|
||||
|
||||
// 规则卡片类型
|
||||
interface FilterCard {
|
||||
// 优先级
|
||||
pri: string
|
||||
// 已选规则
|
||||
rules: string[]
|
||||
}
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const groupInfoDialog = ref(false)
|
||||
|
||||
// 规则详情
|
||||
const groupInfo = ref<FilterRuleGroup>({
|
||||
name: props.group?.name ?? '',
|
||||
rule_string: props.group?.rule_string ?? '',
|
||||
media_type: props.group?.media_type ?? '',
|
||||
category: props.group?.category ?? '',
|
||||
})
|
||||
|
||||
// 媒体类型字典
|
||||
const mediaTypeItems = [
|
||||
{ title: t('common.all'), value: '' },
|
||||
{ title: t('mediaType.movie'), value: '电影' },
|
||||
{ title: t('mediaType.tv'), value: '电视剧' },
|
||||
]
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: t('common.all'), value: '' }]
|
||||
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
|
||||
return default_value
|
||||
}
|
||||
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
|
||||
})
|
||||
|
||||
// 规则组规则卡片列表
|
||||
const filterRuleCards = ref<FilterCard[]>([])
|
||||
|
||||
// 导入代码弹窗
|
||||
const importCodeDialog = ref(false)
|
||||
|
||||
// 导入代码类型
|
||||
const importCodeType = ref('')
|
||||
|
||||
// 更新规则卡片的值
|
||||
function updateFilterCardValue(pri: string, rules: string[]) {
|
||||
const card = filterRuleCards.value.find(card => card.pri === pri)
|
||||
if (card && Array.isArray(rules)) card.rules = rules
|
||||
/** 打开共享过滤规则组配置弹窗。 */
|
||||
function openGroupInfoDialog() {
|
||||
openSharedDialog(
|
||||
FilterRuleGroupInfoDialog,
|
||||
{
|
||||
group: props.group,
|
||||
groups: props.groups,
|
||||
categories: props.categories,
|
||||
custom_rules: props.custom_rules,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
done: () => emit('done'),
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 移除卡片
|
||||
function filterCardClose(pri: string) {
|
||||
filterRuleCards.value = filterRuleCards.value
|
||||
.filter(card => card.pri !== pri)
|
||||
.map((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
return card
|
||||
})
|
||||
}
|
||||
|
||||
// 分享规则
|
||||
async function shareRules() {
|
||||
if (filterRuleCards.value.length === 0) return
|
||||
|
||||
const value = filterRuleCards.value
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
|
||||
try {
|
||||
let success
|
||||
success = copyToClipboard(value)
|
||||
if (await success) $toast.success(t('filterRule.shareSuccess'))
|
||||
else $toast.error(t('filterRule.shareFailed'))
|
||||
} catch (error) {
|
||||
$toast.error(t('filterRule.shareFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入规则
|
||||
async function importRules(ruleType: string) {
|
||||
importCodeType.value = ruleType
|
||||
importCodeDialog.value = true
|
||||
}
|
||||
|
||||
// 保存导入的代码,直接覆盖原有值
|
||||
function saveCodeString(type: string, code: any) {
|
||||
try {
|
||||
code = code.value
|
||||
if (type === 'priority') {
|
||||
// 解析值
|
||||
if (!code) return
|
||||
// 首尾增加空格
|
||||
if (!code.startsWith(' ')) code = ` ${code}`
|
||||
if (!code.endsWith(' ')) code = `${code} `
|
||||
const groups = code.split('>')
|
||||
filterRuleCards.value = groups.map((group: string, index: number) => ({
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&').filter(rule => rule),
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(t('filterRule.importFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 增加卡片
|
||||
function addFilterCard() {
|
||||
const pri = (filterRuleCards.value.length + 1).toString()
|
||||
const newCard: FilterCard = { pri, rules: [] }
|
||||
filterRuleCards.value.push(newCard)
|
||||
}
|
||||
|
||||
// 根据列表的拖动顺序更新优先级
|
||||
function dragOrderEnd() {
|
||||
filterRuleCards.value.forEach((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
})
|
||||
}
|
||||
|
||||
// 打开详情弹窗
|
||||
function opengroupInfoDialog() {
|
||||
groupInfo.value = cloneDeep(props.group)
|
||||
if (props.group.rule_string) {
|
||||
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&').filter(rule => rule),
|
||||
}))
|
||||
}
|
||||
groupInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveGroupInfo() {
|
||||
if (!groupInfo.value.name.trim()) {
|
||||
$toast.error(t('filterRule.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
|
||||
$toast.error(t('filterRule.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
|
||||
groupInfoDialog.value = false
|
||||
groupInfo.value.rule_string = filterRuleCards.value
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
emit('change', groupInfo.value, props.group.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
/** 关闭过滤规则组卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="opengroupInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
|
||||
<div class="text-body-1 mb-3">
|
||||
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
|
||||
<span v-else>{{ props.group.category }}</span>
|
||||
</div>
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@click="openGroupInfoDialog"
|
||||
>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
|
||||
<div class="app-card-summary__subtitle text-body-1">
|
||||
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
|
||||
<span v-else>{{ props.group.category }}</span>
|
||||
</div>
|
||||
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog
|
||||
v-if="groupInfoDialog"
|
||||
v-model="groupInfoDialog"
|
||||
scrollable
|
||||
max-width="80rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
|
||||
<VDialogCloseBtn v-model="groupInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardItem class="pt-1">
|
||||
<VRow class="mt-1">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="groupInfo.name"
|
||||
:label="t('filterRule.groupName')"
|
||||
:placeholder="t('filterRule.nameRequired')"
|
||||
:hint="t('filterRule.groupName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VAutocomplete
|
||||
v-model="groupInfo.media_type"
|
||||
:label="t('filterRule.mediaType')"
|
||||
:items="mediaTypeItems"
|
||||
:hint="t('filterRule.mediaType')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-movie-open"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VAutocomplete
|
||||
v-model="groupInfo.category"
|
||||
:items="getCategories"
|
||||
:label="t('filterRule.category')"
|
||||
:hint="t('filterRule.category')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-folder-open"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
v-model="filterRuleCards"
|
||||
handle=".cursor-move"
|
||||
item-key="pri"
|
||||
tag="div"
|
||||
@end="dragOrderEnd"
|
||||
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<FilterRuleCard
|
||||
:pri="element.pri"
|
||||
:maxpri="filterRuleCards.length.toString()"
|
||||
:rules="element.rules"
|
||||
:custom_rules="props.custom_rules"
|
||||
@changed="updateFilterCardValue"
|
||||
@close="filterCardClose(element.pri)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VBtn color="success" @click="importRules('priority')">
|
||||
<VIcon icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn color="info" @click="shareRules">
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<ImportCodeDialog
|
||||
v-if="importCodeDialog"
|
||||
v-model="importCodeDialog"
|
||||
:title="t('filterRule.import')"
|
||||
:dataType="importCodeType"
|
||||
@close="importCodeDialog = false"
|
||||
@save="saveCodeString"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg ref="imageRef" :src="filter_group_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MediaServerLibrary } from '@/api/types'
|
||||
import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import trimemedia from '@images/logos/trimemedia.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
@@ -33,14 +33,17 @@ function imageLoadHandler() {
|
||||
// 图片加载错误
|
||||
function imageErrorHandler() {
|
||||
imageError.value = true
|
||||
imgUrl.value = getDefaultImage()
|
||||
}
|
||||
|
||||
// 默认图片
|
||||
function getDefaultImage() {
|
||||
if (props.media?.server_type === 'plex') return plex
|
||||
else if (props.media?.server_type === 'emby') return emby
|
||||
else if (props.media?.server_type === 'zspace') return getLogoUrl('zspace')
|
||||
else if (props.media?.server_type === 'jellyfin') return jellyfin
|
||||
else if (props.media?.server_type === 'trimemedia') return trimemedia
|
||||
else if (props.media?.server_type === 'trimemedia') return getLogoUrl('trimemedia')
|
||||
else if (props.media?.server_type === 'ugreen') return getLogoUrl('ugreen')
|
||||
else return plex
|
||||
}
|
||||
|
||||
@@ -52,31 +55,39 @@ async function goPlay() {
|
||||
}
|
||||
|
||||
// 生成图片代理路径
|
||||
function getImgUrl(url: string) {
|
||||
if (!url) return getDefaultImage()
|
||||
else return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url || imageError.value) return getDefaultImage()
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
if (use_cookies) {
|
||||
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return imgurl
|
||||
}
|
||||
|
||||
// 根据多张图片生成媒体库封面
|
||||
async function drawImages(imageList: string[]) {
|
||||
async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 图片
|
||||
const IMAGES = imageList
|
||||
const IMAGES = [...imageList]
|
||||
if (IMAGES.length === 0) return getDefaultImage()
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
for (let i = 0; i < IMAGES.length; i++)
|
||||
for (let i = 0; i < IMAGES.length; i++) {
|
||||
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
|
||||
if (use_cookies) {
|
||||
IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
}
|
||||
|
||||
// canvas
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return getDefaultImage()
|
||||
|
||||
// 画布参数
|
||||
const POSTER_WIDTH = (canvas.width - 40) / 4 // 左右边框8px + 3个间隔24px = 40px
|
||||
const POSTER_HEIGHT = 256 // 上方海报高256
|
||||
const MARGIN_WIDTH = 8 // 左右间隔为8
|
||||
const MARGIN_HEIGHT = 4 // 海报和倒影之间的间隔为4
|
||||
const REFLECTION_HEIGHT = canvas.height - POSTER_HEIGHT - MARGIN_HEIGHT // 下方倒影使用剩余全部高度
|
||||
const POSTER_WIDTH = (canvas.width - 40) / 4 // 左右边框8px + 3个间隔24px = 40px
|
||||
const POSTER_HEIGHT = 256 // 上方海报高256
|
||||
const MARGIN_WIDTH = 8 // 左右间隔为8
|
||||
const MARGIN_HEIGHT = 4 // 海报和倒影之间的间隔为4
|
||||
const REFLECTION_HEIGHT = canvas.height - POSTER_HEIGHT - MARGIN_HEIGHT // 下方倒影使用剩余全部高度
|
||||
|
||||
// 获取画布上下文
|
||||
const ctx = canvas.getContext('2d')
|
||||
@@ -107,30 +118,20 @@ async function drawImages(imageList: string[]) {
|
||||
}
|
||||
|
||||
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
|
||||
const y = 0 // 海报紧贴顶部
|
||||
const y = 0 // 海报紧贴顶部
|
||||
|
||||
ctx.drawImage(img, x, y, POSTER_WIDTH, POSTER_HEIGHT)
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(0, canvas.height)
|
||||
ctx.scale(1, -1)
|
||||
ctx.drawImage(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
img.width,
|
||||
img.height,
|
||||
x,
|
||||
0,
|
||||
POSTER_WIDTH,
|
||||
REFLECTION_HEIGHT,
|
||||
)
|
||||
ctx.drawImage(img, 0, 0, img.width, img.height, x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height - (POSTER_HEIGHT + MARGIN_HEIGHT))
|
||||
|
||||
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.7)')
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)
|
||||
|
||||
@@ -147,8 +148,8 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.media?.image_list && props.media?.image_list.length > 0)
|
||||
imgUrl.value = await drawImages(props.media?.image_list || [])
|
||||
else imgUrl.value = getImgUrl(props.media?.image || '')
|
||||
imgUrl.value = await drawImages(props.media?.image_list || [], props.media?.use_cookies)
|
||||
else imgUrl.value = getImgUrl(props.media?.image || '', props.media?.use_cookies)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import tmdbImage from '@images/logos/tmdb.png'
|
||||
import doubanImage from '@images/logos/douban-black.png'
|
||||
import bangumiImage from '@images/logos/bangumi.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
@@ -10,19 +8,32 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeSeasonDialog = defineAsyncComponent(() => import('../dialog/SubscribeSeasonDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
interface MediaCardMedia extends MediaInfo {
|
||||
total_episode?: number
|
||||
episode_count?: number
|
||||
}
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaInfo>,
|
||||
media: Object as PropType<MediaCardMedia>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
@@ -50,23 +61,14 @@ const isSubscribed = ref(false)
|
||||
// 本地存在状态
|
||||
const isExists = ref(false)
|
||||
|
||||
// 订阅季弹窗
|
||||
const subscribeSeasonDialog = ref(false)
|
||||
|
||||
// 订阅编辑弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 订阅ID
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
themoviedb: tmdbImage,
|
||||
douban: doubanImage,
|
||||
bangumi: bangumiImage,
|
||||
themoviedb: getLogoUrl('tmdb'),
|
||||
douban: getLogoUrl('douban-black'),
|
||||
bangumi: getLogoUrl('bangumi'),
|
||||
}
|
||||
|
||||
// 绑定MediaCard元素
|
||||
@@ -84,12 +86,48 @@ const selectedSites = ref<number[]>([])
|
||||
// 搜索菜单显示状态
|
||||
const searchMenuShow = ref(false)
|
||||
|
||||
// 选择站点对话框
|
||||
const chooseSiteDialog = ref(false)
|
||||
|
||||
// 选择的剧集组
|
||||
const episodeGroup = ref('')
|
||||
|
||||
// 打开订阅季选择弹窗,避免每个媒体卡片都持有弹窗实例。
|
||||
function openSubscribeSeasonDialog() {
|
||||
openSharedDialog(
|
||||
SubscribeSeasonDialog,
|
||||
{ media: props.media },
|
||||
{
|
||||
subscribe: subscribeSeasons,
|
||||
},
|
||||
{ closeOn: ['close', 'subscribe'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开订阅编辑弹窗,保存、关闭或删除时释放共享弹窗实例。
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
remove: onRemoveSubscribe,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
|
||||
function openSearchSiteDialog() {
|
||||
openSharedDialog(
|
||||
SearchSiteDialog,
|
||||
{
|
||||
sites: allSites.value,
|
||||
selected: selectedSites.value,
|
||||
},
|
||||
{
|
||||
search: searchSites,
|
||||
},
|
||||
{ closeOn: ['close', 'search'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 查询所有站点
|
||||
async function querySites() {
|
||||
try {
|
||||
@@ -120,6 +158,22 @@ function getMediaId() {
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
return `${getMediaId()}::${season ?? 'all'}`
|
||||
}
|
||||
|
||||
function getExistsStatusKey() {
|
||||
return [
|
||||
props.media?.tmdb_id ?? '',
|
||||
props.media?.title ?? '',
|
||||
props.media?.year ?? '',
|
||||
props.media?.season ?? '',
|
||||
props.media?.type ?? '',
|
||||
props.media?.mediaid_prefix ?? '',
|
||||
props.media?.media_id ?? '',
|
||||
].join('::')
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -132,7 +186,7 @@ async function handleAddSubscribe() {
|
||||
if (props.media?.type === '电视剧') {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
subscribeSeasonDialog.value = true
|
||||
openSubscribeSeasonDialog()
|
||||
} else {
|
||||
// 电影
|
||||
addSubscribe()
|
||||
@@ -140,7 +194,7 @@ async function handleAddSubscribe() {
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
async function addSubscribe(season: number | null = null, best_version: number = 0) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
@@ -155,7 +209,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season,
|
||||
season: props.media?.type === '电影' ? null : season,
|
||||
best_version,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
@@ -164,6 +218,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
@@ -173,8 +228,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
if (result.success && seasonsSelected.value.length <= 1) {
|
||||
const show_edit_dialog = await queryDefaultSubscribeConfig()
|
||||
if (show_edit_dialog) {
|
||||
subscribeId.value = result.data.id
|
||||
subscribeEditDialog.value = true
|
||||
openSubscribeEditDialog(result.data.id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -185,8 +239,8 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
}
|
||||
|
||||
// 弹出添加订阅提示
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
|
||||
if (season) title = `${title} ${formatSeason(season.toString())}`
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
let subname = t('subscribe.normalSub')
|
||||
if (best_version > 0) subname = t('subscribe.versionSub')
|
||||
@@ -210,6 +264,7 @@ async function removeSubscribe() {
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
@@ -224,8 +279,10 @@ async function removeSubscribe() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
const result = await checkSubscribe(props.media?.season)
|
||||
if (result) isSubscribed.value = true
|
||||
const subscribed = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), () =>
|
||||
checkSubscribe(props.media?.season ?? null),
|
||||
)
|
||||
isSubscribed.value = subscribed
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -234,24 +291,29 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.success)
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
isExists.value = exists
|
||||
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season = 0) {
|
||||
async function checkSubscribe(season: number | null) {
|
||||
try {
|
||||
// AbortController 现在由全局请求优化器自动管理
|
||||
const mediaid = getMediaId()
|
||||
@@ -262,12 +324,14 @@ async function checkSubscribe(season = 0) {
|
||||
},
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
@@ -294,7 +358,6 @@ function handleSubscribe() {
|
||||
|
||||
// 订阅多季
|
||||
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
|
||||
subscribeSeasonDialog.value = false
|
||||
episodeGroup.value = groudId
|
||||
seasonsSelected.value = seasons || []
|
||||
seasonsSelected.value.forEach(season => {
|
||||
@@ -302,7 +365,7 @@ function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
|
||||
addSubscribe(season.season_number, best_version)
|
||||
addSubscribe(season.season_number ?? null, best_version)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,7 +402,7 @@ async function clickSearch() {
|
||||
await querySelectedSites()
|
||||
}
|
||||
if (allSites.value?.length > 0) {
|
||||
chooseSiteDialog.value = true
|
||||
openSearchSiteDialog()
|
||||
} else {
|
||||
handleSearch()
|
||||
}
|
||||
@@ -363,7 +426,6 @@ function handleSearch() {
|
||||
|
||||
// 搜索多站点
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
handleSearch()
|
||||
}
|
||||
@@ -413,7 +475,7 @@ const getImgUrl: Ref<string> = computed(() => {
|
||||
|
||||
// 移除订阅
|
||||
function onRemoveSubscribe() {
|
||||
subscribeEditDialog.value = false
|
||||
isSubscribed.value = false
|
||||
}
|
||||
|
||||
// 获取媒体类型文本
|
||||
@@ -529,32 +591,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅季弹窗 -->
|
||||
<subscribeSeasonDialog
|
||||
v-if="subscribeSeasonDialog"
|
||||
v-model="subscribeSeasonDialog"
|
||||
:media="media"
|
||||
@subscribe="subscribeSeasons"
|
||||
@close="subscribeSeasonDialog = false"
|
||||
/>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="onRemoveSubscribe"
|
||||
/>
|
||||
<!-- 站点选择对话框 -->
|
||||
<SearchSiteDialog
|
||||
v-if="chooseSiteDialog"
|
||||
v-model="chooseSiteDialog"
|
||||
:sites="allSites"
|
||||
:selected="selectedSites"
|
||||
@search="searchSites"
|
||||
@close="chooseSiteDialog = false"
|
||||
/>
|
||||
</template>
|
||||
<style scoped>
|
||||
.media-card-title {
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import emby_image from '@images/logos/emby.png'
|
||||
import jellyfin_image from '@images/logos/jellyfin.png'
|
||||
import plex_image from '@images/logos/plex.png'
|
||||
import trimemedia_image from '@images/logos/trimemedia.png'
|
||||
import custom_image from '@images/logos/mediaserver.png'
|
||||
import api from '@/api'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import type { MediaServerConf, MediaStatistic } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaServerDict } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const MediaServerInfoDialog = defineAsyncComponent(() => import('@/components/dialog/MediaServerInfoDialog.vue'))
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#56CA00')
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -32,9 +27,6 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'done', 'change'])
|
||||
|
||||
@@ -57,76 +49,48 @@ const infoItems = ref([
|
||||
},
|
||||
])
|
||||
|
||||
// 同步媒体库选项
|
||||
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
|
||||
{
|
||||
title: t('common.all'),
|
||||
value: 'all',
|
||||
},
|
||||
])
|
||||
|
||||
// 媒体服务器详情弹窗
|
||||
const mediaServerInfoDialog = ref(false)
|
||||
|
||||
// 媒体服务器详情
|
||||
const mediaServerInfo = ref<MediaServerConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 打开详情弹窗
|
||||
/** 打开共享媒体服务器配置弹窗。 */
|
||||
function openMediaServerInfoDialog() {
|
||||
loadLibrary(props.mediaserver.name)
|
||||
// 深复制
|
||||
mediaServerInfo.value = cloneDeep(props.mediaserver)
|
||||
mediaServerInfoDialog.value = true
|
||||
if (!props.mediaserver.sync_libraries) {
|
||||
mediaServerInfo.value.sync_libraries = ['all']
|
||||
}
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveMediaServerInfo() {
|
||||
// 为空不保存,跳出警告框
|
||||
if (!mediaServerInfo.value.name) {
|
||||
$toast.error(t('common.nameRequired'))
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
|
||||
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
|
||||
return
|
||||
}
|
||||
// 执行保存
|
||||
mediaServerInfoDialog.value = false
|
||||
emit('change', mediaServerInfo.value, props.mediaserver.name)
|
||||
emit('done')
|
||||
openSharedDialog(
|
||||
MediaServerInfoDialog,
|
||||
{
|
||||
mediaserver: props.mediaserver,
|
||||
mediaservers: props.mediaservers,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
done: () => emit('done'),
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.mediaserver.type) {
|
||||
case 'emby':
|
||||
return emby_image
|
||||
return getLogoUrl('emby')
|
||||
case 'zspace':
|
||||
return getLogoUrl('zspace')
|
||||
case 'jellyfin':
|
||||
return jellyfin_image
|
||||
return getLogoUrl('jellyfin')
|
||||
case 'trimemedia':
|
||||
return trimemedia_image
|
||||
return getLogoUrl('trimemedia')
|
||||
case 'ugreen':
|
||||
return getLogoUrl('ugreen')
|
||||
case 'plex':
|
||||
return plex_image
|
||||
return getLogoUrl('plex')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('mediaserver')
|
||||
}
|
||||
})
|
||||
|
||||
// 按钮点击
|
||||
/** 关闭媒体服务器卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 调用API加载媒体统计数据
|
||||
/** 调用 API 加载媒体服务器统计数据。 */
|
||||
async function loadMediaStatistic() {
|
||||
try {
|
||||
const res: MediaStatistic = await api.get('dashboard/statistic', {
|
||||
@@ -159,353 +123,38 @@ async function loadMediaStatistic() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API查询媒体库
|
||||
async function loadLibrary(server: string) {
|
||||
try {
|
||||
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
|
||||
if (result && result.length > 0) {
|
||||
librariesOptions.value = result.map(item => ({
|
||||
title: item.name,
|
||||
value: item.id?.toString(),
|
||||
}))
|
||||
} else {
|
||||
librariesOptions.value = []
|
||||
}
|
||||
librariesOptions.value.unshift({
|
||||
title: t('common.all'),
|
||||
value: 'all',
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMediaStatistic()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openMediaServerInfoDialog">
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
|
||||
<div v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled" class="text-sm mt-5 flex flex-wrap">
|
||||
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
|
||||
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="!mediaServerDict[mediaserver.type]" class="text-sm mt-5 flex flex-wrap">
|
||||
<span class="me-2 mb-1">自定义媒体服务器</span>
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VDialog
|
||||
v-if="mediaServerInfoDialog"
|
||||
v-model="mediaServerInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('common.config') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'emby'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
:label="t('mediaserver.apiKey')"
|
||||
:hint="t('mediaserver.embyApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
:label="t('mediaserver.apiKey')"
|
||||
:hint="t('mediaserver.jellyfinApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'trimemedia'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'plex'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.token"
|
||||
:label="t('mediaserver.plexToken')"
|
||||
:hint="t('mediaserver.plexTokenHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.type"
|
||||
:label="t('mediaserver.type')"
|
||||
:hint="t('mediaserver.customTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
:label="t('common.name')"
|
||||
:hint="t('mediaserver.nameRequired')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
<template>
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@click="openMediaServerInfoDialog"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="app-card-summary app-card-summary--single-action">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title text-h6">{{ mediaserver.name }}</div>
|
||||
<div
|
||||
v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled"
|
||||
class="grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis"
|
||||
>
|
||||
<span v-for="item in infoItems" :key="item.title" class="flex min-w-0 items-center">
|
||||
<VIcon rounded :icon="item.avatar" class="me-1 shrink-0" />
|
||||
<span class="truncate">{{ item.amount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="!mediaServerDict[mediaserver.type]" class="app-card-summary__subtitle text-sm">
|
||||
{{ t('setting.system.custom') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
@@ -19,6 +21,22 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -42,10 +60,10 @@ function noteToJson() {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 将\n转换为html属性的换行符
|
||||
function replaceNewLine(value: string) {
|
||||
// 渲染 Markdown
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return value.replace(/\n/g, '<br/>')
|
||||
return md.render(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -85,19 +103,23 @@ function replaceNewLine(value: string) {
|
||||
</VCardTitle>
|
||||
<div
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
|
||||
>
|
||||
<p class="mb-0">{{ props.message?.text }}</p>
|
||||
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
|
||||
</div>
|
||||
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
|
||||
<VCardText
|
||||
v-if="props.message?.text && props.message?.action === 1"
|
||||
class="markdown-body"
|
||||
v-html="renderMarkdown(props.message?.text)"
|
||||
/>
|
||||
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title_year }}
|
||||
{{ Number(key) + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
||||
@@ -116,3 +138,89 @@ function replaceNewLine(value: string) {
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-body {
|
||||
word-break: break-all;
|
||||
|
||||
p {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
margin-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
font-family: monospace;
|
||||
padding-block: 0.2rem;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
margin-block-end: 0.5rem;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
|
||||
font-style: italic;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
margin-block-end: 1rem;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.1);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: rgba(var(--v-border-color), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { NotificationConf } from '@/api/types'
|
||||
import wechat_image from '@images/logos/wechat.png'
|
||||
import telegram_image from '@images/logos/telegram.webp'
|
||||
import vocechat_image from '@images/logos/vocechat.png'
|
||||
import synologychat_image from '@images/logos/synologychat.png'
|
||||
import slack_image from '@images/logos/slack.webp'
|
||||
import chrome_image from '@images/logos/chrome.png'
|
||||
import custom_image from '@images/logos/notification.png'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import type { NotificationConf } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const NotificationChannelInfoDialog = defineAsyncComponent(() => import('@/components/dialog/NotificationChannelInfoDialog.vue'))
|
||||
|
||||
const { t } = useI18n()
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -34,448 +27,95 @@ const props = defineProps({
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'change', 'done'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 通知详情弹窗
|
||||
const notificationInfoDialog = ref(false)
|
||||
|
||||
// 通知详情
|
||||
const notificationInfo = ref<NotificationConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 各通知类型的名称字典
|
||||
const notificationTypeNames: { [key: string]: string } = {
|
||||
wechat: t('notification.wechat.name'),
|
||||
feishu: t('notification.feishu.name'),
|
||||
wechatclawbot: t('notification.wechatclawbot.name'),
|
||||
telegram: t('notification.telegram.name'),
|
||||
qqbot: t('notification.qqbot.name'),
|
||||
vocechat: t('notification.vocechat.name'),
|
||||
synologychat: t('notification.synologychat.name'),
|
||||
slack: t('notification.slack.name'),
|
||||
discord: t('notification.discord.name'),
|
||||
webpush: t('notification.webpush.name'),
|
||||
custom: t('setting.notification.custom'),
|
||||
}
|
||||
|
||||
// 消息类型下拉字典
|
||||
const notificationTypes = [
|
||||
{ value: '资源下载', title: t('notificationSwitch.resourceDownload') },
|
||||
{ value: '整理入库', title: t('notificationSwitch.organize') },
|
||||
{ value: '订阅', title: t('notificationSwitch.subscribe') },
|
||||
{ value: '站点', title: t('notificationSwitch.site') },
|
||||
{ value: '媒体服务器', title: t('notificationSwitch.mediaServer') },
|
||||
{ value: '手动处理', title: t('notificationSwitch.manual') },
|
||||
{ value: '插件', title: t('notificationSwitch.plugin') },
|
||||
{ value: '其它', title: t('notificationSwitch.other') },
|
||||
]
|
||||
|
||||
// 打开详情弹窗
|
||||
/** 打开共享通知渠道配置弹窗。 */
|
||||
function openNotificationInfoDialog() {
|
||||
// 替换成深复制,避免修改时影响原数据
|
||||
notificationInfo.value = cloneDeep(props.notification)
|
||||
notificationInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveNotificationInfo() {
|
||||
// 为空不保存,跳出警告框
|
||||
if (!notificationInfo.value.name) {
|
||||
$toast.error(t('notification.name') + t('common.required'))
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
|
||||
$toast.error(t('notification.channel') + `【${notificationInfo.value.name}】` + t('common.exists'))
|
||||
return
|
||||
}
|
||||
notificationInfoDialog.value = false
|
||||
emit('change', notificationInfo.value, props.notification.name)
|
||||
emit('done')
|
||||
openSharedDialog(
|
||||
NotificationChannelInfoDialog,
|
||||
{
|
||||
notification: props.notification,
|
||||
notifications: props.notifications,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
done: () => emit('done'),
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.notification.type) {
|
||||
case 'wechat':
|
||||
return wechat_image
|
||||
return getLogoUrl('wechat')
|
||||
case 'wechatclawbot':
|
||||
return getLogoUrl('wechatclawbot')
|
||||
case 'feishu':
|
||||
return getLogoUrl('feishu')
|
||||
case 'telegram':
|
||||
return telegram_image
|
||||
return getLogoUrl('telegram')
|
||||
case 'qqbot':
|
||||
return getLogoUrl('qq')
|
||||
case 'vocechat':
|
||||
return vocechat_image
|
||||
return getLogoUrl('vocechat')
|
||||
case 'synologychat':
|
||||
return synologychat_image
|
||||
return getLogoUrl('synologychat')
|
||||
case 'slack':
|
||||
return slack_image
|
||||
return getLogoUrl('slack')
|
||||
case 'discord':
|
||||
return getLogoUrl('discord')
|
||||
case 'webpush':
|
||||
return chrome_image
|
||||
return getLogoUrl('chrome')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('notification')
|
||||
}
|
||||
})
|
||||
|
||||
// 按钮点击
|
||||
/** 关闭通知渠道卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openNotificationInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<div class="flex items-center">
|
||||
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
|
||||
<span class="text-h6">{{ props.notification.name }}</span>
|
||||
</div>
|
||||
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-7 me-1" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VDialog
|
||||
v-if="notificationInfoDialog"
|
||||
v-model="notificationInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('common.config') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.notification.name }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="notificationInfoDialog = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="notificationInfo.enabled" :label="t('notification.enabled')" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="notificationInfo.switchs"
|
||||
:items="notificationTypes"
|
||||
:label="t('notification.type')"
|
||||
:hint="t('notification.typeHint')"
|
||||
multiple
|
||||
clearable
|
||||
chips
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-bell-outline"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="notificationInfo.type == 'wechat'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_CORPID"
|
||||
:label="t('notification.wechat.corpId')"
|
||||
:hint="t('notification.wechat.corpIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-domain"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_ID"
|
||||
:label="t('notification.wechat.appId')"
|
||||
:hint="t('notification.wechat.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_SECRET"
|
||||
:label="t('notification.wechat.appSecret')"
|
||||
:hint="t('notification.wechat.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_PROXY"
|
||||
:label="t('notification.wechat.proxy')"
|
||||
:hint="t('notification.wechat.proxyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_TOKEN"
|
||||
:label="t('notification.wechat.token')"
|
||||
:hint="t('notification.wechat.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
|
||||
:label="t('notification.wechat.encodingAesKey')"
|
||||
:hint="t('notification.wechat.encodingAesKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
:label="t('notification.wechat.admins')"
|
||||
:placeholder="t('notification.wechat.adminsPlaceholder')"
|
||||
:hint="t('notification.wechat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'telegram'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_TOKEN"
|
||||
:label="t('notification.telegram.token')"
|
||||
:hint="t('notification.telegram.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_CHAT_ID"
|
||||
:label="t('notification.telegram.chatId')"
|
||||
:hint="t('notification.telegram.chatIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-chat"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_USERS"
|
||||
:label="t('notification.telegram.users')"
|
||||
:placeholder="t('notification.telegram.usersPlaceholder')"
|
||||
:hint="t('notification.telegram.usersHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.TELEGRAM_ADMINS"
|
||||
:label="t('notification.telegram.admins')"
|
||||
:placeholder="t('notification.telegram.adminsPlaceholder')"
|
||||
:hint="t('notification.telegram.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.API_URL"
|
||||
:label="t('notification.telegram.apiUrl')"
|
||||
:placeholder="t('notification.telegram.apiUrlPlaceholder')"
|
||||
:hint="t('notification.telegram.apiUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-web"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'slack'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SLACK_OAUTH_TOKEN"
|
||||
:label="t('notification.slack.oauthToken')"
|
||||
:placeholder="t('notification.slack.oauthTokenPlaceholder')"
|
||||
:hint="t('notification.slack.oauthTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SLACK_APP_TOKEN"
|
||||
:label="t('notification.slack.appToken')"
|
||||
:placeholder="t('notification.slack.appTokenPlaceholder')"
|
||||
:hint="t('notification.slack.appTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SLACK_CHANNEL"
|
||||
:label="t('notification.slack.channel')"
|
||||
:placeholder="t('notification.slack.channelPlaceholder')"
|
||||
:hint="t('notification.slack.channelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'synologychat'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SYNOLOGYCHAT_WEBHOOK"
|
||||
:label="t('notification.synologychat.webhook')"
|
||||
:hint="t('notification.synologychat.webhookHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-webhook"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SYNOLOGYCHAT_TOKEN"
|
||||
:label="t('notification.synologychat.token')"
|
||||
:hint="t('notification.synologychat.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'vocechat'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.VOCECHAT_HOST"
|
||||
:label="t('notification.vocechat.host')"
|
||||
:hint="t('notification.vocechat.hostHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.VOCECHAT_API_KEY"
|
||||
:label="t('notification.vocechat.apiKey')"
|
||||
:hint="t('notification.vocechat.apiKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.VOCECHAT_CHANNEL_ID"
|
||||
:label="t('notification.vocechat.channelId')"
|
||||
:placeholder="t('notification.vocechat.channelIdPlaceholder')"
|
||||
:hint="t('notification.vocechat.channelIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'webpush'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WEBPUSH_USERNAME"
|
||||
:label="t('notification.webpush.username')"
|
||||
:hint="t('notification.webpush.usernameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.type"
|
||||
:label="t('notification.type')"
|
||||
:hint="t('notification.customTypeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:hint="t('notification.nameRequired')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
<template>
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@click="openNotificationInfoDialog"
|
||||
>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title-row">
|
||||
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
|
||||
<span class="app-card-summary__title text-h6">{{ props.notification.name }}</span>
|
||||
</div>
|
||||
<div class="app-card-summary__subtitle text-body-1">{{ notificationTypeNames[notification.type] }}</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import VersionHistory from '../misc/VersionHistory.vue'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/PluginVersionHistoryDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -29,15 +30,6 @@ const backgroundColor = ref('#28A9E1')
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度框文本
|
||||
const progressText = ref('')
|
||||
|
||||
// 获取当前插件的标签
|
||||
const pluginLabels = computed(() => {
|
||||
if (!props.plugin?.plugin_label) return []
|
||||
@@ -54,12 +46,6 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 更新日志弹窗
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
// 插件详情弹窗
|
||||
const detailDialog = ref(false)
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -68,42 +54,9 @@ async function imageLoaded() {
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
}
|
||||
|
||||
// 安装插件
|
||||
async function installPlugin() {
|
||||
try {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: props?.plugin?.plugin_version,
|
||||
})
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: props.plugin?.has_update,
|
||||
},
|
||||
})
|
||||
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
detailDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算图标路径
|
||||
const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
@@ -117,6 +70,9 @@ const iconPath: Ref<string> = computed(() => {
|
||||
function visitPluginPage() {
|
||||
// 将raw.githubusercontent.com转换为项目地址
|
||||
let repoUrl = props.plugin?.repo_url
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
@@ -138,7 +94,27 @@ function visitPluginPage() {
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory() {
|
||||
releaseDialog.value = true
|
||||
openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin },
|
||||
{},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
/** 打开共享插件市场详情弹窗。 */
|
||||
function showPluginDetail() {
|
||||
openSharedDialog(
|
||||
PluginMarketDetailDialog,
|
||||
{
|
||||
plugin: props.plugin,
|
||||
count: props.count,
|
||||
},
|
||||
{
|
||||
install: () => emit('install'),
|
||||
},
|
||||
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
@@ -162,6 +138,7 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -172,7 +149,7 @@ const dropdownItems = ref([
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="detailDialog = true"
|
||||
@click="showPluginDetail"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
@@ -244,11 +221,11 @@ const dropdownItems = ref([
|
||||
</div>
|
||||
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<IconBtn @click.stop>
|
||||
<VIcon size="small" icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -266,77 +243,5 @@ const dropdownItems = ref([
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 安装插件进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
<!-- 更新日志 -->
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VDivider />
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 插件详情-->
|
||||
<VDialog v-if="detailDialog" v-model="detailDialog" max-width="30rem">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="detailDialog = false" />
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="mx-auto mt-5">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<VCardTitle class="text-center text-md-left">
|
||||
{{ props.plugin?.plugin_name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.version') }}:</span>
|
||||
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.author') }}:</span>
|
||||
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<div class="text-center text-md-left">
|
||||
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download">{{
|
||||
t('plugin.installToLocal')
|
||||
}}</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />{{
|
||||
t('plugin.totalDownloads', { count: props.count?.toLocaleString() })
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,18 +4,20 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@core/utils'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
|
||||
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
|
||||
import LoggingView from '@/views/system/LoggingView.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
||||
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
||||
const PluginDataDialog = defineAsyncComponent(() => import('../dialog/PluginDataDialog.vue'))
|
||||
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
||||
const PluginCloneDialog = defineAsyncComponent(() => import('../dialog/PluginCloneDialog.vue'))
|
||||
const PluginLogDialog = defineAsyncComponent(() => import('../dialog/PluginLogDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('../dialog/PluginVersionHistoryDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -24,6 +26,10 @@ const props = defineProps({
|
||||
action: Boolean, // 动作标识
|
||||
width: String,
|
||||
height: String,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -32,6 +38,9 @@ const emit = defineEmits(['remove', 'save', 'actionDone'])
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
|
||||
@@ -47,24 +56,9 @@ const createConfirm = useConfirm()
|
||||
// 本身是否可见
|
||||
const isVisible = ref(true)
|
||||
|
||||
// 插件配置页面
|
||||
const pluginConfigDialog = ref(false)
|
||||
|
||||
// 菜单显示状态
|
||||
const menuVisible = ref(false)
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 插件数据页面
|
||||
const pluginInfoDialog = ref(false)
|
||||
|
||||
// 实时日志弹窗
|
||||
const loggingDialog = ref(false)
|
||||
|
||||
// 进度框文本
|
||||
const progressText = ref('正在更新插件...')
|
||||
|
||||
// 用户头像是否加载完成
|
||||
const isAvatarLoaded = ref(false)
|
||||
|
||||
@@ -74,20 +68,20 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 更新日志弹窗
|
||||
const releaseDialog = ref(false)
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
// 插件分身对话框
|
||||
const pluginCloneDialog = ref(false)
|
||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||
function showPluginProgress(text: string) {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
|
||||
}
|
||||
|
||||
// 插件分身表单
|
||||
const cloneForm = ref({
|
||||
suffix: '',
|
||||
name: '',
|
||||
description: '',
|
||||
version: '',
|
||||
icon: '',
|
||||
})
|
||||
/** 关闭当前插件操作进度弹窗。 */
|
||||
function closePluginProgress() {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = null
|
||||
}
|
||||
|
||||
// 监听动作标识,如为true则打开详情
|
||||
watch(
|
||||
@@ -114,7 +108,12 @@ function showUpdateHistory() {
|
||||
if (isNullOrEmptyObject(props.plugin?.history)) {
|
||||
updatePlugin()
|
||||
} else {
|
||||
releaseDialog.value = true
|
||||
openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, showUpdateAction: true },
|
||||
{ update: updatePlugin },
|
||||
{ closeOn: ['close', 'update', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,11 +128,10 @@ async function uninstallPlugin() {
|
||||
|
||||
try {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = t('plugin.uninstalling', { name: props.plugin?.plugin_name })
|
||||
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
||||
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
closePluginProgress()
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
|
||||
|
||||
@@ -148,26 +146,39 @@ async function uninstallPlugin() {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示插件数据
|
||||
async function showPluginInfo() {
|
||||
pluginConfigDialog.value = false
|
||||
pluginInfoDialog.value = true
|
||||
openSharedDialog(
|
||||
PluginDataDialog,
|
||||
{ plugin: props.plugin },
|
||||
{
|
||||
switch: showPluginConfig,
|
||||
},
|
||||
{ closeOn: ['close', 'switch'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 显示插件配置
|
||||
async function showPluginConfig() {
|
||||
// 显示对话框
|
||||
pluginInfoDialog.value = false
|
||||
pluginConfigDialog.value = true
|
||||
openSharedDialog(
|
||||
PluginConfigDialog,
|
||||
{ plugin: props.plugin },
|
||||
{
|
||||
save: configDone,
|
||||
switch: showPluginInfo,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'switch'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 计算图标路径
|
||||
const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
@@ -215,11 +226,14 @@ async function resetPlugin() {
|
||||
|
||||
// 更新插件
|
||||
async function updatePlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
releaseDialog.value = false
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = t('plugin.updating', { name: props.plugin?.plugin_name })
|
||||
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
@@ -229,7 +243,7 @@ async function updatePlugin() {
|
||||
})
|
||||
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
closePluginProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||
@@ -245,6 +259,7 @@ async function updatePlugin() {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -254,74 +269,77 @@ function visitAuthorPage() {
|
||||
window.open(props.plugin?.author_url, '_blank')
|
||||
}
|
||||
|
||||
// 查看日志URL
|
||||
function openLoggerWindow() {
|
||||
const url = `${
|
||||
import.meta.env.VITE_API_BASE_URL
|
||||
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 打开插件详情
|
||||
function openPluginDetail() {
|
||||
if (props.plugin?.has_page) showPluginInfo()
|
||||
else showPluginConfig()
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openPluginDetail()
|
||||
}
|
||||
|
||||
// 配置完成
|
||||
function configDone() {
|
||||
pluginConfigDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// 显示插件分身对话框
|
||||
/** 显示插件分身共享弹窗。 */
|
||||
function showPluginClone() {
|
||||
cloneForm.value = {
|
||||
suffix: '',
|
||||
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
|
||||
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
|
||||
version: props.plugin?.plugin_version || '1.0',
|
||||
icon: props.plugin?.plugin_icon || '',
|
||||
}
|
||||
pluginCloneDialog.value = true
|
||||
cloneDialogController?.close()
|
||||
cloneDialogController = openSharedDialog(
|
||||
PluginCloneDialog,
|
||||
{ plugin: props.plugin },
|
||||
{ clone: executePluginClone },
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 执行插件分身
|
||||
async function executePluginClone() {
|
||||
if (!cloneForm.value.suffix.trim()) {
|
||||
async function executePluginClone(cloneForm: { suffix: string; name: string; description: string; version: string; icon: string }) {
|
||||
if (!cloneForm.suffix.trim()) {
|
||||
$toast.error(t('plugin.suffixRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
progressDialog.value = true
|
||||
progressText.value = t('plugin.cloning', { name: props.plugin?.plugin_name })
|
||||
showPluginProgress(t('plugin.cloning', { name: props.plugin?.plugin_name }))
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
||||
suffix: cloneForm.value.suffix.trim(),
|
||||
name: cloneForm.value.name.trim(),
|
||||
description: cloneForm.value.description.trim(),
|
||||
version: cloneForm.value.version.trim(),
|
||||
icon: cloneForm.value.icon.trim(),
|
||||
suffix: cloneForm.suffix.trim(),
|
||||
name: cloneForm.name.trim(),
|
||||
description: cloneForm.description.trim(),
|
||||
version: cloneForm.version.trim(),
|
||||
icon: cloneForm.icon.trim(),
|
||||
})
|
||||
|
||||
progressDialog.value = false
|
||||
closePluginProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.value.name }))
|
||||
pluginCloneDialog.value = false
|
||||
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
|
||||
cloneDialogController?.close()
|
||||
cloneDialogController = null
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
} else {
|
||||
$toast.error(t('plugin.cloneFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
progressDialog.value = false
|
||||
closePluginProgress()
|
||||
$toast.error(t('plugin.cloneFailedGeneral'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
closePluginProgress()
|
||||
cloneDialogController?.close()
|
||||
})
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -389,7 +407,7 @@ const dropdownItems = ref([
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
click: () => {
|
||||
loggingDialog.value = true
|
||||
openSharedDialog(PluginLogDialog, { plugin: props.plugin }, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -419,6 +437,7 @@ watch(
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) openPluginDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -432,11 +451,13 @@ watch(
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="openPluginDetail"
|
||||
@click="handleCardClick"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
@@ -457,7 +478,10 @@ watch(
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
|
||||
<div
|
||||
class="relative flex-shrink-0 self-center pb-3"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
@@ -481,7 +505,11 @@ watch(
|
||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||
</template>
|
||||
</VImg>
|
||||
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
@@ -492,11 +520,11 @@ watch(
|
||||
</div>
|
||||
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -524,183 +552,6 @@ watch(
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<!-- 插件配置页面 -->
|
||||
<PluginConfigDialog
|
||||
v-if="pluginConfigDialog"
|
||||
v-model="pluginConfigDialog"
|
||||
:plugin="props.plugin"
|
||||
@save="configDone"
|
||||
@close="pluginConfigDialog = false"
|
||||
@switch="showPluginInfo"
|
||||
/>
|
||||
|
||||
<!-- 插件数据页面 -->
|
||||
<PluginDataDialog
|
||||
v-if="pluginInfoDialog"
|
||||
v-model="pluginInfoDialog"
|
||||
:plugin="props.plugin"
|
||||
@close="pluginInfoDialog = false"
|
||||
@switch="showPluginConfig"
|
||||
/>
|
||||
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
|
||||
<!-- 更新日志 -->
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VDivider />
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
<VDivider />
|
||||
<VCardItem>
|
||||
<VBtn @click="updatePlugin" block>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
{{ t('plugin.updateToLatest') }}
|
||||
</VBtn>
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 实时日志弹窗 -->
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="60rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="loggingDialog = false" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
<VIcon icon="mdi-file-document" class="me-2" />
|
||||
{{ t('plugin.logTitle') }}
|
||||
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 插件分身对话框 -->
|
||||
<VDialog
|
||||
v-if="pluginCloneDialog"
|
||||
v-model="pluginCloneDialog"
|
||||
width="600"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-copy" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="pluginCloneDialog = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.suffix"
|
||||
:label="t('plugin.suffix') + ' *'"
|
||||
:placeholder="t('plugin.suffixPlaceholder')"
|
||||
:hint="t('plugin.suffixHint')"
|
||||
persistent-hint
|
||||
:rules="[
|
||||
v => !!v || t('plugin.suffixRequired'),
|
||||
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
|
||||
v => v.length <= 20 || t('plugin.suffixLengthError'),
|
||||
]"
|
||||
required
|
||||
prepend-inner-icon="mdi-tag"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.name"
|
||||
:label="t('plugin.cloneName')"
|
||||
:placeholder="t('plugin.cloneNamePlaceholder')"
|
||||
:hint="t('plugin.cloneNameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-rename-box"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="cloneForm.description"
|
||||
:label="t('plugin.cloneDescriptionLabel')"
|
||||
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
|
||||
:hint="t('plugin.cloneDescriptionHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-text"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.version"
|
||||
:label="t('plugin.cloneVersion')"
|
||||
:placeholder="t('plugin.cloneVersionPlaceholder')"
|
||||
:hint="t('plugin.cloneVersionHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-numeric"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.icon"
|
||||
:label="t('plugin.cloneIcon')"
|
||||
:placeholder="t('plugin.cloneIconPlaceholder')"
|
||||
:hint="t('plugin.cloneIconHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-image"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<!-- 重要提醒 -->
|
||||
<VCol cols="12">
|
||||
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
|
||||
<div class="text-body-2">
|
||||
<strong>{{ t('common.notice') }}</strong
|
||||
>:{{ t('plugin.cloneNotice') }}
|
||||
</div>
|
||||
</VAlert>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="executePluginClone"
|
||||
prepend-icon="mdi-content-copy"
|
||||
class="px-5"
|
||||
:disabled="!cloneForm.suffix.trim()"
|
||||
>
|
||||
{{ t('plugin.createClone') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
|
||||
const PluginFolderSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderSettingsDialog.vue'))
|
||||
|
||||
// 文件夹配置接口
|
||||
interface FolderConfig {
|
||||
@@ -25,6 +29,10 @@ const props = defineProps({
|
||||
},
|
||||
width: String,
|
||||
height: String,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -44,15 +52,7 @@ const createConfirm = useConfirm()
|
||||
|
||||
// 菜单显示状态
|
||||
const menuVisible = ref(false)
|
||||
|
||||
// 重命名对话框
|
||||
const renameDialog = ref(false)
|
||||
|
||||
// 设置对话框
|
||||
const settingDialog = ref(false)
|
||||
|
||||
// 新名称
|
||||
const newFolderName = ref('')
|
||||
let renameDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
// 默认颜色
|
||||
const defaultColor = '#2196F3'
|
||||
@@ -62,130 +62,76 @@ const defaultIcon = 'mdi-folder'
|
||||
const defaultGradient =
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8s) 100%)'
|
||||
|
||||
// 文件夹设置
|
||||
const folderSettings = ref<FolderConfig>({
|
||||
background: '',
|
||||
icon: defaultIcon,
|
||||
color: defaultColor,
|
||||
gradient: defaultGradient,
|
||||
showIcon: true,
|
||||
})
|
||||
|
||||
// 计算背景图片
|
||||
const backgroundImage = computed(() => {
|
||||
return props.folderConfig.background || folderSettings.value.background
|
||||
return props.folderConfig.background
|
||||
})
|
||||
|
||||
// 预设图标选项
|
||||
const iconOptions = [
|
||||
'mdi-folder',
|
||||
'mdi-folder-star',
|
||||
'mdi-folder-heart',
|
||||
'mdi-folder-cog',
|
||||
'mdi-folder-music',
|
||||
'mdi-folder-image',
|
||||
'mdi-folder-video',
|
||||
'mdi-folder-download',
|
||||
'mdi-folder-network',
|
||||
'mdi-folder-special',
|
||||
]
|
||||
|
||||
// 预设颜色选项
|
||||
const colorOptions = [
|
||||
'#2196F3', // 蓝色
|
||||
'#4CAF50', // 绿色
|
||||
'#FF9800', // 橙色
|
||||
'#9C27B0', // 紫色
|
||||
'#F44336', // 红色
|
||||
'#607D8B', // 蓝灰色
|
||||
'#795548', // 棕色
|
||||
'#E91E63', // 粉色
|
||||
]
|
||||
|
||||
// 预设渐变选项
|
||||
const gradientOptions = [
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
|
||||
]
|
||||
|
||||
// 计算背景渐变
|
||||
const backgroundGradient = computed(() => {
|
||||
const config = props.folderConfig || {}
|
||||
const settings = folderSettings.value
|
||||
|
||||
return config.gradient || settings.gradient || gradientOptions[0]
|
||||
return config.gradient || defaultGradient
|
||||
})
|
||||
|
||||
// 计算图标
|
||||
const folderIcon = computed(() => {
|
||||
const config = props.folderConfig || {}
|
||||
const settings = folderSettings.value
|
||||
|
||||
return config.icon || settings.icon || defaultIcon
|
||||
return config.icon || defaultIcon
|
||||
})
|
||||
|
||||
// 计算图标颜色
|
||||
const iconColor = computed(() => {
|
||||
const config = props.folderConfig || {}
|
||||
const settings = folderSettings.value
|
||||
|
||||
return config.color || settings.color || defaultColor
|
||||
return config.color || defaultColor
|
||||
})
|
||||
|
||||
// 计算是否显示图标
|
||||
const shouldShowIcon = computed(() => {
|
||||
const config = props.folderConfig || {}
|
||||
const settings = folderSettings.value
|
||||
|
||||
return config.showIcon !== undefined ? config.showIcon : settings.showIcon !== undefined ? settings.showIcon : true
|
||||
return config.showIcon !== undefined ? config.showIcon : true
|
||||
})
|
||||
|
||||
// 监听props变化,更新本地设置
|
||||
watch(
|
||||
() => props.folderConfig,
|
||||
newConfig => {
|
||||
if (newConfig) {
|
||||
folderSettings.value = {
|
||||
...folderSettings.value,
|
||||
...newConfig,
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
// 打开文件夹
|
||||
function openFolder() {
|
||||
emit('open', props.folderName)
|
||||
}
|
||||
|
||||
// 重命名文件夹
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openFolder()
|
||||
}
|
||||
|
||||
/** 打开文件夹重命名共享弹窗。 */
|
||||
function showRenameDialog() {
|
||||
newFolderName.value = props.folderName || ''
|
||||
renameDialog.value = true
|
||||
renameDialogController?.close()
|
||||
renameDialogController = openSharedDialog(
|
||||
PluginFolderRenameDialog,
|
||||
{ folderName: props.folderName },
|
||||
{ rename: confirmRename },
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 确认重命名
|
||||
async function confirmRename() {
|
||||
if (!newFolderName.value.trim()) {
|
||||
async function confirmRename(newFolderName: string) {
|
||||
if (!newFolderName.trim()) {
|
||||
$toast.error(t('folder.folderNameCannotBeEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
if (newFolderName.value === props.folderName) {
|
||||
renameDialog.value = false
|
||||
if (newFolderName === props.folderName) {
|
||||
renameDialogController?.close()
|
||||
renameDialogController = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
emit('rename', props.folderName, newFolderName.value)
|
||||
renameDialog.value = false
|
||||
emit('rename', props.folderName, newFolderName)
|
||||
renameDialogController?.close()
|
||||
renameDialogController = null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -209,28 +155,24 @@ async function deleteFolder() {
|
||||
|
||||
// 显示设置对话框
|
||||
function showSettingDialog() {
|
||||
folderSettings.value = {
|
||||
background: props.folderConfig?.background || '',
|
||||
icon: props.folderConfig?.icon || defaultIcon,
|
||||
color: props.folderConfig?.color || defaultColor,
|
||||
gradient: props.folderConfig?.gradient || gradientOptions[0],
|
||||
showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,
|
||||
}
|
||||
settingDialog.value = true
|
||||
openSharedDialog(
|
||||
PluginFolderSettingsDialog,
|
||||
{ folderConfig: props.folderConfig },
|
||||
{ save: saveSettings },
|
||||
{ closeOn: ['close', 'save', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
function saveSettings() {
|
||||
const config = {
|
||||
...props.folderConfig,
|
||||
...folderSettings.value,
|
||||
}
|
||||
|
||||
function saveSettings(config: FolderConfig) {
|
||||
emit('update-config', props.folderName, config)
|
||||
settingDialog.value = false
|
||||
$toast.success(t('folder.folderSettingsSaved'))
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
renameDialogController?.close()
|
||||
})
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -275,11 +217,12 @@ const dropdownItems = ref([
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="openFolder"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
@@ -302,14 +245,14 @@ const dropdownItems = ref([
|
||||
:icon="folderIcon"
|
||||
:size="display.mobile ? 56 : 72"
|
||||
:color="iconColor"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹信息 -->
|
||||
<div
|
||||
class="plugin-folder-card__info"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
>
|
||||
<!-- 文件夹名称 -->
|
||||
<h3 class="plugin-folder-card__name">
|
||||
@@ -321,7 +264,7 @@ const dropdownItems = ref([
|
||||
</div>
|
||||
|
||||
<!-- 更多菜单按钮 - 右下角 -->
|
||||
<div class="absolute top-0 right-0">
|
||||
<div v-if="!props.sortable" class="absolute top-0 right-0">
|
||||
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<IconBtn v-bind="menuProps" @click.stop>
|
||||
@@ -348,139 +291,6 @@ const dropdownItems = ref([
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<!-- 重命名对话框 -->
|
||||
<VDialog v-if="renameDialog" v-model="renameDialog" max-width="400">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-pencil" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="renameDialog = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField
|
||||
v-model="newFolderName"
|
||||
:label="t('folder.folderName')"
|
||||
variant="outlined"
|
||||
autofocus
|
||||
@keyup.enter="confirmRename"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 设置对话框 -->
|
||||
<VDialog
|
||||
v-if="settingDialog"
|
||||
v-model="settingDialog"
|
||||
max-width="600"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="settingDialog = false" />
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-palette" class="mr-2" />
|
||||
{{ t('folder.folderAppearanceSettings') }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<!-- 显示图标开关 -->
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="folderSettings.showIcon"
|
||||
:label="t('folder.showFolderIcon')"
|
||||
color="primary"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<!-- 图标选择 -->
|
||||
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.icon') }}</VCardSubtitle>
|
||||
<div class="icon-grid">
|
||||
<VBtn
|
||||
v-for="icon in iconOptions"
|
||||
icon
|
||||
:key="icon"
|
||||
:variant="folderSettings.icon === icon ? 'tonal' : 'text'"
|
||||
:color="folderSettings.icon === icon ? 'primary' : 'default'"
|
||||
size="large"
|
||||
class="ma-1"
|
||||
@click="folderSettings.icon = icon"
|
||||
>
|
||||
<VIcon :icon="icon" size="24" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<!-- 颜色选择 -->
|
||||
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.iconColor') }}</VCardSubtitle>
|
||||
<div class="color-grid">
|
||||
<VBtn
|
||||
v-for="color in colorOptions"
|
||||
:key="color"
|
||||
:variant="folderSettings.color === color ? 'tonal' : 'text'"
|
||||
:color="color"
|
||||
size="large"
|
||||
class="ma-1 color-btn"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="folderSettings.color = color"
|
||||
>
|
||||
<VIcon v-if="folderSettings.color === color" icon="mdi-check" color="white" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<!-- 渐变背景选择 -->
|
||||
<VCol cols="12">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.backgroundGradient') }}</VCardSubtitle>
|
||||
<div class="gradient-grid">
|
||||
<VBtn
|
||||
v-for="(gradient, index) in gradientOptions"
|
||||
:key="index"
|
||||
:variant="folderSettings.gradient === gradient ? 'tonal' : 'text'"
|
||||
class="ma-1 gradient-btn"
|
||||
:style="{ background: gradient }"
|
||||
size="large"
|
||||
@click="folderSettings.gradient = gradient"
|
||||
>
|
||||
<VIcon v-if="folderSettings.gradient === gradient" icon="mdi-check" color="white" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<!-- 自定义背景图片 -->
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="folderSettings.background"
|
||||
:label="t('folder.customBackgroundImageURL')"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
variant="outlined"
|
||||
:hint="t('folder.customBackgroundImageHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-image"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -491,6 +301,10 @@ const dropdownItems = ref([
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&--sortable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ interface Props {
|
||||
pluginStatistics?: { [key: string]: number }
|
||||
pluginActions?: { [key: string]: boolean }
|
||||
showRemoveButton?: boolean
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pluginStatistics: () => ({}),
|
||||
pluginActions: () => ({}),
|
||||
showRemoveButton: false,
|
||||
sortable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -36,7 +38,7 @@ const emit = defineEmits<{
|
||||
// 拖拽事件处理
|
||||
function handleDragOver(event: DragEvent) {
|
||||
// 只有当拖拽的是插件时才允许放入文件夹
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
@@ -46,14 +48,14 @@ function handleDragOver(event: DragEvent) {
|
||||
}
|
||||
|
||||
function handleDragEnter(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
@@ -62,7 +64,7 @@ function handleDragLeave(event: DragEvent) {
|
||||
}
|
||||
|
||||
function handleDropToFolder(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
@@ -89,6 +91,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:folder-name="item.data.name"
|
||||
:plugin-count="item.data.pluginCount"
|
||||
:folder-config="item.data.config"
|
||||
:sortable="sortable"
|
||||
@open="$emit('openFolder', item.id)"
|
||||
@delete="$emit('deleteFolder', item.id)"
|
||||
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
|
||||
@@ -102,6 +105,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:count="pluginStatistics[item.id] || 0"
|
||||
:plugin="item.data"
|
||||
:action="pluginActions[item.id] || false"
|
||||
:sortable="sortable"
|
||||
@remove="$emit('refreshData')"
|
||||
@save="$emit('refreshData')"
|
||||
@action-done="$emit('actionDone', item.id)"
|
||||
@@ -109,7 +113,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
|
||||
<!-- 移出文件夹按钮(仅在文件夹内显示) -->
|
||||
<VBtn
|
||||
v-if="showRemoveButton"
|
||||
v-if="showRemoveButton && !sortable"
|
||||
icon="mdi-folder-remove"
|
||||
variant="text"
|
||||
color="warning"
|
||||
|
||||
@@ -28,7 +28,12 @@ function getChipColor(type: string) {
|
||||
const getImgUrl = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
const image = props.media?.image || ''
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const use_cookies = props.media?.use_cookies
|
||||
if (use_cookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return url
|
||||
})
|
||||
|
||||
// 跳转播放
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import noImage from '@images/logos/site.webp'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
|
||||
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
|
||||
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue'
|
||||
import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
|
||||
import api from '@/api'
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const SiteAddEditDialog = defineAsyncComponent(() => import('../dialog/SiteAddEditDialog.vue'))
|
||||
const SiteCookieUpdateDialog = defineAsyncComponent(() => import('../dialog/SiteCookieUpdateDialog.vue'))
|
||||
const SiteResourceDialog = defineAsyncComponent(() => import('../dialog/SiteResourceDialog.vue'))
|
||||
const SiteUserDataDialog = defineAsyncComponent(() => import('../dialog/SiteUserDataDialog.vue'))
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -25,6 +28,10 @@ const cardProps = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
data: Object as PropType<SiteUserData>,
|
||||
stats: Object as PropType<SiteStatistic>,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -34,7 +41,8 @@ const emit = defineEmits(['update', 'remove', 'refresh-stats'])
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 图标
|
||||
const siteIcon = ref<string>('')
|
||||
const defaultSiteIcon = getLogoUrl('site')
|
||||
const siteIcon = ref<string>(defaultSiteIcon)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -45,26 +53,22 @@ const testButtonText = ref(t('site.testConnectivity'))
|
||||
// 测试按钮可用性
|
||||
const testButtonDisable = ref(false)
|
||||
|
||||
// 更新站点Cookie UA弹窗
|
||||
const siteCookieDialog = ref(false)
|
||||
|
||||
// 站点编辑弹窗
|
||||
const siteEditDialog = ref(false)
|
||||
|
||||
// 资源浏览弹窗
|
||||
const resourceDialog = ref(false)
|
||||
|
||||
// 用户数据弹窗
|
||||
const siteUserDataDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
const siteId = cardProps.site?.id
|
||||
if (!siteId) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
|
||||
if (!siteIcon.value) {
|
||||
siteIcon.value = noImage
|
||||
}
|
||||
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
|
||||
const response = await api.get(`site/icon/${siteId}`)
|
||||
|
||||
return response?.data?.icon || defaultSiteIcon
|
||||
})
|
||||
} catch (error) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -91,17 +95,44 @@ async function testSite() {
|
||||
|
||||
// 打开更新站点Cookie UA弹窗
|
||||
async function handleSiteUpdate() {
|
||||
siteCookieDialog.value = true
|
||||
openSharedDialog(
|
||||
SiteCookieUpdateDialog,
|
||||
{ site: cardProps.site },
|
||||
{
|
||||
done: onSiteCookieUpdated,
|
||||
},
|
||||
{ closeOn: ['close', 'done'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开资源浏览弹窗
|
||||
async function handleResourceBrowse() {
|
||||
resourceDialog.value = true
|
||||
openSharedDialog(
|
||||
SiteResourceDialog,
|
||||
{ site: cardProps.site },
|
||||
{
|
||||
close: onSiteResourceDone,
|
||||
},
|
||||
{ closeOn: ['close'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开站点用户数据弹窗
|
||||
async function handleSiteUserData() {
|
||||
siteUserDataDialog.value = true
|
||||
openSharedDialog(SiteUserDataDialog, { site: cardProps.site }, {}, { closeOn: ['close'] })
|
||||
}
|
||||
|
||||
// 打开站点编辑弹窗
|
||||
function handleSiteEdit() {
|
||||
openSharedDialog(
|
||||
SiteAddEditDialog,
|
||||
{ siteid: cardProps.site?.id },
|
||||
{
|
||||
save: saveSite,
|
||||
remove: () => emit('remove'),
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开站点页面
|
||||
@@ -109,6 +140,22 @@ function openSitePage() {
|
||||
window.open(cardProps.site?.url, '_blank')
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (cardProps.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
handleResourceBrowse()
|
||||
}
|
||||
|
||||
function handleSiteUrlClick() {
|
||||
if (cardProps.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openSitePage()
|
||||
}
|
||||
|
||||
// 调用API删除站点信息
|
||||
async function deleteSiteInfo() {
|
||||
const isConfirmed = await createConfirm({
|
||||
@@ -169,20 +216,17 @@ const getDownloadPercent = computed(() => {
|
||||
|
||||
// 保存站点
|
||||
function saveSite() {
|
||||
siteEditDialog.value = false
|
||||
emit('update')
|
||||
}
|
||||
|
||||
// 更新站点Cookie UA后的回调
|
||||
function onSiteCookieUpdated() {
|
||||
siteCookieDialog.value = false
|
||||
// Cookie更新后刷新统计数据
|
||||
emit('refresh-stats', cardProps.site?.domain)
|
||||
}
|
||||
|
||||
// 资源浏览弹窗关闭后的回调
|
||||
function onSiteResourceDone() {
|
||||
resourceDialog.value = false
|
||||
// 资源操作完成后刷新统计数据
|
||||
emit('refresh-stats', cardProps.site?.domain)
|
||||
}
|
||||
@@ -196,31 +240,40 @@ onMounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300 cursor-pointer hover:-translate-y-1"
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
rounded="lg"
|
||||
hover
|
||||
@click="handleResourceBrowse"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
|
||||
<!-- 主体部分 -->
|
||||
<div class="relative flex-1 flex flex-col p-3 z-1">
|
||||
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
|
||||
<!-- 顶部:图标和站点名称 -->
|
||||
<div class="flex items-center mb-1">
|
||||
<div class="mb-1 flex min-w-0 items-center gap-2">
|
||||
<!-- 站点图标 -->
|
||||
<VAvatar tile rounded="lg" size="32" class="me-2" :class="{ 'cursor-move': display.mdAndUp.value }">
|
||||
<VAvatar
|
||||
tile
|
||||
rounded="lg"
|
||||
size="32"
|
||||
class="shrink-0"
|
||||
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
@@ -231,22 +284,42 @@ onMounted(() => {
|
||||
</VAvatar>
|
||||
|
||||
<!-- 站点名称和特性图标 -->
|
||||
<div class="flex-1 min-w-0 flex items-center">
|
||||
<h3 class="text-lg font-semibold leading-tight truncate">{{ cardProps.site?.name }}</h3>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
|
||||
|
||||
<!-- 站点特性图标 -->
|
||||
<div class="flex items-center gap-2 ml-auto mr-10">
|
||||
<div v-if="cardProps.site?.limit_interval" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-speedometer" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<div v-if="cardProps.site?.limit_interval" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-speedometer"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.proxy" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-network-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.proxy" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-network-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.render" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-apple-safari" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.render" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-apple-safari"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.filter" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-filter-cog-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.filter" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-filter-cog-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,10 +327,10 @@ onMounted(() => {
|
||||
|
||||
<!-- 中间部分:网址 -->
|
||||
<div class="my-3">
|
||||
<div class="text-sm text-medium-emphasis truncate" @click.stop="openSitePage">
|
||||
{{ cardProps.site?.url }}
|
||||
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
|
||||
{{ cardProps.site?.url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:数据统计 -->
|
||||
<div class="flex-1 flex flex-col justify-end">
|
||||
@@ -289,7 +362,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作按钮区 -->
|
||||
<VSheet class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<VSheet v-if="!cardProps.sortable" class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<!-- 测试按钮 -->
|
||||
<VBtn
|
||||
icon
|
||||
@@ -327,11 +400,11 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
|
||||
<!-- 更多选项按钮 -->
|
||||
<VBtn icon variant="text" class="mt-auto" size="36">
|
||||
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" size="20" />
|
||||
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
|
||||
<VList>
|
||||
<VListItem @click="siteEditDialog = true" base-color="info">
|
||||
<VListItem @click="handleSiteEdit" base-color="info">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-file-edit-outline" size="20" />
|
||||
</template>
|
||||
@@ -348,35 +421,6 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VSheet>
|
||||
</VCard>
|
||||
|
||||
<!-- 对话框组件 -->
|
||||
<SiteCookieUpdateDialog
|
||||
v-if="siteCookieDialog"
|
||||
v-model="siteCookieDialog"
|
||||
:site="cardProps.site"
|
||||
@close="siteCookieDialog = false"
|
||||
@done="onSiteCookieUpdated"
|
||||
/>
|
||||
<SiteAddEditDialog
|
||||
v-if="siteEditDialog"
|
||||
v-model="siteEditDialog"
|
||||
:siteid="cardProps.site?.id"
|
||||
@save="saveSite"
|
||||
@remove="emit('remove')"
|
||||
@close="siteEditDialog = false"
|
||||
/>
|
||||
<SiteUserDataDialog
|
||||
v-if="siteUserDataDialog"
|
||||
v-model="siteUserDataDialog"
|
||||
:site="cardProps.site"
|
||||
@close="siteUserDataDialog = false"
|
||||
/>
|
||||
<SiteResourceDialog
|
||||
v-if="resourceDialog"
|
||||
v-model="resourceDialog"
|
||||
:site="cardProps.site"
|
||||
@close="onSiteResourceDone"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -412,7 +456,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:hover .site-status-indicator {
|
||||
.site-card:not(.site-card--sortable):hover .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { StorageConf } from '@/api/types'
|
||||
import type { StorageConf } from '@/api/types'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
import storage_png from '@images/misc/storage.png'
|
||||
import alipan_png from '@images/misc/alipan.webp'
|
||||
@@ -9,21 +9,22 @@ import alist_png from '@images/misc/openlist.svg'
|
||||
import custom_png from '@images/misc/database.png'
|
||||
import smb_png from '@images/misc/smb.png'
|
||||
import api from '@/api'
|
||||
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
|
||||
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
|
||||
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
|
||||
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
|
||||
import SmbConfigDialog from '../dialog/SmbConfigDialog.vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const AliyunAuthDialog = defineAsyncComponent(() => import('../dialog/AliyunAuthDialog.vue'))
|
||||
const U115AuthDialog = defineAsyncComponent(() => import('../dialog/U115AuthDialog.vue'))
|
||||
const RcloneConfigDialog = defineAsyncComponent(() => import('../dialog/RcloneConfigDialog.vue'))
|
||||
const AlistConfigDialog = defineAsyncComponent(() => import('../dialog/AlistConfigDialog.vue'))
|
||||
const SmbConfigDialog = defineAsyncComponent(() => import('../dialog/SmbConfigDialog.vue'))
|
||||
const StorageCustomConfigDialog = defineAsyncComponent(() => import('../dialog/StorageCustomConfigDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#FFB400')
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -50,53 +51,34 @@ const used = computed(() => {
|
||||
return total.value - available.value
|
||||
})
|
||||
|
||||
// 存储
|
||||
const storage_ref = ref(props.storage)
|
||||
|
||||
// 自定义存储名称
|
||||
const customName = ref(props.storage.name)
|
||||
|
||||
// 自定义存储类型
|
||||
const storageType = ref(props.storage.type)
|
||||
|
||||
// 阿里云盘认证对话框
|
||||
const aliyunAuthDialog = ref(false)
|
||||
// 115网盘认证对话框
|
||||
const u115AuthDialog = ref(false)
|
||||
// Rclone配置对话框
|
||||
const rcloneConfigDialog = ref(false)
|
||||
// AList配置对话框
|
||||
const aListConfigDialog = ref(false)
|
||||
// SMB配置对话框
|
||||
const smbConfigDialog = ref(false)
|
||||
// 自定义存储配置对话框
|
||||
const customConfigDialog = ref(false)
|
||||
|
||||
// 打开存储对话框
|
||||
/** 打开指定类型的共享存储配置弹窗。 */
|
||||
function openStorageDialog() {
|
||||
switch (props.storage.type) {
|
||||
case 'alipan':
|
||||
aliyunAuthDialog.value = true
|
||||
break
|
||||
case 'u115':
|
||||
u115AuthDialog.value = true
|
||||
break
|
||||
case 'rclone':
|
||||
rcloneConfigDialog.value = true
|
||||
break
|
||||
case 'alist':
|
||||
aListConfigDialog.value = true
|
||||
break
|
||||
case 'smb':
|
||||
smbConfigDialog.value = true
|
||||
break
|
||||
case 'local':
|
||||
$toast.info(t('storage.noConfigNeeded'))
|
||||
break
|
||||
default:
|
||||
customConfigDialog.value = true
|
||||
break
|
||||
const dialogMap: Record<string, Component> = {
|
||||
alipan: AliyunAuthDialog,
|
||||
u115: U115AuthDialog,
|
||||
rclone: RcloneConfigDialog,
|
||||
alist: AlistConfigDialog,
|
||||
smb: SmbConfigDialog,
|
||||
}
|
||||
|
||||
if (props.storage.type === 'local') {
|
||||
$toast.info(t('storage.noConfigNeeded'))
|
||||
return
|
||||
}
|
||||
|
||||
const dialog = dialogMap[props.storage.type] || StorageCustomConfigDialog
|
||||
const dialogProps = dialog === StorageCustomConfigDialog
|
||||
? { storage: props.storage }
|
||||
: { conf: props.storage.config || {} }
|
||||
|
||||
openSharedDialog(
|
||||
dialog,
|
||||
dialogProps,
|
||||
{
|
||||
done: handleDone,
|
||||
},
|
||||
{ closeOn: ['close', 'done', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 根据存储类型选择图标
|
||||
@@ -135,7 +117,7 @@ const usage = computed(() => {
|
||||
return Math.round((used.value / (total.value || 1)) * 1000) / 10
|
||||
})
|
||||
|
||||
// 查询存储信息
|
||||
/** 查询存储空间使用信息。 */
|
||||
async function queryStorage() {
|
||||
try {
|
||||
const data: { total: number; available: number } = await api.get(`storage/usage/${props.storage.type}`)
|
||||
@@ -146,123 +128,47 @@ async function queryStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 完成配置后的处理
|
||||
function handleDone() {
|
||||
aliyunAuthDialog.value = false
|
||||
u115AuthDialog.value = false
|
||||
rcloneConfigDialog.value = false
|
||||
aListConfigDialog.value = false
|
||||
smbConfigDialog.value = false
|
||||
customConfigDialog.value = false
|
||||
// 更新存储
|
||||
storage_ref.value.name = customName.value
|
||||
storage_ref.value.type = storageType.value
|
||||
emit('done', storage_ref.value)
|
||||
/** 完成配置后的处理并通知父级刷新。 */
|
||||
function handleDone(storage?: StorageConf) {
|
||||
emit('done', storage || props.storage)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryStorage()
|
||||
})
|
||||
|
||||
// 关闭
|
||||
/** 关闭存储卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openStorageDialog">
|
||||
<VDialogCloseBtn @click="onClose" class="absolute top-1 right-1" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
|
||||
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
|
||||
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-8" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
|
||||
<VCard
|
||||
variant="tonal"
|
||||
class="app-card-shell app-card-colorful"
|
||||
:style="{ '--app-card-accent-rgb': accentRgb }"
|
||||
@click="openStorageDialog"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
|
||||
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
|
||||
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
|
||||
</div>
|
||||
</VCard>
|
||||
<AliyunAuthDialog
|
||||
v-if="aliyunAuthDialog"
|
||||
v-model="aliyunAuthDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="aliyunAuthDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<U115AuthDialog
|
||||
v-if="u115AuthDialog"
|
||||
v-model="u115AuthDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="u115AuthDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<RcloneConfigDialog
|
||||
v-if="rcloneConfigDialog"
|
||||
v-model="rcloneConfigDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="rcloneConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<AlistConfigDialog
|
||||
v-if="aListConfigDialog"
|
||||
v-model="aListConfigDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="aListConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<SmbConfigDialog
|
||||
v-if="smbConfigDialog"
|
||||
v-model="smbConfigDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="smbConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<VDialog
|
||||
v-if="customConfigDialog"
|
||||
v-model="customConfigDialog"
|
||||
scrollable
|
||||
max-width="30rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('storage.custom') }}</VCardTitle>
|
||||
<VDialogCloseBtn v-model="customConfigDialog" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="storageType"
|
||||
:label="t('storage.type')"
|
||||
:hint="t('storage.customTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-database"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="customName"
|
||||
:label="t('storage.name')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="getIcon"
|
||||
cover
|
||||
class="mt-8"
|
||||
max-width="3rem"
|
||||
min-width="3rem"
|
||||
@load="updateAccentColor"
|
||||
/>
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
|
||||
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'
|
||||
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
@@ -11,6 +8,11 @@ import router from '@/router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
|
||||
const SubscribeShareDialog = defineAsyncComponent(() => import('../dialog/SubscribeShareDialog.vue'))
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -29,6 +31,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
@@ -48,33 +54,100 @@ const $toast = useToast()
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 订阅弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 订阅文件信息弹窗
|
||||
const subscribeFilesDialog = ref(false)
|
||||
|
||||
// 分享订阅弹窗
|
||||
const subscribeShareDialog = ref(false)
|
||||
|
||||
// 当前的订阅状态
|
||||
const subscribeState = ref<string>(props.media?.state ?? 'P')
|
||||
|
||||
// 上一次更新时间
|
||||
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
|
||||
|
||||
// 判断后端数字/布尔开关是否启用
|
||||
function isEnabledFlag(value: any) {
|
||||
return value === true || value === 1 || value === '1'
|
||||
}
|
||||
|
||||
// 订阅列表接口通常返回中文媒体类型,插件或缓存数据可能只保留剧集字段
|
||||
function isTvSubscribe(media?: Subscribe) {
|
||||
return media?.type === '电视剧' || media?.type === 'tv' || !!media?.season || !!media?.total_episode
|
||||
}
|
||||
|
||||
// 已下载集数:total_episode - lack_episode
|
||||
const downloadedEpisode = computed(() => {
|
||||
const total = props.media?.total_episode || 0
|
||||
if (!total) return 0
|
||||
return Math.min(Math.max(total - (props.media?.lack_episode || 0), 0), total)
|
||||
})
|
||||
|
||||
// 是否为洗版订阅(影响进度条与 tooltip 的展示分支)
|
||||
const isBestVersion = computed(() => isEnabledFlag(props.media?.best_version) && isTvSubscribe(props.media))
|
||||
|
||||
const rightBottomStateDisplay = computed(() => {
|
||||
if (subscribeState.value === 'S') {
|
||||
return { icon: 'mdi-pause-circle', label: t('subscribe.cardStatePaused') }
|
||||
}
|
||||
if (subscribeState.value === 'P') {
|
||||
return { icon: 'mdi-clock', label: t('subscribe.cardStatePending') }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
|
||||
const bestVersionBadge = computed(() => {
|
||||
if (!isEnabledFlag(props.media?.best_version)) return null
|
||||
return {
|
||||
icon: 'mdi-shimmer',
|
||||
full: isEnabledFlag(props.media?.best_version_full),
|
||||
}
|
||||
})
|
||||
|
||||
// 已洗版集数:取后端派生字段 completed_episode
|
||||
const completedEpisode = computed(() => {
|
||||
const total = props.media?.total_episode || 0
|
||||
return Math.min(Math.max(props.media?.completed_episode ?? 0, 0), total)
|
||||
})
|
||||
|
||||
// 卡片主文案:已下载集数 / 总集数
|
||||
const subscribeProgressText = computed(() => {
|
||||
const total = props.media?.total_episode || 0
|
||||
if (!total) return ''
|
||||
return `${downloadedEpisode.value} / ${total}`
|
||||
})
|
||||
|
||||
// 订阅卡片 hover 文案:
|
||||
// - 普通订阅:「已下载 X · 共 Y 集」
|
||||
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
|
||||
const subscribeProgressTooltip = computed(() => {
|
||||
const total = props.media?.total_episode || 0
|
||||
if (!total) return ''
|
||||
|
||||
if (isBestVersion.value) {
|
||||
return t('subscribe.bestVersionEpisodeProgressTooltip', {
|
||||
completed: completedEpisode.value,
|
||||
downloaded: downloadedEpisode.value,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
return t('subscribe.subscribeProgressTooltip', { downloaded: downloadedEpisode.value, total })
|
||||
})
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 计算百分比
|
||||
// 进度条 model 段百分比:洗版订阅表示"已洗版"占比(亮段),普通订阅表示"已下载"占比
|
||||
function getPercentage() {
|
||||
if (props.media?.total_episode === 0) return 0
|
||||
const total = props.media?.total_episode || 0
|
||||
if (!total) return 0
|
||||
const value = isBestVersion.value ? completedEpisode.value : downloadedEpisode.value
|
||||
return Math.round((value / total) * 100)
|
||||
}
|
||||
|
||||
return Math.round(
|
||||
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0)) / (props.media?.total_episode ?? 1)) * 100,
|
||||
)
|
||||
// 洗版进度条的 buffer 段百分比:表示"已下载"占比,仅在洗版场景被模板调用
|
||||
function getBufferPercentage() {
|
||||
const total = props.media?.total_episode || 0
|
||||
if (!isBestVersion.value || !total) return 0
|
||||
return Math.round((downloadedEpisode.value / total) * 100)
|
||||
}
|
||||
|
||||
// 删除订阅
|
||||
@@ -153,12 +226,22 @@ async function resetSubscribe() {
|
||||
|
||||
// 分享订阅
|
||||
async function shareSubscribe() {
|
||||
subscribeShareDialog.value = true
|
||||
if (!props.media) return
|
||||
|
||||
openSharedDialog(SubscribeShareDialog, { sub: props.media }, {}, { closeOn: ['close'] })
|
||||
}
|
||||
|
||||
// 编辑订阅响应
|
||||
async function editSubscribeDialog() {
|
||||
subscribeEditDialog.value = true
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid: props.media?.id },
|
||||
{
|
||||
remove: onSubscribeEditRemove,
|
||||
save: onSubscribeEditSave,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 获得mediaid
|
||||
@@ -184,7 +267,7 @@ async function viewMediaDetail() {
|
||||
|
||||
// 查看文件详情
|
||||
async function viewSubscribeFiles() {
|
||||
subscribeFilesDialog.value = true
|
||||
openSharedDialog(SubscribeFilesDialog, { subid: props.media?.id }, {}, { closeOn: ['close'] })
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
@@ -266,6 +349,7 @@ watch(
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) editSubscribeDialog()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听订阅状态
|
||||
@@ -296,18 +380,20 @@ const posterUrl = computed(() => {
|
||||
|
||||
// 订阅编辑保存
|
||||
function onSubscribeEditSave() {
|
||||
subscribeEditDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// 订阅编辑取消
|
||||
function onSubscribeEditRemove() {
|
||||
subscribeEditDialog.value = false
|
||||
emit('remove')
|
||||
}
|
||||
|
||||
// 处理卡片点击事件
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.batchMode) {
|
||||
// 批量模式下触发选择事件
|
||||
emit('select')
|
||||
@@ -323,11 +409,11 @@ function handleCardClick() {
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="w-full h-full rounded-lg overflow-hidden"
|
||||
class="w-full h-full rounded-lg overflow-hidden relative"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
@@ -335,15 +421,23 @@ function handleCardClick() {
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'opacity-70': subscribeState === 'S',
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
rounded="0"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
>
|
||||
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
|
||||
</div>
|
||||
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -370,17 +464,13 @@ function handleCardClick() {
|
||||
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
<div
|
||||
v-if="subscribeState === 'P'"
|
||||
class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<VCardText class="flex items-center pt-3 pb-2">
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
||||
v-if="imageLoaded"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
@@ -398,35 +488,69 @@ function handleCardClick() {
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex align-center">
|
||||
<VCardText class="flex min-w-0 justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex min-w-0 max-w-full align-center">
|
||||
<VIcon
|
||||
v-if="props.media?.total_episode && props.sortable"
|
||||
icon="mdi-progress-download"
|
||||
size="small"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-else-if="props.media?.total_episode"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
/>
|
||||
<div v-if="props.media?.season" class="text-subtitle-2 me-2 text-white">
|
||||
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}
|
||||
<!-- 守卫改用 total_episode:电视剧订阅可能不带 season 字段(旧数据或自定义来源),仍应展示集数进度 -->
|
||||
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
||||
{{ subscribeProgressText }}
|
||||
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
|
||||
{{ subscribeProgressTooltip }}
|
||||
</VTooltip>
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.username" icon="mdi-account" size="small" color="white" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
|
||||
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="flex-shrink-0 me-1" />
|
||||
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" class="flex-shrink-0" />
|
||||
<!-- 用户名过长时限制在卡片宽度内,并用省略号展示剩余内容 -->
|
||||
<span v-if="props.media?.username" class="min-w-0 truncate text-subtitle-2 text-white" :title="props.media?.username">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<!-- 右下角元数据:暂停 / 待定时替换"x 天前"为状态文案 -->
|
||||
<VCardText
|
||||
v-if="lastUpdateText"
|
||||
v-if="rightBottomStateDisplay"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
||||
{{ rightBottomStateDisplay.label }}
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="lastUpdateText"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<!--
|
||||
分集洗版模式:底色保持深绿、buffer 段显示"已下载未洗版"为浅绿、model 段显示"已洗版完成"为亮绿,
|
||||
形成两段语义;其余订阅维持原有单段进度条
|
||||
-->
|
||||
<VProgressLinear
|
||||
v-if="getPercentage() > 0"
|
||||
v-if="isBestVersion && getBufferPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
:buffer-value="getBufferPercentage()"
|
||||
bg-color="success"
|
||||
bg-opacity="0.25"
|
||||
color="success"
|
||||
buffer-color="success"
|
||||
buffer-opacity="0.55"
|
||||
/>
|
||||
<VProgressLinear
|
||||
v-else-if="getPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
@@ -437,34 +561,60 @@ function handleCardClick() {
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="props.media?.id"
|
||||
@remove="onSubscribeEditRemove"
|
||||
@save="onSubscribeEditSave"
|
||||
@close="subscribeEditDialog = false"
|
||||
/>
|
||||
|
||||
<!-- 订阅文件信息弹窗 -->
|
||||
<SubscribeFilesDialog
|
||||
v-if="subscribeFilesDialog"
|
||||
v-model="subscribeFilesDialog"
|
||||
:subid="props.media?.id"
|
||||
@close="subscribeFilesDialog = false"
|
||||
/>
|
||||
<!-- 分享订阅弹窗 -->
|
||||
<SubscribeShareDialog
|
||||
v-if="subscribeShareDialog"
|
||||
v-model="subscribeShareDialog"
|
||||
:sub="props.media"
|
||||
@close="subscribeShareDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停:降低不透明度表达"已停止活动"
|
||||
*/
|
||||
.subscribe-card-paused {
|
||||
opacity: 0.65;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/**
|
||||
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
|
||||
*/
|
||||
.subscribe-card-pending-tint {
|
||||
position: relative;
|
||||
}
|
||||
.subscribe-card-pending-tint::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 48px rgba(56, 189, 248, 0.4); // sky-400
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 洗版标识:卡片左上角 24x24 圆形徽标
|
||||
* 分集:深色半透底 + 模糊
|
||||
* 全集:磨砂玻璃半透白底 + 大模糊
|
||||
*/
|
||||
.best-version-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 4;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.best-version-badge-full {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import type { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -22,15 +24,6 @@ const globalSettings = globalSettingsStore.globalSettings
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 订阅编辑弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 复用订阅弹窗
|
||||
const forkSubscribeDialog = ref(false)
|
||||
|
||||
// 订阅ID
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -78,19 +71,24 @@ async function viewMediaDetail() {
|
||||
|
||||
// 复用订阅
|
||||
function showForkSubscribe() {
|
||||
forkSubscribeDialog.value = true
|
||||
openSharedDialog(
|
||||
ForkSubscribeDialog,
|
||||
{ media: props.media },
|
||||
{
|
||||
fork: finishForkSubscribe,
|
||||
delete: doDelete,
|
||||
},
|
||||
{ closeOn: ['close', 'fork', 'delete'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 完成复用订阅
|
||||
function finishForkSubscribe(subid: number) {
|
||||
subscribeId.value = subid
|
||||
forkSubscribeDialog.value = false
|
||||
subscribeEditDialog.value = true
|
||||
openSharedDialog(SubscribeEditDialog, { subid }, {}, { closeOn: ['close', 'save', 'remove'] })
|
||||
}
|
||||
|
||||
// 删除订阅分享时处理
|
||||
function doDelete() {
|
||||
forkSubscribeDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('delete')
|
||||
}
|
||||
@@ -167,24 +165,6 @@ function doDelete() {
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="subscribeEditDialog = false"
|
||||
/>
|
||||
<!-- 复用订阅弹窗 -->
|
||||
<ForkSubscribeDialog
|
||||
v-if="forkSubscribeDialog"
|
||||
v-model="forkSubscribeDialog"
|
||||
:media="props.media"
|
||||
@close="forkSubscribeDialog = false"
|
||||
@fork="finishForkSubscribe"
|
||||
@delete="doDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -3,8 +3,13 @@ import type { PropType } from 'vue'
|
||||
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
||||
const TorrentMoreSourcesDialog = defineAsyncComponent(() => import('../dialog/TorrentMoreSourcesDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -14,9 +19,6 @@ const props = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 更多来源界面
|
||||
const showMoreTorrents = ref(false)
|
||||
|
||||
// 种子信息
|
||||
const torrent = ref(props.torrent?.torrent_info)
|
||||
|
||||
@@ -32,31 +34,36 @@ const downloadItem = ref(props.torrent)
|
||||
// 站点图标
|
||||
const siteIcons = ref<Record<number, string>>({})
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
markTorrentDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
addDownloadDialog.value = false
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon(site: number | undefined) {
|
||||
if (!site) return
|
||||
|
||||
try {
|
||||
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
|
||||
siteIcons.value[site] = await getCachedSiteIcon(site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
siteIcons.value[site] = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +73,21 @@ async function handleAddDownload(item: Context | null = null) {
|
||||
downloadItem.value = item
|
||||
}
|
||||
// 打开下载对话框
|
||||
addDownloadDialog.value = true
|
||||
openSharedDialog(
|
||||
AddDownloadDialog,
|
||||
{
|
||||
title: `${downloadItem.value?.media_info?.title_year || downloadItem.value?.meta_info?.name} ${
|
||||
downloadItem.value?.meta_info?.season_episode
|
||||
}`,
|
||||
media: downloadItem.value?.media_info,
|
||||
torrent: downloadItem.value?.torrent_info,
|
||||
},
|
||||
{
|
||||
done: addDownloadSuccess,
|
||||
error: addDownloadError,
|
||||
},
|
||||
{ closeOn: ['close', 'done', 'error'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开种子详情页面
|
||||
@@ -92,37 +113,46 @@ function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolum
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 获取优惠标签类
|
||||
function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||
if (!downloadVolumeFactor) return 'chip-free'
|
||||
if (downloadVolumeFactor === 0) return 'chip-free'
|
||||
else if (downloadVolumeFactor < 1) return 'chip-discount'
|
||||
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 打开更多来源对话框
|
||||
async function openMoreTorrentsDialog() {
|
||||
props.more?.forEach(t => {
|
||||
return getSiteIcon(t.torrent_info?.site)
|
||||
})
|
||||
showMoreTorrents.value = true
|
||||
openSharedDialog(
|
||||
TorrentMoreSourcesDialog,
|
||||
{
|
||||
items: props.more || [],
|
||||
siteIcons: siteIcons.value,
|
||||
},
|
||||
{
|
||||
download: handleAddDownload,
|
||||
detail: openTorrentDetail,
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon(props.torrent?.torrent_info?.site)
|
||||
})
|
||||
watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
downloadItem.value = value
|
||||
getSiteIcon(value?.torrent_info?.site)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
|
||||
:class="{ 'border-success border-2 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
@@ -136,8 +166,8 @@ onMounted(() => {
|
||||
|
||||
<!-- 媒体标题 -->
|
||||
<VCardItem class="pt-3 pb-0">
|
||||
<div class="d-flex flex-row flex-wrap justify-start mb-2 pr-8">
|
||||
<span class="text-h6 font-weight-bold text-truncate me-2">
|
||||
<div class="d-flex flex-row flex-wrap justify-start align-center mb-2 pr-8">
|
||||
<span class="text-h6 font-weight-bold me-2">
|
||||
{{ media?.title ?? meta?.name }}
|
||||
</span>
|
||||
<VChip
|
||||
@@ -183,14 +213,14 @@ onMounted(() => {
|
||||
<!-- 种子内容 -->
|
||||
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
|
||||
<!-- 种子标题 -->
|
||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1" :title="torrent?.title">
|
||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1 break-all" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<!-- 种子描述 -->
|
||||
<div
|
||||
v-if="meta?.subtitle || torrent?.description"
|
||||
class="text-body-2 text-medium-emphasis mb-2"
|
||||
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||
:title="meta?.subtitle || torrent?.description"
|
||||
>
|
||||
{{ meta?.subtitle || torrent?.description }}
|
||||
@@ -258,7 +288,7 @@ onMounted(() => {
|
||||
class="pa-1 d-flex align-center"
|
||||
@click.stop="openMoreTorrentsDialog"
|
||||
>
|
||||
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" size="small" class="mr-1"></VIcon>
|
||||
<VIcon icon="mdi-chevron-down" size="small" class="mr-1"></VIcon>
|
||||
更多来源 ({{ props.more.length }})
|
||||
</VBtn>
|
||||
</div>
|
||||
@@ -276,105 +306,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
|
||||
<!-- 更多来源对话框 -->
|
||||
<VDialog v-model="showMoreTorrents" max-width="25rem" location="center">
|
||||
<VCard>
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<span>其他来源</span>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="showMoreTorrents = false"></VBtn>
|
||||
</VCardTitle>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText class="more-sources-content pa-0">
|
||||
<VList lines="one" density="compact">
|
||||
<VListItem
|
||||
v-for="(item, index) in props.more"
|
||||
:key="index"
|
||||
@click.stop="handleAddDownload(item)"
|
||||
class="hover:bg-primary-lighten-5"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="d-flex align-center gap-1">
|
||||
<VImg
|
||||
v-if="siteIcons[item.torrent_info?.site || 0]"
|
||||
:src="siteIcons[item.torrent_info?.site || 0]"
|
||||
:alt="item.torrent_info?.site_name"
|
||||
width="16"
|
||||
height="16"
|
||||
class="rounded"
|
||||
/>
|
||||
<VAvatar v-else size="16" class="text-caption bg-surface-variant">
|
||||
{{ item.torrent_info?.site_name?.substring(0, 1) }}
|
||||
</VAvatar>
|
||||
<span class="text-body-2 font-weight-bold">{{ item.torrent_info.site_name }}</span>
|
||||
|
||||
<VChip
|
||||
v-if="item.meta_info?.season_episode"
|
||||
class="chip-season rounded-sm ml-1"
|
||||
size="x-small"
|
||||
variant="elevated"
|
||||
>
|
||||
{{ item.meta_info.season_episode }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||
:class="
|
||||
getPromotionChipClass(
|
||||
item.torrent_info?.downloadvolumefactor,
|
||||
item.torrent_info?.uploadvolumefactor,
|
||||
)
|
||||
"
|
||||
size="x-small"
|
||||
variant="elevated"
|
||||
class="rounded-sm ml-1"
|
||||
>
|
||||
{{ item.torrent_info?.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<span class="text-caption font-weight-bold text-primary">
|
||||
{{ formatFileSize(item.torrent_info?.size) }}
|
||||
</span>
|
||||
<span class="d-flex align-center text-caption font-weight-bold">
|
||||
<VIcon size="small" color="success" icon="mdi-arrow-up" class="mr-1"></VIcon>
|
||||
{{ item.torrent_info?.seeders }}
|
||||
</span>
|
||||
<span>
|
||||
<VIcon
|
||||
@click.stop="openTorrentDetail(item)"
|
||||
size="small"
|
||||
color="secondary"
|
||||
icon="mdi-arrow-top-right"
|
||||
class="mr-1"
|
||||
></VIcon>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
:title="`${downloadItem?.media_info?.title_year || downloadItem?.meta_info?.name} ${
|
||||
downloadItem?.meta_info?.season_episode
|
||||
}`"
|
||||
:media="downloadItem?.media_info"
|
||||
:torrent="downloadItem?.torrent_info"
|
||||
@done="addDownloadSuccess"
|
||||
@error="addDownloadError"
|
||||
@close="addDownloadDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -385,11 +316,6 @@ onMounted(() => {
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.more-sources-content {
|
||||
max-block-size: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: 1px solid transparent;
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { PropType } from 'vue'
|
||||
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -22,37 +26,28 @@ const meta = ref(props.torrent?.meta_info)
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 站点图标加载状态
|
||||
const iconLoading = ref(false)
|
||||
const iconError = ref(false)
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!torrent?.value?.site || iconLoading.value) {
|
||||
if (!torrent?.value?.site) {
|
||||
return
|
||||
}
|
||||
|
||||
iconLoading.value = true
|
||||
iconError.value = false
|
||||
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value.site}`)
|
||||
if (response && response.data && response.data.icon) {
|
||||
siteIcon.value = response.data.icon
|
||||
} else {
|
||||
iconError.value = true
|
||||
}
|
||||
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value?.site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
iconError.value = true
|
||||
} finally {
|
||||
iconLoading.value = false
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,19 +72,29 @@ function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadV
|
||||
// 询问并添加下载
|
||||
async function handleAddDownload() {
|
||||
// 打开下载对话框
|
||||
addDownloadDialog.value = true
|
||||
openSharedDialog(
|
||||
AddDownloadDialog,
|
||||
{
|
||||
title: `${media.value?.title_year || meta.value?.name} ${meta.value?.season_episode || ''}`,
|
||||
media: media.value,
|
||||
torrent: torrent.value,
|
||||
},
|
||||
{
|
||||
done: addDownloadSuccess,
|
||||
error: addDownloadError,
|
||||
},
|
||||
{ closeOn: ['close', 'done', 'error'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
markTorrentDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
addDownloadDialog.value = false
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
// 打开种子详情页面
|
||||
@@ -97,10 +102,16 @@ function openTorrentDetail() {
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
})
|
||||
watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
getSiteIcon()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,7 +119,7 @@ onMounted(() => {
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
@@ -140,7 +151,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VListItemTitle>
|
||||
<VListItemTitle class="whitespace-normal">
|
||||
<div class="d-flex flex-row flex-wrap align-center mb-2">
|
||||
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
|
||||
<VChip
|
||||
@@ -153,12 +164,12 @@ onMounted(() => {
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2" :title="torrent?.title">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-body-2 text-medium-emphasis mb-2"
|
||||
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||
:title="meta?.subtitle || torrent?.description || '暂无描述'"
|
||||
>
|
||||
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
|
||||
@@ -240,17 +251,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
|
||||
:media="media"
|
||||
:torrent="torrent"
|
||||
@done="addDownloadSuccess"
|
||||
@error="addDownloadError"
|
||||
@close="addDownloadDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useUserStore } from '@/stores'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const UserAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/UserAddEditDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -46,9 +48,6 @@ const emit = defineEmits(['remove', 'save'])
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 用户信息弹窗
|
||||
const userEditDialog = ref(false)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -104,12 +103,22 @@ async function removeUser() {
|
||||
|
||||
// 编辑用户
|
||||
function editUser() {
|
||||
userEditDialog.value = true
|
||||
openSharedDialog(
|
||||
UserAddEditDialog,
|
||||
{
|
||||
username: props.user?.name,
|
||||
usernames: props.users.map(item => item.name),
|
||||
oper: 'edit',
|
||||
},
|
||||
{
|
||||
save: onUserUpdate,
|
||||
},
|
||||
{ closeOn: ['close', 'save'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 用户更新完成时
|
||||
function onUserUpdate() {
|
||||
userEditDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
@@ -123,10 +132,10 @@ onMounted(() => {
|
||||
'transition-transform duration-300 hover:-translate-y-1',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="flex flex-column"
|
||||
@click="userEditDialog = true"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<div class="user-card__body flex-grow flex-grow-1">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
<template v-slot:prepend>
|
||||
@@ -247,7 +256,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<!-- 独立的邮箱显示 -->
|
||||
<VDivider class="mx-4" />
|
||||
<div>
|
||||
<div class="user-card__footer">
|
||||
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
|
||||
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
|
||||
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
|
||||
@@ -294,20 +303,19 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 用户编辑弹窗 -->
|
||||
<UserAddEditDialog
|
||||
v-if="userEditDialog"
|
||||
v-model="userEditDialog"
|
||||
:username="props.user?.name"
|
||||
:usernames="props.users.map(item => item.name)"
|
||||
oper="edit"
|
||||
@save="onUserUpdate"
|
||||
@close="userEditDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
/* 让邮箱和订阅统计固定在卡片底部,保证同一行用户卡片视觉等高。 */
|
||||
.user-card__footer {
|
||||
flex-shrink: 0;
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.admin-decoration {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import type { WorkflowShare } from '@/api/types'
|
||||
import ForkWorkflowDialog from '../dialog/ForkWorkflowDialog.vue'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const ForkWorkflowDialog = defineAsyncComponent(() => import('../dialog/ForkWorkflowDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -15,9 +17,6 @@ const props = defineProps({
|
||||
// 定义删除事件
|
||||
const emit = defineEmits(['delete', 'update'])
|
||||
|
||||
// 复用工作流弹窗
|
||||
const forkWorkflowDialog = ref(false)
|
||||
|
||||
// 工作流ID
|
||||
const workflowId = ref<string>()
|
||||
|
||||
@@ -65,19 +64,28 @@ onMounted(() => {
|
||||
|
||||
// 复用工作流
|
||||
function showForkWorkflow() {
|
||||
forkWorkflowDialog.value = true
|
||||
openSharedDialog(
|
||||
ForkWorkflowDialog,
|
||||
{
|
||||
workflow: props.workflow,
|
||||
eventTypes: props.eventTypes,
|
||||
},
|
||||
{
|
||||
fork: finishForkWorkflow,
|
||||
delete: doDelete,
|
||||
},
|
||||
{ closeOn: ['close', 'fork', 'delete'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 完成复用工作流
|
||||
function finishForkWorkflow(wid: string) {
|
||||
workflowId.value = wid
|
||||
forkWorkflowDialog.value = false
|
||||
emit('update')
|
||||
}
|
||||
|
||||
// 删除工作流分享时处理
|
||||
function doDelete() {
|
||||
forkWorkflowDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('delete')
|
||||
}
|
||||
@@ -134,15 +142,5 @@ function doDelete() {
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 复用工作流弹窗 -->
|
||||
<ForkWorkflowDialog
|
||||
v-if="forkWorkflowDialog"
|
||||
v-model="forkWorkflowDialog"
|
||||
:workflow="props.workflow"
|
||||
:event-types="props.eventTypes"
|
||||
@close="forkWorkflowDialog = false"
|
||||
@fork="finishForkWorkflow"
|
||||
@delete="doDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
||||
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
|
||||
import WorkflowShareDialog from '@/components/dialog/WorkflowShareDialog.vue'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const WorkflowActionsDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowActionsDialog.vue'))
|
||||
const WorkflowAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowAddEditDialog.vue'))
|
||||
const WorkflowShareDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowShareDialog.vue'))
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -31,15 +33,6 @@ const $toast = useToast()
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 编辑对话框
|
||||
const editDialog = ref(false)
|
||||
|
||||
// 流程对话框
|
||||
const flowDialog = ref(false)
|
||||
|
||||
// 分享对话框
|
||||
const shareDialog = ref(false)
|
||||
|
||||
// 加载中
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -51,24 +44,35 @@ const getEventTypeText = (eventTypeValue: string) => {
|
||||
|
||||
// 编辑任务
|
||||
function handleEdit(item: Workflow) {
|
||||
editDialog.value = true
|
||||
openSharedDialog(
|
||||
WorkflowAddEditDialog,
|
||||
{ workflow: item },
|
||||
{
|
||||
save: editDone,
|
||||
},
|
||||
{ closeOn: ['close', 'save'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑流程
|
||||
function handleFlow(item: Workflow) {
|
||||
flowDialog.value = true
|
||||
openSharedDialog(
|
||||
WorkflowActionsDialog,
|
||||
{ workflow: item },
|
||||
{
|
||||
save: editDone,
|
||||
},
|
||||
{ closeOn: ['close', 'save'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 分享工作流
|
||||
function handleShare(item: Workflow) {
|
||||
shareDialog.value = true
|
||||
openSharedDialog(WorkflowShareDialog, { workflow: item }, {}, { closeOn: ['close'] })
|
||||
}
|
||||
|
||||
// 编辑完成
|
||||
function editDone() {
|
||||
editDialog.value = false
|
||||
flowDialog.value = false
|
||||
shareDialog.value = false
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
@@ -365,23 +369,5 @@ const resolveProgress = (item: Workflow) => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VHover>
|
||||
<!-- 流程对话框 -->
|
||||
<WorkflowActionsDialog
|
||||
v-if="flowDialog"
|
||||
v-model="flowDialog"
|
||||
@close="flowDialog = false"
|
||||
@save="editDone"
|
||||
:workflow="workflow"
|
||||
/>
|
||||
<!-- 编辑对话框 -->
|
||||
<WorkflowAddEditDialog
|
||||
v-if="editDialog"
|
||||
v-model="editDialog"
|
||||
@close="editDialog = false"
|
||||
@save="editDone"
|
||||
:workflow="workflow"
|
||||
/>
|
||||
<!-- 分享对话框 -->
|
||||
<WorkflowShareDialog v-if="shareDialog" v-model="shareDialog" :workflow="workflow" @close="shareDialog = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
668
src/components/dialog/AboutDialog.vue
Normal file
@@ -0,0 +1,668 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { clearCacheAndReload } from '@/composables/useVersionChecker'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// APP版本
|
||||
const appVersion = __APP_VERSION__
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 显示器
|
||||
const display = useDisplay()
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 系统环境变量
|
||||
const systemEnv = ref<any>({})
|
||||
|
||||
// 所有Release
|
||||
const allRelease = ref<any>([])
|
||||
|
||||
// 支持站点
|
||||
const supportingSites = ref<any>({})
|
||||
|
||||
// 支持站点折叠状态
|
||||
const sitesExpanded = ref(false)
|
||||
|
||||
// 去重后的支持站点
|
||||
const uniqueSupportingSites = computed(() => {
|
||||
const sitesMap = new Map()
|
||||
|
||||
Object.entries(supportingSites.value).forEach(([domain, site]: [string, any]) => {
|
||||
if (!sitesMap.has(site.name)) {
|
||||
sitesMap.set(site.name, {
|
||||
name: site.name,
|
||||
urls: [{ domain, url: site.url }],
|
||||
})
|
||||
} else {
|
||||
sitesMap.get(site.name).urls.push({ domain, url: site.url })
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(sitesMap.values())
|
||||
})
|
||||
|
||||
// 显示的支持站点(折叠时只显示前5个)
|
||||
const displayedSites = computed(() => {
|
||||
if (sitesExpanded.value) {
|
||||
return uniqueSupportingSites.value
|
||||
}
|
||||
return uniqueSupportingSites.value.slice(0, 5)
|
||||
})
|
||||
|
||||
// 变更日志对话框
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
// 最新版本
|
||||
const latestRelease = ref('')
|
||||
|
||||
// 变更日志对话框标题
|
||||
const releaseDialogTitle = ref('')
|
||||
|
||||
// 变更日志对话框内容
|
||||
const releaseDialogBody = ref('')
|
||||
|
||||
// 版本统计对话框
|
||||
const versionStatisticDialog = ref(false)
|
||||
|
||||
// 版本统计加载状态
|
||||
const versionStatisticLoading = ref(false)
|
||||
|
||||
// 版本统计数据
|
||||
const versionStatistic = ref<any>({})
|
||||
|
||||
// 后端版本统计
|
||||
const backendVersionStatistics = computed(() => versionStatistic.value?.backend_versions ?? [])
|
||||
|
||||
// 前端版本统计
|
||||
const frontendVersionStatistics = computed(() => versionStatistic.value?.frontend_versions ?? [])
|
||||
|
||||
// 活跃用户统计
|
||||
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
|
||||
|
||||
// 打开日志对话框
|
||||
function showReleaseDialog(title: string, body: string) {
|
||||
releaseDialogTitle.value = title
|
||||
releaseDialogBody.value = body ? md.render(body) : ''
|
||||
releaseDialog.value = true
|
||||
}
|
||||
|
||||
// 查询版本统计
|
||||
async function queryVersionStatistic() {
|
||||
if (!systemEnv.value.USAGE_STATISTIC_SHARE) return
|
||||
versionStatisticLoading.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/usage/statistic')
|
||||
|
||||
versionStatistic.value = result.data ?? {}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
versionStatistic.value = {}
|
||||
} finally {
|
||||
versionStatisticLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开版本统计对话框
|
||||
async function showVersionStatisticDialog() {
|
||||
versionStatisticDialog.value = true
|
||||
await queryVersionStatistic()
|
||||
}
|
||||
|
||||
// 查询系统环境变量
|
||||
async function querySystemEnv() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env')
|
||||
|
||||
systemEnv.value = result.data
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有Release
|
||||
async function queryAllRelease() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/versions')
|
||||
|
||||
allRelease.value = result.data ?? []
|
||||
|
||||
// 最新版本
|
||||
if (allRelease.value.length > 0) latestRelease.value = allRelease.value[0].tag_name
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询支持站点
|
||||
async function querySupportingSites() {
|
||||
try {
|
||||
supportingSites.value = await api.get('site/supporting')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换站点列表展开状态
|
||||
function toggleSitesExpanded() {
|
||||
sitesExpanded.value = !sitesExpanded.value
|
||||
}
|
||||
|
||||
// 计算发布时间
|
||||
function releaseTime(releaseDate: string) {
|
||||
// 上一次更新时间
|
||||
return formatDateDifference(releaseDate)
|
||||
}
|
||||
|
||||
// 强制清除缓存
|
||||
async function clearCache() {
|
||||
await clearCacheAndReload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
querySystemEnv()
|
||||
queryAllRelease()
|
||||
querySupportingSites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-information" class="me-2" />
|
||||
{{ t('setting.about.title') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="px-3">
|
||||
<div class="section">
|
||||
<div class="section border-gray-800">
|
||||
<dl>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.softwareVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.VERSION }}</code>
|
||||
<a
|
||||
v-if="latestRelease === systemEnv.VERSION"
|
||||
href="https://github.com/jxxghp/MoviePilot/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 ml-2 !cursor-pointer transition hover:bg-green-400"
|
||||
>
|
||||
{{ t('setting.about.latest') }}
|
||||
</span>
|
||||
</a>
|
||||
<VTooltip v-if="systemEnv.USAGE_STATISTIC_SHARE" :text="t('setting.about.versionStatistic')">
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
v-bind="props"
|
||||
icon="mdi-chart-bar"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ms-2 flex-shrink-0"
|
||||
@click="showVersionStatisticDialog"
|
||||
/>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="systemEnv.FRONTEND_VERSION">
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.frontendVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.FRONTEND_VERSION }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.browserVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ appVersion }}</code>
|
||||
<VBtn size="x-small" variant="tonal" class="ms-2" @click="clearCache">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" size="14" />
|
||||
</template>
|
||||
{{ t('setting.about.clearCache') }}
|
||||
</VBtn>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.authVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.AUTH_VERSION }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.indexerVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.INDEXER_VERSION }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.configDir') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<code>{{ systemEnv.CONFIG_DIR }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.dataDir') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all"
|
||||
><code>{{ t('setting.about.dataDirectory') }}</code></span
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.timezone') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<code>{{ systemEnv.TZ }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2 mt-1 ms-1">
|
||||
<VChip v-for="site in displayedSites" :key="site.name" variant="outlined" size="small">
|
||||
<span class="truncate max-w-32">{{ site.name }}</span>
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="!sitesExpanded && uniqueSupportingSites.length > 5"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="toggleSitesExpanded"
|
||||
>
|
||||
<span> {{ uniqueSupportingSites.length }}+ ...</span>
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="sitesExpanded && uniqueSupportingSites.length > 5"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="toggleSitesExpanded"
|
||||
>
|
||||
<span>< {{ t('setting.about.collapse') }}</span>
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">{{ t('setting.about.support') }}</h3>
|
||||
</div>
|
||||
<div class="section border-t border-gray-800">
|
||||
<dl>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.documentation') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://movie-pilot.org"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://movie-pilot.org
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.feedback') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://github.com/jxxghp/MoviePilot/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://github.com/jxxghp/MoviePilot/issues/new/choose
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.channel') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://t.me/moviepilot_channel"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://t.me/moviepilot_channel
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">{{ t('setting.about.versions') }}</h3>
|
||||
<div class="section space-y-3">
|
||||
<div>
|
||||
<div
|
||||
v-for="release in allRelease"
|
||||
:key="release.tag_name"
|
||||
class="mb-3 flex w-full flex-col space-y-3 rounded-md px-4 py-2 ring-1 ring-gray-400 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||
>
|
||||
<div class="flex w-full flex-grow items-center justify-start space-x-2 truncate sm:justify-start">
|
||||
<span class="truncate text-lg font-bold">
|
||||
<span class="mr-2 whitespace-nowrap text-xs font-normal">{{
|
||||
releaseTime(release.published_at)
|
||||
}}</span>
|
||||
{{ release.tag_name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="release.tag_name === latestRelease"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-green-500 bg-opacity-80 border border-green-500 !text-green-100"
|
||||
>
|
||||
{{ t('setting.about.latestVersion') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="release.tag_name === systemEnv.VERSION"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100"
|
||||
>
|
||||
{{ t('setting.about.currentVersion') }}
|
||||
</span>
|
||||
</div>
|
||||
<VBtn @click.stop="showReleaseDialog(release.tag_name, release.body)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-text-box-outline" />
|
||||
</template>
|
||||
{{ t('setting.about.viewChangelog') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable max-height="85vh">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VCardTitle>{{ releaseDialogTitle }} {{ t('setting.about.changelog') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText class="markdown-body" v-html="releaseDialogBody" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<VDialog v-if="versionStatisticDialog" v-model="versionStatisticDialog" width="680" scrollable max-height="85vh">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VDialogCloseBtn @click="versionStatisticDialog = false" />
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-chart-bar" class="me-2" />
|
||||
{{ t('setting.about.versionStatisticTitle') }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VProgressLinear v-if="versionStatisticLoading" indeterminate color="primary" />
|
||||
<VCardText>
|
||||
<div class="version-stat-summary">
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</div>
|
||||
<div class="version-stat-number">{{ versionStatistic.total_users ?? 0 }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.activeToday') }}</div>
|
||||
<div class="version-stat-number">{{ activeUsers.today ?? 0 }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</div>
|
||||
<div class="version-stat-number">{{ activeUsers.last_7_days ?? 0 }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active30Days') }}</div>
|
||||
<div class="version-stat-number">{{ activeUsers.last_30_days ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="text-subtitle-2 mb-2">{{ t('setting.about.backendVersionStatistic') }}</div>
|
||||
<VTable density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('setting.about.version') }}</th>
|
||||
<th class="text-end">{{ t('setting.about.users') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in backendVersionStatistics" :key="`backend-${item.version}`">
|
||||
<td>
|
||||
<code>{{ item.version }}</code>
|
||||
</td>
|
||||
<td class="text-end">{{ item.count }}</td>
|
||||
</tr>
|
||||
<tr v-if="!backendVersionStatistics.length">
|
||||
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="text-subtitle-2 mb-2">{{ t('setting.about.frontendVersionStatistic') }}</div>
|
||||
<VTable density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('setting.about.version') }}</th>
|
||||
<th class="text-end">{{ t('setting.about.users') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in frontendVersionStatistics" :key="`frontend-${item.version}`">
|
||||
<td>
|
||||
<code>{{ item.version }}</code>
|
||||
</td>
|
||||
<td class="text-end">{{ item.count }}</td>
|
||||
</tr>
|
||||
<tr v-if="!frontendVersionStatistics.length">
|
||||
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
<div v-if="versionStatistic.updated_at" class="mt-4 text-caption text-medium-emphasis">
|
||||
{{ t('setting.about.lastUpdated') }}: {{ versionStatistic.updated_at }}
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style type="scss" scoped>
|
||||
.heading {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 2rem;
|
||||
|
||||
--tw-text-opacity: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-block: 0.5rem 2.5rem;
|
||||
}
|
||||
|
||||
.version-stat-summary {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
}
|
||||
|
||||
.version-stat-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
font-weight: 600;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h2) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h3) {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol) {
|
||||
margin-block: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(li) {
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a) {
|
||||
color: rgb(99 102 241);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body :deep(code) {
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(127, 127, 127, 15%);
|
||||
font-size: 0.875em;
|
||||
padding-block: 0.15rem;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgba(127, 127, 127, 15%);
|
||||
margin-block: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-block: 0.75rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre code) {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body :deep(blockquote) {
|
||||
border-inline-start: 3px solid rgba(127, 127, 127, 40%);
|
||||
color: rgba(127, 127, 127, 80%);
|
||||
margin-block: 0.5rem;
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(hr) {
|
||||
border: none;
|
||||
border-block-start: 1px solid rgba(127, 127, 127, 30%);
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(table) {
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th),
|
||||
.markdown-body :deep(td) {
|
||||
border: 1px solid rgba(127, 127, 127, 30%);
|
||||
padding-block: 0.4rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th) {
|
||||
background-color: rgba(127, 127, 127, 10%);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body :deep(img) {
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -6,10 +6,20 @@ import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } fr
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
@@ -38,6 +48,18 @@ const directories = ref<TransferDirectoryConf[]>([])
|
||||
// 是否正在加载
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
|
||||
// 计算按钮图标
|
||||
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
|
||||
|
||||
@@ -56,9 +78,21 @@ async function loadDirectories() {
|
||||
}
|
||||
}
|
||||
|
||||
function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
}
|
||||
|
||||
// 获取保存目录
|
||||
const targetDirectories = computed(() => {
|
||||
const downloadDirectories = directories.value.map(item => item.download_path)
|
||||
const downloadDirectories = directories.value
|
||||
.map(item => convertToUri(item))
|
||||
.filter((item): item is string => item !== undefined)
|
||||
return [...new Set(downloadDirectories)]
|
||||
})
|
||||
|
||||
@@ -96,6 +130,14 @@ async function addDownload() {
|
||||
payload.media_in = props.media
|
||||
}
|
||||
|
||||
// 添加媒体ID辅助识别
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
}
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
|
||||
result = await api.post(endpoint, payload)
|
||||
@@ -181,7 +223,6 @@ onMounted(() => {
|
||||
<VSelect
|
||||
v-model="selectedDownloader"
|
||||
:items="downloaderOptions"
|
||||
size="small"
|
||||
:label="t('dialog.addDownload.downloader')"
|
||||
variant="underlined"
|
||||
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
|
||||
@@ -194,7 +235,6 @@ onMounted(() => {
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
:label="t('dialog.addDownload.saveDirectory')"
|
||||
size="small"
|
||||
:placeholder="t('dialog.addDownload.autoPlaceholder')"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@@ -202,6 +242,53 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow class="px-5 mt-2">
|
||||
<VCol cols="12">
|
||||
<VBtn
|
||||
variant="text"
|
||||
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click="showAdvancedOptions = !showAdvancedOptions"
|
||||
>
|
||||
{{
|
||||
showAdvancedOptions
|
||||
? t('dialog.addDownload.hideAdvancedOptions')
|
||||
: t('dialog.addDownload.showAdvancedOptions')
|
||||
}}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardText class="text-center">
|
||||
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
|
||||
@@ -209,5 +296,15 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 媒体ID选择器 -->
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
95
src/components/dialog/CacheReidentifyDialog.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
itemTitle?: string
|
||||
loading?: boolean
|
||||
modelValue?: boolean
|
||||
recognizeSource?: string
|
||||
}>(),
|
||||
{
|
||||
itemTitle: '',
|
||||
loading: false,
|
||||
modelValue: true,
|
||||
recognizeSource: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'confirm', payload: { doubanId?: string; tmdbId?: number }): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const tmdbId = ref<number | undefined>()
|
||||
const doubanId = ref<string | undefined>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 提交重新识别参数给缓存页执行接口调用。
|
||||
function submitReidentify() {
|
||||
emit('confirm', {
|
||||
doubanId: doubanId.value,
|
||||
tmdbId: tmdbId.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon>mdi-text-recognition</VIcon>
|
||||
</template>
|
||||
<VCardTitle>{{ t('setting.cache.reidentifyDialog.title') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.itemTitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="props.recognizeSource === 'themoviedb'"
|
||||
v-model="tmdbId"
|
||||
:label="t('setting.cache.reidentifyDialog.tmdbId')"
|
||||
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-id-card"
|
||||
persistent-hint
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('setting.cache.reidentifyDialog.doubanId')"
|
||||
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-id-card"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VAlert type="info" variant="tonal" class="mt-4">
|
||||
{{ t('setting.cache.reidentifyDialog.autoHint') }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" :loading="props.loading" prepend-icon="mdi-check" @click="submitReidentify">
|
||||
{{ t('setting.cache.reidentifyDialog.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
663
src/components/dialog/CategoryEditDialog.vue
Normal file
@@ -0,0 +1,663 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import type { CategoryConfig } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 定义输入参数
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
}>()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const activeTab = ref('movie')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const generateId = () => {
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now()
|
||||
}
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
rule: any
|
||||
}
|
||||
|
||||
const movieList = ref<CategoryItem[]>([])
|
||||
const tvList = ref<CategoryItem[]>([])
|
||||
|
||||
// TMDB 类型映射
|
||||
const genreOptions = [
|
||||
{ title: '动作 (Action)', value: '28' },
|
||||
{ title: '冒险 (Adventure)', value: '12' },
|
||||
{ title: '动画 (Animation)', value: '16' },
|
||||
{ title: '喜剧 (Comedy)', value: '35' },
|
||||
{ title: '犯罪 (Crime)', value: '80' },
|
||||
{ title: '纪录 (Documentary)', value: '99' },
|
||||
{ title: '剧情 (Drama)', value: '18' },
|
||||
{ title: '家庭 (Family)', value: '10751' },
|
||||
{ title: '奇幻 (Fantasy)', value: '14' },
|
||||
{ title: '历史 (History)', value: '36' },
|
||||
{ title: '恐怖 (Horror)', value: '27' },
|
||||
{ title: '音乐 (Music)', value: '10402' },
|
||||
{ title: '悬疑 (Mystery)', value: '9648' },
|
||||
{ title: '爱情 (Romance)', value: '10749' },
|
||||
{ title: '科幻 (SF)', value: '878' },
|
||||
{ title: '电视电影', value: '10770' },
|
||||
{ title: '惊悚 (Thriller)', value: '53' },
|
||||
{ title: '战争 (War)', value: '10752' },
|
||||
{ title: '西部 (Western)', value: '37' },
|
||||
{ title: '儿童 (Kids)', value: '10762' },
|
||||
{ title: '新闻 (News)', value: '10763' },
|
||||
{ title: '真人秀 (Reality)', value: '10764' },
|
||||
{ title: '科幻/奇幻 (Sci-Fi)', value: '10765' },
|
||||
{ title: '肥皂剧 (Soap)', value: '10766' },
|
||||
{ title: '访谈 (Talk)', value: '10767' },
|
||||
{ title: '战争/政治', value: '10768' },
|
||||
]
|
||||
|
||||
// 语种选项 (original_language)
|
||||
const languageOptions = [
|
||||
{ title: '中文', value: 'zh' },
|
||||
{ title: '中文', value: 'cn' },
|
||||
{ title: '英语 (English)', value: 'en' },
|
||||
{ title: '日语 (Japanese)', value: 'ja' },
|
||||
{ title: '韩语 (Korean)', value: 'ko' },
|
||||
{ title: '法语 (French)', value: 'fr' },
|
||||
{ title: '德语 (German)', value: 'de' },
|
||||
{ title: '西班牙语 (Spanish)', value: 'es' },
|
||||
{ title: '意大利语 (Italian)', value: 'it' },
|
||||
{ title: '葡萄牙语 (Portuguese)', value: 'pt' },
|
||||
{ title: '俄语 (Russian)', value: 'ru' },
|
||||
{ title: '阿拉伯语', value: 'ar' },
|
||||
{ title: '泰语 (Thai)', value: 'th' },
|
||||
{ title: '越南语 (Vietnamese)', value: 'vi' },
|
||||
{ title: '印地语 (Hindi)', value: 'hi' },
|
||||
{ title: '土耳其语 (Turkish)', value: 'tr' },
|
||||
{ title: '荷兰语 (Dutch)', value: 'nl' },
|
||||
{ title: '波兰语 (Polish)', value: 'pl' },
|
||||
{ title: '瑞典语 (Swedish)', value: 'sv' },
|
||||
{ title: '丹麦语 (Danish)', value: 'da' },
|
||||
{ title: '挪威语 (Norwegian)', value: 'nb' },
|
||||
{ title: '芬兰语 (Finnish)', value: 'fi' },
|
||||
{ title: '希腊语 (Greek)', value: 'el' },
|
||||
{ title: '捷克语 (Czech)', value: 'cs' },
|
||||
{ title: '匈牙利语 (Hungarian)', value: 'hu' },
|
||||
{ title: '罗马尼亚语 (Romanian)', value: 'ro' },
|
||||
{ title: '乌克兰语 (Ukrainian)', value: 'uk' },
|
||||
{ title: '印度尼西亚语 (Indonesian)', value: 'id' },
|
||||
{ title: '马来语 (Malay)', value: 'ms' },
|
||||
{ title: '希伯来语 (Hebrew)', value: 'he' },
|
||||
]
|
||||
|
||||
// 国家/地区选项 (origin_country/production_countries)
|
||||
const countryOptions = [
|
||||
{ title: '中国大陆 (CN)', value: 'CN' },
|
||||
{ title: '中国香港 (HK)', value: 'HK' },
|
||||
{ title: '中国台湾 (TW)', value: 'TW' },
|
||||
{ title: '美国 (US)', value: 'US' },
|
||||
{ title: '英国 (GB)', value: 'GB' },
|
||||
{ title: '日本 (JP)', value: 'JP' },
|
||||
{ title: '韩国 (KR)', value: 'KR' },
|
||||
{ title: '法国 (FR)', value: 'FR' },
|
||||
{ title: '德国 (DE)', value: 'DE' },
|
||||
{ title: '意大利 (IT)', value: 'IT' },
|
||||
{ title: '西班牙 (ES)', value: 'ES' },
|
||||
{ title: '加拿大 (CA)', value: 'CA' },
|
||||
{ title: '澳大利亚 (AU)', value: 'AU' },
|
||||
{ title: '俄罗斯 (RU)', value: 'RU' },
|
||||
{ title: '印度 (IN)', value: 'IN' },
|
||||
{ title: '泰国 (TH)', value: 'TH' },
|
||||
{ title: '新加坡 (SG)', value: 'SG' },
|
||||
{ title: '马来西亚 (MY)', value: 'MY' },
|
||||
{ title: '越南 (VN)', value: 'VN' },
|
||||
{ title: '菲律宾 (PH)', value: 'PH' },
|
||||
{ title: '巴西 (BR)', value: 'BR' },
|
||||
{ title: '墨西哥 (MX)', value: 'MX' },
|
||||
{ title: '阿根廷 (AR)', value: 'AR' },
|
||||
{ title: '荷兰 (NL)', value: 'NL' },
|
||||
{ title: '比利时 (BE)', value: 'BE' },
|
||||
{ title: '瑞士 (CH)', value: 'CH' },
|
||||
{ title: '瑞典 (SE)', value: 'SE' },
|
||||
{ title: '挪威 (NO)', value: 'NO' },
|
||||
{ title: '丹麦 (DK)', value: 'DK' },
|
||||
{ title: '波兰 (PL)', value: 'PL' },
|
||||
{ title: '捷克 (CZ)', value: 'CZ' },
|
||||
{ title: '土耳其 (TR)', value: 'TR' },
|
||||
{ title: '以色列 (IL)', value: 'IL' },
|
||||
{ title: '埃及 (EG)', value: 'EG' },
|
||||
{ title: '南非 (ZA)', value: 'ZA' },
|
||||
{ title: '新西兰 (NZ)', value: 'NZ' },
|
||||
]
|
||||
|
||||
const fetchConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await api.get('media/category/config')
|
||||
if (res && res.data) {
|
||||
parseConfig(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseConfig = (data: CategoryConfig) => {
|
||||
// 将对象 { "Name": { ... } } 转换为数组 [ { id: uuid, name: "Name", rule: { ... } } ]
|
||||
movieList.value = []
|
||||
if (data.movie) {
|
||||
for (const [key, value] of Object.entries(data.movie)) {
|
||||
// 为了UI一致性处理 genre_ids 为数组或字符串,但 API 发送的是字符串
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// UI 多选预期为数组,检查输入。实际上 VAutocomplete 多选预期数组。我们需要将字符串分割为数组。
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理制片国家/地区
|
||||
if (rule.production_countries && typeof rule.production_countries === 'string') {
|
||||
// @ts-ignore
|
||||
rule.production_countries = rule.production_countries.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.production_countries = []
|
||||
}
|
||||
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tvList.value = []
|
||||
if (data.tv) {
|
||||
for (const [key, value] of Object.entries(data.tv)) {
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理发行国家/地区
|
||||
if (rule.origin_country && typeof rule.origin_country === 'string') {
|
||||
// @ts-ignore
|
||||
rule.origin_country = rule.origin_country.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.origin_country = []
|
||||
}
|
||||
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addMovieItem = () => {
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeMovieItem = (index: number) => {
|
||||
movieList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const addTvItem = () => {
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeTvItem = (index: number) => {
|
||||
tvList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// 将数组转换回对象
|
||||
const payload: CategoryConfig = {
|
||||
movie: {},
|
||||
tv: {},
|
||||
}
|
||||
|
||||
movieList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
// 将 genre_ids 数组转换回字符串
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 production_countries 数组转换回字符串
|
||||
if (Array.isArray(rule.production_countries) && rule.production_countries.length > 0) {
|
||||
rule.production_countries = rule.production_countries.join(',')
|
||||
} else {
|
||||
rule.production_countries = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.movie[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
tvList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 origin_country 数组转换回字符串
|
||||
if (Array.isArray(rule.origin_country) && rule.origin_country.length > 0) {
|
||||
rule.origin_country = rule.origin_country.join(',')
|
||||
} else {
|
||||
rule.origin_country = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.tv[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
const res: any = await api.post('media/category/config', payload)
|
||||
if (res && res.success) {
|
||||
toast.success(t('setting.category.saveSuccess'))
|
||||
emit('save')
|
||||
emit('close')
|
||||
} else {
|
||||
toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.saveFailed', { message: 'Network or Config Error' }))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="modelValue" max-width="1000" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem class="py-3">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-shape-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('setting.category.title') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ t('setting.category.subtitle') }}
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VTabs v-model="activeTab" show-arrows class="mb-4">
|
||||
<VTab value="movie">
|
||||
<VIcon icon="mdi-movie-outline" class="me-2" />
|
||||
{{ t('setting.category.movie') }}
|
||||
</VTab>
|
||||
<VTab value="tv">
|
||||
<VIcon icon="mdi-television" class="me-2" />
|
||||
{{ t('setting.category.tv') }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
|
||||
<VProgressCircular indeterminate color="primary" size="64" />
|
||||
</div>
|
||||
|
||||
<VWindow v-else v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="movie">
|
||||
<draggable v-model="movieList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeMovieItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.production_countries"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addMovieItem"
|
||||
>
|
||||
{{ t('setting.category.addMovie') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
|
||||
<VWindowItem value="tv">
|
||||
<draggable v-model="tvList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeTvItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.origin_country"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addTvItem"
|
||||
>
|
||||
{{ t('setting.category.addTv') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.category-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.add-category-btn {
|
||||
border-style: dashed !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-category-btn:hover {
|
||||
border-style: solid !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.disable-tab-transition > * {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
237
src/components/dialog/ContentToggleSettingsDialog.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
|
||||
type UnknownRecord = Record<string, any>
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
enabled: Record<string, boolean>
|
||||
elevated?: boolean
|
||||
hint: string
|
||||
items: UnknownRecord[]
|
||||
labelGetter?: (item: UnknownRecord) => string
|
||||
modelValue?: boolean
|
||||
selectAllText?: string
|
||||
selectNoneText?: string
|
||||
showBulkActions?: boolean
|
||||
switchLabel?: string
|
||||
title: string
|
||||
valueGetter?: (item: UnknownRecord) => string
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
elevated: false,
|
||||
labelGetter: undefined,
|
||||
modelValue: true,
|
||||
selectAllText: '',
|
||||
selectNoneText: '',
|
||||
showBulkActions: false,
|
||||
switchLabel: '',
|
||||
valueGetter: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'save', payload: { elevated: boolean; enabled: Record<string, boolean> }): void
|
||||
(event: 'update:elevated', value: boolean): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const localEnabled = ref<Record<string, boolean>>({})
|
||||
const localElevated = ref(props.elevated)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const elevatedValue = computed({
|
||||
get: () => localElevated.value,
|
||||
set: value => {
|
||||
localElevated.value = value
|
||||
emit('update:elevated', value)
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.enabled, props.elevated, props.items],
|
||||
() => {
|
||||
resetLocalSettings()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
// 重置弹窗内部设置副本,避免直接修改父级 props。
|
||||
function resetLocalSettings() {
|
||||
localEnabled.value = { ...props.enabled }
|
||||
localElevated.value = props.elevated
|
||||
}
|
||||
|
||||
// 获取设置项的稳定键值。
|
||||
function getItemValue(item: UnknownRecord) {
|
||||
return props.valueGetter?.(item) ?? String(item.id ?? item.title ?? item.name ?? '')
|
||||
}
|
||||
|
||||
// 获取设置项展示名称。
|
||||
function getItemLabel(item: UnknownRecord) {
|
||||
return props.labelGetter?.(item) ?? String(item.attrs?.title ?? item.name ?? item.title ?? '')
|
||||
}
|
||||
|
||||
// 切换单个设置项的启用状态。
|
||||
function toggleItem(item: UnknownRecord) {
|
||||
const key = getItemValue(item)
|
||||
localEnabled.value[key] = !localEnabled.value[key]
|
||||
}
|
||||
|
||||
// 批量设置所有项目启用状态。
|
||||
function setAllItems(value: boolean) {
|
||||
props.items.forEach(item => {
|
||||
localEnabled.value[getItemValue(item)] = value
|
||||
})
|
||||
}
|
||||
|
||||
// 提交通用内容开关设置。
|
||||
function submitSettings() {
|
||||
emit('save', {
|
||||
elevated: localElevated.value,
|
||||
enabled: { ...localEnabled.value },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="35rem" class="settings-dialog" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="settings-card">
|
||||
<VCardItem class="settings-card-header">
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-tune" size="small" class="me-2" />
|
||||
{{ props.title }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="settings-hint">{{ props.hint }}</p>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
v-for="item in props.items"
|
||||
:key="getItemValue(item)"
|
||||
class="setting-item"
|
||||
:class="{ 'enabled': localEnabled[getItemValue(item)] }"
|
||||
:style="{ '--item-color': props.colors[getItemValue(item)] }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<div class="setting-item-inner">
|
||||
<div class="setting-check">
|
||||
<VIcon
|
||||
:icon="localEnabled[getItemValue(item)] ? 'mdi-check-circle' : 'mdi-circle-outline'"
|
||||
:color="localEnabled[getItemValue(item)] ? 'primary' : undefined"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<span class="setting-label">{{ getItemLabel(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="props.switchLabel" class="mt-3">
|
||||
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
|
||||
{{ props.selectAllText }}
|
||||
</VBtn>
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(false)">
|
||||
{{ props.selectNoneText }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" class="px-5" @click="submitSettings">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-card-header {
|
||||
padding-block: 16px;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
cursor: pointer;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, var(--item-color, rgb(var(--v-theme-primary))) 0%, transparent 100%);
|
||||
content: '';
|
||||
inline-size: 3px;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.4);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.setting-item.enabled::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-check {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
151
src/components/dialog/CustomCssDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
css?: string
|
||||
editorTheme?: string
|
||||
modelValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
css: '',
|
||||
editorTheme: 'monokai',
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'save', css: string): void
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 正在编辑的 CSS 内容
|
||||
const editableCSS = ref(props.css)
|
||||
const editorOptions = {
|
||||
displayIndentGuides: true,
|
||||
fontSize: 14,
|
||||
highlightActiveLine: true,
|
||||
scrollPastEnd: 0.2,
|
||||
showPrintMargin: false,
|
||||
tabSize: 2,
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.css,
|
||||
value => {
|
||||
editableCSS.value = value
|
||||
},
|
||||
)
|
||||
|
||||
/** 提交当前 CSS 内容给调用方保存。 */
|
||||
function submitCustomCSS() {
|
||||
emit('save', editableCSS.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="custom-css-dialog">
|
||||
<VCardItem class="custom-css-header py-3">
|
||||
<template #prepend>
|
||||
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
|
||||
<VIcon icon="mdi-palette" size="22" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('theme.custom') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<div class="custom-css-editor-body">
|
||||
<VAceEditor
|
||||
v-model:value="editableCSS"
|
||||
lang="css"
|
||||
:theme="props.editorTheme"
|
||||
:options="editorOptions"
|
||||
wrap
|
||||
class="custom-css-editor"
|
||||
/>
|
||||
</div>
|
||||
<VCardActions class="custom-css-actions">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-css-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-css-header {
|
||||
flex: 0 0 auto;
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.custom-css-editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.custom-css-editor {
|
||||
overflow: hidden;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
block-size: min(62vh, 34rem);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.custom-css-actions {
|
||||
flex: 0 0 auto;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 0.875rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.custom-css-dialog {
|
||||
block-size: 100dvh;
|
||||
max-block-size: 100dvh;
|
||||
}
|
||||
|
||||
.custom-css-editor-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.custom-css-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
.custom-css-actions {
|
||||
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
209
src/components/dialog/CustomRuleInfoDialog.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<script lang="ts" setup>
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
import type { CustomRule } from '@/api/types'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 单条规则
|
||||
rule: {
|
||||
type: Object as PropType<CustomRule>,
|
||||
required: true,
|
||||
},
|
||||
// 所有规则
|
||||
rules: {
|
||||
type: Array as PropType<CustomRule[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const ruleInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 规则详情
|
||||
const ruleInfo = ref<CustomRule>({
|
||||
id: '',
|
||||
name: '',
|
||||
include: '',
|
||||
exclude: '',
|
||||
size_range: '',
|
||||
seeders: '',
|
||||
publish_time: '',
|
||||
})
|
||||
|
||||
/** 初始化规则编辑表单数据。 */
|
||||
function initializeRuleInfo() {
|
||||
ruleInfo.value = cloneDeep(props.rule)
|
||||
}
|
||||
|
||||
/** 保存规则编辑结果并通知父级刷新。 */
|
||||
function saveRuleInfo() {
|
||||
if (!ruleInfo.value.id || !ruleInfo.value.name) {
|
||||
if (!ruleInfo.value.id && !ruleInfo.value.name) {
|
||||
$toast.error(t('customRule.error.emptyIdName'))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
|
||||
$toast.error(t('customRule.error.idOccupied'))
|
||||
return
|
||||
}
|
||||
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
|
||||
$toast.error(t('customRule.error.nameOccupied'))
|
||||
return
|
||||
}
|
||||
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
|
||||
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
|
||||
return
|
||||
}
|
||||
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
|
||||
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
|
||||
return
|
||||
}
|
||||
ruleInfoDialog.value = false
|
||||
emit('change', ruleInfo.value, props.rule.id)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 规范化规则 ID 输入,只保留英文和数字。 */
|
||||
function validateRuleId() {
|
||||
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeRuleInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-if="ruleInfoDialog"
|
||||
v-model="ruleInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-filter-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="ruleInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.id"
|
||||
:label="t('customRule.field.ruleId')"
|
||||
:placeholder="t('customRule.placeholder.ruleId')"
|
||||
:hint="t('customRule.hint.ruleId')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
@input="validateRuleId"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.name"
|
||||
:label="t('customRule.field.ruleName')"
|
||||
:placeholder="t('customRule.placeholder.ruleName')"
|
||||
:hint="t('customRule.hint.ruleName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.include"
|
||||
:label="t('customRule.field.include')"
|
||||
:placeholder="t('customRule.placeholder.include')"
|
||||
:hint="t('customRule.hint.include')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-plus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.exclude"
|
||||
:label="t('customRule.field.exclude')"
|
||||
:placeholder="t('customRule.placeholder.exclude')"
|
||||
:hint="t('customRule.hint.exclude')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-minus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.size_range"
|
||||
:label="t('customRule.field.sizeRange')"
|
||||
:placeholder="t('customRule.placeholder.sizeRange')"
|
||||
:hint="t('customRule.hint.sizeRange')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-harddisk"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.seeders"
|
||||
:label="t('customRule.field.seeders')"
|
||||
:placeholder="t('customRule.placeholder.seeders')"
|
||||
:hint="t('customRule.hint.seeders')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.publish_time"
|
||||
:label="t('customRule.field.publishTime')"
|
||||
:placeholder="t('customRule.placeholder.publishTime')"
|
||||
:hint="t('customRule.hint.publishTime')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-calendar-clock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('customRule.action.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
161
src/components/dialog/DiscoverTabOrderDialog.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import type { DiscoverSource } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
modelValue?: boolean
|
||||
tabs: DiscoverSource[]
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'save', tabs: DiscoverSource[]): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const localTabs = ref<DiscoverSource[]>([])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.tabs,
|
||||
() => {
|
||||
resetLocalTabs()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
// 重置弹窗内部排序副本。
|
||||
function resetLocalTabs() {
|
||||
localTabs.value = props.tabs.map(item => ({ ...item }))
|
||||
}
|
||||
|
||||
// 保存当前拖拽后的发现标签顺序。
|
||||
function submitOrder() {
|
||||
emit('save', localTabs.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="35rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
|
||||
{{ t('discover.setTabOrder') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="settings-hint">{{ t('discover.dragToReorder') }}</p>
|
||||
<draggable
|
||||
v-model="localTabs"
|
||||
handle=".cursor-move"
|
||||
item-key="mediaid_prefix"
|
||||
tag="div"
|
||||
:component-data="{ 'class': 'settings-grid' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<VCard
|
||||
variant="text"
|
||||
class="setting-item enabled"
|
||||
:style="{ '--item-color': props.colors[element.mediaid_prefix] }"
|
||||
>
|
||||
<div class="setting-item-inner">
|
||||
<span class="setting-label">{{ element.name }}</span>
|
||||
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn @click="submitOrder">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-hint {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.3);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
cursor: grab;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background-color: var(--item-color, #4caf50);
|
||||
block-size: 100%;
|
||||
content: '';
|
||||
inline-size: 4px;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
529
src/components/dialog/DownloaderInfoDialog.vue
Normal file
@@ -0,0 +1,529 @@
|
||||
<script setup lang="ts">
|
||||
import type { DownloaderConf } from '@/api/types'
|
||||
import { storageAttributes } from '@/api/constants'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
downloader: {
|
||||
type: Object as PropType<DownloaderConf>,
|
||||
required: true,
|
||||
},
|
||||
downloaders: {
|
||||
type: Array as PropType<DownloaderConf[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 表单
|
||||
const downloaderForm = ref()
|
||||
|
||||
// 下载器详情弹窗
|
||||
const downloaderInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 下载器详情
|
||||
const downloaderInfo = ref<DownloaderConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
default: false,
|
||||
enabled: false,
|
||||
config: {},
|
||||
path_mapping: [],
|
||||
})
|
||||
|
||||
// 路径映射行定义
|
||||
interface PathMappingRow {
|
||||
id: string
|
||||
storage: string
|
||||
download: string
|
||||
}
|
||||
|
||||
// 路径映射行数据
|
||||
const pathMappingRows = ref<PathMappingRow[]>([])
|
||||
|
||||
// 路径前缀选项
|
||||
const prefixOptions = computed(() => {
|
||||
return storageAttributes.map(item => ({
|
||||
title: t(`storage.${item.type}`),
|
||||
value: item.type,
|
||||
}))
|
||||
})
|
||||
|
||||
/** 获取路径所属的存储类型。 */
|
||||
function getStorageType(path: string) {
|
||||
if (!path) return 'local'
|
||||
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
|
||||
return storage?.type || 'local'
|
||||
}
|
||||
|
||||
/** 将存储类型转换为路径前缀。 */
|
||||
function storage2Prefix(storage: string) {
|
||||
return storage === 'local' ? '' : storage + ':'
|
||||
}
|
||||
|
||||
/** 拆分存储路径的前缀和真实路径。 */
|
||||
function parseStoragePath(path: string): [prefix: string, suffix: string] {
|
||||
if (!path) return ['', '']
|
||||
const storage = getStorageType(path)
|
||||
const prefix = storage2Prefix(storage)
|
||||
return [prefix, path.slice(prefix.length)]
|
||||
}
|
||||
|
||||
/** 更新单行路径映射的存储前缀。 */
|
||||
function updateStoragePrefix(row: PathMappingRow, storage: string) {
|
||||
const [, currentSuffix] = parseStoragePath(row.storage)
|
||||
const prefix = storage2Prefix(storage)
|
||||
row.storage = prefix + currentSuffix
|
||||
}
|
||||
|
||||
/** 更新单行路径映射的存储路径主体。 */
|
||||
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
|
||||
const [currentPrefix] = parseStoragePath(row.storage)
|
||||
row.storage = currentPrefix + suffix
|
||||
}
|
||||
|
||||
const pathValidationRules = [
|
||||
(v: string) => !!v || t('downloader.pathMappingRequired'),
|
||||
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
|
||||
]
|
||||
|
||||
/** 生成路径映射行使用的临时唯一 ID。 */
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
/** 初始化下载器编辑表单数据。 */
|
||||
function initializeDownloaderInfo() {
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||||
id: generateId(),
|
||||
storage: item[0],
|
||||
download: item[1],
|
||||
}))
|
||||
}
|
||||
|
||||
/** 保存下载器编辑结果并通知父级刷新。 */
|
||||
async function saveDownloaderInfo() {
|
||||
const { valid } = (await downloaderForm.value?.validate()) || { valid: true }
|
||||
if (!valid) return
|
||||
|
||||
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
|
||||
|
||||
if (!downloaderInfo.value.name) {
|
||||
$toast.error(t('downloader.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
|
||||
$toast.error(t('downloader.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
if (downloaderInfo.value.default) {
|
||||
props.downloaders.forEach(item => {
|
||||
if (item.default && item !== props.downloader) {
|
||||
item.default = false
|
||||
$toast.info(t('downloader.defaultChanged'))
|
||||
}
|
||||
})
|
||||
}
|
||||
downloaderInfoDialog.value = false
|
||||
emit('change', downloaderInfo.value, props.downloader.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 新增一行路径映射。 */
|
||||
function addPathMapping() {
|
||||
pathMappingRows.value.push({
|
||||
id: generateId(),
|
||||
storage: '',
|
||||
download: '',
|
||||
})
|
||||
}
|
||||
|
||||
/** 移除指定位置的路径映射。 */
|
||||
function removePathMapping(index: number) {
|
||||
pathMappingRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeDownloaderInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-if="downloaderInfoDialog"
|
||||
v-model="downloaderInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('common.config') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm ref="downloaderForm">
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.default"
|
||||
:label="t('downloader.default')"
|
||||
:disabled="!downloaderInfo.enabled"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port"
|
||||
:hint="t('downloader.host')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.apikey"
|
||||
type="password"
|
||||
:label="t('downloader.apiKey')"
|
||||
:hint="t('downloader.qbittorrentApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.category"
|
||||
:label="t('downloader.category')"
|
||||
:hint="t('downloader.category')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.sequentail"
|
||||
:label="t('downloader.sequentail')"
|
||||
:hint="t('downloader.sequentail')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.force_resume"
|
||||
:label="t('downloader.force_resume')"
|
||||
:hint="t('downloader.force_resume')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.first_last_piece"
|
||||
:label="t('downloader.first_last_piece')"
|
||||
:hint="t('downloader.first_last_piece')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'transmission'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port"
|
||||
:hint="t('downloader.host')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port/RPC2"
|
||||
:hint="t('downloader.rtorrentHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.type"
|
||||
:label="t('downloader.type')"
|
||||
:hint="t('downloader.customTypeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:hint="t('downloader.nameRequired')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VDivider class="my-2">
|
||||
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
|
||||
</VDivider>
|
||||
|
||||
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
|
||||
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
|
||||
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
|
||||
</div>
|
||||
|
||||
<VCard
|
||||
v-for="(row, index) in pathMappingRows"
|
||||
:key="row.id"
|
||||
variant="outlined"
|
||||
class="path-mapping-card my-2"
|
||||
>
|
||||
<VCardText class="pa-3">
|
||||
<VRow align="center" no-gutters>
|
||||
<VCol cols="12" class="mb-2">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
|
||||
</div>
|
||||
<VRow no-gutters>
|
||||
<VCol cols="12" sm="4" class="path-storage-select-col pe-sm-2">
|
||||
<VSelect
|
||||
:model-value="getStorageType(row.storage)"
|
||||
:items="prefixOptions"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="v => updateStoragePrefix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="8">
|
||||
<VTextField
|
||||
:model-value="parseStoragePath(row.storage)[1]"
|
||||
:placeholder="'/path/to/storage'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
@update:model-value="v => updateStorageSuffix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="mb-1">
|
||||
<div class="d-flex align-center justify-center my-1">
|
||||
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
|
||||
</div>
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
|
||||
</div>
|
||||
<VRow no-gutters>
|
||||
<VCol cols="12" sm="4" class="d-none d-sm-block" />
|
||||
<VCol cols="12" sm="8">
|
||||
<VTextField
|
||||
v-model="row.download"
|
||||
:placeholder="'/path/to/download'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="d-flex justify-end pt-1">
|
||||
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
|
||||
<VIcon icon="mdi-delete-outline" />
|
||||
</IconBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
@click="addPathMapping"
|
||||
class="mt-1"
|
||||
size="small"
|
||||
>
|
||||
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.path-mapping-card {
|
||||
border-color: rgba(var(--v-border-color), 0.08) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 599.98px) {
|
||||
.path-storage-select-col {
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
src/components/dialog/FileNewFolderDialog.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'create'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:name', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const folderName = computed({
|
||||
get: () => props.name,
|
||||
set: value => emit('update:name', value),
|
||||
})
|
||||
|
||||
// 关闭新建目录弹窗并通知共享弹窗 Host 回收实例。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-folder-plus-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('file.newFolder') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<div class="flex-grow-1" />
|
||||
<VBtn :disabled="!folderName" prepend-icon="mdi-folder-plus" class="px-5 me-3" @click="emit('create')">
|
||||
{{ t('common.create') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
94
src/components/dialog/FileRenameDialog.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FileItem } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
item: Object as PropType<FileItem>,
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
recursive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'auto-name'): void
|
||||
(event: 'close'): void
|
||||
(event: 'rename'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:name', value: string): void
|
||||
(event: 'update:recursive', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const renameName = computed({
|
||||
get: () => props.name,
|
||||
set: value => emit('update:name', value),
|
||||
})
|
||||
|
||||
const includeSubfolders = computed({
|
||||
get: () => props.recursive,
|
||||
set: value => emit('update:recursive', value),
|
||||
})
|
||||
|
||||
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-pencil" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="renameName"
|
||||
:label="t('file.newName')"
|
||||
:loading="loading"
|
||||
prepend-inner-icon="mdi-format-text"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="item && item.type == 'dir'" cols="12">
|
||||
<VSwitch v-model="includeSubfolders" :label="t('file.includeSubfolders')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="success" prepend-icon="mdi-magic" class="px-5 me-3" @click="emit('auto-name')">
|
||||
{{ t('file.autoRecognizeName') }}
|
||||
</VBtn>
|
||||
<VBtn :disabled="!renameName" prepend-icon="mdi-check" class="px-5 me-3" @click="emit('rename')">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
314
src/components/dialog/FilterRuleGroupInfoDialog.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<script lang="ts" setup>
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
|
||||
// 规则组详情弹窗内才需要拖拽和导入代码,避免规则组卡片列表首屏带入重交互依赖。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 单个规则组
|
||||
group: {
|
||||
type: Object as PropType<FilterRuleGroup>,
|
||||
required: true,
|
||||
},
|
||||
// 所有规则组
|
||||
groups: {
|
||||
type: Array as PropType<FilterRuleGroup[]>,
|
||||
required: true,
|
||||
},
|
||||
// 媒体类型字典
|
||||
categories: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
},
|
||||
// 自定义规则列表
|
||||
custom_rules: Array as PropType<CustomRule[]>,
|
||||
})
|
||||
|
||||
// 规则卡片类型
|
||||
interface FilterCard {
|
||||
// 优先级
|
||||
pri: string
|
||||
// 已选规则
|
||||
rules: string[]
|
||||
}
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const groupInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 规则详情
|
||||
const groupInfo = ref<FilterRuleGroup>({
|
||||
name: props.group?.name ?? '',
|
||||
rule_string: props.group?.rule_string ?? '',
|
||||
media_type: props.group?.media_type ?? '',
|
||||
category: props.group?.category ?? '',
|
||||
})
|
||||
|
||||
// 媒体类型字典
|
||||
const mediaTypeItems = [
|
||||
{ title: t('common.all'), value: '' },
|
||||
{ title: t('mediaType.movie'), value: '电影' },
|
||||
{ title: t('mediaType.tv'), value: '电视剧' },
|
||||
]
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: t('common.all'), value: '' }]
|
||||
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
|
||||
return default_value
|
||||
}
|
||||
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
|
||||
})
|
||||
|
||||
// 规则组规则卡片列表
|
||||
const filterRuleCards = ref<FilterCard[]>([])
|
||||
|
||||
|
||||
/** 更新指定优先级规则卡片的选中规则。 */
|
||||
function updateFilterCardValue(pri: string, rules: string[]) {
|
||||
const card = filterRuleCards.value.find(card => card.pri === pri)
|
||||
if (card && Array.isArray(rules)) card.rules = rules
|
||||
}
|
||||
|
||||
/** 移除指定优先级规则卡片并重排优先级。 */
|
||||
function filterCardClose(pri: string) {
|
||||
filterRuleCards.value = filterRuleCards.value
|
||||
.filter(card => card.pri !== pri)
|
||||
.map((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
return card
|
||||
})
|
||||
}
|
||||
|
||||
/** 将当前规则组规则串复制到剪贴板。 */
|
||||
async function shareRules() {
|
||||
if (filterRuleCards.value.length === 0) return
|
||||
|
||||
const value = filterRuleCards.value
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
|
||||
try {
|
||||
let success
|
||||
success = copyToClipboard(value)
|
||||
if (await success) $toast.success(t('filterRule.shareSuccess'))
|
||||
else $toast.error(t('filterRule.shareFailed'))
|
||||
} catch (error) {
|
||||
$toast.error(t('filterRule.shareFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开共享导入弹窗并导入规则串。 */
|
||||
async function importRules(ruleType: string) {
|
||||
openSharedDialog(
|
||||
ImportCodeDialog,
|
||||
{
|
||||
title: t('filterRule.import'),
|
||||
dataType: ruleType,
|
||||
},
|
||||
{
|
||||
save: saveCodeString,
|
||||
},
|
||||
{ closeOn: ['close', 'save'] },
|
||||
)
|
||||
}
|
||||
|
||||
/** 保存导入的规则代码并覆盖当前规则卡片。 */
|
||||
function saveCodeString(type: string, code: any) {
|
||||
try {
|
||||
code = code.value
|
||||
if (type === 'priority') {
|
||||
// 解析值
|
||||
if (!code) return
|
||||
// 首尾增加空格
|
||||
if (!code.startsWith(' ')) code = ` ${code}`
|
||||
if (!code.endsWith(' ')) code = `${code} `
|
||||
const groups = code.split('>')
|
||||
filterRuleCards.value = groups.map((group: string, index: number) => ({
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&').filter(rule => rule),
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(t('filterRule.importFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 新增一个空的规则优先级卡片。 */
|
||||
function addFilterCard() {
|
||||
const pri = (filterRuleCards.value.length + 1).toString()
|
||||
const newCard: FilterCard = { pri, rules: [] }
|
||||
filterRuleCards.value.push(newCard)
|
||||
}
|
||||
|
||||
/** 根据列表的拖动顺序更新优先级。 */
|
||||
function dragOrderEnd() {
|
||||
filterRuleCards.value.forEach((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化规则组编辑数据。 */
|
||||
function opengroupInfoDialog() {
|
||||
groupInfo.value = cloneDeep(props.group)
|
||||
if (props.group.rule_string) {
|
||||
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&').filter(rule => rule),
|
||||
}))
|
||||
}
|
||||
groupInfoDialog.value = true
|
||||
}
|
||||
|
||||
/** 保存规则组编辑结果并通知父级刷新。 */
|
||||
function saveGroupInfo() {
|
||||
if (!groupInfo.value.name.trim()) {
|
||||
$toast.error(t('filterRule.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
|
||||
$toast.error(t('filterRule.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
|
||||
groupInfoDialog.value = false
|
||||
groupInfo.value.rule_string = filterRuleCards.value
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
emit('change', groupInfo.value, props.group.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 关闭规则组编辑弹窗。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
opengroupInfoDialog()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-if="groupInfoDialog"
|
||||
v-model="groupInfoDialog"
|
||||
scrollable
|
||||
max-width="80rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
|
||||
<VDialogCloseBtn v-model="groupInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardItem class="pt-1">
|
||||
<VRow class="mt-1">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="groupInfo.name"
|
||||
:label="t('filterRule.groupName')"
|
||||
:placeholder="t('filterRule.nameRequired')"
|
||||
:hint="t('filterRule.groupName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VAutocomplete
|
||||
v-model="groupInfo.media_type"
|
||||
:label="t('filterRule.mediaType')"
|
||||
:items="mediaTypeItems"
|
||||
:hint="t('filterRule.mediaType')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-movie-open"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VAutocomplete
|
||||
v-model="groupInfo.category"
|
||||
:items="getCategories"
|
||||
:label="t('filterRule.category')"
|
||||
:hint="t('filterRule.category')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-folder-open"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<Draggable
|
||||
v-model="filterRuleCards"
|
||||
handle=".cursor-move"
|
||||
item-key="pri"
|
||||
tag="div"
|
||||
@end="dragOrderEnd"
|
||||
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<FilterRuleCard
|
||||
:pri="element.pri"
|
||||
:maxpri="filterRuleCards.length.toString()"
|
||||
:rules="element.rules"
|
||||
:custom_rules="props.custom_rules"
|
||||
@changed="updateFilterCardValue"
|
||||
@close="filterCardClose(element.pri)"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VBtn color="success" @click="importRules('priority')">
|
||||
<VIcon icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn color="info" @click="shareRules">
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
82
src/components/dialog/LlmProviderAuthDialog.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import type { LlmProviderAuthSession } from '@/composables/useLlmProviderDirectory'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
authSession?: LlmProviderAuthSession | null
|
||||
modelValue?: boolean
|
||||
polling?: boolean
|
||||
popupBlocked?: boolean
|
||||
}>(),
|
||||
{
|
||||
authSession: null,
|
||||
modelValue: true,
|
||||
polling: false,
|
||||
popupBlocked: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'openAuthPage'): void
|
||||
(event: 'poll'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 关闭授权弹窗并通知调用方停止轮询。
|
||||
function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="560">
|
||||
<VCard>
|
||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||
<VCardText class="d-flex flex-column ga-4">
|
||||
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
|
||||
{{ props.authSession.instructions }}
|
||||
</VAlert>
|
||||
|
||||
<VAlert v-if="props.popupBlocked" type="warning" variant="tonal">
|
||||
{{ t('setting.system.llmProviderPopupBlocked') }}
|
||||
</VAlert>
|
||||
|
||||
<div v-if="props.authSession?.user_code">
|
||||
<div class="text-caption text-medium-emphasis mb-1">{{ t('setting.system.llmProviderDeviceCode') }}</div>
|
||||
<div class="text-h5 font-weight-bold">{{ props.authSession.user_code }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.authSession?.message" class="text-body-2">
|
||||
{{ props.authSession.message }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="emit('openAuthPage')">
|
||||
{{ t('setting.system.llmProviderOpenAuthPage') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" prepend-icon="mdi-refresh" :loading="props.polling" @click="emit('poll')">
|
||||
{{ t('setting.system.llmProviderCheckAuthStatus') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="closeDialog">
|
||||
{{ t('common.close') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
102
src/components/dialog/LoginMfaDialog.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
errorMessage?: string
|
||||
modelValue?: boolean
|
||||
otpPassword?: string
|
||||
passkeyLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
errorMessage: '',
|
||||
modelValue: true,
|
||||
otpPassword: '',
|
||||
passkeyLoading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'otp'): void
|
||||
(event: 'passkey'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:otpPassword', value: string): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const otpValue = computed({
|
||||
get: () => props.otpPassword,
|
||||
set: value => emit('update:otpPassword', value),
|
||||
})
|
||||
|
||||
// 提交 OTP 登录请求。
|
||||
function submitOtp() {
|
||||
emit('otp')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="400" persistent>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
|
||||
<VCardText class="pt-0">
|
||||
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
|
||||
|
||||
<VCard variant="tonal" class="mb-3">
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="submitOtp">
|
||||
<VTextField
|
||||
v-model="otpValue"
|
||||
:label="t('login.otpCode')"
|
||||
:placeholder="t('login.otpPlaceholder')"
|
||||
type="text"
|
||||
name="otp"
|
||||
id="otp"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
class="mb-2"
|
||||
/>
|
||||
<VBtn block type="submit" color="primary" :disabled="!otpValue">
|
||||
{{ t('login.loginWithOtp') }}
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="tonal">
|
||||
<VCardText>
|
||||
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
|
||||
<VBtn
|
||||
block
|
||||
variant="tonal"
|
||||
color="success"
|
||||
class="passkey-btn"
|
||||
prepend-icon="material-symbols:passkey"
|
||||
:loading="props.passkeyLoading"
|
||||
@click="emit('passkey')"
|
||||
>
|
||||
{{ t('login.verifyWithPasskey') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VAlert v-if="props.errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ props.errorMessage }}
|
||||
</VAlert>
|
||||
|
||||
<VBtn block variant="text" class="mt-4" @click="visible = false">{{ t('common.cancel') }}</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
601
src/components/dialog/MediaServerInfoDialog.vue
Normal file
@@ -0,0 +1,601 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
mediaserver: {
|
||||
type: Object as PropType<MediaServerConf>,
|
||||
required: true,
|
||||
},
|
||||
mediaservers: {
|
||||
type: Array as PropType<MediaServerConf[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'done', 'change'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 媒体服务器详情弹窗
|
||||
const mediaServerInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 媒体服务器详情
|
||||
const mediaServerInfo = ref<MediaServerConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 同步媒体库选项
|
||||
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
|
||||
{
|
||||
title: t('common.all'),
|
||||
value: 'all',
|
||||
},
|
||||
])
|
||||
|
||||
const ugreenScanModeOptions = computed(() => [
|
||||
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
|
||||
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
|
||||
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
|
||||
])
|
||||
|
||||
/** 初始化媒体服务器编辑表单数据。 */
|
||||
function initializeMediaServerInfo() {
|
||||
loadLibrary(props.mediaserver.name)
|
||||
mediaServerInfo.value = cloneDeep(props.mediaserver)
|
||||
if (mediaServerInfo.value.type === 'ugreen') {
|
||||
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
|
||||
if (!mediaServerInfo.value.config.scan_mode) {
|
||||
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
|
||||
}
|
||||
if (mediaServerInfo.value.config.verify_ssl === undefined) {
|
||||
mediaServerInfo.value.config.verify_ssl = true
|
||||
}
|
||||
}
|
||||
if (!props.mediaserver.sync_libraries) {
|
||||
mediaServerInfo.value.sync_libraries = ['all']
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存媒体服务器编辑结果并通知父级刷新。 */
|
||||
function saveMediaServerInfo() {
|
||||
if (!mediaServerInfo.value.name) {
|
||||
$toast.error(t('common.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
|
||||
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
|
||||
return
|
||||
}
|
||||
mediaServerInfoDialog.value = false
|
||||
emit('change', mediaServerInfo.value, props.mediaserver.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 调用 API 查询指定媒体服务器的媒体库列表。 */
|
||||
async function loadLibrary(server: string) {
|
||||
try {
|
||||
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
|
||||
if (result && result.length > 0) {
|
||||
librariesOptions.value = result.map(item => ({
|
||||
title: item.name,
|
||||
value: item.id?.toString(),
|
||||
}))
|
||||
} else {
|
||||
librariesOptions.value = []
|
||||
}
|
||||
librariesOptions.value.unshift({
|
||||
title: t('common.all'),
|
||||
value: 'all',
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeMediaServerInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-if="mediaServerInfoDialog"
|
||||
v-model="mediaServerInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('common.config') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'emby'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
:label="t('mediaserver.apiKey')"
|
||||
:hint="t('mediaserver.embyApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'zspace'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
:label="t('mediaserver.apiKey')"
|
||||
:hint="t('mediaserver.jellyfinApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'trimemedia'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="mediaServerInfo.config.scan_mode"
|
||||
:label="t('mediaserver.scanMode')"
|
||||
:items="ugreenScanModeOptions"
|
||||
:hint="t('mediaserver.scanModeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-radar"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaServerInfo.config.verify_ssl"
|
||||
:label="t('mediaserver.verifySsl')"
|
||||
:hint="t('mediaserver.verifySslHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
inset
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'plex'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.token"
|
||||
:label="t('mediaserver.plexToken')"
|
||||
:hint="t('mediaserver.plexTokenHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.type"
|
||||
:label="t('mediaserver.type')"
|
||||
:hint="t('mediaserver.customTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
:label="t('common.name')"
|
||||
:hint="t('mediaserver.nameRequired')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
1131
src/components/dialog/NotificationChannelInfoDialog.vue
Normal file
157
src/components/dialog/NotificationTemplateEditorDialog.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content?: string
|
||||
editorTheme?: string
|
||||
modelValue?: boolean
|
||||
subtitle?: string
|
||||
templateType?: string
|
||||
}>(),
|
||||
{
|
||||
content: '{}',
|
||||
editorTheme: 'monokai',
|
||||
modelValue: true,
|
||||
subtitle: '',
|
||||
templateType: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'save', value: string): void
|
||||
(event: 'update:content', value: string): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const editableContent = ref(props.content)
|
||||
const editorOptions = {
|
||||
displayIndentGuides: true,
|
||||
fontSize: 14,
|
||||
highlightActiveLine: true,
|
||||
scrollPastEnd: 0.2,
|
||||
showPrintMargin: false,
|
||||
tabSize: 2,
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
value => {
|
||||
editableContent.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(editableContent, value => {
|
||||
emit('update:content', value)
|
||||
})
|
||||
|
||||
// 提交通知模板内容,由调用方负责保存到后端。
|
||||
function submitTemplate() {
|
||||
emit('save', editableContent.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="notification-template-editor-dialog">
|
||||
<VCardItem class="template-editor-header py-3">
|
||||
<template #prepend>
|
||||
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
|
||||
<VIcon icon="mdi-code-json" size="22" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('setting.notification.templateConfigTitle') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ props.subtitle }}
|
||||
</VCardSubtitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<div class="template-editor-body">
|
||||
<VAceEditor
|
||||
:key="`${props.templateType}-jinja2-json`"
|
||||
v-model:value="editableContent"
|
||||
lang="jinja2_json"
|
||||
:theme="props.editorTheme"
|
||||
:options="editorOptions"
|
||||
wrap
|
||||
class="template-ace-editor"
|
||||
/>
|
||||
</div>
|
||||
<VCardActions class="template-editor-actions">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-template-editor-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-editor-header {
|
||||
flex: 0 0 auto;
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.template-editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.template-ace-editor {
|
||||
overflow: hidden;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
block-size: min(62vh, 34rem);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.template-editor-actions {
|
||||
flex: 0 0 auto;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 0.875rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.notification-template-editor-dialog {
|
||||
block-size: 100dvh;
|
||||
max-block-size: 100dvh;
|
||||
}
|
||||
|
||||
.template-editor-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.template-ace-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
.template-editor-actions {
|
||||
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
281
src/components/dialog/OTPAuthDialog.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import QRCode from 'qrcode'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
passkeyList?: PassKey[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
passkeyList: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// otp uri
|
||||
const otpUri = ref('')
|
||||
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
const otpPassword = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
|
||||
// OTP 初始化加载状态
|
||||
const otpLoading = ref(false)
|
||||
|
||||
// OTP 初始化失败信息
|
||||
const otpGenerateError = ref('')
|
||||
|
||||
// 二维码图片 base64
|
||||
const qrCodeImage = ref('')
|
||||
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
|
||||
// 清空当前 OTP 设置流程的临时数据。
|
||||
function resetOtpSetupState() {
|
||||
qrCodeImage.value = ''
|
||||
qrCode.value = ''
|
||||
otpUri.value = ''
|
||||
secret.value = ''
|
||||
otpGenerateError.value = ''
|
||||
}
|
||||
|
||||
// 标记 OTP 初始化失败,并向用户显示明确错误。
|
||||
function setOtpGenerateError(message?: string) {
|
||||
const errorMessage = message || t('common.error')
|
||||
otpGenerateError.value = t('profile.otpGenerateFailed', { message: errorMessage })
|
||||
$toast.error(otpGenerateError.value)
|
||||
}
|
||||
|
||||
// 为当前用户获取 OTP URI 并生成二维码图片。
|
||||
async function getOtpUri() {
|
||||
resetOtpSetupState()
|
||||
// 如果已经启用OTP,只打开对话框,不生成新的二维码
|
||||
if (props.isOtp) {
|
||||
return
|
||||
}
|
||||
|
||||
// 未启用OTP,生成新的二维码
|
||||
otpLoading.value = true
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
|
||||
uri: string
|
||||
secret: string
|
||||
}>
|
||||
const uri = result.data?.uri?.trim()
|
||||
const otpSecret = result.data?.secret?.trim()
|
||||
|
||||
if (result.success && uri) {
|
||||
otpUri.value = uri
|
||||
secret.value = otpSecret || ''
|
||||
qrCode.value = uri
|
||||
// 生成二维码图片
|
||||
qrCodeImage.value = await QRCode.toDataURL(uri, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
})
|
||||
} else {
|
||||
setOtpGenerateError(result.message || 'empty otp uri')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setOtpGenerateError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
otpLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 启用Otp
|
||||
async function judgeOtpPassword() {
|
||||
if (!otpPassword.value) {
|
||||
$toast.error(t('profile.otpCodeRequired'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/verify', {
|
||||
uri: otpUri.value,
|
||||
otpPassword: otpPassword.value,
|
||||
})) as ApiResponse
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.otpEnableSuccess'))
|
||||
show.value = false
|
||||
emit('update:isOtp', true)
|
||||
} else {
|
||||
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpEnableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
function disableOtp() {
|
||||
// 如果已绑定PassKey,不允许关闭OTP
|
||||
if (props.passkeyList && props.passkeyList.length > 0 && !allowPasskeyWithoutOtp.value) {
|
||||
$toast.error(t('profile.disableOtpWithPasskeyError'))
|
||||
return
|
||||
}
|
||||
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.disableTwoFactor'),
|
||||
text: t('profile.confirmToDisableOtp'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/disable', {
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
emit('update:isOtp', false)
|
||||
$toast.success(t('profile.otpDisableSuccess'))
|
||||
show.value = false
|
||||
} else {
|
||||
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpDisableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,自动获取 URI
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (val) {
|
||||
getOtpUri()
|
||||
otpPassword.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
resetOtpSetupState()
|
||||
otpLoading.value = false
|
||||
otpPassword.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-cellphone-key" class="me-2" />
|
||||
{{ props.isOtp && !qrCode ? t('profile.authenticatorManagement') : t('profile.setupAuthenticator') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="show = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.authenticatorAppDescription') }}
|
||||
</p>
|
||||
<!-- 如果已启用OTP,显示清除界面 -->
|
||||
<template v-if="props.isOtp && !qrCode">
|
||||
<VAlert type="success" variant="tonal" class="mb-4">
|
||||
{{ t('profile.authenticatorEnabled') }}
|
||||
</VAlert>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.clearAuthenticatorTip') }}
|
||||
</p>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="error" @click="disableOtp">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
{{ t('profile.clearAuthenticator') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 设置新的OTP -->
|
||||
<template v-else>
|
||||
<div
|
||||
class="my-6 rounded text-center p-3 border d-flex align-center justify-center"
|
||||
style="width: 226px; height: 226px; margin: 0 auto"
|
||||
>
|
||||
<img
|
||||
v-if="qrCodeImage"
|
||||
class="mx-auto d-block otp-qrcode-image"
|
||||
:src="qrCodeImage"
|
||||
:alt="t('profile.setupAuthenticator')"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
<VProgressCircular v-else-if="otpLoading" indeterminate color="primary" />
|
||||
<div v-else class="w-100">
|
||||
<VAlert type="error" variant="tonal" density="compact" class="mb-3">
|
||||
{{ otpGenerateError || t('profile.otpGenerateFailed', { message: t('common.error') }) }}
|
||||
</VAlert>
|
||||
<VBtn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="getOtpUri">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
<VAlert v-if="secret" :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
<VForm @submit.prevent="judgeOtpPassword">
|
||||
<VTextField
|
||||
v-model="otpPassword"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
:label="t('profile.enterVerificationCode')"
|
||||
class="mb-8"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn type="submit" :disabled="!otpUri || otpLoading">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</template>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.otp-qrcode-image {
|
||||
inline-size: 200px;
|
||||
block-size: 200px;
|
||||
}
|
||||
</style>
|
||||
175
src/components/dialog/OfflineStatusDialog.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
type?: 'offline' | 'online'
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
type: 'offline',
|
||||
},
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
// 重试连接
|
||||
const retrying = ref(false)
|
||||
|
||||
/** 尝试请求静态资源来触发网络状态重新检测。 */
|
||||
async function handleRetry() {
|
||||
if (retrying.value) return
|
||||
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
await fetch('/favicon.ico?' + new Date().getTime(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
retrying.value = false
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 状态文本
|
||||
const statusText = computed(() => {
|
||||
if (props.type === 'online') {
|
||||
return t('app.onlineMessage')
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
|
||||
// 图标
|
||||
const statusIcon = computed(() => {
|
||||
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
|
||||
})
|
||||
|
||||
// 颜色主题
|
||||
const colorTheme = computed(() => {
|
||||
return props.type === 'online' ? 'success' : 'error'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
|
||||
<VCard class="offline-dialog">
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VCardText class="text-center">
|
||||
<h2 class="offline-title mb-4">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h2>
|
||||
|
||||
<p class="offline-message mb-6">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<div class="action-section mb-6">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="default"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.offline-dialog {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.status-icon-wrapper {
|
||||
padding-block: 24px 0;
|
||||
padding-inline: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
animation: icon-pulse 3s ease-in-out infinite;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status-icon-bg::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: icon-glow 2s ease-in-out infinite alternate;
|
||||
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
|
||||
content: '';
|
||||
inset: -3px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-glow {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
322
src/components/dialog/PasskeyDialog.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<script lang="ts" setup>
|
||||
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
}
|
||||
|
||||
// WebAuthn 相关接口定义
|
||||
interface PublicKeyCredentialDescriptorJSON {
|
||||
id: string
|
||||
type: 'public-key'
|
||||
transports?: AuthenticatorTransport[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPassword'])
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// PassKey列表
|
||||
const passkeyList = ref<PassKey[]>([])
|
||||
|
||||
// PassKey注册loading
|
||||
const passkeyRegistering = ref(false)
|
||||
|
||||
// PassKey名称
|
||||
const passkeyName = ref('')
|
||||
|
||||
// PassKey challenge
|
||||
const passkeyChallenge = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
const canRegisterPasskey = computed(() => props.isOtp || allowPasskeyWithoutOtp.value)
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString(locale.value)
|
||||
}
|
||||
|
||||
// 获取PassKey列表
|
||||
async function fetchPassKeyList() {
|
||||
try {
|
||||
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
|
||||
if (result.success) {
|
||||
passkeyList.value = result.data || []
|
||||
emit('update:passkeyList', passkeyList.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册PassKey
|
||||
async function registerPassKey() {
|
||||
if (!passkeyName.value) {
|
||||
$toast.error(t('profile.passkeyNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查浏览器环境
|
||||
if (!window.PublicKeyCredential) {
|
||||
if (!window.isSecureContext) {
|
||||
$toast.error(t('login.passkeySecureContextRequired'))
|
||||
} else {
|
||||
$toast.error(t('login.passkeyNotSupported'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passkeyRegistering.value = true
|
||||
try {
|
||||
// 1. 开始注册
|
||||
const startResult = (await api.post('mfa/passkey/register/start', {
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse<{ options: string; challenge: string }>
|
||||
|
||||
if (!startResult.success) {
|
||||
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const { options, challenge } = startResult.data
|
||||
const publicKeyOptions = JSON.parse(options)
|
||||
passkeyChallenge.value = challenge
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
const credential = (await navigator.credentials.create({
|
||||
publicKey: {
|
||||
...publicKeyOptions,
|
||||
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
|
||||
user: {
|
||||
...publicKeyOptions.user,
|
||||
id: base64UrlToUint8Array(publicKeyOptions.user.id),
|
||||
},
|
||||
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: PublicKeyCredentialDescriptorJSON) => ({
|
||||
...cred,
|
||||
id: base64UrlToUint8Array(cred.id),
|
||||
})),
|
||||
},
|
||||
})) as PublicKeyCredential
|
||||
|
||||
if (!credential) {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 转换credential为可传输格式
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
const credentialJSON = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
transports: typeof response.getTransports === 'function' ? response.getTransports() : [],
|
||||
},
|
||||
}
|
||||
|
||||
// 4. 完成注册
|
||||
const finishResult = (await api.post('mfa/passkey/register/finish', {
|
||||
credential: credentialJSON,
|
||||
challenge: passkeyChallenge.value,
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse
|
||||
|
||||
if (finishResult.success) {
|
||||
$toast.success(t('profile.passkeyRegisterSuccess'))
|
||||
passkeyName.value = ''
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('PassKey注册失败:', error)
|
||||
if (error.name === 'NotAllowedError') {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
$toast.error(t('login.passkeyNotSupported'))
|
||||
} else if (error.message?.includes('start failed')) {
|
||||
$toast.error(t('login.passkeyLoginStartFailed'))
|
||||
} else if (error.response) {
|
||||
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
|
||||
} else {
|
||||
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} finally {
|
||||
passkeyRegistering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除PassKey
|
||||
async function deletePassKey(passkeyId: number) {
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.deletePasskey'),
|
||||
text: t('profile.confirmToDeletePasskey'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/passkey/delete', {
|
||||
passkey_id: passkeyId,
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.passkeyDeleteSuccess'))
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,自动加载列表
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (val) {
|
||||
fetchPassKeyList()
|
||||
passkeyName.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
passkeyName.value = ''
|
||||
passkeyChallenge.value = ''
|
||||
passkeyList.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="material-symbols:passkey" class="me-2" />
|
||||
{{ t('profile.passkeyManagement') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="show = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.passkeyAppDescription') }}
|
||||
</p>
|
||||
|
||||
<!-- 安全警告 -->
|
||||
<VAlert type="warning" variant="tonal" class="mb-6" icon="mdi-alert">
|
||||
<i18n-t keypath="profile.passkeyDomainWarning" tag="span">
|
||||
<template #domain>
|
||||
<b>{{ t('profile.accessDomain') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 注册新通行密钥 -->
|
||||
<VCard v-if="canRegisterPasskey" variant="tonal" class="mb-6">
|
||||
<VCardText>
|
||||
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
|
||||
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
|
||||
<VForm @submit.prevent="registerPassKey">
|
||||
<VTextField
|
||||
v-model="passkeyName"
|
||||
:label="t('profile.passkeyName')"
|
||||
:placeholder="t('profile.passkeyNamePlaceholder')"
|
||||
class="mb-4"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-form-textbox"
|
||||
/>
|
||||
<VBtn color="primary" type="submit" :loading="passkeyRegistering" prepend-icon="mdi-plus">
|
||||
{{ t('profile.registerPasskey') }}
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- 未启用 OTP 提示 -->
|
||||
<VAlert v-else type="error" variant="tonal" class="mb-6" icon="mdi-shield-lock">
|
||||
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
|
||||
<template #otp>
|
||||
<b>{{ t('profile.otpAuthenticator') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 已注册的通行密钥列表 -->
|
||||
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
|
||||
<div
|
||||
v-for="passkey in passkeyList"
|
||||
:key="passkey.id"
|
||||
class="py-4 d-flex align-center justify-space-between border-b last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-bold mb-1">{{ passkey.name }}</div>
|
||||
<div class="text-caption text-disabled d-flex flex-wrap gap-x-3">
|
||||
<span>{{ t('profile.createdAt') }} {{ formatDate(passkey.created_at) }}</span>
|
||||
<span v-if="passkey.last_used_at">
|
||||
{{ t('profile.lastUsedAt') }} {{ formatDateDifference(passkey.last_used_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<VBtn
|
||||
variant="flat"
|
||||
color="error"
|
||||
size="small"
|
||||
class="rounded delete-btn"
|
||||
@click="deletePassKey(passkey.id)"
|
||||
>
|
||||
<VIcon icon="mdi-trash-can-outline" size="20" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VAlert v-else type="info" variant="tonal" class="mt-6">
|
||||
{{ t('profile.noPasskeys') }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-end px-6 pb-4">
|
||||
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-btn.delete-btn {
|
||||
min-width: 45px;
|
||||
padding: 0;
|
||||
background-color: rgba(var(--v-theme-error), 0.1);
|
||||
color: rgb(var(--v-theme-error));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.v-btn.delete-btn:hover {
|
||||
background-color: rgba(var(--v-theme-error), 0.2);
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
</style>
|
||||
172
src/components/dialog/PluginCloneDialog.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'clone'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 插件分身表单
|
||||
const cloneForm = ref({
|
||||
suffix: '',
|
||||
name: '',
|
||||
description: '',
|
||||
version: '',
|
||||
icon: '',
|
||||
})
|
||||
|
||||
/** 初始化插件分身表单。 */
|
||||
function initializeCloneForm() {
|
||||
cloneForm.value = {
|
||||
suffix: '',
|
||||
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
|
||||
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
|
||||
version: props.plugin?.plugin_version || '1.0',
|
||||
icon: props.plugin?.plugin_icon || '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交插件分身表单。 */
|
||||
function submitClone() {
|
||||
emit('clone', { ...cloneForm.value })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeCloneForm()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="600" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-copy" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.suffix"
|
||||
:label="t('plugin.suffix') + ' *'"
|
||||
:placeholder="t('plugin.suffixPlaceholder')"
|
||||
:hint="t('plugin.suffixHint')"
|
||||
persistent-hint
|
||||
:rules="[
|
||||
v => !!v || t('plugin.suffixRequired'),
|
||||
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
|
||||
v => v.length <= 20 || t('plugin.suffixLengthError'),
|
||||
]"
|
||||
required
|
||||
prepend-inner-icon="mdi-tag"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.name"
|
||||
:label="t('plugin.cloneName')"
|
||||
:placeholder="t('plugin.cloneNamePlaceholder')"
|
||||
:hint="t('plugin.cloneNameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-rename-box"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="cloneForm.description"
|
||||
:label="t('plugin.cloneDescriptionLabel')"
|
||||
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
|
||||
:hint="t('plugin.cloneDescriptionHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-text"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.version"
|
||||
:label="t('plugin.cloneVersion')"
|
||||
:placeholder="t('plugin.cloneVersionPlaceholder')"
|
||||
:hint="t('plugin.cloneVersionHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-numeric"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.icon"
|
||||
:label="t('plugin.cloneIcon')"
|
||||
:placeholder="t('plugin.cloneIconPlaceholder')"
|
||||
:hint="t('plugin.cloneIconHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-image"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
|
||||
<div class="text-body-2">
|
||||
<strong>{{ t('common.notice') }}</strong
|
||||
>:{{ t('plugin.cloneNotice') }}
|
||||
</div>
|
||||
</VAlert>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="submitClone"
|
||||
prepend-icon="mdi-content-copy"
|
||||
class="px-5"
|
||||
:disabled="!cloneForm.suffix.trim()"
|
||||
:loading="props.loading"
|
||||
>
|
||||
{{ t('plugin.createClone') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
65
src/components/dialog/PluginFolderCreateDialog.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'create'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:name', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const folderName = computed({
|
||||
get: () => props.name,
|
||||
set: value => emit('update:name', value),
|
||||
})
|
||||
|
||||
// 关闭插件文件夹新建弹窗。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="400">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('plugin.newFolder') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField
|
||||
v-model="folderName"
|
||||
:label="t('plugin.folderName')"
|
||||
variant="outlined"
|
||||
@keyup.enter="emit('create')"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
|
||||
{{ t('plugin.create') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
66
src/components/dialog/PluginFolderRenameDialog.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
folderName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'rename'])
|
||||
|
||||
// 新名称
|
||||
const newFolderName = ref(props.folderName)
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 提交文件夹重命名。 */
|
||||
function confirmRename() {
|
||||
emit('rename', newFolderName.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="400">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-pencil" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField
|
||||
v-model="newFolderName"
|
||||
:label="t('folder.folderName')"
|
||||
variant="outlined"
|
||||
autofocus
|
||||
@keyup.enter="confirmRename"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
210
src/components/dialog/PluginFolderSettingsDialog.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
interface FolderConfig {
|
||||
plugins?: string[]
|
||||
order?: number
|
||||
background?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
gradient?: string
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 响应式显示
|
||||
const display = useDisplay()
|
||||
|
||||
// 默认颜色
|
||||
const defaultColor = '#2196F3'
|
||||
// 默认图标
|
||||
const defaultIcon = 'mdi-folder'
|
||||
|
||||
// 预设图标选项
|
||||
const iconOptions = [
|
||||
'mdi-folder',
|
||||
'mdi-folder-star',
|
||||
'mdi-folder-heart',
|
||||
'mdi-folder-cog',
|
||||
'mdi-folder-music',
|
||||
'mdi-folder-image',
|
||||
'mdi-folder-video',
|
||||
'mdi-folder-download',
|
||||
'mdi-folder-network',
|
||||
'mdi-folder-special',
|
||||
]
|
||||
|
||||
// 预设颜色选项
|
||||
const colorOptions = [
|
||||
'#2196F3',
|
||||
'#4CAF50',
|
||||
'#FF9800',
|
||||
'#9C27B0',
|
||||
'#F44336',
|
||||
'#607D8B',
|
||||
'#795548',
|
||||
'#E91E63',
|
||||
]
|
||||
|
||||
// 预设渐变选项
|
||||
const gradientOptions = [
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
|
||||
]
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
folderConfig: {
|
||||
type: Object as PropType<FolderConfig>,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'save'])
|
||||
|
||||
// 文件夹设置
|
||||
const folderSettings = ref<FolderConfig>({
|
||||
background: '',
|
||||
icon: defaultIcon,
|
||||
color: defaultColor,
|
||||
gradient: gradientOptions[0],
|
||||
showIcon: true,
|
||||
})
|
||||
|
||||
// 设置对话框
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 初始化文件夹外观设置。 */
|
||||
function initializeSettings() {
|
||||
folderSettings.value = {
|
||||
background: props.folderConfig?.background || '',
|
||||
icon: props.folderConfig?.icon || defaultIcon,
|
||||
color: props.folderConfig?.color || defaultColor,
|
||||
gradient: props.folderConfig?.gradient || gradientOptions[0],
|
||||
showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存文件夹外观设置。 */
|
||||
function saveSettings() {
|
||||
emit('save', {
|
||||
...props.folderConfig,
|
||||
...folderSettings.value,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="600" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-palette" class="mr-2" />
|
||||
{{ t('folder.folderAppearanceSettings') }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSwitch v-model="folderSettings.showIcon" :label="t('folder.showFolderIcon')" color="primary" hide-details />
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.icon') }}</VCardSubtitle>
|
||||
<div class="icon-grid">
|
||||
<VBtn
|
||||
v-for="icon in iconOptions"
|
||||
icon
|
||||
:key="icon"
|
||||
:variant="folderSettings.icon === icon ? 'tonal' : 'text'"
|
||||
:color="folderSettings.icon === icon ? 'primary' : 'default'"
|
||||
size="large"
|
||||
class="ma-1"
|
||||
@click="folderSettings.icon = icon"
|
||||
>
|
||||
<VIcon :icon="icon" size="24" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.iconColor') }}</VCardSubtitle>
|
||||
<div class="color-grid">
|
||||
<VBtn
|
||||
v-for="color in colorOptions"
|
||||
:key="color"
|
||||
:variant="folderSettings.color === color ? 'tonal' : 'text'"
|
||||
:color="color"
|
||||
size="large"
|
||||
class="ma-1 color-btn"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="folderSettings.color = color"
|
||||
>
|
||||
<VIcon v-if="folderSettings.color === color" icon="mdi-check" color="white" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.backgroundGradient') }}</VCardSubtitle>
|
||||
<div class="gradient-grid">
|
||||
<VBtn
|
||||
v-for="(gradient, index) in gradientOptions"
|
||||
:key="index"
|
||||
:variant="folderSettings.gradient === gradient ? 'tonal' : 'text'"
|
||||
class="ma-1 gradient-btn"
|
||||
:style="{ background: gradient }"
|
||||
size="large"
|
||||
@click="folderSettings.gradient = gradient"
|
||||
>
|
||||
<VIcon v-if="folderSettings.gradient === gradient" icon="mdi-check" color="white" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="folderSettings.background"
|
||||
:label="t('folder.customBackgroundImageURL')"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
variant="outlined"
|
||||
:hint="t('folder.customBackgroundImageHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-image"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
69
src/components/dialog/PluginLogDialog.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 打开当前插件日志的新窗口。 */
|
||||
function openLoggerWindow() {
|
||||
const url = `${
|
||||
import.meta.env.VITE_API_BASE_URL
|
||||
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
<VIcon icon="mdi-file-document" class="me-2" />
|
||||
{{ t('plugin.logTitle') }}
|
||||
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
220
src/components/dialog/PluginMarketDetailDialog.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
required: true,
|
||||
},
|
||||
count: Number,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'install'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件安装进度弹窗。 */
|
||||
function showInstallProgress(text: string) {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
|
||||
}
|
||||
|
||||
/** 关闭插件安装进度弹窗。 */
|
||||
function closeInstallProgress() {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = null
|
||||
}
|
||||
|
||||
/** 计算插件图标路径。 */
|
||||
function pluginIconPath() {
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
props.plugin?.plugin_icon,
|
||||
)}&cache=true`
|
||||
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
}
|
||||
|
||||
/** 访问插件项目或作者页面。 */
|
||||
function visitPluginPage() {
|
||||
let repoUrl = props.plugin?.repo_url
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
|
||||
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
||||
|
||||
try {
|
||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
||||
repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
window.open(repoUrl, '_blank')
|
||||
}
|
||||
|
||||
/** 安装插件并通知父级刷新市场列表。 */
|
||||
async function installPlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
showInstallProgress(
|
||||
t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: props?.plugin?.plugin_version,
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: props.plugin?.has_update,
|
||||
},
|
||||
})
|
||||
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
visible.value = false
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closeInstallProgress()
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
closeInstallProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem">
|
||||
<VCard>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="mx-auto mt-5">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="pluginIconPath()"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<VCardTitle class="text-center text-md-left">
|
||||
{{ props.plugin?.plugin_name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.version') }}:</span>
|
||||
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.author') }}:</span>
|
||||
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="props.plugin?.system_version" class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('plugin.systemVersion') }}:</span>
|
||||
<span class="text-body-1">{{ props.plugin?.system_version }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VAlert
|
||||
v-if="props.plugin?.system_version_compatible === false"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<div class="text-center text-md-left">
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />
|
||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -1,51 +1,130 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
|
||||
// 插件仓库设置字符串
|
||||
const repoString = ref('')
|
||||
// 用于显示的仓库地址数组
|
||||
const repoArray = ref<string[]>([])
|
||||
type EditorMode = 'list' | 'text'
|
||||
|
||||
// 计算属性:在数组和换行符分隔的字符串之间转换
|
||||
const displayRepos = computed({
|
||||
get: () => repoArray.value.join('\n'),
|
||||
set: (value: string) => {
|
||||
repoArray.value = value.split('\n').filter((repo: string) => repo.trim() !== '')
|
||||
},
|
||||
})
|
||||
interface RepoParseResult {
|
||||
repos: string[]
|
||||
invalidRepos: string[]
|
||||
duplicateRepos: string[]
|
||||
}
|
||||
|
||||
const editorMode = ref<EditorMode>('list')
|
||||
const repoList = ref<string[]>([])
|
||||
const repoText = ref('')
|
||||
const newRepoUrl = ref('')
|
||||
const editingIndex = ref<number | null>(null)
|
||||
const editingUrl = ref('')
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
// 查询已设置的插件仓库
|
||||
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
|
||||
const activeRepoCount = computed(() => (editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length))
|
||||
const saveDisabled = computed(
|
||||
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
|
||||
)
|
||||
|
||||
/** 判断仓库地址是否为可保存的 HTTP URL。 */
|
||||
function isValidRepoUrl(url: string) {
|
||||
return /^https?:\/\//i.test(url)
|
||||
}
|
||||
|
||||
/** 将粘贴的仓库地址文本解析为有效、无效和重复地址列表。 */
|
||||
function parseRepoInput(value: string): RepoParseResult {
|
||||
const repos: string[] = []
|
||||
const invalidRepos: string[] = []
|
||||
const duplicateRepos: string[] = []
|
||||
const seenRepos = new Set<string>()
|
||||
|
||||
value
|
||||
.split(/[\n,,]+/)
|
||||
.map(repo => repo.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(repo => {
|
||||
if (!isValidRepoUrl(repo)) {
|
||||
invalidRepos.push(repo)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (seenRepos.has(repo)) {
|
||||
duplicateRepos.push(repo)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
seenRepos.add(repo)
|
||||
repos.push(repo)
|
||||
})
|
||||
|
||||
return {
|
||||
repos,
|
||||
invalidRepos,
|
||||
duplicateRepos: [...new Set(duplicateRepos)],
|
||||
}
|
||||
}
|
||||
|
||||
/** 将列表模式中的仓库地址同步到文本模式。 */
|
||||
function syncTextFromList() {
|
||||
repoText.value = repoList.value.join('\n')
|
||||
}
|
||||
|
||||
/** 将文本模式中的仓库地址同步到列表模式,并忽略无法加入列表的无效地址。 */
|
||||
function syncListFromText() {
|
||||
const result = parseRepoInput(repoText.value)
|
||||
|
||||
repoList.value = result.repos
|
||||
syncTextFromList()
|
||||
|
||||
if (result.invalidRepos.length > 0) {
|
||||
$toast.warning(t('dialog.pluginMarketSetting.invalidTextIgnored', { count: result.invalidRepos.length }))
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换仓库维护模式,并在切换时同步当前模式的编辑内容。 */
|
||||
function switchEditorMode(mode: EditorMode | undefined) {
|
||||
if (!mode || mode === editorMode.value) return
|
||||
|
||||
if (editorMode.value === 'text') {
|
||||
syncListFromText()
|
||||
}
|
||||
|
||||
if (mode === 'text') {
|
||||
syncTextFromList()
|
||||
}
|
||||
|
||||
editorMode.value = mode
|
||||
}
|
||||
|
||||
/** 加载插件市场仓库配置。 */
|
||||
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
|
||||
repoArray.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
|
||||
repoList.value = parseRepoInput(result.data.value).repos
|
||||
syncTextFromList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
/** 保存插件市场仓库配置。 */
|
||||
async function saveHandle() {
|
||||
try {
|
||||
// 将数组转换为逗号分隔的字符串
|
||||
const repoStringToSave = repoArray.value.join(',')
|
||||
const reposToSave = normalizeCurrentRepos()
|
||||
if (!reposToSave) return
|
||||
|
||||
const repoStringToSave = reposToSave.join(',')
|
||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
|
||||
|
||||
if (result.success) {
|
||||
@@ -57,37 +136,541 @@ async function saveHandle() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前维护模式下可保存的仓库地址。 */
|
||||
function normalizeCurrentRepos() {
|
||||
if (editorMode.value === 'text') {
|
||||
const result = parseRepoInput(repoText.value)
|
||||
|
||||
if (result.invalidRepos.length > 0) {
|
||||
$toast.error(t('dialog.pluginMarketSetting.invalidText', { count: result.invalidRepos.length }))
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
repoList.value = result.repos
|
||||
syncTextFromList()
|
||||
|
||||
return result.repos
|
||||
}
|
||||
|
||||
return repoList.value
|
||||
}
|
||||
|
||||
/** 校验单个仓库地址是否可以加入或更新到列表。 */
|
||||
function validateRepoUrl(url: string, editingRepoIndex: number | null = null) {
|
||||
if (!url) return false
|
||||
|
||||
if (!isValidRepoUrl(url)) {
|
||||
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const duplicated = repoList.value.some((repo, index) => repo === url && index !== editingRepoIndex)
|
||||
if (duplicated) {
|
||||
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** 添加一个仓库地址到列表。 */
|
||||
function addRepo() {
|
||||
const url = newRepoUrl.value.trim()
|
||||
if (!validateRepoUrl(url)) return
|
||||
|
||||
repoList.value.push(url)
|
||||
newRepoUrl.value = ''
|
||||
syncTextFromList()
|
||||
}
|
||||
|
||||
/** 从列表中删除一个仓库地址。 */
|
||||
function removeRepo(index: number) {
|
||||
repoList.value.splice(index, 1)
|
||||
syncTextFromList()
|
||||
}
|
||||
|
||||
/** 进入指定仓库地址的行内编辑状态。 */
|
||||
function startEdit(index: number) {
|
||||
editingIndex.value = index
|
||||
editingUrl.value = repoList.value[index]
|
||||
}
|
||||
|
||||
/** 保存当前行内编辑的仓库地址。 */
|
||||
function saveEdit(index = editingIndex.value) {
|
||||
if (index === null) return
|
||||
|
||||
const url = editingUrl.value.trim()
|
||||
if (!validateRepoUrl(url, index)) return
|
||||
|
||||
repoList.value[index] = url
|
||||
syncTextFromList()
|
||||
editingIndex.value = null
|
||||
editingUrl.value = ''
|
||||
}
|
||||
|
||||
/** 取消当前行内编辑状态。 */
|
||||
function cancelEdit() {
|
||||
editingIndex.value = null
|
||||
editingUrl.value = ''
|
||||
}
|
||||
|
||||
/** 将仓库地址格式化为更易扫描的显示名称。 */
|
||||
function formatRepoDisplay(url: string) {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
|
||||
|
||||
if (
|
||||
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)
|
||||
&& pathSegments.length >= 2
|
||||
) {
|
||||
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs and fall back to the original value.
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/** 返回拖拽列表项的稳定键。 */
|
||||
function repoItemKey(repo: string) {
|
||||
return repo
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryMarketRepoSetting()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-store-cog" class="me-2" />
|
||||
{{ t('dialog.pluginMarketSetting.title') }}
|
||||
</VCardTitle>
|
||||
<VDialog width="56rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="plugin-market-dialog-card">
|
||||
<VCardItem class="plugin-market-card-item">
|
||||
<div class="plugin-market-header">
|
||||
<VCardTitle class="plugin-market-title d-flex align-center pa-0">
|
||||
<VIcon icon="mdi-store-cog" class="me-2" />
|
||||
{{ t('dialog.pluginMarketSetting.title') }}
|
||||
</VCardTitle>
|
||||
</div>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pt-2">
|
||||
<VTextarea
|
||||
v-model="displayRepos"
|
||||
:placeholder="t('dialog.pluginMarketSetting.repoPlaceholder')"
|
||||
:hint="t('dialog.pluginMarketSetting.repoHint')"
|
||||
persistent-hint
|
||||
auto-grow
|
||||
/>
|
||||
|
||||
<VCardText class="plugin-market-dialog-body pt-4">
|
||||
<div class="plugin-market-toolbar">
|
||||
<VBtnToggle
|
||||
:model-value="editorMode"
|
||||
mandatory
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
variant="tonal"
|
||||
class="plugin-market-mode-toggle"
|
||||
@update:model-value="switchEditorMode"
|
||||
>
|
||||
<VBtn value="list" prepend-icon="mdi-format-list-bulleted">
|
||||
{{ t('dialog.pluginMarketSetting.listMode') }}
|
||||
</VBtn>
|
||||
<VBtn value="text" prepend-icon="mdi-text-box-edit-outline">
|
||||
{{ t('dialog.pluginMarketSetting.textMode') }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</div>
|
||||
|
||||
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
|
||||
<div class="plugin-market-input">
|
||||
<VTextField
|
||||
v-model="newRepoUrl"
|
||||
density="compact"
|
||||
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
|
||||
prepend-inner-icon="mdi-link-plus"
|
||||
clearable
|
||||
hide-details
|
||||
@keyup.enter="addRepo"
|
||||
>
|
||||
<template #append>
|
||||
<VBtn
|
||||
icon="mdi-plus"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
:aria-label="t('dialog.pluginMarketSetting.addRepo')"
|
||||
@click="addRepo"
|
||||
/>
|
||||
</template>
|
||||
</VTextField>
|
||||
</div>
|
||||
|
||||
<div class="plugin-market-list-wrap">
|
||||
<VList v-if="repoList.length > 0" class="plugin-market-repo-list px-0">
|
||||
<draggable
|
||||
v-model="repoList"
|
||||
:item-key="repoItemKey"
|
||||
handle=".drag-handle"
|
||||
animation="200"
|
||||
:disabled="editingIndex !== null"
|
||||
@end="syncTextFromList"
|
||||
>
|
||||
<template #item="{ element: repo, index }">
|
||||
<div>
|
||||
<VListItem class="plugin-market-repo-item py-3">
|
||||
<template #prepend>
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="drag-handle me-2"
|
||||
:disabled="editingIndex !== null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="editingIndex !== index">
|
||||
<VListItemTitle>
|
||||
<div class="plugin-market-repo-title">
|
||||
<span class="plugin-market-repo-index">{{ index + 1 }}</span>
|
||||
<span class="plugin-market-repo-name" :title="repo">{{ formatRepoDisplay(repo) }}</span>
|
||||
</div>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="plugin-market-repo-url mt-1" :title="repo">
|
||||
{{ repo }}
|
||||
</VListItemSubtitle>
|
||||
</template>
|
||||
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="editingUrl"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
autofocus
|
||||
@keyup.enter="saveEdit(index)"
|
||||
@keyup.escape="cancelEdit"
|
||||
/>
|
||||
|
||||
<template #append v-if="editingIndex !== index">
|
||||
<div class="d-flex align-center">
|
||||
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
|
||||
<IconBtn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="removeRepo(index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #append v-else>
|
||||
<div class="d-flex align-center">
|
||||
<VBtn
|
||||
icon="mdi-check"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="success"
|
||||
@click.stop="saveEdit(index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</VList>
|
||||
|
||||
<div v-else class="plugin-market-empty text-center text-medium-emphasis">
|
||||
<VIcon icon="mdi-source-repository-multiple" size="48" class="mb-2" />
|
||||
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="plugin-market-text-panel">
|
||||
<div class="plugin-market-textarea-field">
|
||||
<VIcon icon="mdi-text-box-edit-outline" class="plugin-market-textarea-icon" />
|
||||
<textarea
|
||||
v-model="repoText"
|
||||
class="plugin-market-textarea"
|
||||
:placeholder="t('dialog.pluginMarketSetting.textPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="plugin-market-text-hint">
|
||||
{{ t('dialog.pluginMarketSetting.textHint') }}
|
||||
</div>
|
||||
|
||||
<VAlert
|
||||
v-if="parsedTextRepos.invalidRepos.length > 0"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="plugin-market-invalid-alert"
|
||||
>
|
||||
<div>{{ t('dialog.pluginMarketSetting.invalidText', { count: parsedTextRepos.invalidRepos.length }) }}</div>
|
||||
<div class="text-truncate">
|
||||
{{ parsedTextRepos.invalidRepos.slice(0, 3).join(', ') }}
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-else-if="parsedTextRepos.duplicateRepos.length > 0"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
>
|
||||
{{ t('dialog.pluginMarketSetting.duplicateTextIgnored') }}
|
||||
</VAlert>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
|
||||
<VCardActions class="plugin-market-actions">
|
||||
<VSpacer />
|
||||
<VBtn @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="saveHandle"
|
||||
prepend-icon="mdi-content-save-check"
|
||||
class="px-5"
|
||||
:disabled="saveDisabled"
|
||||
>
|
||||
{{ t('dialog.pluginMarketSetting.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.plugin-market-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: min(82vh, 50rem);
|
||||
}
|
||||
|
||||
.plugin-market-card-item {
|
||||
flex: 0 0 auto;
|
||||
padding-block: 0.875rem;
|
||||
}
|
||||
|
||||
.plugin-market-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-inline-end: 2rem;
|
||||
}
|
||||
|
||||
.plugin-market-title {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.plugin-market-dialog-body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
min-block-size: 0;
|
||||
padding-block: 0.875rem !important;
|
||||
}
|
||||
|
||||
.plugin-market-toolbar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plugin-market-mode-toggle {
|
||||
inline-size: 100%;
|
||||
|
||||
:deep(.v-btn) {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-list-panel,
|
||||
.plugin-market-text-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.plugin-market-input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plugin-market-list-wrap {
|
||||
flex: 1;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.plugin-market-repo-list {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plugin-market-repo-item {
|
||||
min-block-size: 4.5rem;
|
||||
}
|
||||
|
||||
.plugin-market-repo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.plugin-market-repo-name,
|
||||
.plugin-market-repo-url {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-break: anywhere;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.plugin-market-repo-url {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plugin-market-repo-index {
|
||||
flex: 0 0 auto;
|
||||
color: rgba(var(--v-theme-on-surface), 0.48);
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
inline-size: 1.75rem;
|
||||
}
|
||||
|
||||
.plugin-market-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
min-block-size: 14rem;
|
||||
}
|
||||
|
||||
.plugin-market-textarea-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-textarea-icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
inset-block-start: 1.25rem;
|
||||
inset-inline-start: 1rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.plugin-market-textarea {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
min-block-size: 0;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1rem 1rem 3.25rem;
|
||||
resize: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.plugin-market-text-hint {
|
||||
flex: 0 0 auto;
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.plugin-market-invalid-alert {
|
||||
:deep(.v-alert__content) {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-actions {
|
||||
flex: 0 0 auto;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.plugin-market-dialog-card {
|
||||
block-size: 100dvh;
|
||||
}
|
||||
|
||||
.plugin-market-card-item {
|
||||
padding: 0.75rem 1rem 0.625rem;
|
||||
}
|
||||
|
||||
.plugin-market-header {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-inline-end: 2.25rem;
|
||||
}
|
||||
|
||||
.plugin-market-header :deep(.v-card-title) {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.plugin-market-dialog-body {
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem !important;
|
||||
}
|
||||
|
||||
.plugin-market-mode-toggle {
|
||||
inline-size: 100%;
|
||||
|
||||
:deep(.v-btn) {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-list-panel,
|
||||
.plugin-market-text-panel {
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.plugin-market-list-wrap {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.plugin-market-empty {
|
||||
min-block-size: 10rem;
|
||||
}
|
||||
|
||||
.plugin-market-actions {
|
||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
133
src/components/dialog/PluginSearchDialog.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
keyword: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
plugins: {
|
||||
type: Array as PropType<Plugin[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'open-plugin', plugin: Plugin): void
|
||||
(event: 'update:keyword', value: string): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const pluginIconLoaded = ref<Record<string, boolean>>({})
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const searchKeyword = computed({
|
||||
get: () => props.keyword,
|
||||
set: value => emit('update:keyword', value),
|
||||
})
|
||||
|
||||
// 返回插件图标地址,并在远程图标失败后回退到默认图标。
|
||||
function pluginIcon(item: Plugin) {
|
||||
if (pluginIconLoaded.value[item.id || '0'] === false) return getLogoUrl('plugin')
|
||||
if (item?.plugin_icon?.startsWith('http')) {
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}&cache=true`
|
||||
}
|
||||
|
||||
return `./plugin_icon/${item?.plugin_icon}`
|
||||
}
|
||||
|
||||
// 标记指定插件图标加载失败。
|
||||
function pluginIconError(item: Plugin) {
|
||||
pluginIconLoaded.value[item.id || '0'] = false
|
||||
}
|
||||
|
||||
// 获取插件标签列表。
|
||||
function pluginLabels(label: string | undefined) {
|
||||
if (!label) return []
|
||||
return label.split(',')
|
||||
}
|
||||
|
||||
// 关闭搜索弹窗并通知共享弹窗 Host 回收实例。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-model="dialogVisible"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:max-height="!display.mdAndUp.value ? '' : '85vh'"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="mx-auto" width="100%">
|
||||
<VToolbar flat class="p-0">
|
||||
<VTextField
|
||||
v-model="searchKeyword"
|
||||
:label="t('plugin.searchPlugins')"
|
||||
single-line
|
||||
:placeholder="t('plugin.searchPlaceholder')"
|
||||
variant="solo"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
flat
|
||||
class="mx-1"
|
||||
/>
|
||||
</VToolbar>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VList v-if="plugins.length > 0" lines="two">
|
||||
<VVirtualScroll :items="plugins">
|
||||
<template #default="{ item }">
|
||||
<VListItem @click="emit('open-plugin', item)">
|
||||
<template #prepend>
|
||||
<VAvatar>
|
||||
<VImg :src="pluginIcon(item)" @error="pluginIconError(item)">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
{{ item.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ item?.plugin_version }}</span>
|
||||
<VIcon v-if="item.installed" color="success" icon="mdi-check-circle" class="ms-2" size="small" />
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
<VChip
|
||||
v-for="label in pluginLabels(item.plugin_label)"
|
||||
:key="label"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-1 my-1"
|
||||
color="info"
|
||||
label
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
{{ item.plugin_desc }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VList>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
70
src/components/dialog/PluginVersionHistoryDialog.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import type { Plugin } from '@/api/types'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
required: true,
|
||||
},
|
||||
showUpdateAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'update'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 触发插件更新操作。 */
|
||||
function handleUpdate() {
|
||||
emit('update')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="600" max-height="85vh" scrollable>
|
||||
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
<template v-if="props.showUpdateAction">
|
||||
<VDivider />
|
||||
<VCardItem>
|
||||
<VAlert
|
||||
v-if="props.plugin?.system_version_compatible === false"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<VBtn @click="handleUpdate" block :disabled="props.plugin?.system_version_compatible === false">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
{{ t('plugin.updateToLatest') }}
|
||||
</VBtn>
|
||||
</VCardItem>
|
||||
</template>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -7,6 +7,9 @@ const props = defineProps({
|
||||
value: Number,
|
||||
text: String,
|
||||
})
|
||||
|
||||
// 有明确进度值时显示确定进度,否则显示不确定进度条。
|
||||
const hasProgressValue = computed(() => typeof props.value === 'number' && Number.isFinite(props.value))
|
||||
</script>
|
||||
<template>
|
||||
<!-- Progress Dialog -->
|
||||
@@ -14,7 +17,12 @@ const props = defineProps({
|
||||
<VCard elevation="3" color="primary">
|
||||
<VCardText class="text-center">
|
||||
{{ props.text || t('dialog.progress.processing') }}
|
||||
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
|
||||
<VProgressLinear
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
:model-value="hasProgressValue ? props.value : undefined"
|
||||
:indeterminate="!hasProgressValue"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
64
src/components/dialog/SharedDialogHost.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SharedDialogEntry } from '@/composables/useSharedDialog'
|
||||
import { closeSharedDialog, useSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const { dialogs } = useSharedDialog()
|
||||
type ReadonlySharedDialogEntry = Readonly<SharedDialogEntry> & {
|
||||
readonly closeOn: readonly string[]
|
||||
readonly events: Readonly<SharedDialogEntry['events']>
|
||||
readonly props: Readonly<SharedDialogEntry['props']>
|
||||
}
|
||||
|
||||
// 关闭弹窗并同步组件自身的 v-model 状态。
|
||||
function closeEntry(entry: ReadonlySharedDialogEntry) {
|
||||
closeSharedDialog(entry.id)
|
||||
}
|
||||
|
||||
// 处理弹窗内部 v-model 变化,用户点击遮罩或返回键关闭时也能释放实例。
|
||||
function handleModelUpdate(entry: ReadonlySharedDialogEntry, value: boolean) {
|
||||
if (!value) closeSharedDialog(entry.id)
|
||||
}
|
||||
|
||||
// 转发业务事件给调用方,并按配置自动关闭当前弹窗。
|
||||
function handleDialogEvent(entry: ReadonlySharedDialogEntry, eventName: string, args: any[]) {
|
||||
entry.events[eventName]?.(...args)
|
||||
|
||||
if (entry.closeOn.includes(eventName) && (eventName !== 'update:modelValue' || args[0] === false)) {
|
||||
closeEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成动态组件事件监听器,让不同业务弹窗复用同一个 Host。
|
||||
function createDialogListeners(entry: ReadonlySharedDialogEntry) {
|
||||
const listeners: Record<string, (...args: any[]) => void> = {}
|
||||
|
||||
listeners['update:modelValue'] = value => {
|
||||
handleModelUpdate(entry, Boolean(value))
|
||||
entry.events['update:modelValue']?.(value)
|
||||
}
|
||||
|
||||
Object.keys(entry.events).forEach(eventName => {
|
||||
if (eventName === 'update:modelValue') return
|
||||
|
||||
listeners[eventName] = (...args: any[]) => handleDialogEvent(entry, eventName, args)
|
||||
})
|
||||
|
||||
entry.closeOn.forEach(eventName => {
|
||||
if (!listeners[eventName]) {
|
||||
listeners[eventName] = (...args: any[]) => handleDialogEvent(entry, eventName, args)
|
||||
}
|
||||
})
|
||||
|
||||
return listeners
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:is="entry.component"
|
||||
v-for="entry in dialogs"
|
||||
:key="entry.id"
|
||||
v-bind="{ ...entry.props, modelValue: entry.visible }"
|
||||
v-on="createDialogListeners(entry)"
|
||||
/>
|
||||
</template>
|
||||
61
src/components/dialog/ShortcutLogDialog.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 拼接全部日志 URL。 */
|
||||
function allLoggingUrl() {
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
<VIcon icon="mdi-file-document" class="me-2" />
|
||||
{{ t('shortcut.log.subtitle') }}
|
||||
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
|
||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView logfile="moviepilot.log" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
139
src/components/dialog/ShortcutMessageDialog.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { clearAppBadge } from '@/utils/badge'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const MessageView = defineAsyncComponent(() => import('@/views/system/MessageView.vue'))
|
||||
|
||||
type MessageViewExpose = {
|
||||
pauseSSE?: () => void
|
||||
resumeSSE?: () => void
|
||||
refreshLatestMessages?: () => Promise<void> | void
|
||||
forceScrollToEnd?: () => void
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 输入消息
|
||||
const user_message = ref('')
|
||||
|
||||
// 发送按钮是否可用
|
||||
const sendButtonDisabled = ref(false)
|
||||
|
||||
// 消息视图引用
|
||||
const messageViewRef = ref<MessageViewExpose | null>(null)
|
||||
|
||||
/** 发送 Web 消息。 */
|
||||
async function sendMessage() {
|
||||
const messageText = user_message.value.trim()
|
||||
if (!messageText) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
sendButtonDisabled.value = true
|
||||
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
|
||||
user_message.value = ''
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
sendButtonDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, async newValue => {
|
||||
if (newValue) {
|
||||
await nextTick()
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
|
||||
window.setTimeout(() => {
|
||||
void clearAppBadge()
|
||||
}, 500)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
messageViewRef.value?.pauseSSE?.()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
|
||||
window.setTimeout(() => {
|
||||
void clearAppBadge()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
messageViewRef.value?.pauseSSE?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-message" class="me-2" />
|
||||
{{ t('shortcut.message.subtitle') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<MessageView ref="messageViewRef" />
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<VCardActions class="pa-4">
|
||||
<div class="d-flex w-100 gap-2">
|
||||
<VTextField
|
||||
v-model="user_message"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
density="compact"
|
||||
:placeholder="t('common.inputMessage')"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<VBtn
|
||||
variant="elevated"
|
||||
:disabled="sendButtonDisabled"
|
||||
@click="sendMessage"
|
||||
:loading="sendButtonDisabled"
|
||||
color="primary"
|
||||
prepend-icon="mdi-send"
|
||||
>{{ t('common.send') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
82
src/components/dialog/ShortcutToolDialog.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
bodyClass?: string
|
||||
cardClass?: string
|
||||
icon?: string
|
||||
maxWidth?: string
|
||||
modelValue?: boolean
|
||||
subtitle?: string
|
||||
title: string
|
||||
view: Component
|
||||
viewProps?: Record<string, unknown>
|
||||
}>(),
|
||||
{
|
||||
bodyClass: '',
|
||||
cardClass: '',
|
||||
icon: 'mdi-cog',
|
||||
maxWidth: '35rem',
|
||||
modelValue: true,
|
||||
viewProps: () => ({}),
|
||||
},
|
||||
)
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :class="props.cardClass">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon :icon="props.icon" class="me-2" />
|
||||
{{ props.title }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle v-if="props.subtitle">{{ props.subtitle }}</VCardSubtitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText :class="props.bodyClass">
|
||||
<Component :is="props.view" v-bind="props.viewProps" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.system-health-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.system-health-dialog-body {
|
||||
/* 弹窗正文本身不滚动,滚动只交给健康检查结果列表。 */
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
block-size: min(42rem, calc(100dvh - 8rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)));
|
||||
min-block-size: 0;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
:global(.v-dialog--fullscreen) .system-health-dialog-body {
|
||||
block-size: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -140,7 +140,7 @@ onMounted(async () => {
|
||||
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'
|
||||
if (siteForm.value.apikey || siteForm.value.token) siteType.value = 'api'
|
||||
}
|
||||
await loadDownloaderSetting()
|
||||
})
|
||||
@@ -224,15 +224,15 @@ onMounted(async () => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
|
||||
<VTab selected-class="v-tab--selected">
|
||||
<VTab value="cookie" selected-class="v-tab--selected">
|
||||
<div>
|
||||
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
|
||||
<VIcon size="20" start icon="mdi-cookie" />
|
||||
Cookie
|
||||
</div>
|
||||
</VTab>
|
||||
<VTab selected-class="v-tab--selected">
|
||||
<VTab value="api" selected-class="v-tab--selected">
|
||||
<div>
|
||||
<VIcon size="20" start icon="mdi-api" value="api" />
|
||||
<VIcon size="20" start icon="mdi-api" />
|
||||
API
|
||||
</div>
|
||||
</VTab>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { Site } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// 响应式断点
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -23,6 +27,30 @@ const selectCategory = ref<number[]>([])
|
||||
// 全部分类
|
||||
const siteCategoryList = ref<SiteCategory[]>()
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 当前页
|
||||
const resourcePage = ref(1)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 移动端搜索栏是否展开
|
||||
const mobileSearchExpanded = ref(false)
|
||||
|
||||
// 种子元数据
|
||||
const torrent = ref<TorrentInfo>()
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 分类选项
|
||||
const categoryOptions = computed(() => {
|
||||
return siteCategoryList.value?.map(item => {
|
||||
@@ -30,77 +58,89 @@ const categoryOptions = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 注册事件
|
||||
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 resourceTotalItems = computed(() => resourceDataList.value.length)
|
||||
|
||||
// 资源浏览表头
|
||||
const resourceHeaders = [
|
||||
const resourceHeaders = computed(() => [
|
||||
{ title: t('dialog.siteResource.titleColumn'), key: 'title', sortable: false },
|
||||
{ title: t('dialog.siteResource.timeColumn'), key: 'pubdate', sortable: true },
|
||||
{ title: t('dialog.siteResource.sizeColumn'), key: 'size', sortable: true },
|
||||
{ title: t('dialog.siteResource.seedersColumn'), key: 'seeders', sortable: true },
|
||||
{ title: t('dialog.siteResource.peersColumn'), key: 'peers', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false },
|
||||
]
|
||||
])
|
||||
|
||||
// 输入框标签
|
||||
const keywordFieldLabel = computed(() => {
|
||||
return keyword.value ? '' : t('dialog.siteResource.searchKeyword')
|
||||
})
|
||||
|
||||
const categoryFieldLabel = computed(() => {
|
||||
return selectCategory.value.length > 0 ? '' : t('dialog.siteResource.resourceCategory')
|
||||
})
|
||||
|
||||
// 结果统计文案
|
||||
const resultSummaryText = computed(() => {
|
||||
if (locale.value.startsWith('zh')) {
|
||||
return `共 ${resourceTotalItems.value} 条结果`
|
||||
}
|
||||
|
||||
return `${resourceTotalItems.value} results`
|
||||
})
|
||||
|
||||
// 是否小屏幕
|
||||
const isMobileLayout = computed(() => display.smAndDown.value)
|
||||
|
||||
// 移动端分页数据
|
||||
const mobileResourceList = computed(() => resourceDataList.value)
|
||||
|
||||
function getResourceItemKey(item: TorrentInfo, index: number) {
|
||||
return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}`
|
||||
}
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
if (!page_url) return
|
||||
window.open(page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile(enclosure: string) {
|
||||
if (!enclosure) return
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
else return 'text-white bg-gray-500'
|
||||
if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
|
||||
return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_torrent: any) {
|
||||
async function addDownload(_torrent: TorrentInfo) {
|
||||
torrent.value = _torrent
|
||||
addDownloadDialog.value = true
|
||||
}
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
function addDownloadSuccess(_url: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
function addDownloadError(_error: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
resourcePage.value = 1
|
||||
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
|
||||
params: {
|
||||
@@ -111,7 +151,12 @@ async function getResourceList() {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
resourceLoading.value = false
|
||||
|
||||
if (isMobileLayout.value) {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载站点分类
|
||||
@@ -123,16 +168,44 @@ async function getSiteCategoryList() {
|
||||
}
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
watch([resourceItemsPerPage, resourceTotalItems, () => display.mdAndUp.value], () => {
|
||||
if (display.mdAndUp.value) {
|
||||
const maxPage = Math.max(1, Math.ceil(resourceTotalItems.value / resourceItemsPerPage.value))
|
||||
if (resourcePage.value > maxPage) {
|
||||
resourcePage.value = maxPage
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => display.mdAndUp.value,
|
||||
isDesktop => {
|
||||
if (isDesktop) {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function toggleMobileSearch() {
|
||||
mobileSearchExpanded.value = !mobileSearchExpanded.value
|
||||
}
|
||||
|
||||
function closeMobileSearch() {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
|
||||
// 装载时查询站点分类和资源
|
||||
onMounted(() => {
|
||||
getSiteCategoryList()
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
||||
<VCard>
|
||||
<!-- Toolbar -->
|
||||
<VDialog scrollable :fullscreen="display.smAndDown.value" max-width="92rem" transition="dialog-bottom-transition">
|
||||
<VCard class="site-resource-dialog">
|
||||
<div>
|
||||
<VToolbar color="primary" density="comfortable">
|
||||
<VToolbarTitle>{{ t('dialog.siteResource.browseTitle', { name: props.site?.name }) }}</VToolbarTitle>
|
||||
@@ -144,45 +217,153 @@ onMounted(() => {
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<VRow>
|
||||
<VCol cols="6" md="5">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
size="small"
|
||||
density="compact"
|
||||
:label="t('dialog.siteResource.searchKeyword')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
:label="t('dialog.siteResource.resourceCategory')"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2" class="text-center">
|
||||
<VBtn variant="tonal" block prepend-icon="mdi-magnify" @click="getResourceList">
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
|
||||
<div class="pa-3 pb-2">
|
||||
<template v-if="!isMobileLayout">
|
||||
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||
<div class="site-resource-filter-panel__inner">
|
||||
<VRow class="site-resource-filter-row">
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="keywordFieldLabel"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
@keyup.enter="getResourceList"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
chips
|
||||
:label="categoryFieldLabel"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3" class="d-flex align-center">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
block
|
||||
size="default"
|
||||
rounded="lg"
|
||||
prepend-icon="mdi-magnify"
|
||||
class="site-resource-search-btn"
|
||||
@click="getResourceList"
|
||||
>
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div
|
||||
v-if="resourceTotalItems > 0"
|
||||
class="d-flex justify-space-between align-center flex-wrap gap-2 mt-3"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ resultSummaryText }}
|
||||
</div>
|
||||
<VChip size="small" color="primary" variant="tonal" class="site-resource-result-chip">
|
||||
{{ resourceTotalItems }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="site-resource-mobile-search">
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="site-resource-mobile-search__toggle"
|
||||
@click="toggleMobileSearch"
|
||||
>
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<div v-if="resourceTotalItems > 0" class="text-body-2 text-medium-emphasis">
|
||||
{{ resultSummaryText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VExpandTransition>
|
||||
<div v-if="mobileSearchExpanded" class="mt-2">
|
||||
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||
<div class="site-resource-filter-panel__inner">
|
||||
<VRow class="site-resource-filter-row">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="keywordFieldLabel"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
autofocus
|
||||
@keyup.enter="getResourceList"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
chips
|
||||
:label="categoryFieldLabel"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" class="d-flex gap-2">
|
||||
<VBtn color="primary" variant="flat" block rounded="lg" class="site-resource-search-btn" @click="getResourceList">
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
</VBtn>
|
||||
<VBtn variant="text" rounded="lg" @click="closeMobileSearch">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VSheet>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
</template>
|
||||
</div>
|
||||
<VCardText class="px-0 py-0 my-0">
|
||||
|
||||
<VCardText class="site-resource-content px-0 py-0 my-0">
|
||||
<VDataTable
|
||||
v-if="display.mdAndUp.value"
|
||||
v-model:page="resourcePage"
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
:items="resourceDataList"
|
||||
:items-length="resourceTotalItems"
|
||||
:search="resourceSearch"
|
||||
:loading="resourceLoading"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
@@ -191,60 +372,69 @@ onMounted(() => {
|
||||
hover
|
||||
:items-per-page-text="t('dialog.siteResource.itemsPerPage')"
|
||||
:loading-text="t('dialog.siteResource.loading')"
|
||||
class="h-full"
|
||||
:items-per-page-options="[10, 25, 50, 100]"
|
||||
height="100%"
|
||||
class="h-full site-resource-table"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1 font-weight-medium">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-sm my-1">
|
||||
<div v-if="item.description" class="text-sm my-1 text-medium-emphasis">
|
||||
{{ 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>
|
||||
<div class="mt-2">
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.date_elapsed }}</div>
|
||||
<div class="text-sm">
|
||||
<div class="text-sm text-medium-emphasis">
|
||||
{{ 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>
|
||||
@@ -268,11 +458,136 @@ onMounted(() => {
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #no-data>{{ t('dialog.siteResource.noData') }}</template>
|
||||
</VDataTable>
|
||||
|
||||
<div v-else class="site-resource-mobile">
|
||||
<div v-if="resourceLoading" class="px-4 py-6">
|
||||
<VProgressLinear color="primary" indeterminate rounded />
|
||||
<div class="text-center text-body-2 text-medium-emphasis mt-3">
|
||||
{{ t('dialog.siteResource.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mobileResourceList.length > 0" class="site-resource-mobile__list px-3 pb-4">
|
||||
<ProgressiveCardGrid
|
||||
:items="mobileResourceList"
|
||||
:columns="1"
|
||||
:gap="12"
|
||||
:estimated-item-height="320"
|
||||
:overscan-rows="5"
|
||||
:get-item-key="getResourceItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<VCard>
|
||||
<VCardText class="pa-4">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.description"
|
||||
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
|
||||
>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mt-3">
|
||||
<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, chipIndex) in item.labels"
|
||||
:key="chipIndex"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__meta mt-4">
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
|
||||
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__actions mt-4">
|
||||
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
|
||||
{{ t('actionStep.addDownload') }}
|
||||
</VBtn>
|
||||
<div class="site-resource-card__secondary-actions mt-2">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-open-in-new"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
{{ t('common.viewDetails') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-tray-arrow-down"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
{{ t('dialog.siteResource.downloadTorrent') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</div>
|
||||
|
||||
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
|
||||
{{ t('dialog.siteResource.noData') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 添加下载对话框 -->
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
@@ -285,7 +600,169 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.site-resource-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-resource-filter-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-resource-filter-panel {
|
||||
border-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(var(--v-theme-primary), 0.06), transparent 40%),
|
||||
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.93));
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 4%);
|
||||
}
|
||||
|
||||
.site-resource-filter-panel__inner {
|
||||
padding: 0.75rem 0.85rem;
|
||||
}
|
||||
|
||||
.site-resource-filter-input :deep(.v-field) {
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(var(--v-theme-surface), 0.92);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.8));
|
||||
}
|
||||
|
||||
.site-resource-filter-input :deep(.v-field__prepend-inner) {
|
||||
color: rgba(var(--v-theme-primary), 0.85);
|
||||
}
|
||||
|
||||
.site-resource-search-btn {
|
||||
box-shadow: 0 8px 18px rgba(var(--v-theme-primary), 0.18);
|
||||
letter-spacing: 0.02em;
|
||||
min-block-size: 40px;
|
||||
}
|
||||
|
||||
.site-resource-result-chip {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search__toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.site-resource-title-btn {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-content {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-resource-table {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table__wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-table__wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table-footer) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.site-resource-mobile {
|
||||
overflow-y: auto;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-mobile__list {
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
.v-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.site-resource-card__meta {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item {
|
||||
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(var(--v-theme-surface), 0.78);
|
||||
min-block-size: 0;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: 0.65rem;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-caption) {
|
||||
font-size: 0.72rem !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-body-2) {
|
||||
font-size: 0.82rem !important;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions :deep(.v-btn) {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
|
||||
@media (width >= 960px) {
|
||||
.site-resource-dialog {
|
||||
block-size: min(88vh, 960px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 959px) {
|
||||
.site-resource-dialog {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.site-resource-filter-panel__inner {
|
||||
padding: 0.7rem 0.75rem;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search {
|
||||
min-block-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||