Compare commits

...

209 Commits
v1.9.0 ... main

Author SHA1 Message Date
jxxghp
6fdbc8104c v1.9.17 2024-09-18 17:37:57 +08:00
jxxghp
433c14679c 更新 package.json 2024-09-08 13:09:25 +08:00
jxxghp
fcaa4476f0 更新 package.json 2024-09-05 06:59:13 +08:00
jxxghp
85c5c3058c 更新 AccountSettingDirectory.vue 2024-09-05 06:57:32 +08:00
jxxghp
035122a08e Merge pull request #172 from flowclouds/main 2024-08-16 12:07:18 +08:00
jxxghp
0a76875f8e chore: update package.json version to 1.9.14-2 2024-08-16 10:10:41 +08:00
lj
218eac54ce fix: 优化代码移除相关警告 2024-08-15 15:52:32 +08:00
jxxghp
84deeff4f5 chore: update package.json version to 1.9.14-1 2024-08-12 18:13:47 +08:00
jxxghp
0c72d026f6 chore: update package.json version to 1.9.14 2024-08-08 14:37:40 +08:00
jxxghp
aec9ea83c5 更新 package.json 2024-07-30 06:33:53 +08:00
jxxghp
effd13aedd Merge pull request #171 from InfinityPacer/main 2024-07-22 22:12:19 +08:00
InfinityPacer
42b43d65d7 fix(FilterRuleCard): 移除规则中的空白字符并保留前后的空格 2024-07-22 21:47:42 +08:00
jxxghp
c501d824dd Merge pull request #170 from InfinityPacer/main 2024-07-17 06:53:43 +08:00
InfinityPacer
384ac2faf1 feat: ace-editor support python 2024-07-17 01:48:18 +08:00
jxxghp
dd2c4dd24b v1.9.12 2024-07-16 07:56:07 +08:00
jxxghp
356ffddb1c release 2024-07-09 08:00:11 +08:00
jxxghp
de69be7c4e Merge pull request #168 from s0urcelab/main 2024-07-09 06:28:30 +08:00
s0urce
e962f555ae fix: issue #167 2024-07-09 02:56:48 +08:00
jxxghp
1987246585 Merge pull request #166 from JavaZeroo/main 2024-07-08 19:05:23 +08:00
JavaZero
393264f66b 实现根据操作系统动态显示不同的搜索快捷键提示 2024-07-08 13:51:00 +08:00
jxxghp
9b50020b3b Merge pull request #165 from BrettDean/main 2024-07-05 15:26:31 +08:00
Dean
5e5545fe01 优化"最近入库"数量显示 2024-07-05 15:19:50 +08:00
jxxghp
0e8da35b0a fix bug 2024-07-01 10:55:46 +08:00
jxxghp
4d2cf73330 fix https://github.com/jxxghp/MoviePilot/issues/2471 2024-07-01 10:20:50 +08:00
jxxghp
5df89f2ce4 release 2024-06-28 10:48:20 +08:00
jxxghp
045c0b4c0c fix ui 2024-06-28 10:47:50 +08:00
jxxghp
8b4ffa0795 Update SubscribeCard.vue 2024-06-28 10:03:28 +08:00
jxxghp
14359a37ae Update package.json 2024-06-26 16:14:24 +08:00
jxxghp
a8e4a1c2e0 Merge pull request #162 from jxxghp/main
fix bugs
2024-06-24 12:59:49 +08:00
jxxghp
9048d181af Merge branch 'dev' into main 2024-06-24 12:59:22 +08:00
jxxghp
1cb02994bf fix 登录失败的提示信息 2024-06-24 11:51:39 +08:00
jxxghp
6fad85e957 feat:仪表盘不活跃时不刷新 && 网盘整理联动刮削 2024-06-24 09:13:22 +08:00
jxxghp
db9b2ee6b3 init 2024-06-23 09:35:00 +08:00
jxxghp
8efeb77102 v1.9.8 2024-06-23 09:08:19 +08:00
jxxghp
0215a800e2 fix scrape 2024-06-23 09:06:14 +08:00
jxxghp
87d282f98b fix bug 2024-06-21 21:22:42 +08:00
jxxghp
60c392d3d0 fix 2024-06-21 19:15:40 +08:00
jxxghp
34c3aa25da fix: 修复刮削功能中的路径错误 2024-06-21 12:17:57 +08:00
jxxghp
80690d4cc8 fix win 2024-06-21 11:02:42 +08:00
jxxghp
18f3dc2d44 fix buttons 2024-06-20 17:40:51 +08:00
jxxghp
e8256b4e1a fix bug 2024-06-20 15:34:30 +08:00
jxxghp
4f67bb0250 feat:文件管理批量选择 2024-06-20 15:32:17 +08:00
jxxghp
5dd071adf4 fix bug 2024-06-20 14:01:20 +08:00
jxxghp
aaf5e7f49d feat:阿里云盘支持备份盘 2024-06-20 13:16:05 +08:00
jxxghp
6a5958409a fix:优化文件管理 2024-06-20 11:39:25 +08:00
jxxghp
e0ff98b1d7 fix store 2024-06-20 08:11:47 +08:00
jxxghp
a815e07cdd fix store 2024-06-20 07:08:47 +08:00
jxxghp
aa2fe9740c fix 2024-06-19 18:02:47 +08:00
jxxghp
75a358a4d2 feat: improve QR code UI and add loading skeleton 2024-06-19 16:07:37 +08:00
jxxghp
d5646be6f8 fix qrcode ui 2024-06-19 15:58:26 +08:00
jxxghp
cb04ebcd95 批量重命名进度条 2024-06-19 15:20:50 +08:00
jxxghp
9889ccfc74 feat: add keepAlive meta property to filemanager route 2024-06-19 14:43:38 +08:00
jxxghp
f528bd861a chore: update file list layout for renaming feature 2024-06-19 13:45:08 +08:00
jxxghp
f793654bd8 fix 115 2024-06-19 13:02:24 +08:00
jxxghp
8d064a2165 add storage type 2024-06-19 07:12:35 +08:00
jxxghp
1240899b08 fix api path 2024-06-18 19:19:47 +08:00
jxxghp
558752b890 feat:文件管理批量重命名 2024-06-18 16:46:20 +08:00
jxxghp
997548b7d6 feat:自动识别命名 2024-06-18 13:56:34 +08:00
jxxghp
865d597fe8 add thumbnail 2024-06-18 13:05:25 +08:00
jxxghp
b0a043b464 fix 2024-06-18 12:04:10 +08:00
jxxghp
e003b6f9a7 fix aliyunpan ui 2024-06-18 12:01:38 +08:00
jxxghp
9e9e940dfd fix filelist dropdownmenu 2024-06-18 07:12:33 +08:00
jxxghp
d6dac704eb fix 2024-06-18 07:03:29 +08:00
jxxghp
9aa8dff650 fix aliyunpan 2024-06-17 21:04:13 +08:00
jxxghp
14c2503b0d add aliyun 2024-06-17 19:46:21 +08:00
jxxghp
cb282c6f9a Merge pull request #160 from falling/main 2024-06-17 15:55:54 +08:00
falling
66a5a40482 TorrentCardListView.vue
使用VInfiniteScroll
2024-06-17 15:39:13 +08:00
falling
8d211ed20b Merge branch 'jxxghp:main' into main 2024-06-17 09:54:30 +08:00
falling
bbf2814285 TorrentCardListView.vue
显示性能优化,默认只显示前面20个,页面滚到底部才会再加载新的数据。
2024-06-17 09:53:58 +08:00
jxxghp
a15e479a3e Merge pull request #159 from xiangt920/patch-1
修复使用带subpath的反向代理时api可能无法访问的问题
2024-06-17 09:06:31 +08:00
xiangt920
505d6ec010 fix regex expression for denyList 2024-06-16 19:53:30 -05:00
jxxghp
314ac65e23 Merge pull request #158 from falling/main
TorrentRowListView 筛选bug 以及季集选项排序
2024-06-16 20:18:25 +08:00
falling
118a9a2c5d RowListView:
修复搜索页筛选显示bug以及过滤选项季集排序。
2024-06-16 20:09:50 +08:00
jxxghp
347f47bbef fix apps drag 2024-06-16 14:03:39 +08:00
jxxghp
a73c35468d fix login ui 2024-06-16 13:44:47 +08:00
jxxghp
f9a1446ed5 fix login ui 2024-06-16 09:48:56 +08:00
jxxghp
874ba45034 Merge pull request #157 from falling/main 2024-06-15 23:26:35 +08:00
falling
febe08eb9d 修复搜索页筛选显示bug 2024-06-15 22:50:37 +08:00
jxxghp
9123b34c82 add COOKIECLOUD_BLACKLIST 设置 2024-06-15 21:20:29 +08:00
jxxghp
c66d7cafa6 fix ui 2024-06-15 21:06:56 +08:00
jxxghp
73c54992e2 fix subscribe card 2024-06-15 19:13:26 +08:00
jxxghp
be1a44ad61 fix keepalive 2024-06-15 18:10:56 +08:00
jxxghp
28b307fb98 back arrow 2024-06-14 22:51:50 +08:00
jxxghp
a1dc723445 add select-none 2024-06-14 19:50:28 +08:00
jxxghp
23f4a70693 add drag delay 2024-06-14 19:40:58 +08:00
jxxghp
be5b4b39e5 fix ui 2024-06-14 15:51:38 +08:00
jxxghp
cf706e0e30 fix ui 2024-06-14 15:36:25 +08:00
jxxghp
8bc80d2088 fix ui 2024-06-14 15:12:11 +08:00
jxxghp
b94f8c92f0 fix https://github.com/jxxghp/MoviePilot/issues/2335 2024-06-14 14:38:30 +08:00
jxxghp
c3be75bed1 fix loading ui 2024-06-14 12:30:15 +08:00
jxxghp
91c8d8077f fix search ui 2024-06-14 11:39:00 +08:00
jxxghp
f598eed149 fix ui 2024-06-14 08:27:42 +08:00
jxxghp
971bae3be0 更新 Footer.vue 2024-06-14 07:44:54 +08:00
jxxghp
9a6abf4d5a 更新 Footer.vue 2024-06-14 07:27:09 +08:00
jxxghp
d756077a48 更新 Footer.vue 2024-06-14 07:26:55 +08:00
jxxghp
a1fc87bb1e fix footer ui 2024-06-14 07:15:17 +08:00
jxxghp
07186d2ae1 fix app mode margin 2024-06-13 20:39:27 +08:00
jxxghp
d2164d9ada fix ui 2024-06-13 20:29:53 +08:00
jxxghp
7eacaf8fc5 fix ui 2024-06-13 19:52:31 +08:00
jxxghp
9aa2de526e fix 2024-06-13 19:24:04 +08:00
jxxghp
12dfc5b407 fix app mode ui 2024-06-13 19:11:00 +08:00
jxxghp
1fc964ec16 add app mode 2024-06-13 17:30:50 +08:00
jxxghp
7f2f7b100b 更新 AccountSettingNotification.vue 2024-06-13 07:11:03 +08:00
jxxghp
8292140f1f 更新 package.json 2024-06-13 07:07:45 +08:00
jxxghp
c26e610a23 更新 MediaDetailView.vue 2024-06-13 07:06:49 +08:00
jxxghp
c96cfe81ab Merge pull request #154 from Mattoids/main 2024-06-11 18:43:06 +08:00
liufei
bb1cc0b60e 修复 套件版本无法添加用户的问题 2024-06-11 17:59:49 +08:00
jxxghp
1e74073344 更新 SearchBarView.vue 2024-06-10 17:05:11 +08:00
jxxghp
d83d1dd888 fix 榜单 & 订阅弹窗 & 订阅重置 2024-06-10 09:36:42 +08:00
jxxghp
e34573e72f fix webpush仅限管理员 2024-06-08 12:35:05 +08:00
jxxghp
9d3f4879ef feat:增加域名设置 2024-06-08 10:56:29 +08:00
jxxghp
6317277a70 v1.9.4-1 2024-06-08 07:46:20 +08:00
jxxghp
a1130ec60b feat:捷径根据参数自动打开 2024-06-08 07:45:45 +08:00
jxxghp
a1a3ccf6fb fix 2024-06-07 20:24:02 +08:00
jxxghp
aedb8bee9c fix service worker 2024-06-07 20:22:59 +08:00
jxxghp
6620d1c8fe fix service-worker 2024-06-07 08:34:09 +08:00
jxxghp
0ecc7dfead remove defer 2024-06-06 14:07:25 +08:00
jxxghp
9f5859ee93 feat:订阅重置 2024-06-06 07:57:45 +08:00
jxxghp
d559e1717c fix service worker 2024-06-05 22:21:27 +08:00
jxxghp
e649be58a2 add webpush switch 2024-06-05 18:42:39 +08:00
jxxghp
157c37c862 add service worker 2024-06-05 18:12:07 +08:00
jxxghp
da910ac670 Merge pull request #151 from hotlcc/develop-20240604-2 2024-06-04 17:53:02 +08:00
Allen
3831363815 删除和整理场景路由参数未改变,reloadPage不会生效,需要fetchData刷新数据 2024-06-04 17:41:09 +08:00
jxxghp
94a6ea13bd rollback 2024-06-04 16:18:06 +08:00
jxxghp
06c1ad0f69 更新 main.ts 2024-06-04 16:14:25 +08:00
jxxghp
d6873781e8 更新 SearchBarView.vue 2024-06-04 15:46:36 +08:00
jxxghp
ab6c9647a7 Merge pull request #149 from hotlcc/develop-20240604-1 2024-06-04 15:42:36 +08:00
Allen
59b0350993 针对异形屏做了优化 2024-06-04 15:28:17 +08:00
jxxghp
df0be4c070 更新 package.json 2024-06-04 14:02:59 +08:00
jxxghp
87f3ef4353 Merge pull request #148 from hotlcc/develop-20240604-1 2024-06-04 14:00:49 +08:00
Allen
2611bbaea4 弹窗 VDialog 在低版本 iOS Safari 浏览器下宽度异常问题处理 2024-06-04 13:54:48 +08:00
jxxghp
7c0d8cf792 Merge pull request #147 from hotlcc/develop-20240604-1 2024-06-04 12:54:09 +08:00
Allen
2d17baccd2 低版本safari主菜单样式兼容性处理 2024-06-04 12:33:18 +08:00
jxxghp
fe31723726 fix #145 2024-06-04 11:41:38 +08:00
jxxghp
bb10b22421 fix bug 2024-06-04 08:01:10 +08:00
jxxghp
6445f3a634 Merge pull request #144 from falling/main 2024-06-03 21:11:33 +08:00
falling
d1f28d9c94 资源搜索里的季集下拉列表,从字符串排序改成按季集排序 2024-06-03 21:01:12 +08:00
jxxghp
1e5366123c feat:近期搜索记忆 2024-06-03 16:35:32 +08:00
jxxghp
7feff7c90b fix 2024-06-03 11:45:15 +08:00
jxxghp
429b3bc045 Merge pull request #142 from hotlcc/develop-20240603
Develop 20240603
2024-06-03 11:36:01 +08:00
Allen
e76f1b89da fix number 2024-06-03 11:33:53 +08:00
Allen
f25e8595c3 fix number 2024-06-03 11:17:54 +08:00
jxxghp
6977ce55a3 Merge pull request #141 from hotlcc/develop-20240603
Develop 20240603
2024-06-03 11:09:51 +08:00
Allen
222e0e5ff2 fix encodeURIComponent 2024-06-03 11:04:17 +08:00
Allen
6996d9bbe2 历史记录页面搜索关键字、页码、页大小参数路优化,方便外部定位,同时为了解决支持kbar后路由参数和搜索框内容不一致的问题 2024-06-03 11:00:00 +08:00
jxxghp
f70e08adac Merge pull request #140 from hotlcc/develop-20240603
Develop 20240603
2024-06-03 10:47:04 +08:00
jxxghp
223ecc0e6b fix dialog persistent-hint 2024-06-03 10:43:28 +08:00
Allen
43f36f556c kbar支持历史记录 2024-06-03 10:16:01 +08:00
jxxghp
4579e00283 fix persistent-hint 2024-06-03 10:14:03 +08:00
Allen
b5e9b14048 站点卡片代理和仿真图标顺序与配置界面保存一致 2024-06-03 09:21:03 +08:00
Allen
2288e72c5f 站点卡片有代理等图标时高度保持一致 2024-06-03 09:19:52 +08:00
jxxghp
4882cc0417 release v1.9.3 2024-06-03 08:21:15 +08:00
jxxghp
499d3d0424 fix ui 2024-06-03 08:09:03 +08:00
jxxghp
d6b17debb4 fix loading banner 2024-06-02 21:27:24 +08:00
jxxghp
8f970e0008 feat:支持直接搜索站点资源 2024-06-02 21:10:02 +08:00
jxxghp
18d778a1cc feat:聚合搜索支持订阅 2024-06-02 19:50:28 +08:00
jxxghp
d667c4e45d v1.9.3 2024-06-02 18:50:55 +08:00
jxxghp
b7f8ffd56f fix 聚合搜索 2024-06-02 18:45:50 +08:00
jxxghp
c20f9d527f fix icon 2024-06-02 15:41:09 +08:00
jxxghp
b859d00cb9 fix 聚合搜索 2024-06-02 14:58:58 +08:00
jxxghp
a2d28ad360 feat:聚合搜索(working...) 2024-06-02 11:13:03 +08:00
jxxghp
c6702fbc18 更新 package.json 2024-06-01 22:31:44 +08:00
jxxghp
5018f96786 fix VFab 2024-06-01 22:07:19 +08:00
jxxghp
f29f408b67 feat:目录选择组件 2024-05-31 20:35:57 +08:00
jxxghp
a475a3b851 fix DirectoryTreeInput 2024-05-31 18:31:45 +08:00
jxxghp
9335f79c30 add DirectoryTreeInput 2024-05-31 15:06:58 +08:00
jxxghp
9dab691649 Merge pull request #139 from hotlcc/develop-20240531
vuetify升级至3.6.8后,设定中tab的选中样式改变,修复为原版选中样式
2024-05-31 14:10:36 +08:00
Allen
16abc65f49 vuetify升级至3.6.8后,设定中tab的选中样式改变,修复为原版选中样式 2024-05-31 13:00:35 +08:00
jxxghp
23ac80886d upgrade vuetify to 3.6.8 2024-05-31 11:46:27 +08:00
jxxghp
b242e757e0 add kbar 2024-05-31 11:26:19 +08:00
jxxghp
a69965a605 Merge pull request #138 from hotlcc/develop-20240531 2024-05-31 11:25:14 +08:00
Allen
3321427eb4 修正tab路由参数为query,解决原先采用params路由参数时导致主菜单选中状态不同步的问题 2024-05-31 11:18:44 +08:00
jxxghp
3ffe354770 v1.9.2-3 2024-05-31 08:04:48 +08:00
jxxghp
52e0d3a4bc fix tab route 2024-05-31 08:04:27 +08:00
jxxghp
e865a5ca62 Merge pull request #136 from hotlcc/develop-20240530 2024-05-30 15:24:06 +08:00
Allen
528a4ddb03 完善设定tab精确路由 2024-05-30 15:16:49 +08:00
jxxghp
36f3b649c6 Merge pull request #135 from hotlcc/develop-20240530 2024-05-30 11:59:54 +08:00
Allen
ce91c0cc30 await接口请求后才重新获取插件仪表板,解决仪表板调整配置保存时出现重复插件请求的问题 2024-05-30 11:36:13 +08:00
jxxghp
e31e9e3520 更新 dashboard.vue 2024-05-29 15:30:04 +08:00
jxxghp
df313ebe7f fix 2024-05-29 15:26:59 +08:00
jxxghp
e1cf36e952 v1.9.2-2 2024-05-29 15:17:10 +08:00
jxxghp
493194652c 仪表板组件高度拉齐开关 && 无边框组件背景不拉平 && 修复多次定时问题 2024-05-29 15:15:15 +08:00
jxxghp
5030e75c2c fix ui 2024-05-29 09:17:22 +08:00
jxxghp
3c70eac7ca fix 种子剧集过滤 2024-05-27 09:12:46 +08:00
jxxghp
f9b22962a4 fix hover 2024-05-27 08:48:25 +08:00
jxxghp
7ce0c21b0c fix 2024-05-26 18:38:41 +08:00
jxxghp
7a7a8c923f fix 2024-05-26 18:15:41 +08:00
jxxghp
d5d5e28f7e fix 文件管理路径 2024-05-26 18:14:31 +08:00
jxxghp
b22ac27075 fix 2024-05-26 17:55:38 +08:00
jxxghp
3cb5f4bdfe fix 默认下载路径 2024-05-26 17:41:41 +08:00
jxxghp
d355e4575d fix #2179 根据路径自动匹配刮削开关 2024-05-26 09:37:42 +08:00
jxxghp
bdbb118e55 fix https://github.com/jxxghp/MoviePilot-Frontend/issues/131 2024-05-26 08:09:47 +08:00
jxxghp
9a174d99db Update MediaDirectoryCard.vue 2024-05-25 07:07:13 +08:00
jxxghp
9c8725066c Merge pull request #130 from hotlcc/develop-20240524-插件支持多仪表板组件 2024-05-24 15:48:16 +08:00
Allen
9f0f3de864 一个插件支持透出多个仪表板控件,并兼容历史 2024-05-24 14:56:33 +08:00
jxxghp
ac84ed2d6a v1.9.1-1 2024-05-24 11:20:16 +08:00
jxxghp
9d7e15f4df feat:同盘优先选项 2024-05-24 11:18:30 +08:00
jxxghp
c3563f4501 v1.9.1 2024-05-24 09:00:42 +08:00
jxxghp
a543202edc feat:订阅保存路径支持下拉选择 2024-05-24 08:16:10 +08:00
jxxghp
52cf517a91 站点拖动排序 2024-05-23 19:39:33 +08:00
jxxghp
11b649dc8c fix 手动整理选择目录
fix https://github.com/jxxghp/MoviePilot/issues/2145
2024-05-23 12:39:35 +08:00
jxxghp
19663bacb1 更新 TransferHistoryView.vue 2024-05-23 10:34:38 +08:00
jxxghp
41c276d0e0 更新 AccountSettingDirectory.vue 2024-05-23 09:17:11 +08:00
jxxghp
6bb73add28 release-beta 2024-05-23 08:42:00 +08:00
jxxghp
2c16b6c078 fix manual_transfer 2024-05-23 08:09:48 +08:00
jxxghp
5ddc955805 feat:目录设置UI 2024-05-22 18:01:53 +08:00
jxxghp
6a3afa4240 fix dashboard refresh 2024-05-21 20:20:51 +08:00
jxxghp
deabd7b83c fix ui 2024-05-21 10:51:10 +08:00
jxxghp
422e5858ef fix ui 2024-05-19 14:30:20 +08:00
101 changed files with 5889 additions and 3447 deletions

View File

@@ -1 +1,2 @@
VITE_API_BASE_URL=http://localhost:3001/api/v1/
VITE_PUBLIC_VAPID_KEY=BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM

View File

@@ -1 +1,2 @@
VITE_API_BASE_URL=api/v1/
VITE_PUBLIC_VAPID_KEY=BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
.DS_Store
dist
dist-ssr
dev-dist
*.local
/cypress/videos/

View File

@@ -15,7 +15,6 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="manifest.json" crossorigin="use-credentials" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -37,7 +36,7 @@
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
<svg width="100px" height="100px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
<svg width="10rem" height="10rem" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
<g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "1.9.0",
"version": "1.9.17",
"private": true,
"bin": "dist/service.js",
"scripts": {
@@ -36,13 +36,12 @@
"express": "^4.18.2",
"express-http-proxy": "^2.0.0",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"pull-refresh-vue3": "^0.3.1",
"qrcode.vue": "^3.4.1",
"sass": "^1.59.3",
"tailwindcss": "^3.3.2",
"unplugin-vue-define-options": "^1.3.5",
"vite-plugin-pwa": "^0.19.8",
"vue": "^3.3.2",
"vue-router": "^4.2.0",
"vue-toast-notification": "^3",
@@ -50,7 +49,7 @@
"vue3-apexcharts": "^1.4.1",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "3.5.14",
"vuetify": "3.6.8",
"vuetify-use-dialog": "^0.6.11",
"vuex": "^4.1.0",
"vuex-persistedstate": "^4.1.0",
@@ -92,6 +91,7 @@
"unplugin-vue-components": "^0.26.0",
"vite": "^5.2.8",
"vite-plugin-pages": "^0.32.1",
"vite-plugin-pwa": "^0.20.0",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.3",
"vue-shepherd": "^3.0.0",
@@ -101,4 +101,4 @@
"resolutions": {
"postcss": "8"
}
}
}

View File

@@ -3,10 +3,9 @@ body {
}
html {
overflow: hidden auto;
background: var(--initial-loader-bg, #fff);
min-block-size: calc(100% + env(safe-area-inset-top));
overflow-x: hidden;
overflow-y: auto;
}
#loading-bg {
@@ -20,8 +19,8 @@ html {
.loading-logo {
position: absolute;
inset-block-start: 40%;
inset-inline-start: calc(50% - 50px);
inset-block-start: 35%;
inset-inline-start: calc(50% - 5rem);
}
.loading {
@@ -83,4 +82,4 @@ html {
opacity: 1;
transform: rotate(1turn);
}
}
}

View File

@@ -1,80 +0,0 @@
{
"name": "MoviePilot",
"short_name": "MoviePilot",
"start_url": "./",
"display": "standalone",
"icons": [
{
"src": "./android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "./android-chrome-192x192_maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "./android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "./android-chrome-512x512_maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#28243D",
"background_color": "#28243D",
"shortcuts": [
{
"name": "推荐",
"url": "./ranking",
"icons": [
{
"src": "./sparkles-icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "电影订阅",
"url": "./subscribe-movie",
"icons": [
{
"src": "./clock-icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "电视剧订阅",
"url": "./subscribe-tv",
"icons": [
{
"src": "./clock-icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "设置",
"url": "./setting",
"icons": [
{
"src": "./cog-icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
]
}

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -14,7 +14,10 @@ function onClick() {
</script>
<template>
<IconBtn :class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'" @click.stop="onClick">
<IconBtn
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'"
@click.stop="onClick"
>
<VIcon icon="mdi-close" />
</IconBtn>
</template>

View File

@@ -1,28 +1,15 @@
<script lang="ts" setup>
// 定义输入参数
const props = defineProps({
progress: Number,
text: String
})
// 定义输入参数
const props = defineProps({
progress: Number,
text: String,
})
</script>
<template>
<div
class="w-full text-center text-gray-500 text-sm flex flex-col items-center"
>
<VProgressCircular
v-if="!props.text"
size="48"
indeterminate
color="primary"
/>
<VProgressCircular
v-if="props.progress"
class="mb-3"
color="primary"
:model-value="props.progress"
size="64"
/>
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center">
<VProgressCircular v-if="!props.text || !props.progress" class="mb-3" size="64" indeterminate color="primary" />
<VProgressCircular v-if="props.progress" class="mb-3" color="primary" :model-value="props.progress" size="64" />
<span>{{ props.text }}</span>
</div>
</template>

View File

@@ -1,12 +1,15 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useTheme } from 'vuetify'
import { useDisplay, useTheme } from 'vuetify'
import type { ThemeSwitcherTheme } from '@layouts/types'
import api from '@/api'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { useToast } from 'vue-toast-notification'
import { VAceEditor } from 'vue3-ace-editor'
// 显示器宽度
const display = useDisplay()
const props = defineProps<{
themes: ThemeSwitcherTheme[]
}>()
@@ -209,11 +212,17 @@ onMounted(() => {
</VList>
</VMenu>
<!-- 自定义 CSS -- -->
<VDialog v-model="cssDialog" persistent max-width="50rem">
<VDialog v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard title="自定义主题风格">
<DialogCloseBtn @click="cssDialog = false" />
<VDivider />
<VAceEditor v-model:value="customCSS" lang="css" :theme="editorTheme" style="block-size: 30rem" />
<VAceEditor
v-model:value="customCSS"
lang="css"
:theme="editorTheme"
style="block-size: 100%; min-block-size: 30rem"
/>
<VDivider />
<VCardText class="text-center">
<VBtn @click="saveCustomCSS" class="w-1/2">
<template #prepend>

View File

@@ -3,5 +3,5 @@
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
/* stylelint-enable */
background-color: rgb(var(--v-theme-surface), 0.9);
background-color: rgb(var(--v-theme-surface), 0.8);
}

View File

@@ -5,14 +5,14 @@
/**
* 修复低版本Safari等浏览器数组不支持at函数的问题
*/
export function fixArrayAt() {
if (!Array.prototype.at) {
Array.prototype.at = function(index: number) {
if (index >= 0) {
return this[index]
} else {
return this[this.length + index]
}
}
;(function fixArrayAt() {
if (!Array.prototype.at) {
Array.prototype.at = function (index: number) {
if (index >= 0) {
return this[index]
} else {
return this[this.length + index]
}
}
}
}
})()

View File

@@ -8,8 +8,7 @@ dayjs.extend(relativeTime)
dayjs.locale(ZH_CN)
export function avatarText(value: string) {
if (!value)
return ''
if (!value) return ''
const nameArray = value.split(' ')
return nameArray.map(word => word.charAt(0).toUpperCase()).join('')
@@ -19,7 +18,9 @@ export function avatarText(value: string) {
export function kFormatter(num: number) {
const regex = /\B(?=(\d{3})+(?!\d))/g
return Math.abs(num) > 9999 ? `${Math.sign(num) * +((Math.abs(num) / 1000).toFixed(1))}k` : Math.abs(num).toFixed(0).replace(regex, ',')
return Math.abs(num) > 9999
? `${Math.sign(num) * +(Math.abs(num) / 1000).toFixed(1)}k`
: Math.abs(num).toFixed(0).replace(regex, ',')
}
/**
@@ -29,9 +30,11 @@ export function kFormatter(num: number) {
* @param {string} value date to format
* @param {Intl.DateTimeFormatOptions} formatting Intl object to format with
*/
export function formatDate(value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) {
if (!value)
return value
export function formatDate(
value: string,
formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' },
) {
if (!value) return value
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
}
@@ -46,21 +49,19 @@ export function formatDateToMonthShort(value: string, toTimeForCurrentDay = true
const date = new Date(value)
let formatting: Record<string, string> = { month: 'short', day: 'numeric' }
if (toTimeForCurrentDay && isToday(date))
formatting = { hour: 'numeric', minute: 'numeric' }
if (toTimeForCurrentDay && isToday(date)) formatting = { hour: 'numeric', minute: 'numeric' }
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
}
export const prefixWithPlus = (value: number) => value > 0 ? `+${value}` : value
export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : value)
// 格式化为Sxx
export const formatSeason = (value: string) => value ? `S${value.padStart(2, '0')}` : ''
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
// 格式化为xx[TGMK]B
export function formatFileSize(bytes: number) {
if (bytes < 0)
throw new Error('字节数不能为负数。')
if (bytes < 0) throw new Error('字节数不能为负数。')
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
@@ -82,22 +83,18 @@ export function formatSeconds(seconds: number) {
let formattedTime = ''
if (hours > 0)
formattedTime += `${hours}小时`
if (hours > 0) formattedTime += `${hours}小时`
if (minutes > 0)
formattedTime += `${minutes}`
if (minutes > 0) formattedTime += `${minutes}`
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0)
formattedTime += `${remainingSeconds}`
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0) formattedTime += `${remainingSeconds}`
return formattedTime
}
// YYYY-MM-DD 转化为Date
export function parseDate(dateString: string): Date | null {
if (!dateString)
return null
if (!dateString) return null
const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day)
@@ -105,8 +102,7 @@ export function parseDate(dateString: string): Date | null {
// 文件大小格式化
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0)
return '0 bytes'
if (bytes === 0) return '0 bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
@@ -119,11 +115,9 @@ export function formatBytes(bytes: number, decimals = 2) {
// 格式化剧集列表
export function formatEp(nums: number[]): string {
if (!nums.length)
return ''
if (!nums.length) return ''
if (nums.length === 1)
return nums[0].toString()
if (nums.length === 1) return nums[0].toString()
// 将数组升序排序
nums.sort((a, b) => a - b)
@@ -134,44 +128,22 @@ export function formatEp(nums: number[]): string {
for (let i = 1; i < nums.length; i++) {
if (nums[i] === end + 1) {
end = nums[i]
}
else {
if (start === end)
formattedRanges.push(start.toString())
else
formattedRanges.push(`${start.toString()}-${end.toString()}`)
} else {
if (start === end) formattedRanges.push(start.toString())
else formattedRanges.push(`${start.toString()}-${end.toString()}`)
start = end = nums[i]
}
}
if (start === end)
formattedRanges.push(start.toString())
else
formattedRanges.push(`${start.toString()}-${end.toString()}`)
if (start === end) formattedRanges.push(start.toString())
else formattedRanges.push(`${start.toString()}-${end.toString()}`)
return formattedRanges.join('、')
}
// 将yyyy-mm-dd hh:mm:ss转换为时间差1小时前1天前
export function formatDateDifference(dateString: string): string {
// const timeDifference = dayjs().millisecond() - dayjs(dateString).millisecond()
// const secondsDifference = Math.floor(timeDifference / 1000)
// const minutesDifference = Math.floor(secondsDifference / 60)
// const hoursDifference = Math.floor(minutesDifference / 60)
// const daysDifference = Math.floor(hoursDifference / 24)
// if (daysDifference > 0)
// return `${daysDifference}天前`
// else if (hoursDifference > 0)
// return `${hoursDifference}小时前`
// else if (minutesDifference > 0)
// return `${minutesDifference}分钟前`
// else
// return '刚刚'
if (!dateString)
return ''
if (!dateString) return ''
return dayjs(dateString).fromNow()
}

View File

@@ -1,7 +1,6 @@
// 👉 IsEmpty
export function isEmpty(value: unknown): boolean {
if (value === null || value === undefined || value === '')
return true
if (value === null || value === undefined || value === '') return true
return !!(Array.isArray(value) && value.length === 0)
}
@@ -33,73 +32,6 @@ export function isToday(date: Date) {
)
}
/**
* 计算时间差返回xx天/xx小时/xx分钟/xx秒
*
* @deprecated 建议使用:@core/utils/formatters.ts formatDateDifference
*/
export function calculateTimeDifference(inputTime: string): string {
if (!inputTime)
return ''
const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
const currentDate = new Date()
const timeDifference = currentDate.getTime() - inputDate.getTime()
const secondsDifference = Math.floor(timeDifference / 1000)
if (secondsDifference < 60) {
return `${secondsDifference}`
}
else if (secondsDifference < 3600) {
const minutes = Math.floor(secondsDifference / 60)
return `${minutes}分钟`
}
else if (secondsDifference < 86400) {
const hours = Math.floor(secondsDifference / 3600)
return `${hours}小时`
}
else {
const days = Math.floor(secondsDifference / 86400)
return `${days}`
}
}
// 计算时间差返回xx天xx小时xx分钟
export function calculateTimeDiff(inputTime: string): string {
if (!inputTime)
return ''
// 使用当前时区
const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
const currentDate = new Date()
const timeDifference = currentDate.getTime() - inputDate.getTime()
const secondsDifference = Math.floor(timeDifference / 1000)
const days = Math.floor(secondsDifference / 86400)
const hours = Math.floor(secondsDifference % 86400 / 3600)
const minutes = Math.floor(secondsDifference % 86400 % 3600 / 60)
const secones = Math.floor(secondsDifference % 60)
if (days > 0)
return `${days}${hours}小时${minutes}分钟`
else if (hours > 0)
return `${hours}小时${minutes}分钟`
else if (minutes > 0)
return `${minutes}分钟`
else if (secones > 0)
return `${secones}`
return ''
}
// 判断一个数组subArray是不是在另一个数组mainArray中
export function isContained(subArray: any[], mainArray: any[]): boolean {
return subArray.every(element => mainArray.includes(element))
@@ -112,8 +44,7 @@ export function isIntersected(array1: any[], array2: any[]): boolean {
export function isNullOrEmptyObject(obj: any): boolean {
// 首先判断是否为 null 或 undefined
if (obj === null || obj === undefined)
return true
if (obj === null || obj === undefined) return true
// 然后判断是否为空对象
return !!(typeof obj === 'object' && Object.keys(obj).length === 0)
@@ -127,3 +58,10 @@ export function checkPrefersColorSchemeIsDark(): boolean {
return false
}
}
// 从URL中获取参数值
export function getQueryValue(key: string, url = window.location.href): string {
const reg = new RegExp(`[?&]${key}=([^&#]*)`, 'i')
const res = reg.exec(url)
return res ? res[1] : ''
}

View File

@@ -28,3 +28,17 @@ export async function copyToClipboard(content: string) {
document.body.removeChild(input)
}
}
// VAPID公钥转Uint8Array
export function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}

View File

@@ -33,7 +33,10 @@ defineProps<{
.nav-link a {
display: flex;
align-items: center;
border-radius: 0 3.125rem 3.125rem 0 !important;
cursor: pointer;
margin-inline-end: 1.125em;
padding-inline: 1.375rem 1rem;
}
}
</style>

View File

@@ -18,3 +18,12 @@ defineProps<{
</div>
</li>
</template>
<style lang="scss">
.layout-vertical-nav {
.nav-section-title {
padding-left: 1.375rem;
padding-right: 1rem;
}
}
</style>

View File

@@ -120,6 +120,12 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
disable?: boolean
}
export interface NavMenu extends NavLink {
header: string
admin: boolean
description?: string
}
// 👉 Vertical nav group
export interface NavGroup extends Partial<AclProperties> {
title: string

View File

@@ -10,6 +10,8 @@ import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
import modeCssUrl from 'ace-builds/src-noconflict/mode-css?url'
import modePythonUrl from 'ace-builds/src-noconflict/mode-python?url'
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
@@ -38,6 +40,8 @@ import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
import snippertsCssUrl from 'ace-builds/src-noconflict/snippets/css?url'
import snippetsPythonUrl from 'ace-builds/src-noconflict/snippets/python?url'
import 'ace-builds/src-noconflict/ext-language_tools'
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
@@ -45,6 +49,7 @@ ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
ace.config.setModuleUrl('ace/mode/python', modePythonUrl)
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
@@ -59,5 +64,6 @@ ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
ace.config.setModuleUrl('ace/snippets/python', snippetsPythonUrl)
ace.require('ace/ext/language_tools')

View File

@@ -8,32 +8,32 @@ const api = axios.create({
})
// 添加请求拦截器
api.interceptors.request.use((config) => {
api.interceptors.request.use(config => {
// 在请求头中添加token
const token = store.state.auth.token
if (token)
config.headers.Authorization = `Bearer ${token}`
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// 添加响应拦截器
api.interceptors.response.use((response) => {
return response.data
}, (error) => {
if (!error.response) {
// 请求超时
api.interceptors.response.use(
response => {
return response.data
},
error => {
if (!error.response) {
// 请求超时
return Promise.reject(new Error(error))
} else if (error.response.status === 403) {
// 清除登录状态信息
store.dispatch('auth/logout')
// token验证失败跳转到登录页面
router.push('/login')
}
return Promise.reject(error)
}
else if (error.response.status === 403) {
// 清除登录状态信息
store.dispatch('auth/clearToken')
// token验证失败跳转到登录页面
router.push('/login')
}
return Promise.reject(error)
})
},
)
export default api

View File

@@ -58,11 +58,13 @@ export interface Subscribe {
// 当前优先级
current_priority: number
// 保存目录
save_path: string
save_path?: string
// 时间
date: string
// 编辑框设置项
show_edit_dialog: boolean
// 编辑框打开状态
page_open?: boolean
}
// 历史记录
@@ -445,6 +447,8 @@ export interface Plugin {
history?: { [key: string]: string }
// 添加时间
add_time?: number
// 页面打开状态
page_open?: boolean
}
// 渲染结构
@@ -464,6 +468,8 @@ export interface DashboardItem {
id: string
// 名称
name: string
// 插件的仪表板key
key: string
// 全局配置
attrs: { [key: string]: any }
// col列数
@@ -714,12 +720,7 @@ export interface NotificationSwitch {
slack: boolean
synologychat: boolean
vocechat: boolean
}
// 环境设置
export interface Setting {
// 下载目录
DOWNLOAD_PATH: string
webpush: boolean
}
// 文件浏览接口
@@ -740,22 +741,32 @@ export interface EndPoints {
// 文件浏览项目
export interface FileItem {
// 类型
// 类型 dir/file
type: string
// 文件名
name: string
// 文件名不含扩展名
basename: string
basename?: string
// 文件路径
path: string
// 文件扩展名
extension: string
extension?: string
// 文件大小
size: number
size?: number
// 文件子元素
children: FileItem[]
children?: FileItem[]
// 文件创建时间
modify_time: number
modify_time?: number
// 文件ID
fileid?: string
// 上级文件ID
parent_fileid?: string
// 缩略图
thumbnail?: string
// pickcode
pickcode?: string
// drive_id
drive_id?: string
}
// 媒体服务器播放条目
@@ -831,3 +842,23 @@ export interface SystemNotification {
// 通知时间
date: string
}
// 下载目录/媒体库目录
export interface MediaDirectory {
// 类型 download/library
type?: string
// 别名
name?: string
// 路径
path?: string
// 媒体类型 电影/电视剧
media_type?: string
// 媒体类别 动画电影/国产剧
category?: string
// 刮削媒体信息
scrape?: boolean
// 自动二级分类,未指定类别时自动分类
auto_category?: boolean
// 优先级
priority?: number
}

View File

@@ -1,19 +1,28 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import axios from 'axios'
import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue'
import type { EndPoints } from '@/api/types'
import type { EndPoints, FileItem } from '@/api/types'
import api from '@/api'
import AliyunAuthDialog from './dialog/AliyunAuthDialog.vue'
import U115AuthDialog from './dialog/U115AuthDialog.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数
const props = defineProps({
storages: String,
storage: String,
path: String,
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
axios: Object as PropType<Axios>,
axios: {
type: Object as PropType<Axios>,
required: true,
},
axiosconfig: Object,
item: {
type: Object as PropType<FileItem>,
required: true,
},
itemstack: Array as PropType<FileItem[]>,
})
// 对外事件
@@ -25,6 +34,16 @@ const availableStorages = [
code: 'local',
icon: 'mdi-folder-multiple-outline',
},
{
name: '阿里云盘',
code: 'aliyun',
icon: 'mdi-cloud-outline',
},
{
name: '115网盘',
code: 'u115',
icon: 'mdi-cloud-outline',
},
]
const fileIcons = {
@@ -57,8 +76,14 @@ const activeStorage = ref('local')
const refreshPending = ref(false)
// 排序
const sort = ref('name')
// axios实例
const axiosInstance = ref<Axios>()
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 阿里云盘用户信息
const aliyunUserInfo = ref<{ [key: string]: any }>({})
// 115网盘认证对话框
const u115AuthDialog = ref(false)
// 115网盘用户信息
const u115UserInfo = ref<{ [key: string]: any }>({})
// 计算属性
const storagesArray = computed(() => {
@@ -68,19 +93,56 @@ const storagesArray = computed(() => {
// 方法
function loadingChanged(loading: number) {
if (loading)
loading++
else if (loading > 0)
loading--
if (loading) loading++
else if (loading > 0) loading--
}
function storageChanged(storage: string) {
// 查询阿里云
async function loadAliyunUserInfo() {
try {
const result: { [key: string]: any } = await api.get('aliyun/userinfo')
if (result.success) {
aliyunUserInfo.value = result
}
} catch (error) {
console.log(error)
}
}
// 查询115
async function loadU115UserInfo() {
try {
const result: { [key: string]: any } = await api.get('u115/storage')
if (result.success) {
u115UserInfo.value = result
}
} catch (error) {
console.log(error)
}
}
// 存储切换
async function storageChanged(storage: string) {
if (storage == 'aliyun') {
await loadAliyunUserInfo()
if (isNullOrEmptyObject(aliyunUserInfo.value)) {
aliyunAuthDialog.value = true
return
}
} else if (storage == 'u115') {
await loadU115UserInfo()
if (isNullOrEmptyObject(u115UserInfo.value)) {
u115AuthDialog.value = true
return
}
}
activeStorage.value = storage
emit('pathchanged', { path: '/', fileid: 'root' })
}
// 路径变化
function pathChanged(_path: string) {
emit('pathchanged', _path)
function pathChanged(item: FileItem) {
emit('pathchanged', item)
}
// 排序变化
@@ -89,33 +151,40 @@ function sortChanged(s: string) {
refreshPending.value = true
}
// 初始化
onMounted(() => {
activeStorage.value = props.storage ?? 'local'
axiosInstance.value = props.axios ?? axios.create(props.axiosconfig)
})
// aliyun认证完成
function aliyunAuthDone() {
aliyunAuthDialog.value = false
activeStorage.value = 'aliyun'
}
// u115认证完成
function u115AuthDone() {
u115AuthDialog.value = false
activeStorage.value = 'u115'
}
</script>
<template>
<VCard class="mx-auto" :loading="loading > 0 || !path">
<div v-if="path">
<VCard class="mx-auto" :loading="loading > 0">
<div v-if="activeStorage && item">
<FileToolbar
:path="path"
:item="item"
:itemstack="itemstack"
:storages="storagesArray"
:storage="activeStorage"
:endpoints="endpoints"
:axios="axiosInstance"
:axios="axios"
@storagechanged="storageChanged"
@pathchanged="pathChanged"
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<FileList
:path="path"
:item="item"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:axios="axios"
:refreshpending="refreshPending"
:sort="sort"
@pathchanged="pathChanged"
@@ -126,4 +195,11 @@ onMounted(() => {
/>
</div>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
@close="aliyunAuthDialog = false"
@done="aliyunAuthDone"
/>
<U115AuthDialog v-if="u115AuthDialog" v-model="u115AuthDialog" @close="u115AuthDialog = false" @done="u115AuthDone" />
</template>

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup>
import type { MediaDirectory } from '@/api/types'
import { VTextField } from 'vuetify/lib/components/index.mjs'
// 输入参数
const props = defineProps({
type: String, // download/library
directory: {
type: Object as PropType<MediaDirectory>,
required: true, // 必填参数
},
categories: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
width: String,
height: String,
})
// 路径
const path = ref<string>('')
// 类型下拉字典
const typeItems = [
{ title: '全部', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
]
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed', 'update:modelValue'])
// 按钮点击
function onClose() {
emit('close')
}
// 路径更新
function updatePath(value: string) {
path.value = value
emit('update:modelValue', value)
}
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value
return default_value.concat(props.categories[props.directory.media_type ?? ''])
})
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height">
<DialogCloseBtn @click="onClose" />
<VCardItem>
<VTextField
v-model="props.directory.name"
variant="underlined"
label="别名"
class="me-20 text-high-emphasis font-weight-bold"
/>
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol>
<VPathField @update:modelValue="updatePath">
<template #activator="{ menuprops }">
<VTextField v-model="props.directory.path" v-bind="menuprops" variant="underlined" label="路径" />
</template>
</VPathField>
</VCol>
</VRow>
<VRow>
<VCol cols="4">
<VSelect
v-model="props.directory.media_type"
variant="underlined"
:items="typeItems"
label="媒体类型"
@update:modelValue="props.directory.category = ''"
/>
</VCol>
<VCol>
<VSelect v-model="props.directory.category" variant="underlined" :items="getCategories" label="媒体类别" />
</VCol>
</VRow>
<VRow>
<VCol v-if="!props.directory.category || props.directory.category === ''">
<VSwitch v-model="props.directory.auto_category" label="自动分类"></VSwitch>
</VCol>
<VCol v-if="type === 'library'">
<VSwitch v-model="props.directory.scrape" label="刮削元数据"></VSwitch>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</template>

View File

@@ -21,6 +21,14 @@ function filtersChanged(value: string[]) {
emit('changed', props.pri, value)
}
// 清洗规则中的换行符和多余空格,并在前后添加空格
const cleanedRules = computed(() => {
return props.rules.map(rule => {
rule = rule ?? ''
return ` ${rule.replace(/[\r\n]/g, '').replace(/\s+/g, '')} `
})
})
// 过滤规则下拉框
const selectFilterOptions = ref<{ [key: string]: string }[]>([
{ title: '特效字幕', value: ' SPECSUB ' },
@@ -77,7 +85,7 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
<VRow>
<VCol>
<VSelect
v-model="props.rules"
v-model="cleanedRules"
variant="underlined"
:items="selectFilterOptions"
chips

View File

@@ -35,36 +35,28 @@ function imageErrorHandler() {
// 默认图片
function getDefaultImage() {
if (props.media?.server === 'plex')
return plex
else if (props.media?.server === 'emby')
return emby
else if (props.media?.server === 'jellyfin')
return jellyfin
else
return plex
if (props.media?.server === 'plex') return plex
else if (props.media?.server === 'emby') return emby
else if (props.media?.server === 'jellyfin') return jellyfin
else return plex
}
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
if (props.media?.link) window.open(props.media?.link, '_blank')
}
// 生成图片代理路径
function getImgUrl(url: string) {
if (!url)
return getDefaultImage()
else
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
if (!url) return getDefaultImage()
else return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
}
// 根据多张图片生成媒体库封面
async function drawImages(imageList: string[]) {
// 图片
const IMAGES = imageList
if (IMAGES.length === 0)
return getDefaultImage()
if (IMAGES.length === 0) return getDefaultImage()
// 为所有图片添加system/img前缀
for (let i = 0; i < IMAGES.length; i++)
@@ -72,8 +64,7 @@ async function drawImages(imageList: string[]) {
// canvas
const canvas = canvasRef.value
if (!canvas)
return getDefaultImage()
if (!canvas) return getDefaultImage()
// 画布参数
const POSTER_WIDTH = (canvas.width - 32) / 4
@@ -85,8 +76,7 @@ async function drawImages(imageList: string[]) {
// 获取画布上下文
const ctx = canvas.getContext('2d')
if (!ctx)
return getDefaultImage()
if (!ctx) return getDefaultImage()
// 设置背景色为黑色
ctx.fillStyle = '#000000'
@@ -94,16 +84,14 @@ async function drawImages(imageList: string[]) {
// 绘制图片
async function drawImageWithReflection(imgSrc: string, index: number) {
if (!canvas)
return
if (!canvas) return
if (!ctx)
return
if (!ctx) return
const img = new Image()
img.setAttribute('crossorigin', 'anonymous')
img.src = imgSrc
await new Promise(resolve => img.onload = resolve)
await new Promise(resolve => (img.onload = resolve))
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
const y = MARGIN_HEIGHT
@@ -125,12 +113,7 @@ async function drawImages(imageList: string[]) {
REFLECTION_HEIGHT,
)
const gradient = ctx.createLinearGradient(
0,
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
0,
REFLECTION_HEIGHT,
)
const gradient = ctx.createLinearGradient(0, REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT, 0, REFLECTION_HEIGHT)
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)')
@@ -142,8 +125,7 @@ async function drawImages(imageList: string[]) {
// 绘制多张图片
const loopCount = Math.min(4, IMAGES.length)
for (let i = 0; i < loopCount; i++)
await drawImageWithReflection(IMAGES[i], i + 1)
for (let i = 0; i < loopCount; i++) await drawImageWithReflection(IMAGES[i], i + 1)
// 转换为图片地址
return canvas.toDataURL('image/png')
@@ -152,17 +134,12 @@ async function drawImages(imageList: string[]) {
onMounted(async () => {
if (props.media?.image_list && props.media?.image_list.length > 0)
imgUrl.value = await drawImages(props.media?.image_list || [])
else
imgUrl.value = getImgUrl(props.media?.image || '')
else imgUrl.value = getImgUrl(props.media?.image || '')
})
</script>
<template>
<VHover
v-bind="props"
:height="props.height"
:width="props.width"
>
<VHover v-bind="props" :height="props.height" :width="props.width">
<template #default="hover">
<VCard
v-bind="hover.props"
@@ -175,13 +152,7 @@ onMounted(async () => {
>
<template #image>
<canvas ref="canvasRef" class="w-full h-full hidden" />
<VImg
:src="imgUrl"
aspect-ratio="2/3"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
@@ -190,7 +161,7 @@ onMounted(async () => {
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1 class="mb-1 text-white font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.name }}
</h1>
</VCardText>
@@ -200,3 +171,9 @@ onMounted(async () => {
</template>
</VHover>
</template>
<style lang="scss">
.text-shadow {
text-shadow: 1px 1px #777;
}
</style>

View File

@@ -150,55 +150,61 @@ const dropdownItems = ref([
<template>
<VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
<div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" class="text-white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<VAvatar size="6rem">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
<div class="me-n3 absolute bottom-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<VCardTitle>
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="pb-2">
<div>{{ props.plugin?.plugin_desc }}</div>
<div>
<VChip v-for="label in pluginLabels" variant="tonal" size="small" class="me-1 my-1" color="info" label>
{{ label }}
</VChip>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
></div>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="text-white px-2 py-1 text-shadow line-clamp-3">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
</VCardText>
<VCardText class="flex align-self-baseline pb-2 w-full align-end">
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-account" class="me-1" />
<VIcon icon="mdi-github" class="me-1" />
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
@@ -220,15 +226,3 @@ const dropdownItems = ref([
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
.card-cover-blurred::before {
position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
background: rgba(29, 39, 59, 48%);
content: '';
inset: 0;
}
</style>

View File

@@ -365,70 +365,93 @@ const dropdownItems = ref([
// 监听插件状态变化
watch(
() => props.plugin?.has_update,
(newHasUpdate, oldHasUpdate) => {
(newHasUpdate, _) => {
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
},
)
// 监听插件窗口状态变化
watch(
() => props.plugin?.page_open,
(newOpenState, _) => {
if (newOpenState) openPluginDetail()
},
)
</script>
<template>
<!-- 插件卡片 -->
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
<div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 left-1">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" class="text-white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<VAvatar size="6rem">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
<div class="me-n3 absolute bottom-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<VCardItem class="py-2">
<VCardTitle class="flex items-center flex-row">
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" />
{{ props.plugin?.plugin_name }}
<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
</VCardItem>
<VCardText class="pb-1">
{{ props.plugin?.plugin_desc }}
</VCardText>
<VCardText class="flex justify-end align-self-baseline p-1 w-full align-end">
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
/>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
<VBadge v-if="props.plugin?.state" dot inline color="success" />
{{ props.plugin?.plugin_name }}
<span class="text-sm mt-1 text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="px-2 py-1 text-white text-shadow line-clamp-3">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" class="me-1" />
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-fire" />
<span class="text-sm ms-1">{{ props.count?.toLocaleString() }}</span>
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
</VCardText>
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
</VCard>
<!-- 插件配置页面 -->
@@ -456,7 +479,7 @@ watch(
<VCardText class="min-h-40">
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
</VCardText>
<VFab icon="mdi-cog" location="bottom end" size="x-large" fixed app appear @click="showPluginConfig" />
<VFab icon="mdi-cog" location="bottom" size="x-large" fixed app appear @click="showPluginConfig" />
</VCard>
</VDialog>

View File

@@ -167,6 +167,12 @@ watch(resourceDialog, value => {
if (!value) getSiteStats()
})
// 保存站点
function saveSite() {
siteEditDialog.value = false
emit('update')
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
@@ -175,150 +181,142 @@ onMounted(() => {
</script>
<template>
<VCard
:height="cardProps.height"
:width="cardProps.width"
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
class="overflow-hidden"
@click="siteEditDialog = true"
>
<template #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem>
<VCardTitle class="font-bold">
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
</VCardTitle>
<VCardSubtitle>
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
</VCardSubtitle>
</VCardItem>
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
<VCardText class="py-2">
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
</template>
</VTooltip>
<VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
</template>
</VTooltip>
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
</template>
</VTooltip>
<VTooltip v-if="cardProps.site?.filter" text="过滤">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-filter-cog-outline" />
</template>
</VTooltip>
</VCardText>
<VDivider />
<VCardActions>
<VBtn v-if="!cardProps.site?.public" :disabled="updateButtonDisable" @click.stop="handleSiteUpdate">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
更新
</VBtn>
<VBtn :disabled="testButtonDisable" @click.stop="testSite">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
{{ testButtonText }}
</VBtn>
<VBtn @click.stop="handleResourceBrowse">
<template #prepend>
<VIcon icon="mdi-web" />
</template>
浏览
</VBtn>
</VCardActions>
</VCard>
<!-- 更新站点Cookie & UA弹窗 -->
<VDialog v-model="siteCookieDialog" max-width="50rem">
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<DialogCloseBtn @click="siteCookieDialog = false" />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="userPwForm.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.code" label="两步验证" />
</VCol>
</VRow>
</VForm>
<div>
<VCard
:height="cardProps.height"
:width="cardProps.width"
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
class="overflow-hidden"
@click="siteEditDialog = true"
>
<template #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem style="padding-block-end: 0;">
<VCardTitle class="font-bold">
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
</VCardTitle>
<VCardSubtitle>
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
</VCardSubtitle>
</VCardItem>
<VCardText class="py-2" style="block-size: 36px;">
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
</template>
</VTooltip>
<VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
</template>
</VTooltip>
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
</template>
</VTooltip>
<VTooltip v-if="cardProps.site?.filter" text="过滤">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-filter-cog-outline" />
</template>
</VTooltip>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="updateSiteCookie" prepend-icon="mdi-refresh" class="px-5"> 开始更新 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<SiteAddEditDialog
v-if="siteEditDialog"
v-model="siteEditDialog"
:siteid="cardProps.site?.id"
@save="
() => {
siteEditDialog = false
emit('update')
}
"
@remove="emit('remove')"
@close="siteEditDialog = false"
/>
<!-- 站点资源弹窗 -->
<VDialog
v-if="resourceDialog"
v-model="resourceDialog"
max-width="80rem"
scrollable
z-index="1010"
:fullscreen="!display.mdAndUp.value"
>
<!-- Dialog Content -->
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
<DialogCloseBtn @click="resourceDialog = false" />
<VDivider />
<VCardText class="pt-2">
<SiteTorrentTable :site="cardProps.site?.id" />
</VCardText>
<VCardActions>
<VBtn v-if="!cardProps.site?.public" :disabled="updateButtonDisable" @click.stop="handleSiteUpdate">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
更新
</VBtn>
<VBtn :disabled="testButtonDisable" @click.stop="testSite">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
{{ testButtonText }}
</VBtn>
<VBtn @click.stop="handleResourceBrowse">
<template #prepend>
<VIcon icon="mdi-web" />
</template>
浏览
</VBtn>
</VCardActions>
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
<span class="absolute top-1 right-8">
<VIcon class="cursor-move">mdi-drag</VIcon>
</span>
</VCard>
</VDialog>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新站点Cookie & UA弹窗 -->
<VDialog v-model="siteCookieDialog" max-width="50rem">
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<DialogCloseBtn @click="siteCookieDialog = false" />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="userPwForm.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.code" label="两步验证" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="updateSiteCookie" prepend-icon="mdi-refresh" class="px-5"> 开始更新 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 站点编辑弹窗 -->
<SiteAddEditDialog
v-if="siteEditDialog"
v-model="siteEditDialog"
:siteid="cardProps.site?.id"
@save="saveSite"
@remove="emit('remove')"
@close="siteEditDialog = false"
/>
<!-- 站点资源弹窗 -->
<VDialog
v-if="resourceDialog"
v-model="resourceDialog"
max-width="80rem"
scrollable
z-index="1010"
:fullscreen="!display.mdAndUp.value"
>
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
<DialogCloseBtn @click="resourceDialog = false" />
<VDivider />
<VCardText class="pt-2">
<SiteTorrentTable :site="cardProps.site?.id" />
</VCardText>
</VCard>
</VDialog>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</div>
</template>
<style lang="scss">
<style lang="scss" scoped>
.v-table th {
white-space: nowrap;
}

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import { formatDateDifference } from '@/@core/utils/formatters'
import { formatSeason } from '@/@core/utils/formatters'
@@ -15,6 +16,9 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
// 确认框
const createConfirm = useConfirm()
// 提示框
const $toast = useToast()
@@ -34,8 +38,8 @@ function imageLoadHandler() {
// 根据 type 返回不同的图标
function getIcon() {
if (props.media?.type === '电影') return 'mdi-movie'
else if (props.media?.type === '电视剧') return 'mdi-television-classic'
if (props.media?.type === '电影') return 'mdi-movie-open'
else if (props.media?.type === '电视剧') return 'mdi-television-play'
else return 'mdi-help-circle'
}
@@ -48,16 +52,6 @@ function getPercentage() {
)
}
// 计算文本颜色
function getTextColor() {
return imageLoaded.value ? 'white' : ''
}
// 计算文本类
function getTextClass() {
return imageLoaded.value ? 'text-white' : ''
}
// 删除订阅
async function removeSubscribe() {
try {
@@ -84,11 +78,43 @@ async function searchSubscribe() {
}
}
// 重置订阅
async function resetSubscribe() {
// 确认
try {
const isConfirmed = await createConfirm({
title: '确认',
content: `重置后 ${props.media?.name} 已下载记录将被清除,未入库的剧集将会重新下载,是否确认?`,
})
if (!isConfirmed) return
// 重置
const result: { [key: string]: any } = await api.get(`subscribe/reset/${props.media?.id}`)
// 提示
if (result.success) {
$toast.success(`${props.media?.name} 重置成功!`)
emit('save')
} else $toast.error(`${props.media?.name} 重置失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
// 编辑订阅响应
async function editSubscribeDialog() {
subscribeEditDialog.value = true
}
// 查看详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
type: props.media?.type,
},
})
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -112,20 +138,22 @@ const dropdownItems = ref([
value: 3,
props: {
prependIcon: 'mdi-open-in-new',
click: () => {
router.push({
path: '/media',
query: {
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
type: props.media?.type,
},
})
},
click: viewMediaDetail,
},
},
{
title: '取消订阅',
title: '重置',
value: 4,
props: {
prependIcon: 'mdi-restore-alert',
click: resetSubscribe,
color: 'warning',
},
show: props.media?.type === '电视剧',
},
{
title: '取消订阅',
value: 5,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
@@ -133,6 +161,14 @@ const dropdownItems = ref([
},
},
])
// 监听插件窗口状态变化
watch(
() => props.media?.page_open,
(newOpenState, _) => {
if (newOpenState) editSubscribeDialog()
},
)
</script>
<template>
@@ -141,83 +177,103 @@ const dropdownItems = ref([
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col"
class="flex flex-col rounded-lg"
:class="{
'outline-dashed outline-1': props.media?.best_version,
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
min-height="170"
@click="editSubscribeDialog"
>
<div class="me-n3 absolute top-1 right-2">
<IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem
v-if="item.show !== false"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image>
<VImg
:src="props.media?.backdrop || props.media?.poster"
aspect-ratio="2/3"
aspect-ratio="3/2"
cover
class="brightness-50"
@load="imageLoadHandler"
/>
position="top"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<VCardItem>
<template #prepend>
<VIcon size="1.9rem" :color="getTextColor()" :icon="getIcon()" />
</template>
<VCardTitle :class="getTextClass()">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</VCardTitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" :color="getTextColor()" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<div>
<VCardText class="flex items-center">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VImg :src="props.media?.poster" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
</template>
</VCardItem>
<VCardText>
<p class="clamp-text mb-0" :class="getTextClass()">
{{ props.media?.description }}
</p>
</VCardText>
<VCardText class="d-flex justify-space-between align-center flex-wrap">
<div class="d-flex align-center">
<IconBtn
v-if="props.media?.total_episode"
v-bind="props"
icon="mdi-progress-clock"
:color="getTextColor()"
class="me-1"
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div class="mr-2 min-w-0 text-lg font-bold text-white">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn
v-if="props.media?.total_episode"
v-bind="props"
icon="mdi-progress-download"
color="white"
class="me-1"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-4 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
</div>
<IconBtn v-if="props.media?.username" icon="mdi-account" color="white" class="me-1" />
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
/>
<span v-if="props.media?.season" class="text-subtitle-2 me-4" :class="getTextClass()"
>{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}</span
>
<IconBtn v-if="props.media?.username" icon="mdi-account" :color="getTextColor()" class="me-1" />
<span v-if="props.media?.username" class="text-subtitle-2 me-4" :class="getTextClass()">
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<VProgressLinear v-if="getPercentage() > 0" :model-value="getPercentage()" bg-color="success" color="success" />
</div>
</VCard>
</template>
</VHover>
@@ -241,3 +297,8 @@ const dropdownItems = ref([
@close="subscribeEditDialog = false"
/>
</template>
<style lang="scss">
.subscribe-card-background {
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
</style>

View File

@@ -70,18 +70,24 @@ async function handleAddDownload(_site: any = undefined, _media: any = undefined
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress()
try {
const result: { [key: string]: any } = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
let result: { [key: string]: any }
if (result.success) {
if (_media) {
result = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
} else {
result = await api.post('download/add', _torrent)
}
if (result && result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
downloaded.value.push(_torrent?.enclosure || '')
} else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败${result?.message}`)
}
} catch (error) {
console.error(error)
@@ -127,7 +133,7 @@ onMounted(() => {
</template>
<VCardItem class="py-1">
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
{{ media?.title }} {{ meta?.season_episode }}
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VCardTitle>

View File

@@ -67,18 +67,24 @@ async function handleAddDownload(_site: any = undefined, _media: any = undefined
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress()
try {
const result: { [key: string]: any } = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
let result: { [key: string]: any }
if (result.success) {
if (_media) {
result = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
} else {
result = await api.post('download/add', _torrent)
}
if (result && result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
downloaded.value.push(_torrent?.enclosure || '')
} else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败${result?.message}`)
}
} catch (error) {
console.error(error)

View File

@@ -0,0 +1,109 @@
<script lang="ts" setup>
import QrcodeVue from 'qrcode.vue'
import api from '@/api'
// 定义事件
const emit = defineEmits(['done', 'close'])
// 二维码内容
const qrCodeContent = ref('')
// ck参数
const ck = ref('')
// t参数
const t = ref('')
// 下方的提示信息
const text = ref('请用阿里云盘 App 扫码')
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
// timeout定时器
let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
emit('done')
}
// 调用/aliyun/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/aliyun/qrcode')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
ck.value = result.data.ck
t.value = result.data.t
} else {
text.value = result.message
}
} catch (e) {
console.error(e)
}
}
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
try {
const result: { [key: string]: any } = await api.get('/aliyun/check', {
params: {
ck: ck.value,
t: t.value,
},
})
if (result.success && result.data) {
const qrCodeStatus = result.data.qrCodeStatus
text.value = result.data.tip
if (qrCodeStatus == 'CONFIRMED') {
// 已确认完成
alertType.value = 'success'
handleDone()
} else if (qrCodeStatus == 'NEW' || qrCodeStatus == 'SCANED') {
alertType.value = 'info'
// 新建、待扫码
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
// 过期或者已取消
alertType.value = 'error'
}
} else {
alertType.value = 'error'
text.value = result.message
}
} catch (e) {
console.error(e)
}
}
onMounted(async () => {
await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000)
})
onUnmounted(() => {
if (timeoutTimer) clearTimeout(timeoutTimer)
})
</script>
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard title="阿里云盘登录" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 shadow-lg rounded text-center p-3 border">
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
</div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<template #prepend />
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -6,23 +6,28 @@ import api from '@/api'
import { numberValidator } from '@/@validators'
import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { FileItem, MediaDirectory } from '@/api/types'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
path: String,
target: String,
storage: {
type: String,
default: () => 'local',
},
logids: Array<number>,
items: Array<FileItem>,
target: String,
})
// 定义事件
const emit = defineEmits(['done', 'close'])
// 生成1到50季的下拉框选项
// 生成1到100季的下拉框选项
const seasonItems = ref(
Array.from({ length: 51 }, (_, i) => i).map(item => ({
Array.from({ length: 101 }, (_, i) => i).map(item => ({
title: `${item}`,
value: item,
})),
@@ -49,11 +54,26 @@ const progressText = ref('请稍候 ...')
// 整理进度
const progressValue = ref(0)
// 文件转移表单
// 标题
const dialogTitle = computed(() => {
if (props.items) {
if (props.items.length > 1) return `整理 - 共 ${props.items.length}`
return `整理 - ${props.items[0].path}`
} else if (props.logids) {
return `整理 - 共 ${props.logids.length}`
}
return '手动整理'
})
// 表单
const transferForm = reactive({
storage: props.storage,
logid: 0,
path: '',
target: props.target ?? '',
drive_id: '',
fileid: '',
filetype: '',
target: props.target ?? null,
tmdbid: null,
doubanid: null,
season: null,
@@ -64,11 +84,26 @@ const transferForm = reactive({
episode_part: '',
episode_offset: null,
min_filesize: 0,
scrape: false,
})
watchEffect(() => {
transferForm.path = props.path ?? ''
transferForm.target = props.target ?? ''
// 所有媒体库目录
const libraryDirectories = ref<MediaDirectory[]>([])
// 目的目录下拉框
const targetDirectories = computed(() => {
const directories = libraryDirectories.value.map(item => item.path)
return [...new Set(directories)]
})
// 监听目的路径变化,自动查询目录的刮削配置
watch(transferForm, async () => {
if (transferForm.target) {
const directory = libraryDirectories.value.find(item => item.path === transferForm.target)
if (directory) {
transferForm.scrape = directory.scrape ?? false
}
}
})
// 使用SSE监听加载进度
@@ -95,47 +130,25 @@ function stopLoadingProgress() {
}
// 整理文件
// eslint-disable-next-line sonarjs/cognitive-complexity
async function transfer() {
if (!props.logids && !props.path) return
if (!props.logids && !props.items) return
// 显示进度条
progressDialog.value = true
// 开始监听进度
startLoadingProgress()
if (props.path) {
// 文件整理
try {
const result: { [key: string]: any } = await api.post(
'transfer/manual',
{},
{
params: transferForm,
},
)
// 显示结果
if (result.success) $toast.success(`${props.path} 整理完成!`)
else $toast.error(`${props.path} 整理失败:${result.message}`)
} catch (e) {
console.log(e)
// 文件整理
if (props.items) {
for (const item of props.items) {
await handleTransfer(item)
}
} else if (props.logids) {
// 日志整理
}
// 日志整理
if (props.logids) {
for (const logid of props.logids) {
transferForm.logid = logid
try {
const result: { [key: string]: any } = await api.post(
'transfer/manual',
{},
{
params: transferForm,
},
)
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
await handleTransferLog(logid)
}
}
@@ -147,6 +160,32 @@ async function transfer() {
emit('done')
}
// 整理文件
async function handleTransfer(item: FileItem) {
transferForm.path = item.path
transferForm.fileid = item.fileid || ''
transferForm.drive_id = item.drive_id || ''
transferForm.filetype = item.type || 'dir'
try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
if (!result.success) $toast.error(`文件 ${item.path} 整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
// 整理日志
async function handleTransferLog(logid: number) {
transferForm.logid = logid
try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
// 调用API加载当前系统环境设置
async function loadSystemSettings() {
try {
@@ -157,31 +196,43 @@ async function loadSystemSettings() {
}
}
// 查询媒体库目录
async function loadLibraryDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/LibraryDirectories')
if (result.success && result.data?.value) {
libraryDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
onMounted(() => {
loadSystemSettings()
loadLibraryDirectories()
})
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`${props.path ? `整理 - ${props.path}` : `整理 - 共 ${props.logids?.length} 条记录`}`"
class="rounded-t"
>
<VCard :title="dialogTitle" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="8">
<VTextField
<VCol v-if="props.storage == 'local'" cols="12" md="8">
<VCombobox
v-model="transferForm.target"
:items="targetDirectories"
label="目的路径"
placeholder="留空自动"
hint="留空将自动整理到媒体库目录"
hint="整理目的路径,留空将自动匹配"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VCol v-if="props.storage == 'local'" cols="12" md="4">
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
@@ -194,6 +245,8 @@ onMounted(() => {
{ title: 'Rclone复制', value: 'rclone_copy' },
{ title: 'Rclone移动', value: 'rclone_move' },
]"
hint="文件操作整理方式"
persistent-hint
/>
</VCol>
</VRow>
@@ -207,6 +260,8 @@ onMounted(() => {
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
]"
hint="文件的媒体类型"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -218,7 +273,8 @@ onMounted(() => {
placeholder="留空自动识别"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
hint="点击图标按名称搜索,留空自动重新识别"
hint="按名称查询媒体编号,留空自动识别"
persistent-hint
@click:append-inner="mediaSelectorDialog = true"
/>
<VTextField
@@ -229,7 +285,8 @@ onMounted(() => {
placeholder="留空自动识别"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
hint="点击图标按名称搜索,留空自动重新识别"
hint="按名称查询媒体编号,留空自动识别"
persistent-hint
@click:append-inner="mediaSelectorDialog = true"
/>
</VCol>
@@ -239,6 +296,8 @@ onMounted(() => {
v-model.number="transferForm.season"
label="季"
:items="seasonItems"
hint="指定季数"
persistent-hint
/>
</VCol>
</VRow>
@@ -248,7 +307,8 @@ onMounted(() => {
v-model="transferForm.episode_format"
label="集数定位"
placeholder="使用{ep}定位集数"
hint="使用{ep}定位文件名中的集数部分,其余相同部分直接填写,不同部分使用{a}进行忽略,例如:{a}葬送的芙莉莲_Sousou no Frieren 第{ep}话{b}"
hint="使用{ep}定位文件名中的集数部分以辅助识别"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -256,7 +316,8 @@ onMounted(() => {
v-model="transferForm.episode_detail"
label="指定集数"
placeholder="起始集,终止集如1或1,2"
hint="直接指定集数或范围,格式:起始集,终止集,如1或1,2"
hint="指定集数或范围如1或1,2"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -264,7 +325,8 @@ onMounted(() => {
v-model="transferForm.episode_part"
label="指定Part"
placeholder="如part1"
hint="指定集数的Part如part1"
hint="指定Part如part1"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -272,7 +334,8 @@ onMounted(() => {
v-model.number="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
hint="集数进行偏移运算,如-10表示文件名中的集数减10为整理后集数"
hint="集数偏移运算,如-10或EP*2"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -281,7 +344,18 @@ onMounted(() => {
label="最小文件大小MB"
:rules="[numberValidator]"
placeholder="0"
hint="最小文件大小,小于此大小的文件将被忽略不进行整理"
hint="只整理大于最小文件大小的文件"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="transferForm.scrape"
label="刮削元数据"
hint="整理完成后自动刮削元数据"
persistent-hint
/>
</VCol>
</VRow>

View File

@@ -48,7 +48,7 @@ const statusItems = [
// 生成1到50的优先级下拉框选项
const priorityItems = ref(
Array.from({ length: 50 }, (_, i) => i + 1).map(item => ({
Array.from({ length: 100 }, (_, i) => i + 1).map(item => ({
title: item,
value: item,
})),
@@ -143,6 +143,7 @@ async function updateSiteInfo() {
label="站点地址"
:rules="[requiredValidator]"
hint="格式http://www.example.com/"
persistent-hint
/>
</VCol>
<VCol cols="6" md="3">
@@ -151,11 +152,18 @@ async function updateSiteInfo() {
label="优先级"
:items="priorityItems"
:rules="[requiredValidator]"
hint="站点资源下载优先级,优先级数字越小越优先下载"
hint="优先级越小越优先"
persistent-hint
/>
</VCol>
<VCol cols="6" md="3">
<VSelect v-model="siteForm.is_active" :items="statusItems" label="状态" />
<VSelect
v-model="siteForm.is_active"
:items="statusItems"
label="状态"
hint="站点启用/停用"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
@@ -163,34 +171,38 @@ async function updateSiteInfo() {
<VTextField
v-model="siteForm.rss"
label="RSS地址"
hint="订阅模式为站点RSS时,将会使用此地址获取站点种子资源,该地址一般会自动获取,也可手动补充"
hint="订阅模式为`站点RSS`时使用的订阅链接,如未自动获取手动补充"
persistent-hint
/>
</VCol>
<VCol cols="12" md="3">
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间,为空将使用默认值" />
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
</VCol>
<VCol cols="12">
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
hint="浏览器打开站点首页打开开发人员工具刷新页面后在网络选项中找到首页地址在请求头中获取Cookie信息"
/>
<VTextarea v-model="siteForm.cookie" label="站点Cookie" hint="站点请求头中的Cookie信息" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="在开发人员工具,网络请求头中获取Authorization,仅个别站点需要"
hint="站点请求头中Authorization信息,特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="siteForm.apikey" label="令牌API Key" hint="站点的访问API Key仅个别站点需要" />
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="在开发人员工具网络请求头中获取User-Agent信息需与站点Cookie配套使用"
hint="获取Cookie的浏览器对应的User-Agent"
persistent-hint
/>
</VCol>
</VRow>
@@ -200,7 +212,8 @@ async function updateSiteInfo() {
v-model="siteForm.limit_interval"
label="单位周期(秒)"
:rules="[numberValidator]"
hint="设定站点限流的单位周期单位为秒0为不限流"
hint="限流控制的单位周期时长"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -208,7 +221,8 @@ async function updateSiteInfo() {
v-model="siteForm.limit_count"
label="周期内访问次数"
:rules="[numberValidator]"
hint="设定单位周期内站点允许的访问次数0为不限制"
hint="单位周期内允许的访问次数"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -216,20 +230,17 @@ async function updateSiteInfo() {
v-model="siteForm.limit_seconds"
label="访问间隔(秒)"
:rules="[numberValidator]"
hint="设定单位周期内每次站点访问需间隔时间单位为秒0为不限制"
hint="每次访问需间隔的最小时间"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.proxy" label="代理" hint="站点是否需要代理访问,需要设置好代理服务器信息" />
<VSwitch v-model="siteForm.proxy" label="代理" hint="使用代理服务器访问该站点" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="siteForm.render"
label="仿真"
hint="站点是否需要使用浏览器模拟访问,开启可以一定程度上提升连通性,但会大大增加站点请求时间"
/>
<VSwitch v-model="siteForm.render" label="仿真" hint="使用浏览器模拟真实访问该站点" persistent-hint />
</VCol>
</VRow>
</VForm>

View File

@@ -2,7 +2,7 @@
import { useToast } from 'vue-toast-notification'
import { numberValidator } from '@/@validators'
import api from '@/api'
import type { Site, Subscribe } from '@/api/types'
import type { MediaDirectory, Site, Subscribe } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useConfirm } from 'vuetify-use-dialog'
@@ -25,6 +25,9 @@ const emit = defineEmits(['remove', 'save', 'close'])
// 站点数据列表
const siteList = ref<Site[]>([])
// 下载目录列表
const downloadDirectories = ref<MediaDirectory[]>([])
// 站点选择下载框
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
@@ -50,7 +53,7 @@ const subscribeForm = ref<Subscribe>({
last_update: '',
username: '',
current_priority: 0,
save_path: '',
save_path: undefined,
date: '',
show_edit_dialog: false,
})
@@ -167,6 +170,25 @@ async function removeSubscribe() {
}
}
// 查询下载目录
async function loadDownloadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
// 保存目录下拉框
const targetDirectories = computed(() => {
// 去重后的下载目录
const directories = downloadDirectories.value.map(item => item.path)
return [...new Set(directories)]
})
// 质量选择框数据
const qualityOptions = ref([
{
@@ -252,9 +274,9 @@ const effectOptions = ref([
])
onMounted(() => {
loadDownloadDirectories()
getSiteList()
if (props.subid) getSubscribeInfo()
if (props.default) queryDefaultSubscribeConfig()
})
</script>
@@ -279,7 +301,8 @@ onMounted(() => {
v-if="!props.default"
v-model="subscribeForm.keyword"
label="搜索关键词"
hint="定搜索关键词将使用此关键词搜索站点资源否则自动使用themoviedb中的名称搜索"
hint="定搜索站点时使用的关键词"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
@@ -287,7 +310,8 @@ onMounted(() => {
v-model="subscribeForm.total_episode"
label="总集数"
:rules="[numberValidator]"
hint="手动设定总集数"
hint="剧集总集数"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
@@ -295,19 +319,38 @@ onMounted(() => {
v-model="subscribeForm.start_episode"
label="开始集数"
:rules="[numberValidator]"
hint="只下载此集数及之后的集"
hint="开始订阅集数"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VSelect v-model="subscribeForm.quality" label="质量" :items="qualityOptions" />
<VSelect
v-model="subscribeForm.quality"
label="质量"
:items="qualityOptions"
hint="订阅资源质量"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect v-model="subscribeForm.resolution" label="分辨率" :items="resolutionOptions" />
<VSelect
v-model="subscribeForm.resolution"
label="分辨率"
:items="resolutionOptions"
hint="订阅资源分辨率"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect v-model="subscribeForm.effect" label="特效" :items="effectOptions" />
<VSelect
v-model="subscribeForm.effect"
label="特效"
:items="effectOptions"
hint="订阅资源特效"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
@@ -315,14 +358,16 @@ onMounted(() => {
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
hint="支持正则表达式,多个关键字用 | 分隔表示或"
hint="包含规则,支持正则表达式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
hint="支持正则表达式,多个关键字用 | 分隔表示或"
hint="排除规则,支持正则表达式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -332,16 +377,19 @@ onMounted(() => {
chips
label="订阅站点"
multiple
hint="订阅选中的订阅站点,不选则订阅所有可订阅站点"
hint="订阅的站点范围,不选使用系统设置"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VTextField
<VCombobox
v-model="subscribeForm.save_path"
:items="targetDirectories"
label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
persistent-hint
/>
</VCol>
</VRow>
@@ -350,21 +398,24 @@ onMounted(() => {
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"
hint="开启后不管媒体库是否存在,均会根据洗版优先级进行过滤下载,直到下载到了最高优先级的资源为止"
hint="根据洗版优先级进行洗版订阅"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.search_imdbid"
label="使用 ImdbID 搜索"
hint="开启后将使用 ImdbID 搜索资源,搜索结果更精确,但不是所有站点都支持"
hint="开使用 ImdbID 精确搜索资源"
persistent-hint
/>
</VCol>
<VCol v-if="props.default" cols="12" md="4">
<VSwitch
v-model="subscribeForm.show_edit_dialog"
label="订阅时编辑更多规则"
hint="开启后将在添加订阅后弹出编辑订阅对话框,方便用户编辑订阅规则"
hint="添加订阅时显示此编辑订阅对话框"
persistent-hint
/>
</VCol>
</VRow>

View File

@@ -0,0 +1,98 @@
<script lang="ts" setup>
import QrcodeVue from 'qrcode.vue'
import api from '@/api'
// 定义事件
const emit = defineEmits(['done', 'close'])
// 二维码内容
const qrCodeContent = ref('')
// 下方的提示信息
const text = ref('请使用微信或115客户端扫码')
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
// timeout定时器
let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
emit('done')
}
// 调用/aliyun/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/u115/qrcode')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
} else {
text.value = result.message
}
} catch (e) {
console.error(e)
}
}
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
try {
const result: { [key: string]: any } = await api.get('/u115/check')
if (result.success && result.data) {
const status = result.data.status
text.value = result.data.tip
if (status == 1) {
// 已确认完成
alertType.value = 'success'
handleDone()
} else if (status == 0) {
alertType.value = 'info'
// 新建、待扫码
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
// 过期或者已取消
alertType.value = 'error'
}
} else {
alertType.value = 'error'
text.value = result.message
}
} catch (e) {
console.error(e)
}
}
onMounted(async () => {
await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000)
})
onUnmounted(() => {
if (timeoutTimer) clearTimeout(timeoutTimer)
})
</script>
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard title="115网盘登录" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 shadow-lg rounded border">
<VImg class="mx-auto" :src="qrCodeContent" style="block-size: 200px; inline-size: 200px">
<VSkeletonLoader v-if="!qrCodeContent" class="w-full h-full" />
</VImg>
</div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<template #prepend />
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,8 +1,7 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import type { Axios, AxiosRequestConfig } from 'axios'
import type { PropType } from 'vue'
import { useConfirm } from 'vuetify-use-dialog'
import axios from 'axios'
import { useToast } from 'vue-toast-notification'
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
import { formatBytes } from '@core/utils/formatters'
@@ -11,27 +10,51 @@ import store from '@/store'
import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// APP
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 输入参数
const inProps = defineProps({
icons: Object,
storage: String,
path: String,
endpoints: Object as PropType<EndPoints>,
axios: Object as PropType<Axios>,
axios: {
type: Object as PropType<Axios>,
required: true,
},
refreshpending: Boolean,
item: {
type: Object as PropType<FileItem>,
required: true,
},
sort: String,
})
// 对外事件
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
// 确认框
const createConfirm = useConfirm()
// 提示框
const $toast = useToast()
// 是否选择模式
const selectMode = ref(false)
// 是否正在加载
const loading = ref(true)
// 重命名loading
const renameLoading = ref(false)
// 识别进度条
const progressDialog = ref(false)
@@ -41,15 +64,6 @@ const progressText = ref('请稍候 ...')
// 识别进度
const progressValue = ref(0)
// 确认框
const createConfirm = useConfirm()
// 存储空间类型
const storage = ref(inProps.storage ?? '')
// axios实例
const axiosInstance = ref<Axios>(inProps.axios ?? axios)
// 内容列表
const items = ref<FileItem[]>([])
@@ -65,170 +79,358 @@ const transferPopper = ref(false)
// 新名称
const newName = ref('')
// 当前名称
// 处理目录内所有文件
const renameAll = ref(false)
// 当前操作项
const currentItem = ref<FileItem>()
// 选中的项目
const selected = ref<FileItem[]>([])
// 识别结果
const nameTestResult = ref<Context>()
// 识别结果对话框
const nameTestDialog = ref(false)
// 弹出菜单
const dropdownItems = ref<{ [key: string]: any }[]>([])
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 目录过滤
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)))
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.name.includes(filter.value)))
// 文件过滤
const files = computed(() => items.value.filter(item => item.type === 'file' && item.basename.includes(filter.value)))
const files = computed(() => items.value.filter(item => item.type === 'file' && item.name.includes(filter.value)))
// 是否目录
const isDir = computed(() => inProps.path?.endsWith('/'))
const isDir = computed(() => inProps.item.path?.endsWith('/'))
// 是否文件
const isFile = computed(() => !isDir.value)
// 需要整理的文件项
const transferItems = ref<FileItem[]>([])
// 大小控制
const scrollStyle = computed(() => {
return appMode.value
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
})
// 是否为图片文件
const isImage = computed(() => {
const ext = inProps.path?.split('.').pop()?.toLowerCase()
const ext = inProps.item.path?.split('.').pop()?.toLowerCase()
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
})
// 调API加载内容
async function load() {
// 调整选择模式
function changeSelectMode() {
selectMode.value = !selectMode.value
if (!selectMode.value) selected.value = []
}
// 调API加载文件夹内的内容
async function list_files() {
loading.value = true
emit('loading', true)
// 参数
const url = inProps.endpoints?.list.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(inProps.path || ''))
.replace(/{storage}/g, inProps.storage)
.replace(/{sort}/g, inProps.sort || 'name')
const config = {
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.list.method || 'get',
data: inProps.item,
}
// 加载数据
items.value = (await axiosInstance.value.request(config)) ?? []
items.value = (await inProps.axios.request(config)) ?? []
emit('loading', false)
loading.value = false
}
// 删除项目
async function deleteItem(item: FileItem) {
async function deleteItem(item: FileItem, confirm: boolean = true) {
if (confirm) {
const confirmed = await createConfirm({
title: '确认',
content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.name}`,
})
if (!confirmed) return
}
// 加载中
emit('loading', true)
// 请求API
const url = inProps.endpoints?.delete.url.replace(/{storage}/g, inProps.storage)
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.delete.method || 'post',
data: item,
}
await inProps.axios.request(config)
// 删除完成
emit('loading', false)
emit('filedeleted')
// 重新加载
list_files()
}
// 批量删除
async function batchDelete() {
const confirmed = await createConfirm({
title: '确认',
content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.basename}`,
content: `是否确认删除选中的 ${selected.value.length} 个项目`,
})
if (confirmed) {
emit('loading', true)
const url = inProps.endpoints?.delete.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(item.path))
if (!confirmed) return
const config = {
url,
method: inProps.endpoints?.delete.method || 'post',
}
// 显示进度条
progressDialog.value = true
progressValue.value = 0
await axiosInstance.value.request(config)
emit('filedeleted')
emit('loading', false)
// 重新加载
load()
}
// 删除选中的项目
selected.value.every(async item => {
progressText.value = `正在删除 ${item.name} ...`
await deleteItem(item, false)
})
// 关闭进度条
progressDialog.value = false
// 重新加载
list_files()
}
// 切换路径
function changePath(_path: string) {
emit('pathchanged', _path)
function changePath(item: FileItem) {
item.path = inProps.item.path + item.name + (item.type === 'dir' ? '/' : '')
emit('pathchanged', item)
}
// 点击列表项
function listItemClick(item: FileItem) {
if (selectMode.value) {
if (selected.value.includes(item)) {
selected.value = selected.value.filter(i => i !== item)
} else {
selected.value.push(item)
}
// 去重
selected.value = Array.from(new Set(selected.value))
return false
}
changePath(item)
}
// 新窗口中下载文件
function download(path: string) {
if (!path) return
const token = store.state.auth.token
const url_path = inProps.endpoints?.download.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(path))
const url = `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
// 下载文件
window.open(url, '_blank')
async function download(item: FileItem) {
const url = inProps.endpoints?.download.url.replace(/{storage}/g, inProps.storage)
const filterEntries = Object.entries(item).filter(([key, value]) => !['children', 'thumbnail'].includes(key) && value)
const queryParams = filterEntries.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
window.open(
`${import.meta.env.VITE_API_BASE_URL}${url.slice(1)}?${queryParams}&token=${store.state.auth.token}`,
'_blank',
)
}
// 显示图片
function getImgLink(path: string) {
if (!path) return ''
const token = store.state.auth.token
const url_path = inProps.endpoints?.image.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(path))
return `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
// 获取图片地址
function getImgLink(item: FileItem) {
let url = inProps.endpoints?.image.url.replace(/{storage}/g, inProps.storage)
const filterEntries = Object.entries(item).filter(([key, value]) => !['children', 'thumbnail'].includes(key) && value)
const queryParams = filterEntries.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
return `${import.meta.env.VITE_API_BASE_URL}${url.slice(1)}?${queryParams}&token=${store.state.auth.token}`
}
// 显示重命名弹窗
function showRenmae(item: FileItem) {
currentItem.value = item
newName.value = item.name
renameAll.value = false
renamePopper.value = true
}
// 调用API获取新名称
async function get_recommend_name() {
renameLoading.value = true
try {
const result: { [key: string]: any } = await api.get('transfer/name', {
params: {
path: `${inProps.item.path}${currentItem.value?.name}`,
filetype: currentItem.value?.type ?? 'file',
},
})
if (result.success && result.data) {
newName.value = result.data.name
} else {
$toast.error(result.message)
}
} catch (error) {
console.error(error)
}
renameLoading.value = false
}
// 重命名
async function rename() {
emit('loading', true)
const url = inProps.endpoints?.rename.url
.replace(/{storage}/g, inProps.storage)
.replace(/{path}/g, encodeURIComponent(currentItem.value?.path || ''))
.replace(/{newname}/g, encodeURIComponent(newName.value))
const config = {
url,
method: inProps.endpoints?.mkdir.method || 'post',
// 关闭弹窗
renamePopper.value = false
// 显示进度条
progressDialog.value = true
progressValue.value = 0
if (renameAll.value) {
progressText.value = `正在重命名 ${currentItem.value?.path} 及目录内所有文件 ...`
} else {
progressText.value = `正在重命名 ${currentItem.value?.name} ...`
}
if (renameAll.value) {
startLoadingProgress()
}
// 调API
await inProps.axios?.request(config)
let url = inProps.endpoints?.rename.url
.replace(/{storage}/g, inProps.storage)
.replace(/{newname}/g, encodeURIComponent(newName.value))
if (renameAll.value) {
url += '&recursive=true'
}
renamePopper.value = false
newName.value = ''
emit('loading', false)
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.rename.method || 'post',
data: currentItem.value,
}
const result: { [key: string]: any } = await inProps.axios?.request(config)
if (!result.success) {
$toast.error(result.message)
}
// 关闭进度条
if (renameAll.value) {
stopLoadingProgress()
}
progressDialog.value = false
// 通知重新加载
newName.value = ''
renameAll.value = false
emit('loading', false)
emit('renamed')
}
// 显示整理对话框
function showTransfer(item: FileItem) {
currentItem.value = item
transferItems.value = [item]
transferPopper.value = true
}
// 显示批量整理对话框
function showBatchTransfer() {
transferItems.value = selected.value
transferPopper.value = true
}
// 整理完成
function transferDone() {
transferPopper.value = false
list_files()
}
// 将文件修改时间timestape转换为本地时间
function formatTime(timestape: number) {
return new Date(timestape * 1000).toLocaleString()
}
// 监听path变化
watch(
() => inProps.path,
async () => {
items.value = []
nameTestResult.value = undefined
nameTestDialog.value = false
await load()
},
)
// 监听refreshPending变化
watch(
() => inProps.refreshpending,
async () => {
if (inProps.refreshpending) {
await load()
await list_files()
emit('refreshed')
}
},
)
// 监听item变化或者storage变化
watch(
[() => inProps.item, () => inProps.storage],
async () => {
// 清空列表
items.value = []
// 关闭弹窗
nameTestResult.value = undefined
nameTestDialog.value = false
// 重置菜单
dropdownItems.value = [
{
title: '识别',
value: 1,
show: true,
props: {
prependIcon: 'mdi-text-recognition',
click: (_item: FileItem) => {
recognize(_item.path || '')
},
},
},
{
title: '刮削',
value: 2,
show: true,
props: {
prependIcon: 'mdi-auto-fix',
click: (_item: FileItem) => {
scrape(_item)
},
},
},
{
title: '重命名',
value: 3,
show: true,
props: {
prependIcon: 'mdi-rename',
click: showRenmae,
},
},
{
title: '整理',
value: 4,
show: true,
props: {
prependIcon: 'mdi-folder-arrow-right',
click: showTransfer,
},
},
{
title: '删除',
value: 5,
show: true,
props: {
prependIcon: 'mdi-delete-outline',
color: 'error',
click: deleteItem,
},
},
]
await list_files()
},
{ immediate: true },
)
// 调用API识别
async function recognize(path: string) {
try {
@@ -251,75 +453,71 @@ async function recognize(path: string) {
}
// 调用API刮削
async function scrape(path: string) {
async function scrape(item: FileItem, confirm: boolean = true) {
try {
if (confirm) {
// 确认
const confirmed = await createConfirm({
title: '确认',
content: `是否确认刮削 ${item.path}`,
})
if (!confirmed) return
}
// 显示进度条
progressDialog.value = true
progressText.value = `正在刮削 ${path} ...`
const result: { [key: string]: any } = await api.get('media/scrape', {
params: {
path,
},
})
progressText.value = `正在刮削 ${item.path} ...`
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.storage}`, item)
// 关闭进度条
progressDialog.value = false
if (!result.success) $toast.error(result.message)
else $toast.success(`${path}削刮完成!`)
else $toast.success(`${item.path} 削刮完成!`)
} catch (error) {
console.error(error)
}
}
// 弹出菜单
const dropdownItems = ref([
{
title: '识别',
value: 1,
props: {
prependIcon: 'mdi-text-recognition',
click: (_item: FileItem) => {
recognize(_item.path || '')
},
},
},
{
title: '刮削',
value: 2,
props: {
prependIcon: 'mdi-auto-fix',
click: (_item: FileItem) => {
scrape(_item.path || '')
},
},
},
{
title: '重命名',
value: 3,
props: {
prependIcon: 'mdi-rename',
click: showRenmae,
},
},
{
title: '整理',
value: 4,
props: {
prependIcon: 'mdi-folder-arrow-right',
click: showTransfer,
},
},
{
title: '删除',
value: 5,
props: {
prependIcon: 'mdi-delete-outline',
color: 'error',
click: deleteItem,
},
},
])
// 批量刮削
async function batchScrape() {
// 确认
const confirmed = await createConfirm({
title: '确认',
content: `是否确认刮削选中的 ${selected.value.length} 项?`,
})
if (!confirmed) return
selected.value.map(item => {
scrape(item, false)
})
}
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename?token=${token}`,
)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
}
}
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
}
onMounted(() => {
load()
list_files()
})
</script>
@@ -339,99 +537,131 @@ onMounted(() => {
rounded="0"
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
<IconBtn v-if="!isFile" @click="changeSelectMode">
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
<VIcon color="primary" v-else>mdi-select</VIcon>
</IconBtn>
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
<VIcon color="primary"> mdi-text-recognition </VIcon>
</IconBtn>
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
<VIcon color="primary"> mdi-download </VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="load">
<IconBtn v-if="!isFile" @click="list_files">
<VIcon color="primary"> mdi-refresh </VIcon>
</IconBtn>
<!-- 批量操作按钮 -->
<span v-if="selected.length > 0">
<IconBtn @click.stop="batchScrape">
<VIcon color="primary" icon="mdi-auto-fix" />
</IconBtn>
<IconBtn @click.stop="showBatchTransfer">
<VIcon color="primary" icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn @click.stop="batchDelete">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</span>
</VToolbar>
<VCardText v-if="loading" class="text-center flex flex-col items-center">
<VProgressCircular size="48" indeterminate color="primary" />
</VCardText>
<VCardText v-if="!path" class="grow d-flex justify-center align-center grey--text"> 选择目录或文件 </VCardText>
<VCardText v-else-if="isFile && !isImage" class="text-center break-all">
<strong>{{ items[0]?.name }}</strong
><br />
大小{{ formatBytes(items[0]?.size || 0) }}<br />
修改时间{{ formatTime(items[0]?.modify_time || 0) }}
<!-- 文件详情 -->
<VCardText v-else-if="isFile && !isImage && items.length > 0" class="text-center break-all">
<div v-if="items[0]?.thumbnail" class="flex justify-center">
<VImg max-width="15rem" cover :src="items[0]?.thumbnail" class="rounded border shadow-lg">
<template #placeholder>
<VSkeletonLoader class="object-cover w-full h-full" />
</template>
</VImg>
</div>
<div class="text-xl text-high-emphasis mt-3">{{ items[0]?.name }}</div>
<p class="mt-2" v-if="items[0]?.size && items[0].modify_time">
大小{{ formatBytes(items[0]?.size || 0) }}<br />
修改时间{{ formatTime(items[0]?.modify_time || 0) }}
</p>
</VCardText>
<VCardText v-else-if="isFile && isImage" class="grow d-flex justify-center align-center">
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
<!-- 图片 -->
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
<VImg :src="getImgLink(items[0])" max-width="100%" max-height="100%" />
</VCardText>
<!-- 目录和文件列表 -->
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList subheader>
<VVirtualScroll class="virtual-scroll-div" :items="[...dirs, ...files]">
<VVirtualScroll :items="[...dirs, ...files]" :style="scrollStyle">
<template #default="{ item }">
<VHover>
<template #default="hover">
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="changePath(item.path)">
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
<template #prepend>
<VIcon
v-if="inProps.icons && item.extension"
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
/>
<VIcon v-else icon="mdi-folder-outline" />
<VListItemAction v-if="selectMode">
<VCheckbox v-model="selected" :value="item" />
</VListItemAction>
<template v-else>
<VIcon
v-if="inProps.icons && item.extension"
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
/>
<VIcon v-else icon="mdi-folder-outline" />
</template>
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle v-if="item.size">
{{ formatBytes(item.size) }}
</VListItemSubtitle>
<template #append>
<IconBtn class="d-sm-none">
<IconBtn v-if="display.smAndDown.value && !selectMode">
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
<template v-for="(menu, i) in dropdownItems" :key="i">
<VListItem
v-if="menu.show"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
<span v-if="hover.isHovering" class="flex">
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
<VTooltip text="识别">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
<IconBtn v-bind="props" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="刮削">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
<IconBtn v-bind="props" @click.stop="scrape(item)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="重命名">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
<IconBtn v-bind="props" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="整理">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
<IconBtn v-bind="props" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="删除">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
<IconBtn v-bind="props" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</template>
@@ -453,13 +683,25 @@ onMounted(() => {
<!-- 重命名弹窗 -->
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="50rem">
<VCard title="重命名">
<DialogCloseBtn @click="renamePopper = false" />
<VDivider />
<VCardText>
<VTextField v-model="newName" label="名称" />
<VRow>
<VCol cols="12">
<VTextField v-model="newName" label="新名称" :loading="renameLoading" />
</VCol>
<VCol cols="12" md="6" v-if="currentItem && currentItem.type == 'dir'">
<VSwitch v-model="renameAll" label="自动重命名目录内所有媒体文件" />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn depressed @click="renamePopper = false"> 取消 </VBtn>
<VSpacer />
<VBtn :disabled="!newName" depressed variant="tonal" @click="rename"> 重命名 </VBtn>
<VBtn color="success" variant="elevated" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
自动识别名称
</VBtn>
<VBtn :disabled="!newName" variant="elevated" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
@@ -467,13 +709,9 @@ onMounted(() => {
<ReorganizeDialog
v-if="transferPopper"
v-model="transferPopper"
:path="currentItem?.path"
@done="
() => {
transferPopper = false
load()
}
"
:storage="inProps.storage"
:items="transferItems"
@done="transferDone"
@close="transferPopper = false"
/>
<!-- 进度框 -->
@@ -497,14 +735,4 @@ onMounted(() => {
.v-toolbar {
background: rgb(var(--v-table-header-background));
}
.virtual-scroll-div {
block-size: calc(100vh - 14rem);
}
@media (width <= 768px) {
.virtual-scroll-div {
block-size: calc(100vh - 17rem);
}
}
</style>

View File

@@ -1,14 +1,28 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import type { EndPoints } from '@/api/types'
import type { Axios, AxiosRequestConfig } from 'axios'
import type { EndPoints, FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 输入参数
const inProps = defineProps({
storages: Array as PropType<any[]>,
storage: String,
path: String,
item: {
type: Object as PropType<FileItem>,
required: true,
},
itemstack: {
type: Array as PropType<FileItem[]>,
required: true,
},
endpoints: Object as PropType<EndPoints>,
axios: Object as PropType<Axios>,
axios: {
type: Object as PropType<Axios>,
required: true,
},
})
// 对外事件
@@ -25,10 +39,8 @@ const sort = ref('name')
// 调整排序方式
function changeSort() {
if (sort.value === 'name')
sort.value = 'time'
else
sort.value = 'name'
if (sort.value === 'name') sort.value = 'time'
else sort.value = 'name'
emit('sortchanged', sort.value)
}
@@ -36,18 +48,20 @@ function changeSort() {
// 计算PATH面包屑
const pathSegments = computed(() => {
let path_str = ''
const isFolder = inProps.path?.endsWith('/')
const segments = inProps.path?.split('/').filter(item => item)
return segments?.map((item, index) => {
path_str += item + ((index < segments.length - 1 || isFolder) ? '/' : '')
return {
name: item,
path: path_str,
}
}) ?? []
const isFolder = inProps.item.path?.endsWith('/')
const segments = inProps.item.path?.split('/').filter(item => item)
return (
segments?.map((item, index) => {
path_str += item + (index < segments.length - 1 || isFolder ? '/' : '')
return {
name: item,
path: path_str,
}
}) ?? []
)
})
// 当前存储
const storageObject = computed(() => {
return inProps.storages?.find(item => item.code === inProps.storage)
})
@@ -56,20 +70,19 @@ const storageObject = computed(() => {
function changeStorage(code: string) {
if (inProps.storage !== code) {
emit('storagechanged', code)
emit('pathchanged', '')
}
}
// 路径变化
function changePath(_path: string) {
emit('pathchanged', _path)
function changePath(item: FileItem) {
emit('pathchanged', item)
}
// 返回上一级
function goUp() {
const segments = pathSegments.value ?? []
const path = segments?.length === 1 ? '/' : segments[segments.length - 2].path
changePath(path)
const fileitem = inProps.itemstack[segments.length - 1]
changePath(fileitem)
}
// 创建目录
@@ -77,15 +90,16 @@ async function mkdir() {
emit('loading', true)
const url = inProps.endpoints?.mkdir.url
.replace(/{storage}/g, inProps.storage)
.replace(/{path}/g, encodeURIComponent(inProps.path + newFolderName.value))
.replace(/{name}/g, newFolderName.value)
const config = {
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.mkdir.method || 'post',
data: inProps.item,
}
// 调API
await inProps.axios?.request(config)
await inProps.axios.request(config)
newFolderPopper.value = false
newFolderName.value = ''
@@ -97,10 +111,8 @@ async function mkdir() {
// 计算排序图标
const sortIcon = computed(() => {
if (sort.value === 'time')
return 'mdi-sort-clock-ascending-outline'
else
return 'mdi-sort-alphabetical-ascending'
if (sort.value === 'time') return 'mdi-sort-clock-ascending-outline'
else return 'mdi-sort-alphabetical-ascending'
})
</script>
@@ -127,16 +139,17 @@ const sortIcon = computed(() => {
</VListItem>
</VList>
</VMenu>
<VBtn variant="text" :input-value="path === '/'" class="px-1" @click="changePath('/')">
<VBtn variant="text" :input-value="item.path === '/'" class="px-1" @click="changePath(inProps.itemstack[0])">
<VIcon :icon="storageObject?.icon" class="mr-2" />
{{ storageObject?.name }}
</VBtn>
<template v-for="(segment, index) in pathSegments" :key="index">
<VBtn
v-if="display.mdAndUp.value"
variant="text"
:input-value="index === pathSegments.length - 1"
class="px-1 d-none d-md-block"
@click="changePath(segment.path)"
class="px-1"
@click="changePath(inProps.itemstack[index + 1])"
>
<VIcon icon=" mdi-chevron-right" />
{{ segment.name }}
@@ -158,10 +171,7 @@ const sortIcon = computed(() => {
</IconBtn>
</template>
</VTooltip>
<VDialog
v-model="newFolderPopper"
max-width="50rem"
>
<VDialog v-model="newFolderPopper" max-width="50rem">
<template #activator="{ props }">
<IconBtn v-bind="props">
<VTooltip text="新建文件夹">
@@ -172,20 +182,14 @@ const sortIcon = computed(() => {
</IconBtn>
</template>
<VCard title="新建文件夹">
<DialogCloseBtn @click="newFolderPopper = false" />
<VDivider />
<VCardText>
<VTextField v-model="newFolderName" label="名称" />
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn depressed @click="newFolderPopper = false">
取消
</VBtn>
<VBtn
:disabled="!newFolderName"
depressed
variant="tonal"
@click="mkdir"
>
<VBtn :disabled="!newFolderName" variant="elevated" @click="mkdir" prepend-icon="mdi-check" class="px-5 me-3">
新建
</VBtn>
</VCardActions>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import api from '@/api'
import { FileItem } from '@/api/types'
import { VTreeview } from 'vuetify/labs/VTreeview'
// 输入变量为默认路径
const props = defineProps({
root: {
type: String,
default: '/',
},
})
// update:modelValue 事件
const emit = defineEmits(['update:modelValue'])
// 激活的目录
const activedDirs = ref<string[]>([])
// 打开的目录
const openedDirs = ref<string[]>([])
// 目录列表
const treeItems = ref<FileItem[]>([
{
name: '/',
path: props.root,
children: [],
type: '',
basename: props.root,
extension: '',
size: 0,
modify_time: 0,
fileid: '',
parent_fileid: '',
},
])
// 拉取子目录
async function fetchDirs(item: any) {
return api
.get('/local/listdir?path=' + item.path)
.then((data: any) => {
item.children.push(...data)
})
.catch(err => console.warn(err))
}
// 获取选择的目录路径
const selectedPath = computed(() => {
if (activedDirs.value.length > 0) {
return activedDirs.value[0]
}
return ''
})
// 监听目录变化
watch(activedDirs, newVal => {
if (!newVal.length) return
emit('update:modelValue', selectedPath)
})
onMounted(() => {
fetchDirs(treeItems.value[0])
})
</script>
<template>
<VMenu :close-on-content-click="false" content-class="cursor-default">
<template v-slot:activator="{ props }">
<slot name="activator" :menuprops="props" />
</template>
<VTreeview
v-model:activated="activedDirs"
v-model:opened="openedDirs"
:items="treeItems"
:load-children="fetchDirs"
item-key="path"
item-title="name"
item-value="path"
item-type="unknown"
activatable
return-object
max-height="20rem"
expand-icon="mdi-folder"
collapse-icon="mdi-folder-open"
>
</VTreeview>
</VMenu>
</template>

View File

@@ -12,12 +12,18 @@ import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
import DashboardRender from '@/components/render/DashboardRender.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数
const props = defineProps({
// 仪表板配置
config: Object as PropType<DashboardItem>,
// 刷新状态
refreshStatus: Boolean,
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['update:refreshStatus'])
@@ -32,17 +38,29 @@ onUnmounted(() => {
<AnalyticsStorage v-if="config?.id === 'storage'" />
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
<AnalyticsSpeed v-else-if="config?.id === 'speed'" />
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" />
<AnalyticsCpu v-else-if="config?.id === 'cpu'" />
<AnalyticsMemory v-else-if="config?.id === 'memory'" />
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
<AnalyticsCpu v-else-if="config?.id === 'cpu'" :allowRefresh="props.allowRefresh" />
<AnalyticsMemory v-else-if="config?.id === 'memory'" :allowRefresh="props.allowRefresh" />
<MediaServerLibrary v-else-if="config?.id === 'library'" />
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
<MediaServerLatest v-else-if="config?.id === 'latest'" />
<!-- 插件仪表板 -->
<VHover v-else-if="!isNullOrEmptyObject(props.config)">
<template #default="hover">
<VCard v-bind="hover.props">
<!-- 无边框 -->
<div v-if="props.config?.attrs.border === false">
<VCard v-bind="hover.props">
<VCardText class="p-0">
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
<div v-if="hover.isHovering" class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</VCard>
</div>
<!-- 有边框 -->
<VCard v-else v-bind="hover.props">
<VCardItem v-if="props.config?.attrs.border !== false">
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
@@ -52,12 +70,9 @@ onUnmounted(() => {
</VCardTitle>
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
</VCardItem>
<VCardText :class="{ 'p-0': props.config?.attrs.border === false }">
<VCardText>
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
<div v-if="props.config?.attrs.border === false && hover.isHovering" class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</VCard>
</template>
</VHover>

View File

@@ -1,5 +1,9 @@
<script lang="ts" setup>
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 元素
const slideview_content = ref()
@@ -27,12 +31,10 @@ function slideNext(next: boolean) {
if (run_to_left_px >= slideview_content.value.scrollWidth - slideview_content.value.clientWidth)
run_to_left_px = slideview_content.value.scrollWidth - slideview_content.value.clientWidth
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
}
else {
} else {
const card_index = card_current - card_max
run_to_left_px = card_index * card_width
if (run_to_left_px <= 0)
run_to_left_px = 0
if (run_to_left_px <= 0) run_to_left_px = 0
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
}
slideview_content.value.scrollTo({
@@ -46,7 +48,7 @@ function slideNext(next: boolean) {
function countMaxNumber() {
slide_card_length = slideview_content.value.children.length
card_width = slideview_content.value.firstElementChild.getBoundingClientRect().width
slide_gap_px = (slideview_content.value.scrollWidth / slide_card_length) - card_width
slide_gap_px = slideview_content.value.scrollWidth / slide_card_length - card_width
card_width += slide_gap_px
card_max = Math.trunc(slideview_content.value.clientWidth / card_width)
countDisabled()
@@ -55,16 +57,18 @@ function countMaxNumber() {
// 修改分页切换按钮状态
function countDisabled() {
slideview_scrollLeft.value = slideview_content.value.scrollLeft
card_current = slideview_content.value.scrollLeft === 0 ? 0 : Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
if (slide_card_length * card_width <= slideview_content.value.clientWidth)
disabled.value = 3
else if (slideview_content.value.scrollLeft === 0)
disabled.value = 0
else if (slideview_content.value.scrollLeft >= slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2)
card_current =
slideview_content.value.scrollLeft === 0
? 0
: Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
if (slide_card_length * card_width <= slideview_content.value.clientWidth) disabled.value = 3
else if (slideview_content.value.scrollLeft === 0) disabled.value = 0
else if (
slideview_content.value.scrollLeft >=
slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2
)
disabled.value = 2
else
disabled.value = 1
else disabled.value = 1
}
// 组件加载完成
@@ -91,7 +95,7 @@ onActivated(() => {
<slot name="title">
<SlideViewTitle />
</slot>
<div v-if="disabled !== 3" class="me-1 d-none d-md-flex">
<div v-if="disabled !== 3 && display.mdAndUp.value" class="me-1 d-flex">
<VBtn
class="rounded-circle"
variant="text"
@@ -122,9 +126,8 @@ onActivated(() => {
<style lang="scss" scoped>
.slideview_content {
overflow: scroll hidden !important;
-ms-overflow-style: none !important;
overflow-x: scroll !important;
overflow-y: hidden !important;
overscroll-behavior-x: contain !important;
scrollbar-width: none !important;
}

View File

@@ -1,22 +1,13 @@
<script lang="ts" setup>
// 输入参数
const props = inject('rankingPropsKey')
const props: any = inject('rankingPropsKey')
</script>
<template>
<div
class="ms-1"
>
<RouterLink
:to="props.linkurl ? props.linkurl : ''"
class="slider-title"
>
<span>{{ props.title }}</span>
<VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
<div class="ms-1">
<RouterLink :to="props?.linkurl ? props?.linkurl : ''" class="slider-title">
<span>{{ props?.title }}</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
</div>
</template>

View File

@@ -2,8 +2,6 @@
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
// Components
import Footer from '@/layouts/components/Footer.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import UserNofification from '@/layouts/components/UserNotification.vue'
@@ -11,9 +9,27 @@ import SearchBar from '@/layouts/components/SearchBar.vue'
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import store from '@/store'
import { SystemNavMenus } from '@/router/menu'
import { NavMenu } from '@/@layouts/types'
import { useDisplay } from 'vuetify'
const display = useDisplay()
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 从Vuex Store中获取superuser信息
const superUser = store.state.auth.superUser
// 根据分类获取菜单列表
const getMenuList = (header: string) => {
return SystemNavMenus.filter((item: NavMenu) => item.header === header && (!item.admin || superUser))
}
// 返回上一页
function goBack() {
history.back()
}
</script>
<template>
@@ -22,116 +38,51 @@ const superUser = store.state.auth.superUser
<template #navbar="{ toggleVerticalOverlayNavActive }">
<div class="d-flex h-100 align-center mx-1">
<!-- 👉 Vertical Nav Toggle -->
<IconBtn class="ms-n2 d-lg-none" @click="toggleVerticalOverlayNavActive(true)">
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
<VIcon icon="mdi-menu" />
</IconBtn>
<!-- 👉 Back Button -->
<IconBtn v-if="appMode" class="ms-n2" @click="goBack">
<VIcon icon="mdi-arrow-left" size="32" />
</IconBtn>
<!-- 👉 Search Bar -->
<SearchBar />
<!-- 👉 Spacer -->
<VSpacer />
<!-- 👉 Shortcuts -->
<ShortcutBar v-if="superUser" />
<!-- 👉 Theme -->
<NavbarThemeSwitcher />
<!-- 👉 Notification -->
<UserNofification />
<!-- 👉 UserProfile -->
<UserProfile />
</div>
</template>
<template #vertical-nav-content>
<VerticalNavLink
:item="{
title: '仪表板',
icon: 'mdi-home-outline',
to: '/dashboard',
}"
/>
<VerticalNavLink v-for="item in getMenuList('开始')" :item="item" />
<!-- 👉 发现 -->
<VerticalNavSectionTitle
:item="{
heading: '发现',
}"
/>
<VerticalNavLink
:item="{
title: '推荐',
icon: 'mdi-table-star',
to: '/ranking',
}"
/>
<VerticalNavLink
:item="{
title: '资源搜索',
icon: 'mdi-magnify',
to: '/resource',
}"
/>
<VerticalNavLink v-for="item in getMenuList('发现')" :item="item" />
<!-- 👉 订阅 -->
<VerticalNavSectionTitle
:item="{
heading: '订阅',
}"
/>
<VerticalNavLink
:item="{
title: '电影',
icon: 'mdi-movie-check-outline',
to: '/subscribe-movie',
}"
/>
<VerticalNavLink
:item="{
title: '电视剧',
icon: 'mdi-television-classic',
to: '/subscribe-tv',
}"
/>
<VerticalNavLink
:item="{
title: '日历',
icon: 'mdi-calendar',
to: '/calendar',
}"
/>
<VerticalNavLink v-for="item in getMenuList('订阅')" :item="item" />
<!-- 👉 整理 -->
<VerticalNavSectionTitle
:item="{
heading: '整理',
}"
/>
<VerticalNavLink
:item="{
title: '正在下载',
icon: 'mdi-download-outline',
to: '/downloading',
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '历史记录',
icon: 'mdi-history',
to: '/history',
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '文件管理',
icon: 'mdi-folder-multiple-outline',
to: '/filemanager',
}"
/>
<VerticalNavLink v-for="item in getMenuList('整理')" :item="item" />
<!-- 👉 系统 -->
<VerticalNavSectionTitle
v-if="superUser"
@@ -139,37 +90,12 @@ const superUser = store.state.auth.superUser
heading: '系统',
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '插件',
icon: 'mdi-apps',
to: '/plugins',
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '站点管理',
icon: 'mdi-web',
to: '/site',
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '设定',
icon: 'mdi-cog',
to: '/setting',
}"
/>
<VerticalNavLink v-for="item in getMenuList('系统')" :item="item" />
</template>
<template #after-vertical-nav-items />
<!-- 👉 Pages -->
<slot />
<!-- 👉 Footer -->
<template #footer>
<Footer />

View File

@@ -1,3 +1,69 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
const display = useDisplay()
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
const route = useRoute()
// 各按钮活动状态
const activeState = computed(() => {
return {
home: route.path === '/dashboard',
ranking: route.path === '/ranking',
movie: route.path === '/subscribe/movie',
tv: route.path === '/subscribe/tv',
apps: route.path === '/apps',
}
})
</script>
<template>
<div class="h-100 d-flex align-center justify-space-between" />
<div v-if="appMode" class="w-100" style="block-size: calc(3.5rem + env(safe-area-inset-bottom))">
<VBottomNavigation
grow
horizontal
color="primary"
class="footer-nav border-t"
style="block-size: calc(3.5rem + env(safe-area-inset-bottom))"
>
<VBtn to="/dashboard" :ripple="false">
<VIcon v-if="activeState.home" size="28">mdi-home</VIcon>
<VIcon v-else size="28">mdi-home-outline</VIcon>
</VBtn>
<VBtn to="/ranking" :ripple="false">
<VIcon v-if="activeState.ranking" size="28">mdi-star</VIcon>
<VIcon v-else size="28">mdi-star-outline</VIcon>
</VBtn>
<VBtn to="/subscribe/movie" :ripple="false">
<VIcon v-if="activeState.movie" size="28">mdi-movie-open</VIcon>
<VIcon v-else size="28">mdi-movie-open-outline</VIcon>
</VBtn>
<VBtn to="/subscribe/tv" :ripple="false">
<VIcon v-if="activeState.tv" size="28">mdi-television-play</VIcon>
<VIcon v-else size="28">mdi-television</VIcon>
</VBtn>
<VBtn to="/apps" :ripple="false">
<VIcon v-if="activeState.apps" size="28">mdi-dots-horizontal-circle</VIcon>
<VIcon v-else size="28">mdi-dots-horizontal</VIcon>
</VBtn>
</VBottomNavigation>
</div>
</template>
<style lang="scss">
.footer-nav {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
background-color: rgb(var(--v-theme-surface), 0.8);
padding-block-end: env(safe-area-inset-bottom);
}
.footer-nav .v-btn--variant-text .v-btn__overlay {
background-color: transparent !important;
}
</style>
}

View File

@@ -1,109 +1,52 @@
<script lang="ts" setup>
// 路由
const router = useRouter()
import * as Mousetrap from 'mousetrap'
import SearchBarView from '@/views/system/SearchBarView.vue'
import { useDisplay } from 'vuetify'
import { ref, computed } from 'vue'
// 搜索词
const searchWord = ref(null)
const display = useDisplay()
// 搜索弹窗
const searchDialog = ref(false)
// ref
const searchWordInput = ref<HTMLElement | null>(null)
// 当前的搜索类型 media/person
const searchType = ref('media')
// 搜索提示词列表
const searchHintList = ref<string[]>([])
// Search
function search() {
if (!searchWord.value) return
if (!searchHintList.value.includes(searchWord.value)) searchHintList.value.push(searchWord.value)
searchDialog.value = false
router.push({
path: '/browse/media/search',
query: {
title: searchWord.value,
type: searchType.value,
},
})
}
// 切换搜索类型
function switchSearchType() {
searchType.value = searchType.value === 'media' ? 'person' : 'media'
}
// 注册快捷键
Mousetrap.bind(['command+k', 'ctrl+k'], openSearchDialog)
// 打开搜索弹窗
function openSearchDialog() {
searchDialog.value = true
nextTick(() => {
searchWordInput.value?.focus()
})
return false
}
// 检测操作系统是否是Mac
function isMac() {
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
}
// 计算属性:根据操作系统显示不同的按键提示
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
</script>
<template>
<!-- 👉 Search Button -->
<div class="d-flex align-center cursor-pointer" style="user-select: none">
<VDialog v-model="searchDialog" max-width="50rem" transition="dialog-top-transition">
<!-- Dialog Content -->
<VCard title="搜索">
<VCardText>
<VRow>
<VCol cols="12">
<VCombobox
ref="searchWordInput"
v-model="searchWord"
:items="searchHintList"
:prepend-inner-icon="searchType == 'person' ? 'mdi-account' : 'mdi-movie'"
:label="searchType == 'person' ? '搜索演员' : '搜索电影、电视剧'"
@keydown.enter="search"
@click:prepend-inner="switchSearchType"
clearable
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="tonal" @click="search"> 搜索 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
<!-- 👉 Search Icon -->
<IconBtn class="d-md-none" @click="openSearchDialog">
<VIcon icon="mdi-magnify" />
</IconBtn>
<!-- 👉 Search Textfield -->
<span class="w-full me-3">
<VCombobox
key="search_navbar"
v-model="searchWord"
:items="searchHintList"
class="d-none d-md-block text-disabled search-box"
density="compact"
variant="solo"
:prepend-inner-icon="searchType == 'person' ? 'mdi-account' : 'mdi-movie'"
:label="searchType == 'person' ? '搜索演员' : '搜索电影、电视剧'"
append-inner-icon="mdi-magnify"
single-line
hide-details
flat
rounded
@click:append-inner="search"
@click:prepend-inner="switchSearchType"
@keydown.enter="search"
/>
</span>
<div class="d-flex align-center cursor-pointer ms-lg-n2" style="user-select: none">
<IconBtn @click="openSearchDialog">
<VIcon icon="ri-search-line" />
</IconBtn>
<span v-if="display.lgAndUp.value" class="flex align-center text-disabled ms-2" @click="openSearchDialog">
<span class="me-3">搜索</span>
<span class="meta-key">{{ metaKey }}</span>
</span>
</div>
<!-- 搜索弹窗 -->
<SearchBarView v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
</template>
<style lang="scss">
.search-box div.v-input__control div[role='textbox'] {
border: 1px solid rgb(var(--v-theme-background));
<style type="scss" scoped>
.meta-key {
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 6px;
block-size: 1.75rem;
padding-block: 0.1rem;
padding-inline: 0.25rem;
}
</style>
</style>

View File

@@ -8,6 +8,7 @@ import MessageView from '@/views/system/MessageView.vue'
import store from '@/store'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { getQueryValue } from '@/@core/utils'
// 显示器宽度
const display = useDisplay()
@@ -75,6 +76,29 @@ async function sendMessage() {
onMounted(() => {
scrollMessageToEnd()
const shortcut = getQueryValue('shortcut')
if (shortcut) {
switch (shortcut) {
case 'nameTest':
nameTestDialog.value = true
break
case 'netTest':
netTestDialog.value = true
break
case 'logging':
loggingDialog.value = true
break
case 'ruleTest':
ruleTestDialog.value = true
break
case 'systemTest':
systemTestDialog.value = true
break
case 'message':
messageDialog.value = true
break
}
}
})
</script>

View File

@@ -6,6 +6,9 @@ import router from '@/router'
import avatar1 from '@images/avatars/avatar-1.png'
import api from '@/api'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify'
const display = useDisplay()
// Vuex Store
const store = useStore()
@@ -22,9 +25,7 @@ const progressDialog = ref(false)
// 执行注销操作
function logout() {
// 清除登录状态信息
store.dispatch('auth/clearToken')
// 主动登出时清除路由标记
store.state.auth.originalPath = null
store.dispatch('auth/logout')
// 重定向到登录页面或其他适当的页面
router.push('/login')
}
@@ -58,10 +59,20 @@ async function restart() {
}
}
// 是否精简模式
const isCompactMode = ref(localStorage.getItem('MP_APPMODE') != '0')
// 从Vuex Store中获取信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
const avatar = store.state.auth.avatar
// 监听精简模式切换
watch(isCompactMode, value => {
localStorage.setItem('MP_APPMODE', value ? '1' : '0')
//刷新页面
location.reload()
})
</script>
<template>
@@ -86,10 +97,21 @@ const avatar = store.state.auth.avatar
</VListItemTitle>
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
</VListItem>
<!-- Divider -->
<VDivider v-if="display.mdAndDown.value" class="my-2" />
<!-- 👉 AppMode -->
<VListItem v-if="display.mdAndDown.value">
<template #prepend>
<VSwitch class="me-2" v-model="isCompactMode"></VSwitch>
</template>
<VListItemTitle>App模式</VListItemTitle>
</VListItem>
<VDivider class="my-2" />
<!-- 👉 Profile -->
<VListItem v-if="superUser" link to="setting">
<VListItem v-if="superUser" link @click="router.push('/setting?tab=account')">
<template #prepend>
<VIcon class="me-2" icon="mdi-account-outline" size="22" />
</template>
@@ -97,7 +119,7 @@ const avatar = store.state.auth.avatar
</VListItem>
<!-- 👉 FAQ -->
<VListItem href="https://github.com/jxxghp/MoviePilot/blob/main/README.md" target="_blank">
<VListItem href="https://wiki.movie-pilot.org" target="_blank">
<template #prepend>
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
</template>
@@ -105,7 +127,7 @@ const avatar = store.state.auth.avatar
</VListItem>
<!-- Divider -->
<VDivider class="my-2" />
<VDivider v-if="superUser" class="my-2" />
<!-- 👉 restart -->
<VListItem v-if="superUser" @click="restart">
@@ -115,9 +137,6 @@ const avatar = store.state.auth.avatar
<VListItemTitle>重启</VListItemTitle>
</VListItem>
<!-- Divider -->
<VDivider class="my-2" />
<!-- 👉 Logout -->
<VListItem @click="logout">
<VBtn color="error" block>

View File

@@ -1,22 +1,19 @@
import { VAceEditor } from 'vue3-ace-editor'
import { createApp } from 'vue'
import '@/@iconify/icons-bundle'
import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import '@/@core/utils/compatibility'
import './ace-config'
import VueApexCharts from 'vue3-apexcharts'
import { removeEl } from './@core/utils/dom'
import '@/@iconify/icons-bundle'
import '@/plugins/webfontloader'
import App from '@/App.vue'
import vuetify from '@/plugins/vuetify'
import { loadFonts } from '@/plugins/webfontloader'
import router from '@/router'
import store from '@/store'
import '@core/scss/template/index.scss'
import '@layouts/styles/index.scss'
import '@styles/styles.scss'
import 'vue-toast-notification/dist/theme-bootstrap.css'
import { VAceEditor } from 'vue3-ace-editor'
import { createApp } from 'vue'
import { removeEl } from './@core/utils/dom'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
import 'vue3-perfect-scrollbar/style.css'
import { VTreeview } from 'vuetify/labs/VTreeview'
import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import VueApexCharts from 'vue3-apexcharts'
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import MediaCard from './components/cards/MediaCard.vue'
import PosterCard from './components/cards/PosterCard.vue'
@@ -25,13 +22,12 @@ import PersonCard from './components/cards/PersonCard.vue'
import MediaInfoCard from './components/cards/MediaInfoCard.vue'
import TorrentCard from './components/cards/TorrentCard.vue'
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
import { fixArrayAt } from '@/@core/utils/compatibility'
// 修复低版本Safari等浏览器数组不支持at函数的问题
fixArrayAt()
// 加载字体
loadFonts()
import PathField from './components/input/PathField.vue'
import '@core/scss/template/index.scss'
import '@layouts/styles/index.scss'
import '@styles/styles.scss'
import 'vue-toast-notification/dist/theme-bootstrap.css'
import 'vue3-perfect-scrollbar/style.css'
// 创建Vue实例
const app = createApp(App)
@@ -48,6 +44,8 @@ app
.component('VMediaInfoCard', MediaInfoCard)
.component('VTorrentCard', TorrentCard)
.component('VMediaIdSelector', MediaIdSelector)
.component('VTreeview', VTreeview)
.component('VPathField', PathField)
// 注册插件
app

71
src/pages/appcenter.vue Normal file
View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { NavMenu } from '@/@layouts/types'
import { SystemNavMenus } from '@/router/menu'
import store from '@/store'
import draggable from 'vuedraggable'
// 从Vuex Store中获取superuser信息
const superUser = store.state.auth.superUser
// APP图标顺序
const appOrder = ref<string[]>([])
// 根据分类获取菜单列表
const getMenuList = () => {
return SystemNavMenus.filter((item: NavMenu) => !item.admin || superUser)
}
// APP列表
const appList = ref<NavMenu[]>(getMenuList())
// 保存APP图标顺序到localStorage
function saveAppsOrder() {
appOrder.value = appList.value.map(app => app.title)
localStorage.setItem('MP_APPS_ORDER', JSON.stringify(appOrder.value))
}
onMounted(() => {
const localOrder = localStorage.getItem('MP_APPS_ORDER')
if (localOrder) {
appOrder.value = JSON.parse(localOrder)
// 对appList进行排序
appList.value.sort((a, b) => {
const aIndex = appOrder.value.findIndex(item => item === a.title)
const bIndex = appOrder.value.findIndex(item => item === b.title)
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
})
}
})
</script>
<template>
<div class="ps ps--active-y mx-3 appcenter-grid" tabindex="0">
<draggable
v-model="appList"
item-key="title"
tag="VRow"
delay="300"
@end="saveAppsOrder"
:component-data="{ 'class': 'ma-0 mt-n1' }"
>
<template #item="{ element }">
<VCol cols="6" md="4" lg="3" class="text-center cursor-pointer shortcut-icon select-none">
<VCard class="pa-4" :to="element.to" variant="flat">
<VAvatar size="64" variant="text">
<VIcon size="48" :icon="element.icon" color="primary" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">{{ element.full_title || element.title }}</h6>
</VCard>
</VCol>
</template>
</draggable>
</div>
</template>
<style type="scss">
.appcenter-grid .v-card {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
background-color: rgb(var(--v-theme-surface), 0.8);
}
</style>

View File

@@ -31,7 +31,7 @@ function getApiPath(paths: string[] | string) {
<div v-if="title" class="mt-3 md:flex md:items-center md:justify-between">
<div class="min-w-0 flex-1 mx-0">
<h2
class="mb-4 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0"
class="mb-4 ms-3 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0"
data-testid="page-header"
>
<span class="text-moviepilot">{{ title }}</span>

View File

@@ -5,10 +5,34 @@ import { isNullOrEmptyObject } from '@/@core/utils'
import { DashboardItem } from '@/api/types'
import store from '@/store'
import DashboardElement from '@/components/misc/DashboardElement.vue'
import { useDisplay } from 'vuetify'
// APP
const display = useDisplay()
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 从Vuex Store中获取superuser信息
const superUser = store.state.auth.superUser
// 是否拉升高度
const isElevated = ref(true)
// 是否发送请求的总开关
const isRequest = ref(true)
// 计算属性,控制是否拉升高度
const elevatedConf = controlledComputed(
() => isElevated.value,
() => ({
class: { 'match-height': isElevated.value },
}),
)
// 所有组件刷新定时器的句柄
const refreshTimers = ref<{ [key: string]: NodeJS.Timeout }>({})
// 仪表板启用配置
const enableConfig = ref<{ [key: string]: boolean }>({
mediaStatistic: true,
@@ -24,13 +48,14 @@ const enableConfig = ref<{ [key: string]: boolean }>({
})
// 仪表板顺序配置
const orderConfig = ref<{ id: string }[]>([])
const orderConfig = ref<{ id: string; key: string }[]>([])
// 仪表板配置
const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'storage',
name: '存储空间',
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
elements: [],
@@ -38,6 +63,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'mediaStatistic',
name: '媒体统计',
key: '',
attrs: {},
cols: { cols: 12, md: 8 },
elements: [],
@@ -45,6 +71,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'weeklyOverview',
name: '最近入库',
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
elements: [],
@@ -52,6 +79,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'speed',
name: '实时速率',
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
elements: [],
@@ -59,6 +87,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'scheduler',
name: '后台任务',
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
elements: [],
@@ -66,6 +95,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'cpu',
name: 'CPU',
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
elements: [],
@@ -73,6 +103,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'memory',
name: '内存',
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
elements: [],
@@ -80,6 +111,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'library',
name: '我的媒体库',
key: '',
attrs: {},
cols: { cols: 12 },
elements: [],
@@ -87,6 +119,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'playing',
name: '继续观看',
key: '',
attrs: {},
cols: { cols: 12 },
elements: [],
@@ -94,14 +127,15 @@ const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'latest',
name: '最近添加',
key: '',
attrs: {},
cols: { cols: 12 },
elements: [],
},
])
// 有仪表板的插件
const dashboardPlugins = ref<any[]>([])
// 插件的仪表板元信息
const pluginDashboardMeta = ref<any[]>([])
// 插件仪表板的刷新状态
const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
@@ -133,6 +167,9 @@ async function loadDashboardConfig() {
localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify(orderConfig.value))
}
}
// 是否拉升高度
const local_elevated = localStorage.getItem('MP_DASHBOARD_ELEVATED')
if (local_elevated) isElevated.value = local_elevated === 'true'
// 排序
if (orderConfig.value) {
sortDashboardConfigs()
@@ -142,28 +179,34 @@ async function loadDashboardConfig() {
// 按order的顺序对dashboardConfigs进行排序
function sortDashboardConfigs() {
dashboardConfigs.value.sort((a, b) => {
const aIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === a.id)
const bIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === b.id)
const aIndex = orderConfig.value.findIndex(
(item: { id: string; key: string }) => item.id === a.id && item.key === a.key,
)
const bIndex = orderConfig.value.findIndex(
(item: { id: string; key: string }) => item.id === b.id && item.key === b.key,
)
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
})
}
// 设置项目
function saveDashboardConfig() {
async function saveDashboardConfig() {
// 启用配置
const data = JSON.stringify(enableConfig.value)
localStorage.setItem('MP_DASHBOARD', data)
// 顺序配置从dashboardConfigs中提取
const order = JSON.stringify(dashboardConfigs.value.map(item => ({ id: item.id })))
const order = JSON.stringify(dashboardConfigs.value.map(item => ({ id: item.id, key: item.key })))
localStorage.setItem('MP_DASHBOARD_ORDER', order)
// 是否拉升高度
localStorage.setItem('MP_DASHBOARD_ELEVATED', isElevated.value.toString())
// 保存到服务端
try {
api.post('/user/config/Dashboard', data, {
await api.post('/user/config/Dashboard', data, {
headers: {
'Content-Type': 'application/json',
},
})
api.post('/user/config/DashboardOrder', order, {
await api.post('/user/config/DashboardOrder', order, {
headers: {
'Content-Type': 'application/json',
},
@@ -172,22 +215,29 @@ function saveDashboardConfig() {
console.error(error)
}
// 保存后重新获取插件仪表板
getDashboardPlugins()
getPluginDashboardMeta()
dialog.value = false
}
// 调用API获取有仪表板的插件
async function getDashboardPlugins() {
// 只有超级用户才能获取插件仪表板
// 构造插件仪表板主ID
function buildPluginDashboardId(plugin_id: string, key: string) {
if (!key) return plugin_id
return plugin_id + ':' + key
}
// 调用API获取所有插件的仪表板元信息
async function getPluginDashboardMeta() {
// 只有超级用户才能获取
if (!superUser) return
pluginDashboardMeta.value = await api.get('/plugin/dashboard/meta')
try {
dashboardPlugins.value = await api.get('/plugin/dashboards')
if (!isNullOrEmptyObject(dashboardPlugins.value)) {
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
// 下载插件仪表板配置
dashboardPlugins.value.forEach(async (plugin: { id: string }) => {
pluginDashboardMeta.value.forEach(async (pluginDashboard: { id: string; key: string }) => {
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
// 初始化插件仪表板的刷新状态
pluginDashboardRefreshStatus.value[plugin.id] = true
await getPluginDashboard(plugin.id)
pluginDashboardRefreshStatus.value[pluginDashboardId] = true
await getPluginDashboard(pluginDashboard.id, pluginDashboard.key)
})
}
} catch (error) {
@@ -196,12 +246,20 @@ async function getDashboardPlugins() {
}
// 获取一个插件的仪表板配置项
async function getPluginDashboard(id: string) {
async function getPluginDashboard(id: string, key: string) {
try {
api.get(`/plugin/dashboard/${id}`).then((res: any) => {
const url = key ? `/plugin/dashboard/${id}/${key}` : `/plugin/dashboard/${id}`
api.get(url).then((res: any) => {
if (res) {
// 名称替换为元信息的名称
const meta = pluginDashboardMeta.value.find(
(item: { id: string; key: string }) => item.id === id && item.key === key,
)
if (meta) res.name = meta.name
// 保存到仪表板配置中,如果已经存在则替换
const index = dashboardConfigs.value.findIndex((item: { id: string }) => item.id === id)
const index = dashboardConfigs.value.findIndex(
(item: { id: string; key: string }) => item.id === id && item.key === key,
)
if (index !== -1) {
dashboardConfigs.value[index] = res
} else {
@@ -209,11 +267,23 @@ async function getPluginDashboard(id: string) {
// 排序
sortDashboardConfigs()
}
const pluginDashboardId = buildPluginDashboardId(id, key)
// 定时刷新
if (res.attrs?.refresh) {
setTimeout(() => {
pluginDashboardRefreshStatus.value[id] && getPluginDashboard(id)
if (
res.attrs?.refresh &&
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
enableConfig.value[pluginDashboardId] &&
isRequest.value
) {
// 清除之前的定时器
if (refreshTimers.value[pluginDashboardId]) {
clearTimeout(refreshTimers.value[pluginDashboardId])
}
// 设置新的定时器
let timer = setTimeout(() => {
getPluginDashboard(id, key)
}, res.attrs.refresh * 1000)
refreshTimers.value[pluginDashboardId] = timer
}
}
})
@@ -230,7 +300,15 @@ function dragOrderEnd() {
onBeforeMount(async () => {
await loadDashboardConfig()
getDashboardPlugins()
getPluginDashboardMeta()
})
onActivated(async () => {
isRequest.value = true
})
onDeactivated(() => {
isRequest.value = false
})
</script>
@@ -242,17 +320,30 @@ onBeforeMount(async () => {
handle=".cursor-move"
item-key="id"
tag="VRow"
:component-data="{ 'class': 'match-height' }"
:component-data="elevatedConf"
>
<template #item="{ element }">
<VCol v-if="enableConfig[element.id] && element.cols" v-bind:="element.cols">
<DashboardElement :config="element" v-model:refreshStatus="pluginDashboardRefreshStatus[element.id]" />
<VCol v-if="enableConfig[buildPluginDashboardId(element.id, element.key)] && element.cols" v-bind:="element.cols">
<DashboardElement
:config="element"
:allow-refresh="isRequest"
v-model:refreshStatus="pluginDashboardRefreshStatus[buildPluginDashboardId(element.id, element.key)]"
/>
</VCol>
</template>
</draggable>
<!-- 底部操作按钮 -->
<VFab icon="mdi-view-dashboard-edit" location="bottom end" size="x-large" fixed app appear @click="dialog = true" />
<VFab
icon="mdi-view-dashboard-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="dialog = true"
:class="{ 'mb-12': appMode }"
/>
<!-- 弹窗根据配置生成选项 -->
<VDialog v-model="dialog" max-width="35rem" scrollable>
@@ -263,8 +354,22 @@ onBeforeMount(async () => {
<VDivider />
<VCardText>
<VRow>
<VCol v-for="item in dashboardConfigs" :key="item.id" cols="6" md="4" sm="4">
<VCheckbox v-model="enableConfig[item.id]" :label="item.attrs?.title ?? item.name" />
<VCol
v-for="item in dashboardConfigs"
:key="buildPluginDashboardId(item.id, item.key)"
cols="6"
md="4"
sm="4"
>
<VCheckbox
v-model="enableConfig[buildPluginDashboardId(item.id, item.key)]"
:label="item.attrs?.title ?? item.name"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="isElevated" label="自适应组件高度" />
</VCol>
</VRow>
</VCardText>

View File

@@ -8,6 +8,7 @@ import router from '@/router'
import logo from '@images/logo.png'
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
const { global: globalTheme } = useTheme()
@@ -33,6 +34,9 @@ const errorMessage = ref('')
// 背景图片
const backgroundImageUrl = ref('')
// 所有的背景图片
const backgroundImages = ref<string[]>([])
// 背景图片加载状态
const isImageLoaded = ref(false)
@@ -42,17 +46,21 @@ const isOTP = ref(false)
// 用户名称输入框
const usernameInput = ref()
// Interval定时器
let intervalTimer: NodeJS.Timeout | null = null
// 获取背景图片
async function fetchBackgroundImage() {
api
.get('/login/wallpaper')
.then((response: any) => {
backgroundImageUrl.value = response.message
})
.catch((error: any) => {
console.log(error)
})
try {
backgroundImages.value = await api.get('/login/wallpapers')
if (backgroundImages.value && backgroundImages.value.length > 0) {
backgroundImageUrl.value = backgroundImages.value[0]
}
} catch (e) {
console.log(e)
}
}
// 查询是否开启双重验证
const fetchOTP = debounce(async () => {
const userid = usernameInput.value?.value
@@ -89,11 +97,39 @@ async function setTheme() {
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
}
async function afterLogin() {
// 订阅推送通知
async function subscribeForPushNotifications() {
if ('serviceWorker' in navigator && 'PushManager' in window) {
const registration = await navigator.serviceWorker.ready
// 获取订阅信息
const subscription = await registration.pushManager.getSubscription().then(function (subscription) {
if (subscription === null) {
const convertedVapidKey = urlBase64ToUint8Array(import.meta.env.VITE_PUBLIC_VAPID_KEY)
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey,
})
} else {
return subscription
}
})
// 发送订阅请求
try {
await api.post('/message/webpush/subscribe', subscription)
} catch (e) {
console.log(e)
}
}
}
// 登录后处理
async function afterLogin(superuser: boolean) {
// 生效主题配置
await setTheme()
// 跳转到首页或回原始页面
router.push(store.state.auth.originalPath ?? '/')
// 订阅推送通知
if (superuser) await subscribeForPushNotifications()
}
// 登录获取token事件
@@ -122,19 +158,17 @@ function login() {
.then((response: any) => {
// 获取token
const token = response.access_token
const superuser = response.super_user
const username = response.user_name
const superUser = response.super_user
const userName = response.user_name
const avatar = response.avatar
const level = response.level
const remember = form.value.remember
// 更新token和remember状态到Vuex Store
store.dispatch('auth/updateToken', token)
store.dispatch('auth/updateRemember', form.value.remember)
store.dispatch('auth/updateSuperUser', superuser)
store.dispatch('auth/updateUserName', username)
store.dispatch('auth/updateAvatar', avatar)
store.dispatch('auth/login', { token, remember, superUser, userName, avatar, level })
// 登录后处理
afterLogin()
afterLogin(superUser)
})
.catch((error: any) => {
// 登录失败,显示错误提示
@@ -147,7 +181,7 @@ function login() {
}
// 自动登录
onMounted(() => {
onMounted(async () => {
// 从Vuex Store中获取token和remember状态
const token = store.state.auth.token
const remember = store.state.auth.remember
@@ -157,81 +191,97 @@ onMounted(() => {
router.push('/')
} else {
// 获取背景图片
fetchBackgroundImage()
await fetchBackgroundImage()
// 每隔5秒更换一次背景图片
intervalTimer = setInterval(() => {
if (backgroundImages.value.length > 0) {
const index = Math.floor(Math.random() * backgroundImages.value.length)
backgroundImageUrl.value = backgroundImages.value[index]
}
}, 5000)
}
})
onUnmounted(() => {
if (intervalTimer) clearInterval(intervalTimer)
})
</script>
<template>
<VImg
aspect-ratio="4/3"
:src="backgroundImageUrl"
class="w-full h-full overflow-hidden"
cover
@load="isImageLoaded = true"
>
<div class="auth-wrapper d-flex align-center justify-center pa-4">
<VCard
class="auth-card pa-7 w-full h-full"
:class="isImageLoaded ? 'backdrop-blur-xl bg-white/50' : ''"
max-width="25rem"
:theme="isImageLoaded ? 'light' : ''"
>
<VCardItem class="justify-center mb-7">
<template #prepend>
<div class="d-flex pe-0">
<VImg :src="logo" width="64" height="64" />
</div>
</template>
<VCardTitle class="font-weight-semibold text-2xl text-uppercase"> MoviePilot </VCardTitle>
</VCardItem>
<VCardText>
<VForm ref="refForm" @submit.prevent="() => {}">
<VRow>
<!-- username -->
<VCol cols="12">
<VTextField
ref="usernameInput"
v-model="form.username"
label="用户名"
type="text"
:rules="[requiredValidator]"
@input="fetchOTP"
/>
</VCol>
<!-- password -->
<VCol cols="12">
<VTextField
v-model="form.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" label="保持登录" required />
</div>
</VCol>
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login"> 登录 </VBtn>
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
{{ errorMessage }}
</div>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
<template v-for="image in backgroundImages">
<div v-if="backgroundImageUrl == image" class="absolute inset-0">
<VImg :src="image" class="w-full h-full" cover position="center top" @load="isImageLoaded = true">
<template #placeholder>
<VSkeletonLoader v-if="!isImageLoaded" class="object-cover" />
</template>
<div
class="absolute inset-0"
style="background-image: linear-gradient(rgba(45, 55, 72, 33%) 0%, rgb(26, 32, 46) 100%)"
/>
</VImg>
</div>
</VImg>
</template>
<div class="auth-wrapper d-flex align-center justify-center pa-4">
<VCard
class="auth-card px-7 py-3 w-full h-full rounded-lg"
:class="{ 'opacity-85': isImageLoaded }"
max-width="24rem"
>
<VCardItem class="justify-center">
<template #prepend>
<div class="d-flex pe-0">
<VImg :src="logo" width="64" height="64" />
</div>
</template>
<VCardTitle class="font-weight-semibold text-2xl text-uppercase"> MoviePilot </VCardTitle>
</VCardItem>
<VCardText>
<VForm ref="refForm" @submit.prevent="() => {}">
<VRow>
<!-- username -->
<VCol cols="12">
<VTextField
ref="usernameInput"
v-model="form.username"
label="用户名"
type="text"
:rules="[requiredValidator]"
@input="fetchOTP"
/>
</VCol>
<!-- password -->
<VCol cols="12">
<VTextField
v-model="form.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" label="保持登录" required />
</div>
</VCol>
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login"> 登录 </VBtn>
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
{{ errorMessage }}
</div>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</div>
</template>
<style lang="scss">

View File

@@ -1,82 +1,77 @@
<script setup lang="ts">
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
const viewList = reactive<{apipath: string, linkurl: string, title: string}[]>([
const viewList = reactive<{ apipath: string; linkurl: string; title: string }[]>([
{
apipath: 'tmdb/trending',
linkurl: "/browse/tmdb/trending?title=流行趋势",
title: "流行趋势",
linkurl: '/browse/tmdb/trending?title=流行趋势',
title: '流行趋势',
},
{
apipath: "douban/showing",
linkurl: "/browse/douban/showing?title=正在热映",
title: "正在热映"
apipath: 'douban/showing',
linkurl: '/browse/douban/showing?title=正在热映',
title: '正在热映',
},
{
apipath: "bangumi/calendar",
linkurl: "/browse/bangumi/calendar?title=Bangumi每日放送",
title: "Bangumi每日放送"
apipath: 'bangumi/calendar',
linkurl: '/browse/bangumi/calendar?title=Bangumi每日放送',
title: 'Bangumi每日放送',
},
{
apipath: "tmdb/movies",
linkurl: "/browse/tmdb/movies?title=热门电影",
title: "热门电影"
apipath: 'tmdb/movies',
linkurl: '/browse/tmdb/movies?title=TMDB热门电影',
title: 'TMDB热门电影',
},
{
apipath: "tmdb/tvs?with_original_language=zh|en|ja|ko",
linkurl: "/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=热门电视剧",
title: "热门电视剧"
apipath: 'tmdb/tvs?with_original_language=zh|en|ja|ko',
linkurl: '/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=TMDB热门电视剧',
title: 'TMDB热门电视剧',
},
{
apipath: "douban/movie_hot",
linkurl: "/browse/douban/movie_hot?title=热门电影",
title: "热门电影"
apipath: 'douban/movie_hot',
linkurl: '/browse/douban/movie_hot?title=豆瓣热门电影',
title: '豆瓣热门电影',
},
{
apipath: "douban/tv_hot",
linkurl: "/browse/douban/tv_hot?title=热门电视剧",
title: "热门电视剧"
apipath: 'douban/tv_hot',
linkurl: '/browse/douban/tv_hot?title=豆瓣热门电视剧',
title: '豆瓣热门电视剧',
},
{
apipath: "douban/tv_animation",
linkurl: "/browse/douban/tv_animation?title=热门动漫",
title: "热门动漫"
apipath: 'douban/tv_animation',
linkurl: '/browse/douban/tv_animation?title=豆瓣热门动漫',
title: '豆瓣热门动漫',
},
{
apipath: "douban/movies",
linkurl: "/browse/douban/movies?title=最新电影",
title: "最新电影"
apipath: 'douban/movies',
linkurl: '/browse/douban/movies?title=豆瓣最新电影',
title: '豆瓣最新电影',
},
{
apipath: "douban/tvs",
linkurl: "/browse/douban/tvs?title=最新电视剧",
title: "最新电视剧"
apipath: 'douban/tvs',
linkurl: '/browse/douban/tvs?title=豆瓣最新电视剧',
title: '豆瓣最新电视剧',
},
{
apipath: "douban/movie_top250",
linkurl: "/browse/douban/movie_top250?title=电影TOP250",
title: "电影TOP250"
apipath: 'douban/movie_top250',
linkurl: '/browse/douban/movie_top250?title=电影TOP250',
title: '豆瓣电影TOP250',
},
{
apipath: "douban/tv_weekly_chinese",
linkurl: "/browse/douban/tv_weekly_chinese?title=国产剧集榜",
title: "国产剧集榜"
apipath: 'douban/tv_weekly_chinese',
linkurl: '/browse/douban/tv_weekly_chinese?title=豆瓣国产剧集榜',
title: '豆瓣国产剧集榜',
},
{
apipath: "douban/tv_weekly_global",
linkurl: "/browse/douban/tv_weekly_global?title=全球剧集榜",
title: "全球剧集榜"
}
apipath: 'douban/tv_weekly_global',
linkurl: '/browse/douban/tv_weekly_global?title=豆瓣全球剧集榜',
title: '豆瓣全球剧集榜',
},
])
</script>
<template>
<div>
<MediaCardSlideView
v-for="item in viewList"
:key="item.apipath"
v-bind="item"
/>
<MediaCardSlideView v-for="(item, index) in viewList" :key="index" v-bind="item" />
</div>
</template>

View File

@@ -5,6 +5,13 @@ import type { Context } from '@/api/types'
import store from '@/store'
import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
import TorrentRowListView from '@/views/discover/TorrentRowListView.vue'
import { useDisplay } from 'vuetify'
// APP
const display = useDisplay()
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 路由参数
const route = useRoute()
@@ -54,7 +61,7 @@ function startLoadingProgress() {
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/search?token=${token}`,
)
progressEventSource.value.onmessage = (event) => {
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
@@ -65,7 +72,7 @@ function startLoadingProgress() {
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
if (progressEventSource.value) progressEventSource.value?.close()
}
// 设置视图类型
@@ -80,34 +87,38 @@ async function fetchData() {
if (!keyword) {
// 查询上次搜索结果
dataList.value = await api.get('search/last')
}
else {
} else {
startLoadingProgress()
let result: { [key: string]: any }
// 优先按TMDBID精确查询
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
const result: {[key: string]: any} = await api.get(`search/media/${keyword}`, {
result = await api.get(`search/media/${keyword}`, {
params: {
mtype: type,
area,
season,
},
})
if (result.success){
dataList.value = result.data
} else {
errorDescription.value = result.message
}
}
else {
} else {
// 按标题模糊查询
dataList.value = await api.get(`search/title/${keyword}`)
result = await api.get(`search/title`, {
params: {
keyword,
},
})
}
if (result && result.success) {
dataList.value = result.data
} else if (result && result.message) {
errorDescription.value = result.message
}
stopLoadingProgress()
// 从浏览器历史中删除当前搜索
window.history.replaceState(null, '', window.location.pathname)
}
// 标记已刷新
isRefreshed.value = true
}
catch (error) {
} catch (error) {
console.error(error)
return Promise.reject(error)
}
@@ -117,49 +128,46 @@ async function fetchData() {
onMounted(() => {
fetchData()
})
// 卸载时停止加载进度
onUnmounted(() => {
stopLoadingProgress()
})
</script>
<template>
<LoadingBanner
v-if="!isRefreshed"
class="mt-12"
:text="progressText"
:progress="progressValue"
/>
<LoadingBanner v-if="!isRefreshed" class="mt-12" :text="progressText" :progress="progressValue" />
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
:error-title="errorTitle"
:error-description="errorDescription"
/>
<div v-if="dataList.length > 0">
<TorrentRowListView
v-if="viewType === 'list'"
:items="dataList"
/>
<TorrentCardListView
v-else
:items="dataList"
/>
<TorrentRowListView v-if="viewType === 'list'" :items="dataList" />
<TorrentCardListView v-else :items="dataList" />
</div>
<!-- 视图切换 -->
<VFab
v-if="viewType === 'list'"
class="mb-12"
icon="mdi-view-grid"
location="bottom end"
location="bottom"
size="x-large"
fixed
absolute
app
appear
@click="setViewType('card')"
:class="{ 'mb-12': appMode }"
/>
<VFab
v-else
icon="mdi-view-list"
location="bottom end"
location="bottom"
size="x-large"
fixed
app
appear
@click="setViewType('list')"
:class="{ 'mb-12': appMode }"
/>
</template>

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { useRoute } from 'vue-router'
import router from '@/router'
import AccountSettingAccount from '@/views/setting/AccountSettingAccount.vue'
import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'
import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
@@ -9,67 +10,32 @@ import AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue'
import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'
import AccountSettingService from '@/views/setting/AccountSettingService.vue'
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
import { SettingTabs } from '@/router/menu'
const route = useRoute()
const activeTab = ref(route.params.tab)
const activeTab = ref(route.query.tab)
// tabs
const tabs = [
{
title: '用户',
icon: 'mdi-account',
tab: 'account',
},
{
title: '系统',
icon: 'mdi-cog',
tab: 'system',
},
{
title: '站点',
icon: 'mdi-web',
tab: 'site',
},
{
title: '搜索',
icon: 'mdi-magnify',
tab: 'search',
},
{
title: '订阅',
icon: 'mdi-rss',
tab: 'subscribe',
},
{
title: '服务',
icon: 'mdi-list-box',
tab: 'service',
},
{
title: '通知',
icon: 'mdi-bell',
tab: 'notification',
},
{
title: '词表',
icon: 'mdi-file-word-box',
tab: 'words',
},
{
title: '关于',
icon: 'mdi-information',
tab: 'about',
},
]
function jumpTab(tab: string) {
router.push('/setting?tab=' + tab)
}
</script>
<template>
<div>
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill">
<VTab v-for="item in tabs" :key="item.icon" :value="item.tab">
<VIcon size="20" start :icon="item.icon" />
{{ item.title }}
<VTab
v-for="item in SettingTabs"
:key="item.icon"
:value="item.tab"
@click="jumpTab(item.tab)"
selected-class="v-slide-group-item--active v-tab--selected"
>
<div>
<VIcon size="20" start :icon="item.icon" />
{{ item.title }}
</div>
</VTab>
</VTabs>
@@ -77,61 +43,90 @@ const tabs = [
<!-- 用户 -->
<VWindowItem value="account">
<transition name="fade-slide" appear>
<AccountSettingAccount />
<div>
<AccountSettingAccount />
</div>
</transition>
</VWindowItem>
<!-- 系统 -->
<!-- 连接 -->
<VWindowItem value="system">
<transition name="fade-slide" appear>
<AccountSettingSystem />
<div>
<AccountSettingSystem />
</div>
</transition>
</VWindowItem>
<!-- 目录 -->
<VWindowItem value="directory">
<transition name="fade-slide" appear>
<div>
<AccountSettingDirectory />
</div>
</transition>
</VWindowItem>
<!-- 站点 -->
<VWindowItem value="site">
<transition name="fade-slide" appear>
<AccountSettingSite />
<div>
<AccountSettingSite />
</div>
</transition>
</VWindowItem>
<!-- 搜索 -->
<VWindowItem value="search">
<transition name="fade-slide" appear>
<AccountSettingSearch />
<div>
<AccountSettingSearch />
</div>
</transition>
</VWindowItem>
<!-- 订阅 -->
<VWindowItem value="subscribe">
<transition name="fade-slide" appear>
<AccountSettingSubscribe />
<div>
<AccountSettingSubscribe />
</div>
</transition>
</VWindowItem>
<!-- 服务 -->
<VWindowItem value="service">
<transition name="fade-slide" appear>
<AccountSettingService />
<div>
<AccountSettingService />
</div>
</transition>
</VWindowItem>
<!-- 通知 -->
<VWindowItem value="notification">
<transition name="fade-slide" appear>
<AccountSettingNotification />
<div>
<AccountSettingNotification />
</div>
</transition>
</VWindowItem>
<!-- 词表 -->
<VWindowItem value="words">
<transition name="fade-slide" appear>
<AccountSettingWords />
<div>
<AccountSettingWords />
</div>
</transition>
</VWindowItem>
<!-- 关于 -->
<VWindowItem value="about">
<transition name="fade-slide" appear>
<AccountSettingAbout />
<div>
<AccountSettingAbout />
</div>
</transition>
</VWindowItem>
</VWindow>

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
const route = useRoute()
// 标签页
const tabs = [
{
title: '我的订阅',
tab: 'mysub',
},
{
title: '热门订阅',
tab: 'popular',
},
]
// 当前标签
const activeTab = ref(route.params.tab)
</script>
<template>
<div>
<VTabs v-model="activeTab">
<VTab v-for="item in tabs" :value="item.tab">
<span class="mx-5">{{ item.title }}</span>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="mysub">
<transition name="fade-slide" appear>
<SubscribeListView type="电视剧" />
</transition>
</VWindowItem>
<VWindowItem value="popular">
<transition name="fade-slide" appear>
<SubscribePopularView type="电视剧" />
</transition>
</VWindowItem>
</VWindow>
</div>
</template>

View File

@@ -1,29 +1,24 @@
<script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import { SubscribeMovieTabs } from '@/router/menu'
import router from '@/router'
const route = useRoute()
//
const tabs = [
{
title: '我的订阅',
tab: 'mysub',
},
{
title: '热门订阅',
tab: 'popular',
},
]
const subType = route.meta.subType?.toString()
const subId = ref(route.query.id as string)
const activeTab = ref(route.query.tab)
//
const activeTab = ref(route.params.tab)
function jumpTab(tab: string) {
router.push('/subscribe/movie?tab=' + tab)
}
</script>
<template>
<div>
<VTabs v-model="activeTab">
<VTab v-for="item in tabs" :value="item.tab">
<VTab v-for="item in SubscribeMovieTabs" :value="item.tab" @to="jumpTab(item.tab)">
<span class="mx-5">{{ item.title }}</span>
</VTab>
</VTabs>
@@ -31,12 +26,12 @@ const activeTab = ref(route.params.tab)
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="mysub">
<transition name="fade-slide" appear>
<SubscribeListView type="电影" />
<SubscribeListView :type="subType" :subid="subId" />
</transition>
</VWindowItem>
<VWindowItem value="popular">
<transition name="fade-slide" appear>
<SubscribePopularView type="电影" />
<SubscribePopularView :type="subType" />
</transition>
</VWindowItem>
</VWindow>

View File

@@ -4,12 +4,12 @@
* webfontloader documentation: https://github.com/typekit/webfontloader
*/
export async function loadFonts() {
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */'webfontloader')
;(async function loadFonts() {
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */ 'webfontloader')
webFontLoader.load({
google: {
families: ['Inter:100,200,300,400,500,600,700&display=swap'],
},
})
}
})()

View File

@@ -1,5 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { configureNProgress, doneNProgress, startNProgress } from '@/api/nprogress'
import { configureNProgress } from '@/api/nprogress'
import store from '@/store'
// Nprogress
@@ -10,8 +10,7 @@ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) {
// 如果页面有缓存那么恢复其位置, 否则始终滚动到顶部
if (to.meta.keepAlive && savedPosition)
return savedPosition
if (to.meta.keepAlive && savedPosition) return savedPosition
return { top: 0 }
},
routes: [
@@ -21,14 +20,15 @@ const router = createRouter({
component: () => import('../layouts/default.vue'),
children: [
{
path: 'dashboard',
path: '/dashboard',
component: () => import('../pages/dashboard.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: 'ranking',
path: '/ranking',
component: () => import('../pages/ranking.vue'),
meta: {
keepAlive: true,
@@ -36,63 +36,70 @@ const router = createRouter({
},
},
{
path: 'resource',
path: '/resource',
component: () => import('../pages/resource.vue'),
meta: {
requiresAuth: true,
},
},
{
path: 'subscribe-movie',
component: () => import('../pages/subscribe-movie.vue'),
path: '/subscribe/movie',
component: () => import('../pages/subscribe.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
subType: '电影',
},
},
{
path: 'subscribe-tv',
component: () => import('../pages/subscribe-tv.vue'),
path: '/subscribe/tv',
component: () => import('../pages/subscribe.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
subType: '电视剧',
},
},
{
path: 'calendar',
path: '/calendar',
component: () => import('../pages/calendar.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: 'downloading',
path: '/downloading',
component: () => import('../pages/downloading.vue'),
meta: {
requiresAuth: true,
},
},
{
path: 'history',
path: '/history',
component: () => import('../pages/history.vue'),
meta: {
requiresAuth: true,
},
},
{
path: 'site',
path: '/site',
component: () => import('../pages/site.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: 'plugins',
path: '/plugins',
component: () => import('../pages/plugin.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: 'setting',
path: '/setting',
component: () => import('../pages/setting.vue'),
meta: {
requiresAuth: true,
@@ -121,6 +128,7 @@ const router = createRouter({
component: () => import('../pages/person.vue'),
props: true,
meta: {
keepAlive: true,
requiresAuth: true,
},
},
@@ -128,12 +136,21 @@ const router = createRouter({
path: '/media',
component: () => import('../pages/media.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: '/filemanager',
component: () => import('../pages/filemanager.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: '/apps',
component: () => import('../pages/appcenter.vue'),
meta: {
requiresAuth: true,
},
@@ -162,18 +179,11 @@ router.beforeEach((to, from, next) => {
// 总是记录非login路由
if (to.fullPath != '/login') store.state.auth.originalPath = to.fullPath
const isAuthenticated = store.state.auth.token !== null
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
}
else {
startNProgress()
} else {
next()
}
})
router.afterEach(() => {
doneNProgress()
})
export default router

224
src/router/menu.ts Normal file
View File

@@ -0,0 +1,224 @@
// 导般菜单
export const SystemNavMenus = [
{
title: '仪表板',
icon: 'mdi-home-outline',
to: '/dashboard',
header: '开始',
admin: false,
},
{
title: '推荐',
icon: 'mdi-star-outline',
to: '/ranking',
header: '发现',
admin: false,
},
{
title: '资源搜索',
icon: 'mdi-magnify',
to: '/resource',
header: '发现',
admin: false,
},
{
title: '电影',
full_title: '电影订阅',
icon: 'mdi-movie-open-outline',
to: '/subscribe/movie',
header: '订阅',
admin: false,
},
{
title: '电视剧',
full_title: '电视剧订阅',
icon: 'mdi-television',
to: '/subscribe/tv',
header: '订阅',
admin: false,
},
{
title: '日历',
full_title: '订阅日历',
icon: 'mdi-calendar',
to: '/calendar',
header: '订阅',
admin: false,
},
{
title: '正在下载',
icon: 'mdi-download-outline',
to: '/downloading',
header: '整理',
admin: false,
},
{
title: '历史记录',
icon: 'mdi-history',
to: '/history',
header: '整理',
admin: true,
},
{
title: '文件管理',
icon: 'mdi-folder-multiple-outline',
to: '/filemanager',
header: '整理',
admin: true,
},
{
title: '插件',
icon: 'mdi-apps',
to: '/plugins',
header: '系统',
admin: true,
},
{
title: '站点管理',
icon: 'mdi-web',
to: '/site',
header: '系统',
admin: true,
},
{
title: '设定',
icon: 'mdi-cog',
to: '/setting',
header: '系统',
admin: true,
},
]
// 常用菜单功能
export const UserfulMenus = [
{
title: '搜索设置',
icon: 'mdi-magnify',
to: 'setting?tab=search',
},
{
title: '订阅设置',
icon: 'mdi-rss',
to: 'setting?tab=subscribe',
},
{
title: '服务',
icon: 'mdi-list-box',
to: 'setting?tab=service',
},
{
title: '词表',
icon: 'mdi-file-word-box',
to: 'setting?tab=words',
},
{
title: '历史记录',
icon: 'mdi-history',
to: 'history',
},
]
// 设定标签页
export const SettingTabs = [
{
title: '用户',
icon: 'mdi-account',
tab: 'account',
description: '个人信息、用户管理、修改密码、双重认证',
},
{
title: '连接',
icon: 'mdi-server-network',
tab: 'system',
description: '下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex',
},
{
title: '目录',
icon: 'mdi-folder',
tab: 'directory',
description: '下载目录、媒体库目录、整理模式',
},
{
title: '站点',
icon: 'mdi-web',
tab: 'site',
description: '站点同步、下载优先规则、站点重置',
},
{
title: '搜索',
icon: 'mdi-magnify',
tab: 'search',
description: '媒体数据源TheMovieDb、豆瓣、Bangumi、搜索站点、搜索优先级、默认过滤规则',
},
{
title: '订阅',
icon: 'mdi-rss',
tab: 'subscribe',
description: '订阅站点、订阅模式、订阅优先级、洗版优先级、默认过滤规则',
},
{
title: '服务',
icon: 'mdi-list-box',
tab: 'service',
description: '定时作业',
},
{
title: '通知',
icon: 'mdi-bell',
tab: 'notification',
description: '通知渠道微信、Telegram、Slack、SynologyChat、VoceChat、消息类型',
},
{
title: '词表',
icon: 'mdi-file-word-box',
tab: 'words',
description: '自定义识别词、自定义制作组/字幕组、自定义占位符、文件整理屏蔽词',
},
{
title: '关于',
icon: 'mdi-information',
tab: 'about',
},
]
// 电影订阅标签页
export const SubscribeMovieTabs = [
{
title: '我的订阅',
tab: 'mysub',
icon: 'mdi-movie-open-outline',
},
{
title: '热门订阅',
tab: 'popular',
icon: 'mdi-movie-open-outline',
},
]
// 电视剧订阅标签页
export const SubscribeTvTabs = [
{
title: '我的订阅',
tab: 'mysub',
icon: 'mdi-television',
},
{
title: '热门订阅',
tab: 'popular',
icon: 'mdi-television',
},
]
// 插件标签页
export const PluginTabs = [
{
title: '我的插件',
tab: 'installed',
icon: 'mdi-puzzle',
},
{
title: '插件市场',
tab: 'market',
icon: 'mdi-store',
},
]

74
src/service-worker.ts Normal file
View File

@@ -0,0 +1,74 @@
import { createHandlerBoundToURL, cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { clientsClaim } from 'workbox-core'
declare let self: ServiceWorkerGlobalScope
cleanupOutdatedCaches()
// self.__WB_MANIFEST is default injection point
precacheAndRoute(self.__WB_MANIFEST)
// to allow work offline
registerRoute(new NavigationRoute(createHandlerBoundToURL('index.html'), { denylist: [/^(\/[\w-]+)*\/api/] }))
// 通知选项
const options = {
icon: '/logo.png',
vibrate: [100, 50, 100],
actions: [{ action: 'close', title: '关闭' }],
}
// 监听 push 事件,显示通知
self.addEventListener('push', function (event) {
console.log('notification push')
if (!event.data) {
return
}
// 解析获取推送消息
let payload
try {
payload = event.data?.json()
} catch (err) {
console.log(err)
payload = {
title: event.data?.text(),
}
}
// 根据推送消息生成桌面通知并展现出来
try {
const content = {
body: payload.body || '',
icon: payload.icon || options.icon,
vibrate: [100, 50, 100],
data: { url: payload.url },
actions: options.actions,
}
event.waitUntil(self.registration.showNotification(payload.title, content))
} catch (e) {
console.error(e)
}
})
// 安装
self.addEventListener('install', function (e) {
console.log('worker install')
self.skipWaiting()
})
// 激活
self.addEventListener('activate', function (e) {
console.log('worker activate')
e.waitUntil(self.clients.claim())
})
// 监听通知点击事件
self.addEventListener('notificationclick', function (event) {
console.log('notification click')
const info = event.notification
if (event.action === 'close') {
info.close()
} else if (info.data?.url) {
event.waitUntil(self.clients.openWindow(info.data?.url))
}
})

View File

@@ -8,6 +8,7 @@ interface AuthState {
userName: string
avatar: string
originalPath: string | null
level: number
}
// 定义根状态类型
@@ -25,6 +26,7 @@ const authModule: Module<AuthState, RootState> = {
userName: '',
avatar: '',
originalPath: null,
level: 1,
},
mutations: {
setToken(state, token: string) {
@@ -45,25 +47,25 @@ const authModule: Module<AuthState, RootState> = {
setAvatar(state, avatar: string) {
state.avatar = avatar
},
setOriginalPath(state, originalPath: string) {
state.originalPath = originalPath
},
setLevel(state, level: number) {
state.level = level
},
},
actions: {
updateToken({ commit }, token: string) {
login({ commit }, { token, remember, superUser, userName, avatar, level }) {
commit('setToken', token)
},
clearToken({ commit }) {
commit('clearToken')
},
updateRemember({ commit }, remember: boolean) {
commit('setRemember', remember)
},
updateSuperUser({ commit }, superUser: boolean) {
commit('setSuperUser', superUser)
},
updateUserName({ commit }, userName: string) {
commit('setUserName', userName)
},
updateAvatar({ commit }, avatar: string) {
commit('setAvatar', avatar)
commit('setLevel', level)
},
logout({ commit }) {
commit('clearToken')
commit('setOriginalPath', null)
},
},
getters: {
@@ -72,6 +74,8 @@ const authModule: Module<AuthState, RootState> = {
getSuperUser: state => state.superUser,
getUserName: state => state.userName,
getAvatar: state => state.avatar,
getOriginalPath: state => state.originalPath,
getLevel: state => state.level,
},
}

View File

@@ -24,14 +24,11 @@
}
.v-dialog > .v-overlay__content {
inline-size: calc(100% - 1rem);
margin-block-start: calc(env(safe-area-inset-top) + 1rem);
max-block-size: calc(100% - env(safe-area-inset-top) - 1rem);
}
.v-dialog > .v-overlay__content{
inline-size: calc(100% - 1rem);
}
.v-dialog--fullscreen > .v-overlay__content{
inline-size: 100%;
margin-block-start: env(safe-area-inset-top);
@@ -65,7 +62,6 @@
color: transparent;
--tw-gradient-from: #818cf8;
--tw-gradient-to: rgba(129,140,248,0%);
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
--tw-gradient-to: #c084fc;
}
@@ -139,3 +135,77 @@
.apexcharts-title-text {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
}
.grid-site-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
.grid-backdrop-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
.grid-torrent-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;
}
.grid-plugin-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;
}
.grid-downloading-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
.grid-directory-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
.grid-filterrule-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
.grid-subscribe-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;
}
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.v-fab__container {
padding-block-end: env(safe-area-inset-bottom);
}
.v-overlay__content .v-list{
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
background-color: rgb(var(--v-theme-surface), 0.9) !important;
}
.v-overlay__content .v-card:not(.bg-primary){
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
background-color: rgb(var(--v-theme-surface), 0.95) !important;
.v-list, .v-table {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: none;
backdrop-filter: none;
background-color: transparent !important;
}
}

View File

@@ -4,6 +4,15 @@ import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
import api from '@/api'
// 输入参数
const props = defineProps({
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
})
const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(
@@ -16,7 +25,7 @@ const variableTheme = controlledComputed(
)
// 定时器
let refreshTimer: NodeJS.Timer | null = null
let refreshTimer: NodeJS.Timeout | null = null
// 时间序列
const series = ref([
@@ -94,6 +103,7 @@ const chartOptions = controlledComputed(
// 调用API接口获取最新CPU使用率
async function getCpuUsage() {
if (!props.allowRefresh) return
try {
// 请求数据
current.value = (await api.get('dashboard/cpu')) ?? 0

View File

@@ -2,14 +2,7 @@
import api from '@/api'
import type { MediaStatistic } from '@/api/types'
const statistics = ref([
{
title: '',
stats: '',
icon: '',
color: '',
},
])
const statistics = ref<{ [key: string]: string }[]>([])
// 调用API加载媒体统计数据
async function loadMediaStatistic() {

View File

@@ -5,6 +5,15 @@ import { hexToRgb } from '@layouts/utils'
import api from '@/api'
import { formatBytes } from '@/@core/utils/formatters'
// 输入参数
const props = defineProps({
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
})
const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(
@@ -17,7 +26,7 @@ const variableTheme = controlledComputed(
)
// 定时器
let refreshTimer: NodeJS.Timer | null = null
let refreshTimer: NodeJS.Timeout | null = null
// 时间序列
const series = ref([
@@ -100,6 +109,7 @@ const chartOptions = controlledComputed(
// 调用API接口获取最新内存使用量
async function getMemorgUsage() {
if (!props.allowRefresh) return
try {
// 请求数据
;[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')

View File

@@ -10,7 +10,7 @@ const headers = ['进程ID', '进程名称', '运行时间', '内存占用']
const processList = ref<Process[]>([])
// 定时器
let refreshTimer: NodeJS.Timer | null = null
let refreshTimer: NodeJS.Timeout | null = null
// 调用API加载数据
async function loadProcessList() {

View File

@@ -2,14 +2,26 @@
import api from '@/api'
import type { ScheduleInfo } from '@/api/types'
// 输入参数
const props = defineProps({
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
})
// 定时服务列表
const schedulerList = ref<ScheduleInfo[]>([])
// 定时器
let refreshTimer: NodeJS.Timer | null = null
let refreshTimer: NodeJS.Timeout | null = null
// 调用API加载定时服务列表
async function loadSchedulerList() {
if (!props.allowRefresh) {
return
}
try {
const res: ScheduleInfo[] = await api.get('dashboard/schedule')

View File

@@ -3,8 +3,17 @@ import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import type { DownloaderInfo } from '@/api/types'
// 输入参数
const props = defineProps({
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
})
// 定时器
let refreshTimer: NodeJS.Timer | null = null
let refreshTimer: NodeJS.Timeout | null = null
// 下载器信息
const downloadInfo = ref<DownloaderInfo>({
@@ -35,6 +44,10 @@ const infoItems = ref([
// 调用API查询下载器数据
async function loadDownloaderInfo() {
if (!props.allowRefresh) {
return
}
try {
const res: DownloaderInfo = await api.get('dashboard/downloader')

View File

@@ -80,7 +80,13 @@ const options = controlledComputed(
fontSize: '12px',
},
formatter: (value: number) => (value > 999 ? (value / 1000).toFixed(0) : value),
formatter: (value: number) => {
if (value > 999) {
return (value / 1000).toFixed(1) + 'k'
} else {
return value.toString()
}
},
},
},
}

View File

@@ -38,9 +38,3 @@ onMounted(() => {
</template>
</VHover>
</template>
<style lang="scss">
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
</style>

View File

@@ -38,10 +38,3 @@ onMounted(() => {
</template>
</VHover>
</template>
<style lang="scss">
.grid-backdrop-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -38,10 +38,3 @@ onMounted(() => {
</template>
</VHover>
</template>
<style lang="scss">
.grid-backdrop-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -125,7 +125,5 @@ async function fetchData({ done }: { done: any }) {
</template>
<style lang="scss">
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
</style>

View File

@@ -425,7 +425,7 @@ onBeforeMount(() => {
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" class="max-w-8xl mx-auto px-4">
<template v-if="mediaDetail.backdrop_path || mediaDetail.poster_path">
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
<VImg class="h-96" :src="mediaDetail.backdrop_path || mediaDetail.poster_path" cover />
<VImg class="h-96" position="top" :src="mediaDetail.backdrop_path || mediaDetail.poster_path" cover />
</div>
<div class="vue-media-back absolute left-0 top-0 w-full h-96" />
</template>
@@ -861,6 +861,7 @@ onBeforeMount(() => {
/>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"

View File

@@ -124,9 +124,3 @@ async function fetchData({ done }: { done: any }) {
/>
</VInfiniteScroll>
</template>
<style lang="scss">
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
</style>

View File

@@ -2,7 +2,6 @@
import _ from 'lodash'
import type { Context } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue'
import { useDefer } from '@/@core/utils/dom'
interface SearchTorrent extends Context {
more?: Array<Context>
@@ -47,8 +46,10 @@ const editionFilterOptions = ref<Array<string>>([])
// 获取分辨率过滤选项
const resolutionFilterOptions = ref<Array<string>>([])
// 数据列表
const dataList = ref<Array<SearchTorrent>>([])
// 完整的数据列表
let dataList: SearchTorrent[]
// 显示用的数据列表
const displayDataList = ref<Array<SearchTorrent>>([])
// 分组后的数据列表
const groupedDataList = ref<Map<string, Context[]>>()
@@ -71,8 +72,35 @@ function initOptions(data: Context) {
// 对季过滤选项进行排序
const sortSeasonFilterOptions = computed(() => {
return seasonFilterOptions.value.sort((a, b) => {
// 按字符串升序排序
return a.localeCompare(b, 'zh-Hans-CN', { sensitivity: 'accent' })
// 按季,集降序排序
const parseSeasonEpisode = (str: string) => {
const seasonRangeMatch = str.match(/S(\d+)(?:-S(\d+))?/)
const episodeRangeMatch = str.match(/E(\d+)(?:-E(\d+))?/)
return {
seasonStart: seasonRangeMatch?.[1] ? parseInt(seasonRangeMatch[1]) : 0,
seasonEnd: seasonRangeMatch?.[2] ? parseInt(seasonRangeMatch[2]) : 0,
episodeStart: episodeRangeMatch?.[1] ? parseInt(episodeRangeMatch[1]) : 0,
episodeEnd: episodeRangeMatch?.[2] ? parseInt(episodeRangeMatch[2]) : 0,
}
}
const parsedA = parseSeasonEpisode(a)
const parsedB = parseSeasonEpisode(b)
// 先按季降序排序
if (parsedB.seasonStart !== parsedA.seasonStart) {
return parsedB.seasonStart - parsedA.seasonStart
}
if (parsedB.seasonEnd !== parsedA.seasonEnd) {
return parsedB.seasonEnd - parsedA.seasonEnd
}
// 按集降序排序
if (parsedB.episodeStart !== parsedA.episodeStart) {
return parsedB.episodeStart - parsedA.episodeStart
}
if (parsedB.episodeEnd !== parsedA.episodeEnd) {
return parsedB.episodeEnd - parsedA.episodeEnd
}
// 兜底
return b.localeCompare(a)
})
})
@@ -97,15 +125,16 @@ onMounted(() => {
}
})
groupedDataList.value = groupMap
})
let defer = (_: number) => true
// 计算过滤后的列表
watchEffect(() => {
// 只监听filterForm和groupedDataList的变化。因为displayDataList的变化不需要清空列表
watch([filterForm, groupedDataList], filterData)
function filterData() {
// 清空列表
dataList.value = []
// 匹配过滤函数
dataList = []
displayDataList.value = []
// 匹配过滤函数filter中有任一值包含value则返回true
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
@@ -135,12 +164,23 @@ watchEffect(() => {
const firstData = _.cloneDeepWith(matchData[0]) as SearchTorrent
if (matchData.length > 1) firstData.more = matchData.slice(1)
dataList.value.push(firstData)
// 显示前20个4行左右。
if (displayDataList.value.length < 20) {
displayDataList.value.push(firstData)
} else {
// 后续内容不显示存在list里。loadMore的时候再加载。
dataList.push(firstData)
}
}
}
})
defer = useDefer(dataList.value.length)
})
}
function loadMore({ done }: { done: any }) {
const itemsToMove = dataList.splice(0, 20) // 从 dataList 中获取最前面的 20 个元素
displayDataList.value.push(...itemsToMove)
done('ok')
}
</script>
<template>
@@ -225,16 +265,12 @@ watchEffect(() => {
</VCol>
</VRow>
</VCard>
<div class="grid gap-3 grid-torrent-card items-start">
<div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`">
<TorrentCard v-if="defer(index)" :torrent="item" :more="item.more" />
</div>
</div>
<VInfiniteScroll mode="intersect" side="end" :items="displayDataList" class="overflow-hidden"
@load="loadMore">
<template #loading />
<template #empty />
<div class="grid gap-3 grid-torrent-card items-start">
<TorrentCard v-for="item in displayDataList" :key="`${item.torrent_info.page_url}`" :torrent="item" :more="item.more" />
</div>
</VInfiniteScroll>
</template>
<style lang="scss">
.grid-torrent-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -1,7 +1,16 @@
<script lang="ts" setup>
import type { Context } from '@/api/types'
import TorrentItem from '@/components/cards/TorrentItem.vue'
import { useDefer } from '@/@core/utils/dom'
import { list } from 'postcss'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// APP
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 定义输入参数
const props = defineProps({
@@ -27,6 +36,13 @@ const filterForm = reactive({
resolution: [] as string[],
})
// 列表样式
const listStyle = computed(() => {
return appMode.value
? 'height: calc(100vh - 7.5rem - env(safe-area-inset-bottom) - 3.5rem)'
: 'height: calc(100vh - 6.5rem - env(safe-area-inset-bottom)'
})
// 排序字段
const sortField = ref('default')
@@ -63,6 +79,41 @@ function initOptions(data: Context) {
optionValue(resolutionFilterOptions.value, meta_info?.resource_pix)
}
// 对季过滤选项进行排序
const sortSeasonFilterOptions = computed(() => {
return seasonFilterOptions.value.sort((a, b) => {
// 按季,集降序排序
const parseSeasonEpisode = (str: string) => {
const seasonRangeMatch = str.match(/S(\d+)(?:-S(\d+))?/)
const episodeRangeMatch = str.match(/E(\d+)(?:-E(\d+))?/)
return {
seasonStart: seasonRangeMatch?.[1] ? parseInt(seasonRangeMatch[1]) : 0,
seasonEnd: seasonRangeMatch?.[2] ? parseInt(seasonRangeMatch[2]) : 0,
episodeStart: episodeRangeMatch?.[1] ? parseInt(episodeRangeMatch[1]) : 0,
episodeEnd: episodeRangeMatch?.[2] ? parseInt(episodeRangeMatch[2]) : 0,
}
}
const parsedA = parseSeasonEpisode(a)
const parsedB = parseSeasonEpisode(b)
// 先按季降序排序
if (parsedB.seasonStart !== parsedA.seasonStart) {
return parsedB.seasonStart - parsedA.seasonStart
}
if (parsedB.seasonEnd !== parsedA.seasonEnd) {
return parsedB.seasonEnd - parsedA.seasonEnd
}
// 按集降序排序
if (parsedB.episodeStart !== parsedA.episodeStart) {
return parsedB.episodeStart - parsedA.episodeStart
}
if (parsedB.episodeEnd !== parsedA.episodeEnd) {
return parsedB.episodeEnd - parsedA.episodeEnd
}
// 兜底
return b.localeCompare(a)
})
})
// 排序
watchEffect(() => {
const list = dataList.value
@@ -118,21 +169,21 @@ onMounted(() => {
<template>
<VRow>
<VCol>
<VList v-if="dataList.length === 0" lines="three" class="rounded p-0">
<VList v-if="dataList.length === 0" lines="three" class="rounded p-0 shadow-lg">
<VListItem>
<VListItemTitle>没有附合当前过滤条件的资源</VListItemTitle>
</VListItem>
</VList>
<VList v-if="dataList.length !== 0" lines="three" class="rounded p-0 torrent-list-vscroll">
<VVirtualScroll :items="dataList">
<VList v-if="dataList.length !== 0" lines="three" class="rounded p-0 torrent-list-vscroll shadow-lg">
<VVirtualScroll :items="dataList" :style="listStyle">
<template #default="{ item }">
<TorrentItem :torrent="item" :key="`${item.torrent_info.title}_${item.torrent_info.site}`" />
<TorrentItem :torrent="item" :key="`${item.torrent_info.page_url}`" />
</template>
</VVirtualScroll>
</VList>
</VCol>
<VCol xl="2" md="3" class="d-none d-md-block">
<VList lines="one" class="rounded torrent-list-vscroll">
<VCol xl="2" md="3" v-if="display.mdAndUp.value">
<VList lines="one" class="rounded shadow-lg" :style="listStyle">
<VListSubheader> 排序 </VListSubheader>
<VListItem>
<VChipGroup column v-model="sortField">
@@ -242,7 +293,7 @@ onMounted(() => {
<VListItem>
<VChipGroup v-model="filterForm.season" column multiple>
<VChip
v-for="season in seasonFilterOptions"
v-for="season in sortSeasonFilterOptions"
:key="season"
:color="filterForm.season.includes(season) ? 'primary' : ''"
filter
@@ -257,15 +308,3 @@ onMounted(() => {
</VCol>
</VRow>
</template>
<style lang="scss">
.torrent-list-vscroll {
block-size: calc(100vh - 6rem);
overflow-y: auto;
}
@media (width <= 768px) {
.orrent-list-vscroll {
block-size: calc(100vh - 9rem);
}
}
</style>

View File

@@ -8,30 +8,23 @@ import PluginCard from '@/components/cards/PluginCard.vue'
import noImage from '@images/logos/plugin.png'
import { useDisplay } from 'vuetify'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useDefer } from '@/@core/utils/dom'
import { PluginTabs } from '@/router/menu'
const route = useRoute()
// 显示器宽度
const display = useDisplay()
// 延迟加载
let deferApp = (_: number) => true
// APP
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 当前标签
const activeTab = ref(route.params.tab)
const activeTab = ref(route.query.tab)
// 标签页
const tabs = [
{
title: '我的插件',
tab: 'myplugin',
},
{
title: '插件市场',
tab: 'pluginmarket',
},
]
// 插件ID参数
const pluginId = ref(route.query.id)
// 当前排序字段
const activeSort = ref(null)
@@ -45,6 +38,9 @@ const sortOptions = [
{ title: '最新发布', value: 'add_time' },
]
// 加载中
const loading = ref(false)
// 已安装插件列表
const dataList = ref<Plugin[]>([])
@@ -204,11 +200,13 @@ const filterPlugins = computed(() => {
// 获取插件列表数据
async function fetchInstalledPlugins() {
try {
loading.value = true
dataList.value = await api.get('plugin/', {
params: {
state: 'installed',
},
})
loading.value = false
isRefreshed.value = true
} catch (error) {
console.error(error)
@@ -218,6 +216,7 @@ async function fetchInstalledPlugins() {
// 获取未安装插件列表数据
async function fetchUninstalledPlugins() {
try {
loading.value = true
uninstalledList.value = await api.get('plugin/', {
params: {
state: 'market',
@@ -233,6 +232,8 @@ async function fetchUninstalledPlugins() {
}
}
}
loading.value = false
isRefreshed.value = true
// 更新插件市场列表
// 排除已安装且有更新的,上面的问题在于“本地存在未安装的旧版本插件且云端有更新时”不会在插件市场展示
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
@@ -287,8 +288,6 @@ const sortedUninstalledList = computed(() => {
}
})
deferApp = useDefer(ret_list.length)
if (isNullOrEmptyObject(PluginStatistics.value)) return ret_list
// 数据排序
if (!activeSort.value || activeSort.value === 'count') {
@@ -324,24 +323,31 @@ function handleRepoUrl(url: string | undefined) {
onBeforeMount(async () => {
await refreshData()
getPluginStatistics()
if (activeTab.value != 'market' && pluginId.value) {
// 找到这个插件
const plugin = dataList.value.find(item => item.id === pluginId.value)
if (plugin) {
plugin.page_open = true
}
}
})
</script>
<template>
<div>
<VTabs v-model="activeTab">
<VTab v-for="item in tabs" :value="item.tab">
<VTab v-for="item in PluginTabs" :value="item.tab">
<span class="mx-5">{{ item.title }}</span>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<!-- 我的插件 -->
<VWindowItem value="myplugin">
<VWindowItem value="installed">
<transition name="fade-slide" appear>
<div>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<div v-if="dataList.length > 0" class="grid gap-4 grid-plugin-card items-start">
<div v-if="dataList.length > 0" class="grid gap-4 grid-plugin-card">
<template v-for="(data, index) in dataList" :key="`${data.id}_v${data.plugin_version}`">
<PluginCard
:count="PluginStatistics[data.id || '0']"
@@ -363,7 +369,7 @@ onBeforeMount(async () => {
</transition>
</VWindowItem>
<!-- 插件市场 -->
<VWindowItem value="pluginmarket">
<VWindowItem value="market">
<transition name="fade-slide" appear>
<div>
<LoadingBanner v-if="!isAppMarketLoaded" class="mt-12" />
@@ -411,14 +417,9 @@ onBeforeMount(async () => {
</VCol>
</VRow>
</div>
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card items-start">
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card">
<template v-for="(data, index) in sortedUninstalledList" :key="`${data.id}_v${data.plugin_version}`">
<PluginAppCard
v-if="deferApp(index)"
:plugin="data"
:count="PluginStatistics[data.id || '0']"
@install="pluginInstalled"
/>
<PluginAppCard :plugin="data" :count="PluginStatistics[data.id || '0']" @install="pluginInstalled" />
</template>
</div>
<NoDataFound
@@ -437,13 +438,14 @@ onBeforeMount(async () => {
<VFab
icon="mdi-magnify"
color="info"
location="bottom end"
location="bottom"
class="mb-2"
size="x-large"
fixed
app
appear
@click="SearchDialog = true"
:class="{ 'mb-12': appMode }"
/>
<VDialog
v-if="SearchDialog"
@@ -516,14 +518,3 @@ onBeforeMount(async () => {
</VCard>
</VDialog>
</template>
<style lang="scss">
.grid-plugin-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import PullRefresh from 'pull-refresh-vue3'
import { VPullToRefresh } from 'vuetify/labs/VPullToRefresh'
import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
@@ -7,7 +7,7 @@ import DownloadingCard from '@/components/cards/DownloadingCard.vue'
import store from '@/store'
// 定时器
let refreshTimer: NodeJS.Timer | null = null
let refreshTimer: NodeJS.Timeout | null = null
// 数据列表
const dataList = ref<DownloadingInfo[]>([])
@@ -20,8 +20,7 @@ async function fetchData() {
try {
dataList.value = await api.get('download/')
isRefreshed.value = true
}
catch (error) {
} catch (error) {
console.error(error)
}
}
@@ -41,10 +40,8 @@ const filteredDataList = computed(() => {
// 从Vuex Store中获取用户信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
if (superUser)
return dataList.value
else
return dataList.value.filter(data => data.userid === userName || data.username === userName)
if (superUser) return dataList.value
else return dataList.value.filter(data => data.userid === userName || data.username === userName)
})
// 加载时获取数据
@@ -67,23 +64,10 @@ onUnmounted(() => {
</script>
<template>
<LoadingBanner
v-if="!isRefreshed"
class="mt-12"
/>
<PullRefresh
v-model="loading"
@refresh="onRefresh"
>
<div
v-if="filteredDataList.length > 0"
class="grid gap-3 grid-downloading-card"
>
<DownloadingCard
v-for="data in filteredDataList"
:key="data.hash"
:info="data"
/>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VPullToRefresh v-model="loading" @load="onRefresh" :pull-down-threshold="64">
<div v-if="filteredDataList.length > 0" class="grid gap-3 grid-downloading-card">
<DownloadingCard v-for="data in filteredDataList" :key="data.hash" :info="data" />
</div>
<NoDataFound
v-if="filteredDataList.length === 0 && isRefreshed"
@@ -91,12 +75,5 @@ onUnmounted(() => {
error-title="没有任务"
error-description="正在下载的任务将会显示在这里"
/>
</PullRefresh>
</VPullToRefresh>
</template>
<style lang="scss">
.grid-downloading-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -1,80 +1,149 @@
<script lang="ts" setup>
import api from '@/api'
import { FileItem, MediaDirectory } from '@/api/types'
import FileBrowser from '@/components/FileBrowser.vue'
import store from '@/store'
const endpoints = {
list: {
url: '/filebrowser/list?path={path}&sort={sort}',
method: 'get',
url: '/{storage}/list?sort={sort}',
method: 'post',
},
mkdir: {
url: '/filebrowser/mkdir?path={path}',
method: 'get',
url: '/{storage}/mkdir?name={name}',
method: 'post',
},
delete: {
url: '/filebrowser/delete?path={path}',
method: 'get',
url: '/{storage}/delete',
method: 'post',
},
download: {
url: '/filebrowser/download?path={path}',
url: '/{storage}/download',
method: 'get',
},
image: {
url: '/filebrowser/image?path={path}',
url: '/{storage}/image',
method: 'get',
},
rename: {
url: '/filebrowser/rename?path={path}&new_name={newname}',
method: 'get',
url: '/{storage}/rename?new_name={newname}',
method: 'post',
},
}
// 读取下载目录
const path: Ref<string | undefined> = ref()
const user_level = store.state.auth.level
// 调用API加载当前系统环境设置
function loadSystemSettings(): Promise<string> {
return new Promise((resolve, reject) => {
api
.get('system/env')
.then((result: any) => {
let path = '/'
if (result.success)
path = result.data?.DOWNLOAD_PATH || '/'
// 用户存储
const userStorage = user_level > 1 ? 'local,aliyun,u115' : 'local'
if (!path.endsWith('/'))
path += '/'
resolve(path)
})
.catch(error => reject(error))
})
}
function pathChanged(_path: string) {
path.value = _path
}
onMounted(() => {
loadSystemSettings()
.then((res) => {
path.value = res
})
.catch((error) => {
console.error(error)
path.value = '/'
})
// 当前文件项
const operItem = ref<FileItem>({
type: 'dir',
name: '/',
path: '/',
fileid: 'root',
})
// fileid的堆栈
const itemstack = ref<FileItem[]>([
{
type: 'dir',
name: '/',
path: '/',
fileid: 'root',
},
])
// 下载目录列表
const downloadDirectories = ref<MediaDirectory[]>([])
// 计算公共路径
function findCommonPath(paths: string[]): string {
let commonPath
if (!paths || paths.length === 0) {
commonPath = '/'
} else if (paths.length === 1) {
commonPath = paths[0]
commonPath = commonPath.replace(/\\/g, '/')
} else {
const normalizedPaths = paths.map(path => path.replace(/\\/g, '/'))
const splitPaths = normalizedPaths.map(path => path.split('/'))
let commonParts: string[] = []
for (let i = 0; i < splitPaths[0].length; i++) {
const part = splitPaths[0][i]
if (splitPaths.every(pathParts => pathParts[i] === part)) {
commonParts.push(part)
} else {
break
}
}
commonPath = commonParts.join('/')
}
if (!commonPath.endsWith('/')) {
commonPath += '/'
}
if (commonPath.includes(':')) {
commonPath = commonPath.replace('\\', '/')
}
return commonPath
}
// 查询下载目录
async function loadDownloadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value
const path = findCommonPath(downloadDirectories.value.map(item => item.path) as string[])
const name = path.split('/').filter(Boolean).pop() ?? ''
operItem.value = {
type: 'dir',
name: name,
path: path,
}
// 将初始数据拆分到堆栈中
const paths = path.split('/').filter(Boolean)
paths.map((name, index) => {
const path = '/' + paths.slice(0, index + 1).join('/') + '/'
itemstack.value.push({
type: 'dir',
name: name,
path: path,
})
})
}
} catch (error) {
console.log(error)
}
}
// 目录变化
function pathChanged(item: FileItem) {
operItem.value = item
const index = itemstack.value.findIndex(i => i.path === item.path)
if (index >= 0) {
itemstack.value = itemstack.value.slice(0, index + 1)
} else {
itemstack.value.push(item)
}
}
// 加载初始目录
onBeforeMount(loadDownloadDirectories)
</script>
<template>
<div>
<FileBrowser
storages="local"
:storages="userStorage"
:tree="false"
:path="path"
:itemstack="itemstack"
:endpoints="endpoints"
:axios="api"
:item="operItem"
@pathchanged="pathChanged"
/>
</div>

View File

@@ -1,15 +1,28 @@
<script setup lang="ts">
import { debounce } from 'lodash'
import { ref, unref } from 'vue'
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { TransferHistory } from '@/api/types'
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useRoute } from 'vue-router'
import router from '@/router'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// APP
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 提示框
const $toast = useToast()
// 路由
const route = useRoute()
// 重新整理对话框
const redoDialog = ref(false)
@@ -19,9 +32,6 @@ const currentHistory = ref<TransferHistory>()
// 重新整理IDS
const redoIds = ref<number[]>([])
// 重新整理target
const redoTarget = ref('')
// 已选中的数据
const selected = ref<TransferHistory[]>([])
@@ -72,7 +82,7 @@ const pageRange = [
const dataList = ref<TransferHistory[]>([])
// 搜索
const search = ref()
const search = ref(route.query.search as string)
// 搜索提示词列表
const searchHintList = ref<string[]>([])
@@ -84,10 +94,10 @@ const loading = ref(false)
const totalItems = ref(0)
// 每页条数
const itemsPerPage = ref(50)
const itemsPerPage = ref<number>(ensureNumber(route.query.itemsPerPage, 50))
// 当前页码
const currentPage = ref(1)
const currentPage = ref<number>(ensureNumber(route.query.currentPage, 1))
// 进度条
const progressDialog = ref(false)
@@ -114,10 +124,16 @@ const TransferDict: { [key: string]: string } = {
rclone_move: 'Rclone移动',
}
const tableStyle = computed(() => {
return appMode.value
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
})
// 分页提示
const pageTip = computed(() => {
const begin = unref(itemsPerPage) * (unref(currentPage) - 1) + 1
const end = unref(itemsPerPage) * unref(currentPage) === -1 ? 'ALL' : unref(itemsPerPage) * unref(currentPage)
const begin = itemsPerPage.value * (currentPage.value - 1) + 1
const end = itemsPerPage.value * currentPage.value === -1 ? 'ALL' : itemsPerPage.value * currentPage.value
return {
begin,
end,
@@ -126,15 +142,22 @@ const pageTip = computed(() => {
// 分页总数
const totalPage = computed(() => {
const total = Math.ceil(unref(totalItems) / unref(itemsPerPage))
const total = Math.ceil(totalItems.value / itemsPerPage.value)
return total
})
// 切换页签和搜索词
watch(
[() => currentPage.value, () => itemsPerPage.value, () => search.value],
[() => currentPage.value, () => itemsPerPage.value],
debounce(async () => {
await fetchData()
reloadPage()
}, 1000),
)
watch(
[() => search.value],
debounce(async () => {
reloadPage(true)
}, 1000),
)
@@ -271,23 +294,20 @@ async function retransferBatch() {
currentHistory.value = undefined
// 重新整理IDS
redoIds.value = selected.value.map(item => item.id)
// 重新整理target
if (selected.value.length === 1) {
// 目的目录
const dest = selected.value[0].dest ?? ''
// 类型
const mediaType = selected.value[0].type ?? ''
// 分类
const category = selected.value[0].category ?? ''
// 计算根路径
redoTarget.value = getRootPath(dest, mediaType, category)
} else {
redoTarget.value = ''
}
// 打开识别弹窗
redoDialog.value = true
}
// 整理完成
function transferDone() {
redoDialog.value = false
// 清空当前操作记录
currentHistory.value = undefined
selected.value = []
// 刷新
fetchData()
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -297,7 +317,6 @@ const dropdownItems = ref([
prependIcon: 'mdi-redo-variant',
click: (item: TransferHistory) => {
redoIds.value = [item.id]
redoTarget.value = getRootPath(item.dest ?? '', item.type ?? '', item.category ?? '')
redoDialog.value = true
},
},
@@ -315,6 +334,38 @@ const dropdownItems = ref([
},
])
// 添加url参数
function addUrlQuery(url: string, name: string, value: any) {
if (!url || !name || !value) return url
const separator = url.includes('?') ? '&' : '?'
return url + separator + name + '=' + encodeURIComponent(value)
}
// 重载页面
function reloadPage(resetPage = false) {
let url = '/history'
if (search.value) {
url = addUrlQuery(url, 'search', search.value)
}
if (itemsPerPage.value) {
url = addUrlQuery(url, 'itemsPerPage', itemsPerPage.value)
}
if (currentPage.value) {
url = addUrlQuery(url, 'currentPage', resetPage ? 1 : currentPage.value)
}
router.push(url)
}
// 确保值为number类型
function ensureNumber(value: any, defaultValue: number = 0) {
value = Number(value)
// 如果不是数字
if (Number.isNaN(value)) {
value = defaultValue
}
return value
}
// 初始加载数据
onMounted(fetchData)
</script>
@@ -355,7 +406,8 @@ onMounted(fetchData)
fixed-header
show-select
loading-text="加载中..."
class="data-table-div"
hover
:style="tableStyle"
>
<template #item.title="{ item }">
<div class="d-flex align-center">
@@ -430,6 +482,33 @@ onMounted(fetchData)
</VPagination>
</div>
</VCard>
<!-- 底部操作按钮 -->
<span>
<VFab
v-if="selected.length > 0"
icon="mdi-trash-can-outline"
color="error"
location="bottom"
size="x-large"
fixed
app
appear
@click="removeHistoryBatch"
:class="{ 'mb-12': appMode }"
/>
<VFab
v-if="selected.length > 0"
:class="appMode ? 'mb-28' : 'mb-16'"
icon="mdi-redo-variant"
location="bottom"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
</span>
<!-- 底部弹窗 -->
<VBottomSheet v-model="deleteConfirmDialog" inset>
<VCard class="text-center rounded-t">
@@ -456,58 +535,13 @@ onMounted(fetchData)
v-if="redoDialog"
v-model="redoDialog"
:logids="redoIds"
:target="redoTarget"
@done="
() => {
redoDialog = false
// 清空当前操作记录
currentHistory = undefined
selected = []
// 刷新
fetchData()
}
"
@done="transferDone"
@close="redoDialog = false"
/>
<!-- 底部操作按钮 -->
<span>
<VFab
v-if="selected.length > 0"
icon="mdi-trash-can-outline"
color="error"
location="bottom end"
size="x-large"
fixed
app
appear
@click="removeHistoryBatch"
/>
<VFab
v-if="selected.length > 0"
class="mb-2"
icon="mdi-redo-variant"
location="bottom end"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
</span>
</template>
<style lang="scss">
.v-table th {
white-space: nowrap;
}
.data-table-div {
block-size: calc(100vh - 14rem);
}
@media (width <= 768px) {
.data-table-div {
block-size: calc(100vh - 17rem);
}
}
</style>

View File

@@ -170,7 +170,7 @@ onMounted(() => {
rel="noreferrer"
class="text-indigo-500 transition duration-300 hover:underline"
>
https://github.com/jxxghp/MoviePilot/blob/main/README.md
https://wiki.movie-pilot.org
</a>
</span>
</dd>

View File

@@ -6,6 +6,10 @@ import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { User } from '@/api/types'
import avatar1 from '@images/avatars/avatar-1.png'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
const isNewPasswordVisible = ref(false)
const isConfirmPasswordVisible = ref(false)
@@ -160,7 +164,7 @@ async function addUser() {
return
}
try {
const result: { [key: string]: any } = await api.post('user', userForm)
const result: { [key: string]: any } = await api.post('user/', userForm)
if (result.success) {
$toast.success('用户新增成功!')
loadAllUsers()
@@ -250,7 +254,7 @@ onMounted(() => {
<div class="d-flex flex-wrap gap-2">
<VBtn color="primary" @click="refInputEl?.click()">
<VIcon icon="mdi-cloud-upload-outline" />
<span class="d-none d-sm-block ms-2">上传头像</span>
<span v-if="display.mdAndUp.value" class="ms-2">上传头像</span>
</VBtn>
<input
@@ -264,7 +268,7 @@ onMounted(() => {
<VBtn type="reset" color="error" variant="tonal" @click="resetAvatar">
<VIcon icon="mdi-refresh" />
<span class="d-none d-sm-block ms-2">重置</span>
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
</VBtn>
<VBtn
@@ -273,7 +277,9 @@ onMounted(() => {
@click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()"
>
<VIcon icon="mdi-account-key" />
<span class="d-none d-sm-block ms-2">{{ accountInfo.is_otp ? '关闭验证' : '双重验证' }}</span>
<span v-if="display.mdAndUp.value" class="ms-2">{{
accountInfo.is_otp ? '关闭验证' : '双重验证'
}}</span>
</VBtn>
</div>

View File

@@ -0,0 +1,326 @@
<!-- eslint-disable sonarjs/no-duplicate-string -->
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import draggable from 'vuedraggable'
import { VRow } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import { MediaDirectory } from '@/api/types'
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
// 媒体库设置项
const transferSettings = ref({
TRANSFER_TYPE: 'copy',
OVERWRITE_MODE: 'size',
TRANSFER_SAME_DISK: true,
})
// 转移方式字典
const transferTypeItems = [
{ title: '硬链接', value: 'link' },
{ title: '复制', value: 'copy' },
{ title: '移动', value: 'move' },
{ title: '软链接', value: 'softlink' },
{ title: 'rclone复制', value: 'rclone_copy' },
{ title: 'rclone移动', value: 'rclone_move' },
]
// 覆盖模式字典
const overwriteModeItems = [
{ title: '从不覆盖', value: 'never' },
{ title: '按大小覆盖', value: 'size' },
{ title: '总是覆盖', value: 'always' },
{ title: '仅保留最新版本', value: 'latest' },
]
// 所有下载目录
const downloadDirectories = ref<MediaDirectory[]>([])
// 所有媒体库目录
const libraryDirectories = ref<MediaDirectory[]>([])
// 二级分类策略
const mediaCategories = ref<{ [key: string]: any }>({})
// 提示框
const $toast = useToast()
// 加载媒体库设置
async function loadTransferSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result.success) {
const { TRANSFER_TYPE, OVERWRITE_MODE, TRANSFER_SAME_DISK } = result.data
transferSettings.value = {
TRANSFER_TYPE,
OVERWRITE_MODE,
TRANSFER_SAME_DISK,
}
}
} catch (error) {
console.log(error)
}
}
// 调用API保存媒体设置
async function saveTransferSetting() {
try {
const result: { [key: string]: any } = await api.post('system/env', transferSettings.value)
if (result.success) $toast.success('保存媒体库设置成功')
else $toast.error('保存媒体库设置失败!')
} catch (error) {
console.log(error)
}
}
// 移动结束
function orderDownloadCards() {
// 更新所有目录的优先级
downloadDirectories.value.forEach((item, index) => {
item.priority = index
})
}
// 移动结束
function orderLibraryCards() {
// 更新所有目录的优先级
libraryDirectories.value.forEach((item, index) => {
item.priority = index
})
}
// 关闭目录卡片
function libraryCardClose(name: string) {
libraryDirectories.value = libraryDirectories.value.filter(item => item.name !== name)
}
// 关闭下载卡片
function downloadCardClose(name: string) {
downloadDirectories.value = downloadDirectories.value.filter(item => item.name !== name)
}
// 查询下载目录
async function loadDownloadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
// 保存下载目录
async function saveDownloadDirectories() {
orderDownloadCards()
try {
const value = downloadDirectories.value.map(item => {
return {
name: item.name,
path: item.path,
media_type: item.media_type,
category: item.category,
auto_category: item.auto_category,
priority: item.priority,
}
})
const result: { [key: string]: any } = await api.post('system/setting/DownloadDirectories', value)
if (result.success) $toast.success('下载目录设置保存成功!')
} catch (e) {
console.error('保存下载目录设置失败')
}
}
// 添加下载目录
function addDownloadDirectory() {
downloadDirectories.value.push({
name: `下载目录${downloadDirectories.value.length + 1}`,
path: '',
media_type: '',
category: '',
})
}
// 查询媒体库目录
async function loadLibraryDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/LibraryDirectories')
if (result.success && result.data?.value) {
libraryDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
// 保存媒体库目录
async function saveLibraryDirectories() {
orderLibraryCards()
try {
const value = libraryDirectories.value.map(item => {
return {
name: item.name,
path: item.path,
media_type: item.media_type,
category: item.category,
auto_category: item.auto_category,
scrape: item.scrape,
priority: item.priority,
}
})
const result: { [key: string]: any } = await api.post('system/setting/LibraryDirectories', value)
if (result.success) $toast.success('媒体库目录设置保存成功!')
} catch (e) {
console.error('保存媒体库目录设置失败')
}
}
// 添加媒体库目录
function addLibraryDirectory() {
libraryDirectories.value.push({
name: `媒体库目录${libraryDirectories.value.length + 1}`,
path: '',
media_type: '',
category: '',
scrape: true,
})
}
// 调用API查询自动分类配置
async function loadMediaCategories() {
try {
mediaCategories.value = await api.get('media/category')
} catch (error) {
console.log(error)
}
}
// 加载数据
onMounted(() => {
loadTransferSettings()
loadMediaCategories()
loadDownloadDirectories()
loadLibraryDirectories()
})
</script>
<template>
<VRow>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>下载目录</VCardTitle>
<VCardSubtitle>设置下载目录路径和分类按顺序依次匹配使用</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="downloadDirectories"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="orderDownloadCards"
:component-data="{ 'class': 'grid gap-3 grid-directory-card' }"
>
<template #item="{ element }">
<DirectoryCard
type="download"
:directory="element"
:categories="mediaCategories"
@update:modelValue="(value: string) => (element.path = value)"
@close="downloadCardClose(element.name)"
/>
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveDownloadDirectories"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addDownloadDirectory">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>媒体库目录</VCardTitle>
<VCardSubtitle>设置媒体文件整理后存储目录和分类按顺序依次匹配使用</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="libraryDirectories"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="orderLibraryCards"
:component-data="{ 'class': 'grid gap-3 grid-directory-card' }"
>
<template #item="{ element }">
<DirectoryCard
type="library"
:directory="element"
:categories="mediaCategories"
@update:modelValue="(value: string) => (element.path = value)"
@close="libraryCardClose(element.name)"
/>
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveLibraryDirectories"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addLibraryDirectory">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>整理模式</VCardTitle>
<VCardSubtitle>设置文件整理方式和偏好</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="transferSettings.TRANSFER_TYPE"
:items="transferTypeItems"
label="整理方式"
hint="文件从下载目录整理到媒体库目录的操作方式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="transferSettings.OVERWRITE_MODE"
:items="overwriteModeItems"
label="覆盖模式"
hint="媒体库中同名文件已存在时的覆盖方式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="transferSettings.TRANSFER_SAME_DISK"
label="同盘/同根目录优先"
hint="优先整理到与下载目录同一磁盘/同一根路径的媒体库目录中"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn mtype="submit" @click="saveTransferSetting"> 保存 </VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -56,6 +56,10 @@ const NotificationChannels = [
title: 'VoceChat',
value: 'vocechat',
},
{
title: 'WebPush',
value: 'webpush',
},
]
// 提示框
@@ -67,8 +71,7 @@ async function loadNotificationSwitchs() {
const result: NotificationSwitch[] = await api.get('message/switchs')
messagemTypes.value = result
}
catch (error) {
} catch (error) {
console.log(error)
}
}
@@ -76,17 +79,11 @@ async function loadNotificationSwitchs() {
// 调用API保存消息开关
async function saveNotificationSwitchs() {
try {
const result: { [key: string]: any } = await api.post(
'message/switchs',
messagemTypes.value,
)
const result: { [key: string]: any } = await api.post('message/switchs', messagemTypes.value)
if (result.success)
$toast.success('保存通知消息设置成功')
else
$toast.error('保存通知消息设置失败!')
}
catch (error) {
if (result.success) $toast.success('保存通知消息设置成功')
else $toast.error('保存通知消息设置失败!')
} catch (error) {
console.log(error)
}
}
@@ -143,8 +140,7 @@ async function loadNotificationSettings() {
VOCECHAT_CHANNEL_ID,
}
}
}
catch (error) {
} catch (error) {
console.log(error)
}
}
@@ -152,23 +148,17 @@ async function loadNotificationSettings() {
// 调用API保存消息渠道设置
async function saveNotificationSettings() {
try {
const result1: { [key: string]: any } = await api.post(
'system/setting/MESSAGER',
selectedChannels.value.join(','),
)
const result1: { [key: string]: any } = await api.post('system/setting/MESSAGER', selectedChannels.value.join(','))
const result2: { [key: string]: any } = await api.post(
'system/env',
notificationSettings.value,
)
const result2: { [key: string]: any } = await api.post('system/env', notificationSettings.value)
if (result1.success && result2.success) {
$toast.success('保存通知渠道设置成功')
reloadModule()
} else {
$toast.error('保存通知渠道设置失败!')
}
else { $toast.error('保存通知渠道设置失败!') }
}
catch (error) {
} catch (error) {
console.log(error)
}
}
@@ -177,12 +167,9 @@ async function saveNotificationSettings() {
async function reloadModule() {
try {
const result: { [key: string]: any } = await api.get('system/reload')
if (result.success)
$toast.success('重新加载模块成功')
else
$toast.error('重新加载模块失败!')
}
catch (error) {
if (result.success) $toast.success('重新加载模块成功')
else $toast.error('重新加载模块失败!')
} catch (error) {
console.log(error)
}
}
@@ -197,8 +184,11 @@ onMounted(() => {
<template>
<VRow>
<VCol cols="12">
<VCard title="通知渠道">
<VCardSubtitle>只有选中的渠道才会发送消息</VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>通知渠道</VCardTitle>
<VCardSubtitle>只有选中的渠道才会发送消息</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
@@ -209,37 +199,21 @@ onMounted(() => {
chips
:items="NotificationChannels"
label="当前使用通知渠道"
hint="选中的渠道才会按消息类型的设定发送消息"
hint="消息通知渠道总开关"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol>
<VTabs
v-model="messagerTab"
stacked
>
<VTab value="wechat">
微信
</VTab>
<VTab value="telegram">
Telegram
</VTab>
<VTab value="slack">
Slack
</VTab>
<VTab value="synologychat">
SynologyChat
</VTab>
<VTab value="vocechat">
VoceChat
</VTab>
<VTabs v-model="messagerTab" stacked>
<VTab value="wechat"> 微信 </VTab>
<VTab value="telegram"> Telegram </VTab>
<VTab value="slack"> Slack </VTab>
<VTab value="synologychat"> SynologyChat </VTab>
<VTab value="vocechat"> VoceChat </VTab>
</VTabs>
<VWindow
v-model="messagerTab"
class="mt-5 disable-tab-transition"
:touch="false"
>
<VWindow v-model="messagerTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="wechat">
<VForm>
<VRow>
@@ -247,42 +221,48 @@ onMounted(() => {
<VTextField
v-model="notificationSettings.WECHAT_CORPID"
label="企业ID"
hint="登录企业微信后台,在 https://work.weixin.qq.com/wework_admin/frame#profile 中查看"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_APP_SECRET"
label="应用Secret"
hint="在企业微信中创建应用查看应用的Secret"
hint="企业微信后台企业信息中的企业ID"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_APP_ID"
label="应用 AgentId"
hint="企业微信中创建应用,查看应用的AgentId"
hint="企业微信自建应用的AgentId"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_APP_SECRET"
label="应用 Secret"
hint="企业微信自建应用的Secret"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_PROXY"
label="代理地址"
hint="由于微信官方限制2022年6月20日后创建的企业微信应用需要有固定的公网IP地址并加入IP白名单后才能接收消息使用有固定公网IP的代理服务器转发可解决该问题代理服务器需自行搭建搭建方法参考项目主页说明,不使用代理保留默认值"
hint="微信消息的转发代理地址2022年6月20日后创建的自建应用需要,不使用代理时需要保留默认值"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_TOKEN"
label="Token"
hint="微信企业应用管理后台-接收消息设置页面生成"
hint="微信企业自建应用->API接收消息配置中的Token"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_ENCODING_AESKEY"
label="EncodingAESKey"
hint="微信企业应用管理后台-接收消息设置页面生成所有信息填入完成后保存然后再在企业微信应用消息接收服务中输入回调地址http(s)://domain:port/api/v1/message/"
hint="微信企业自建应用->API接收消息配置中的EncodingAESKey"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -290,7 +270,8 @@ onMounted(() => {
v-model="notificationSettings.WECHAT_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="只有在白名单中的用户才能使用菜单管理功能,不填写则所有用户都能使用,菜单会自动生成,不需要手动创建"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
@@ -303,14 +284,16 @@ onMounted(() => {
<VTextField
v-model="notificationSettings.TELEGRAM_TOKEN"
label="Bot Token"
hint="Telegram机器人的token关注BotFather创建机器人并获取token格式123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
hint="Telegram机器人token格式123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.TELEGRAM_CHAT_ID"
label="Chat ID"
hint="接受消息通知的用户、群组或频道Chat ID,关注@getidsbot获取"
hint="接受消息通知的用户、群组或频道Chat ID"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -318,7 +301,8 @@ onMounted(() => {
v-model="notificationSettings.TELEGRAM_USERS"
label="用户白名单"
placeholder="多个用,分隔"
hint="只有在白名单中的用户才能使用Telegram机器人不填写则所有用户都能使用,多个用户用英文,分隔"
hint="使用Telegram机器人的用户ID清单多个用户用,分隔,不填写则所有用户都能使用"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -326,7 +310,8 @@ onMounted(() => {
v-model="notificationSettings.TELEGRAM_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="只有在白名单中的用户才能使用管理功能,不填写则所有用户都能使用,多个用户用英文,分隔。菜单会自动生成,不需要手动创建"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
@@ -340,7 +325,8 @@ onMounted(() => {
v-model="notificationSettings.SLACK_OAUTH_TOKEN"
label="Slack Bot User OAuth Token"
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="在 https://api.slack.com/apps 中创建应用,查看OAuth & Permissions页面中的Bot User OAuth Token"
hint="Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`"
persistent-hint
/>
</VCol>
<VCol cols="12" md="5">
@@ -348,7 +334,8 @@ onMounted(() => {
v-model="notificationSettings.SLACK_APP_TOKEN"
label="Slack App-Level Token"
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="在 https://api.slack.com/apps 中创建应用,查看OAuth & Permissions页面中的App-Level Token"
hint="Slack应用`OAuth & Permissions`页面中的`App-Level Token`"
persistent-hint
/>
</VCol>
<VCol cols="12" md="2">
@@ -356,7 +343,8 @@ onMounted(() => {
v-model="notificationSettings.SLACK_CHANNEL"
label="频道名称"
placeholder="全体"
hint="消息发送到的频道名称,不填写则发送到全体频道"
hint="消息发送频道,默认`全体`"
persistent-hint
/>
</VCol>
</VRow>
@@ -369,14 +357,16 @@ onMounted(() => {
<VTextField
v-model="notificationSettings.SYNOLOGYCHAT_WEBHOOK"
label="机器人传入URL"
hint="Synology Chat中创建机器人,获取机器人传入URL"
hint="Synology Chat机器人传入URL"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.SYNOLOGYCHAT_TOKEN"
label="令牌"
hint="Synology Chat中创建机器人,获取机器人令牌"
hint="Synology Chat机器人令牌"
persistent-hint
/>
</VCol>
</VRow>
@@ -389,13 +379,16 @@ onMounted(() => {
<VTextField
v-model="notificationSettings.VOCECHAT_HOST"
label="地址"
hint="VoceChat服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.VOCECHAT_API_KEY"
label="机器人密钥"
hint="VoceChat中创建机器人,获取机器人密钥"
hint="VoceChat机器人密钥"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -403,7 +396,8 @@ onMounted(() => {
v-model="notificationSettings.VOCECHAT_CHANNEL_ID"
label="频道ID"
placeholder="不包含#号"
hint="VoceChat中创建频道,获取频道ID不包含#号"
hint="VoceChat频道ID不包含#号"
persistent-hint
/>
</VCol>
</VRow>
@@ -417,12 +411,7 @@ onMounted(() => {
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveNotificationSettings"
>
保存
</VBtn>
<VBtn mtype="submit" @click="saveNotificationSettings"> 保存 </VBtn>
</div>
</VForm>
</VCardText>
@@ -431,36 +420,25 @@ onMounted(() => {
</VRow>
<VRow>
<VCol cols="12">
<VCard title="消息类型">
<VCardSubtitle> 对应消息类型只会发送给选中的消息渠道 </VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>消息类型</VCardTitle>
<VCardSubtitle>对应消息类型只会发送给选中的消息渠道</VCardSubtitle>
</VCardItem>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">
消息类型
</th>
<th scope="col">
微信
</th>
<th scope="col">
Telegram
</th>
<th scope="col">
Slack
</th>
<th scope="col">
SynologyChat
</th>
<th scope="col">
VoceChat
</th>
<th scope="col">消息类型</th>
<th scope="col">微信</th>
<th scope="col">Telegram</th>
<th scope="col">Slack</th>
<th scope="col">SynologyChat</th>
<th scope="col">VoceChat</th>
<th scope="col">WebPush</th>
</tr>
</thead>
<tbody>
<tr
v-for="message in messagemTypes"
:key="message.mtype"
>
<tr v-for="message in messagemTypes" :key="message.mtype">
<td>
{{ message.mtype }}
</td>
@@ -479,28 +457,20 @@ onMounted(() => {
<td>
<VCheckbox v-model="message.vocechat" />
</td>
<td>
<VCheckbox v-model="message.webpush" />
</td>
</tr>
<tr v-if="messagemTypes.length === 0">
<td
colspan="6"
class="text-center"
>
没有设置任何通知渠道
</td>
<td colspan="6" class="text-center">没有设置任何通知渠道</td>
</tr>
</tbody>
</VTable>
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveNotificationSwitchs"
>
保存
</VBtn>
<VBtn mtype="submit" @click="saveNotificationSwitchs"> 保存 </VBtn>
</div>
</VForm>
</VCardText>

View File

@@ -280,8 +280,11 @@ onMounted(() => {
<template>
<VRow>
<VCol cols="12">
<VCard title="媒体数据源">
<VCardSubtitle> 设定搜索时展示哪些源的媒体信息</VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>媒体数据源</VCardTitle>
<VCardSubtitle>设定搜索时展示哪些源的媒体信息</VCardSubtitle>
</VCardItem>
<VCardText>
<VRow>
<VCol cols="12" md="6">
@@ -291,22 +294,24 @@ onMounted(() => {
chips
:items="mediaSourcesDict"
label="当前使用数据源"
hint="选中多项时会同时展示来自不同数据源的搜索结果,选择的数据源顺序将会影响搜索结果的排序"
hint="搜索媒体信息时使用的数据源以及排序"
persistent-hint
/>
</VCol>
</VRow>
</VCardText>
<VCardItem>
<VCardText>
<VBtn type="submit" @click="saveMediaSourceSetting"> 保存 </VBtn>
</VCardItem>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="搜索站点">
<VCardSubtitle> 只有选中的站点才会在搜索中使用</VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>搜索站点</VCardTitle>
<VCardSubtitle> 只有选中的站点才会在搜索中使用</VCardSubtitle>
</VCardItem>
<VCardText>
<VChipGroup v-model="selectedSites" column multiple>
<VChip
v-for="site in allSites"
@@ -319,38 +324,40 @@ onMounted(() => {
{{ site.name }}
</VChip>
</VChipGroup>
</VCardItem>
<VCardItem>
</VCardText>
<VCardText>
<VBtn type="submit" @click="saveSelectedSites"> 保存 </VBtn>
</VCardItem>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="搜索优先级">
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="shareRules">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="importCodeDialog = true">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在搜索时默认使用的优先级排序未在优先级中的资源将不在搜索结果中显示 </VCardSubtitle>
<VCard>
<VCardItem>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="shareRules">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="importCodeDialog = true">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardTitle>搜索优先级</VCardTitle>
<VCardSubtitle>设置在搜索时默认使用的优先级排序未在优先级中的资源将不在搜索结果中显示</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="filterCards"
handle=".cursor-move"
@@ -369,18 +376,21 @@ onMounted(() => {
/>
</template>
</draggable>
</VCardItem>
<VCardItem>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveCustomFilters()"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addFilterCard()">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardItem>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="默认过滤规则">
<VCardSubtitle> 设置在搜索时默认使用的过滤规则 </VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>默认过滤规则</VCardTitle>
<VCardSubtitle>设置在搜索时默认使用的过滤规则</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
@@ -389,7 +399,8 @@ onMounted(() => {
v-model="defaultFilterRules.include"
type="text"
label="包含(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或"
hint="包含规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -397,7 +408,8 @@ onMounted(() => {
v-model="defaultFilterRules.exclude"
type="text"
label="排除(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或"
hint="排除规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -407,23 +419,25 @@ onMounted(() => {
label="最小做种数"
placeholder="0"
hint="小于该值的资源将被过滤掉0表示不过滤"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.min_seeders_time"
type="text"
label="最少做种数生效发布时间(分钟)"
label="最少做种数生效发布时间(分钟)"
placeholder="0"
hint="发布时间距现在大于该值的资源将生效最小做种数规则0表示不生效"
hint="发布时间距当前时间大于该值的资源将生效最小做种数规则0表示不生效"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardItem>
<VCardText>
<VBtn type="submit" @click="saveDefaultFilter"> 保存 </VBtn>
</VCardItem>
</VCardText>
</VCard>
</VCol>
</VRow>
@@ -431,10 +445,3 @@ onMounted(() => {
<ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
</VDialog>
</template>
<style lang="scss">
.grid-filterrule-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -10,7 +10,7 @@ const $toast = useToast()
const schedulerList = ref<ScheduleInfo[]>([])
// 定时器
let refreshTimer: NodeJS.Timer | null = null
let refreshTimer: NodeJS.Timeout | null = null
// 调用API加载定时服务列表
async function loadSchedulerList() {
@@ -18,8 +18,7 @@ async function loadSchedulerList() {
const res: ScheduleInfo[] = await api.get('dashboard/schedule')
schedulerList.value = res
}
catch (e) {
} catch (e) {
console.log(e)
}
}
@@ -52,8 +51,7 @@ function runCommand(id: string) {
setTimeout(() => {
loadSchedulerList()
}, 1000)
}
catch (e) {
} catch (e) {
console.log(e)
}
}
@@ -77,32 +75,23 @@ onUnmounted(() => {
</script>
<template>
<VCard title="定时作业">
<VCardSubtitle> 包含系统内置服务以及插件提供的服务手动执行不会影响作业正常的时间表 </VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>定时作业</VCardTitle>
<VCardSubtitle>包含系统内置服务以及插件提供的服务手动执行不会影响作业正常的时间表</VCardSubtitle>
</VCardItem>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">
提供者
</th>
<th scope="col">
任务名称
</th>
<th scope="col">
任务状态
</th>
<th scope="col">
下一次执行时间
</th>
<th scope="col">提供者</th>
<th scope="col">任务名称</th>
<th scope="col">任务状态</th>
<th scope="col">下一次执行时间</th>
<th scope="col" />
</tr>
</thead>
<tbody>
<tr
v-for="scheduler in schedulerList"
:key="scheduler.id"
>
<tr v-for="scheduler in schedulerList" :key="scheduler.id">
<td>
{{ scheduler.provider }}
</td>
@@ -118,11 +107,7 @@ onUnmounted(() => {
{{ scheduler.next_run }}
</td>
<td>
<VBtn
size="small"
:disabled="scheduler.status === '正在运行'"
@click="runCommand(scheduler.id)"
>
<VBtn size="small" :disabled="scheduler.status === '正在运行'" @click="runCommand(scheduler.id)">
<template #prepend>
<VIcon>mdi-play</VIcon>
</template>
@@ -131,12 +116,7 @@ onUnmounted(() => {
</td>
</tr>
<tr v-if="schedulerList.length === 0">
<td
colspan="4"
class="text-center"
>
没有后台服务
</td>
<td colspan="4" class="text-center">没有后台服务</td>
</tr>
</tbody>
</VTable>

View File

@@ -25,6 +25,7 @@ const cookieCloudSetting = ref({
COOKIECLOUD_INTERVAL: 0,
USER_AGENT: '',
COOKIECLOUD_ENABLE_LOCAL: '',
COOKIECLOUD_BLACKLIST: '',
})
// 种子优先规则下拉框
@@ -51,16 +52,12 @@ async function resetSites() {
resetSitesText.value = '正在重置...'
const result: { [key: string]: any } = await api.get('site/reset')
if (result.success)
$toast.success('站点重置成功请等待CookieCloud同步完成')
else
$toast.error('站点重置失败!')
if (result.success) $toast.success('站点重置成功请等待CookieCloud同步完成')
else $toast.error('站点重置失败')
resetSitesDisabled.value = false
resetSitesText.value = '重置站点数据'
}
catch (error) {
} catch (error) {
console.log(error)
}
}
@@ -68,13 +65,10 @@ async function resetSites() {
// 查询种子优先规则
async function queryTorrentPriority() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/TorrentsPriority',
)
const result: { [key: string]: any } = await api.get('system/setting/TorrentsPriority')
selectedTorrentPriority.value = result.data?.value
}
catch (error) {
} catch (error) {
console.log(error)
}
}
@@ -88,12 +82,9 @@ async function saveTorrentPriority() {
selectedTorrentPriority.value,
)
if (result.success)
$toast.success('优先规则保存成功')
else
$toast.error('优先规则保存失败!')
}
catch (error) {
if (result.success) $toast.success('优先规则保存成功')
else $toast.error('优先规则保存失败!')
} catch (error) {
console.log(error)
}
}
@@ -110,6 +101,7 @@ async function loadCookieCloudSettings() {
COOKIECLOUD_INTERVAL,
USER_AGENT,
COOKIECLOUD_ENABLE_LOCAL,
COOKIECLOUD_BLACKLIST,
} = result.data
cookieCloudSetting.value = {
COOKIECLOUD_HOST,
@@ -118,10 +110,10 @@ async function loadCookieCloudSettings() {
COOKIECLOUD_INTERVAL,
USER_AGENT,
COOKIECLOUD_ENABLE_LOCAL,
COOKIECLOUD_BLACKLIST,
}
}
}
catch (error) {
} catch (error) {
console.log(error)
}
}
@@ -129,17 +121,11 @@ async function loadCookieCloudSettings() {
// 调用API保存CookieCloud设置
async function saveCookieCloudetting() {
try {
const result: { [key: string]: any } = await api.post(
'system/env',
cookieCloudSetting.value,
)
const result: { [key: string]: any } = await api.post('system/env', cookieCloudSetting.value)
if (result.success)
$toast.success('保存站点同步设置成功')
else
$toast.error('保存站点同步设置失败!')
}
catch (error) {
if (result.success) $toast.success('保存站点同步设置成功')
else $toast.error('保存站点同步设置失败!')
} catch (error) {
console.log(error)
}
}
@@ -154,8 +140,11 @@ onMounted(() => {
<template>
<VRow>
<VCol cols="12">
<VCard title="站点同步">
<VCardSubtitle> 从CookieCloud快速同步站点数据 </VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>站点同步</VCardTitle>
<VCardSubtitle>从CookieCloud快速同步站点数据</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
@@ -163,7 +152,8 @@ onMounted(() => {
<VCheckbox
v-model="cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
label="启用本地CookieCloud服务器"
hint="启用后,将使用内建CookieCloud服务同步站点数据服务地址为http://localhost:3000/cookiecloud"
hint="使用内建CookieCloud服务同步站点数据服务地址为http://localhost:3000/cookiecloud"
persistent-hint
/>
</VCol>
</VRow>
@@ -171,17 +161,19 @@ onMounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="cookieCloudSetting.COOKIECLOUD_HOST"
label="远程CookieCloud服务地址"
label="服务地址"
placeholder="https://movie-pilot.org/cookiecloud"
:disabled="!!cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
hint="格式https://movie-pilot.org/cookiecloud"
hint="远端CookieCloud服务地址格式https://movie-pilot.org/cookiecloud"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cookieCloudSetting.COOKIECLOUD_KEY"
label="用户KEY"
hint="CookieCloud浏览器插件生成"
hint="CookieCloud浏览器插件生成的用户KEY"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -189,7 +181,8 @@ onMounted(() => {
v-model="cookieCloudSetting.COOKIECLOUD_PASSWORD"
type="password"
label="端对端加密密码"
hint="CookieCloud浏览器插件生成"
hint="CookieCloud浏览器插件生成的端对端加密密码"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -197,32 +190,41 @@ onMounted(() => {
v-model="cookieCloudSetting.COOKIECLOUD_INTERVAL"
label="自动同步间隔"
:items="CookieCloudIntervalItems"
hint="设置定时从CookieCloud服务器同步站点Cookie到MoviePilot的时间周期"
hint="从CookieCloud服务器自动同步站点Cookie到MoviePilot的时间间隔"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VCol cols="12" md="6">
<VTextField
v-model="cookieCloudSetting.COOKIECLOUD_BLACKLIST"
label="同步域名黑名单"
placeholder="多个域名,分割"
hint="CookieCloud同步域名黑名单多个域名,分割"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cookieCloudSetting.USER_AGENT"
label="浏览器User-Agent"
hint="设置为CookieCloud插件所在的浏览器的User-Agent,用于模拟浏览器请求,正确填写后有助于提升站点访问成功率"
hint="CookieCloud插件所在的浏览器的User-Agent"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardItem>
<VBtn
type="submit"
@click="saveCookieCloudetting"
>
保存
</VBtn>
</VCardItem>
<VCardText>
<VBtn type="submit" @click="saveCookieCloudetting"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="下载优先规则">
<VCardSubtitle> 按站点或做种数量优先下载 </VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>下载优先规则</VCardTitle>
<VCardSubtitle>按站点或做种数量优先下载</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
@@ -231,20 +233,16 @@ onMounted(() => {
v-model="selectedTorrentPriority"
:items="TorrentPriorityItems"
label="当前使用下载优先规则"
hint="站点优先:优先下载站点优先级最高的站点的种子;做种数优先:优先下载做种数量最多的种子。注意下载优先级仍然低于搜索和订阅中设定的优先规则"
hint="同时命中多个站点的多个资源时下载的优先规则"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardItem>
<VBtn
type="submit"
@click="saveTorrentPriority"
>
保存
</VBtn>
</VCardItem>
<VCardText>
<VBtn type="submit" @click="saveTorrentPriority"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
@@ -254,7 +252,8 @@ onMounted(() => {
<VCheckbox
v-model="isConfirmResetSites"
label="确认删除所有站点数据并重新同步。"
hint="删除所有站点数据并重新同步站点图标短时间内会因数缓存而混乱重启或者等待2两时自动恢复。"
hint="删除所有站点数据并重新从CookieCloud同步操作请先清空涉及站点的相关设置。"
persistent-hint
/>
</div>

View File

@@ -311,10 +311,12 @@ onMounted(() => {
<template>
<VRow>
<VCol cols="12">
<VCard title="订阅站点">
<VCardSubtitle> 只有选中的站点才会在订阅中使用</VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>订阅站点</VCardTitle>
<VCardSubtitle>只有选中的站点才会在订阅中使用</VCardSubtitle>
</VCardItem>
<VCardText>
<VChipGroup v-model="selectedRssSites" column multiple>
<VChip
v-for="site in allSites"
@@ -327,7 +329,7 @@ onMounted(() => {
{{ site.name }}
</VChip>
</VChipGroup>
</VCardItem>
</VCardText>
<VCardText>
<VForm>
<VRow>
@@ -336,7 +338,8 @@ onMounted(() => {
v-model="selectedSubscribeMode"
:items="subscribeModeItems"
label="订阅模式"
hint="自动:系统自动爬取站点首页资源;站点RSS使用站点RSS订阅资源站点RSS会自动获取也可手动在站点管理中补全"
hint="自动:自动爬取站点首页站点RSS通过站点RSS链接订阅"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -344,7 +347,8 @@ onMounted(() => {
v-model="selectedRssInterval"
:items="rssIntervalItems"
label="站点RSS周期"
hint="设置站点RSS运行周期在订阅模式为站点RSS时生效"
hint="设置站点RSS运行周期在订阅模式为`站点RSS`时生效"
persistent-hint
/>
</VCol>
</VRow>
@@ -353,42 +357,46 @@ onMounted(() => {
<VSwitch
v-model="enableIntervalSearch"
label="开启订阅定时搜索"
hint="开启后系统每隔24小时将按名称搜索全站补全订阅可能漏掉的资源"
hint="每隔24小时全站搜索补全订阅可能漏掉的资源"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardItem>
<VCardText>
<VBtn type="submit" @click="saveSelectedRssSites"> 保存 </VBtn>
</VCardItem>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="订阅优先级">
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="shareRules('SubscribeFilterRules')">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="importRules('SubscribeFilterRules')">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在正常订阅时默认使用的优先级未在优先级中的资源将不会自动下载</VCardSubtitle>
<VCard>
<VCardItem>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="shareRules('SubscribeFilterRules')">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="importRules('SubscribeFilterRules')">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardTitle>订阅优先级</VCardTitle>
<VCardSubtitle> 设置在正常订阅时默认使用的优先级未在优先级中的资源将不会自动下载</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="subscribeFilterCards"
handle=".cursor-move"
@@ -407,40 +415,43 @@ onMounted(() => {
/>
</template>
</draggable>
</VCardItem>
<VCardItem>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveCustomFilters('SubscribeFilterRules')"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addFilterCard('SubscribeFilterRules')">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardItem>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="洗版优先级">
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="shareRules('BestVersionFilterRules')">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="importRules('BestVersionFilterRules')">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在订阅洗版时使用的优先级匹配优先级1时洗版完成</VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>洗版优先级</VCardTitle>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="shareRules('BestVersionFilterRules')">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="importRules('BestVersionFilterRules')">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在订阅洗版时使用的优先级匹配优先级1时洗版完成</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
v-model="bestVersionFilterCards"
handle=".cursor-move"
@@ -459,18 +470,21 @@ onMounted(() => {
/>
</template>
</draggable>
</VCardItem>
<VCardItem>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveCustomFilters('BestVersionFilterRules')"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addFilterCard('BestVersionFilterRules')">
<VIcon icon="mdi-plus" />
</VBtn>
</VCardItem>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="默认过滤规则">
<VCardSubtitle> 设置在订阅时默认使用的过滤规则</VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>默认过滤规则</VCardTitle>
<VCardSubtitle> 设置在订阅时默认使用的过滤规则</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
@@ -479,7 +493,8 @@ onMounted(() => {
v-model="defaultFilterRules.include"
type="text"
label="包含(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或"
hint="包含规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -487,7 +502,8 @@ onMounted(() => {
v-model="defaultFilterRules.exclude"
type="text"
label="排除(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或"
hint="排除规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -496,7 +512,8 @@ onMounted(() => {
type="text"
label="电影文件大小GB"
placeholder="0-30"
hint="格式0-30表示030GB之间的资源"
hint="文件大小范围,格式0-30表示0-30GB之间的资源"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -505,7 +522,8 @@ onMounted(() => {
type="text"
label="剧集单集文件大小GB"
placeholder="0-10"
hint="格式0-10表示010GB之间的资源"
hint="单集文件大小范围,格式0-10表示0-10GB之间的资源"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
@@ -515,23 +533,25 @@ onMounted(() => {
label="最小做种数"
placeholder="0"
hint="小于该值的资源将被过滤掉0表示不过滤"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.min_seeders_time"
type="text"
label="最少做种数生效发布时间(分钟)"
label="最少做种数生效发布时间(分钟)"
placeholder="0"
hint="发布时间距现在大于该值的资源将生效最小做种数规则0表示不生效"
hint="发布时间距当前时间大于该值的资源将生效最小做种数规则0表示不生效"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardItem>
<VCardText>
<VBtn type="submit" @click="saveDefaultFilter"> 保存 </VBtn>
</VCardItem>
</VCardText>
</VCard>
</VCol>
</VRow>
@@ -539,10 +559,3 @@ onMounted(() => {
<ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
</VDialog>
</template>
<style lang="scss">
.grid-filterrule-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -3,7 +3,6 @@
import { useToast } from 'vue-toast-notification'
import { VRow } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import { requiredValidator } from '@/@validators'
// 选中的媒体服务器
const selectedMediaServers = ref([])
@@ -17,21 +16,9 @@ const downloaderTab = ref('qbittorrent')
// 媒体服务器选中标签页
const mediaserverTab = ref('emby')
// 媒体库设置项
const mediaSettings = ref({
SCRAP_METADATA: true,
DOWNLOAD_PATH: '',
DOWNLOAD_MOVIE_PATH: '',
DOWNLOAD_TV_PATH: '',
DOWNLOAD_ANIME_PATH: '',
DOWNLOAD_CATEGORY: false,
TRANSFER_TYPE: 'copy',
OVERWRITE_MODE: 'size',
LIBRARY_PATH: '',
LIBRARY_MOVIE_NAME: '',
LIBRARY_TV_NAME: '',
LIBRARY_ANIME_NAME: '',
LIBRARY_CATEGORY: false,
// 系统设置项
const SystemSettings = ref({
APP_DOMAIN: '',
})
// 下载器设置项
@@ -92,24 +79,6 @@ const MediaServers = [
},
]
// 转移方式字典
const transferTypeItems = [
{ title: '硬链接', value: 'link' },
{ title: '复制', value: 'copy' },
{ title: '移动', value: 'move' },
{ title: '软链接', value: 'softlink' },
{ title: 'rclone复制', value: 'rclone_copy' },
{ title: 'rclone移动', value: 'rclone_move' },
]
// 覆盖模式字典
const overwriteModeItems = [
{ title: '从不覆盖', value: 'never' },
{ title: '按大小覆盖', value: 'size' },
{ title: '总是覆盖', value: 'always' },
{ title: '仅保留最新版本', value: 'latest' },
]
// 媒体库同步周期字典
const syncIntervalItems = [
{ title: '从不', value: 0 },
@@ -123,72 +92,11 @@ const syncIntervalItems = [
// 提示框
const $toast = useToast()
// 加载媒体库设置
async function loadMediaSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result.success) {
const {
SCRAP_METADATA,
DOWNLOAD_PATH,
DOWNLOAD_MOVIE_PATH,
DOWNLOAD_TV_PATH,
DOWNLOAD_ANIME_PATH,
DOWNLOAD_CATEGORY,
TRANSFER_TYPE,
OVERWRITE_MODE,
LIBRARY_PATH,
LIBRARY_MOVIE_NAME,
LIBRARY_TV_NAME,
LIBRARY_ANIME_NAME,
LIBRARY_CATEGORY,
} = result.data
mediaSettings.value = {
SCRAP_METADATA,
DOWNLOAD_PATH,
DOWNLOAD_MOVIE_PATH,
DOWNLOAD_TV_PATH,
DOWNLOAD_ANIME_PATH,
DOWNLOAD_CATEGORY,
TRANSFER_TYPE,
OVERWRITE_MODE,
LIBRARY_PATH,
LIBRARY_MOVIE_NAME,
LIBRARY_TV_NAME,
LIBRARY_ANIME_NAME,
LIBRARY_CATEGORY,
}
}
}
catch (error) {
console.log(error)
}
}
// 调用API保存媒体设置
async function saveMediaSetting() {
try {
const result: { [key: string]: any } = await api.post(
'system/env',
mediaSettings.value,
)
if (result.success)
$toast.success('保存媒体库设置成功')
else
$toast.error('保存媒体库设置失败!')
}
catch (error) {
console.log(error)
}
}
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
const result1: { [key: string]: any } = await api.get('system/setting/DOWNLOADER')
if (result1.success)
selectedDownloaders.value = result1.data?.value?.split(',')
if (result1.success) selectedDownloaders.value = result1.data?.value?.split(',')
const result2: { [key: string]: any } = await api.get('system/env')
if (result2.success) {
@@ -219,8 +127,7 @@ async function loadDownloaderSetting() {
TR_PASSWORD,
}
}
}
catch (error) {
} catch (error) {
console.log(error)
}
}
@@ -232,18 +139,15 @@ async function saveDownloaderSetting() {
'system/setting/DOWNLOADER',
selectedDownloaders.value.join(','),
)
const result2: { [key: string]: any } = await api.post(
'system/env',
downloaderSettings.value,
)
const result2: { [key: string]: any } = await api.post('system/env', downloaderSettings.value)
if (result1.success && result2.success) {
$toast.success('保存下载器设置成功')
reloadModule()
} else {
$toast.error('保存下载器设置失败!')
}
else { $toast.error('保存下载器设置失败!') }
}
catch (error) {
} catch (error) {
console.log(error)
}
}
@@ -252,8 +156,7 @@ async function saveDownloaderSetting() {
async function loadMediaServerSetting() {
try {
const result1: { [key: string]: any } = await api.get('system/setting/MEDIASERVER')
if (result1.success)
selectedMediaServers.value = result1.data?.value?.split(',')
if (result1.success) selectedMediaServers.value = result1.data?.value?.split(',')
const result2: { [key: string]: any } = await api.get('system/env')
if (result2.success) {
@@ -284,8 +187,7 @@ async function loadMediaServerSetting() {
PLEX_TOKEN,
}
}
}
catch (error) {
} catch (error) {
console.log(error)
}
}
@@ -298,18 +200,42 @@ async function saveMediaServerSetting() {
selectedMediaServers.value.join(','),
)
const result2: { [key: string]: any } = await api.post(
'system/env',
mediaServerSettings.value,
)
const result2: { [key: string]: any } = await api.post('system/env', mediaServerSettings.value)
if (result1.success && result2.success) {
$toast.success('保存媒体服务器设置成功')
reloadModule()
} else {
$toast.error('保存媒体服务器设置失败!')
}
else { $toast.error('保存媒体服务器设置失败!') }
} catch (error) {
console.log(error)
}
catch (error) {
}
// 加载系统设置
async function loadSystemSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result.success) {
const { APP_DOMAIN } = result.data
SystemSettings.value = {
APP_DOMAIN,
}
}
} catch (error) {
console.log(error)
}
}
// 调用API保存系统设置
async function saveSystemSetting() {
try {
const result: { [key: string]: any } = await api.post('system/env', SystemSettings.value)
if (result.success) $toast.success('保存设置成功')
else $toast.error('保存设置失败!')
} catch (error) {
console.log(error)
}
}
@@ -318,12 +244,9 @@ async function saveMediaServerSetting() {
async function reloadModule() {
try {
const result: { [key: string]: any } = await api.get('system/reload')
if (result.success)
$toast.success('重新加载模块成功')
else
$toast.error('重新加载模块失败!')
}
catch (error) {
if (result.success) $toast.success('重新加载模块成功')
else $toast.error('重新加载模块失败!')
} catch (error) {
console.log(error)
}
}
@@ -332,15 +255,47 @@ async function reloadModule() {
onMounted(() => {
loadDownloaderSetting()
loadMediaServerSetting()
loadMediaSettings()
loadSystemSettings()
})
</script>
<template>
<VRow>
<VCol cols="12">
<VCard title="下载器">
<VCardSubtitle>只有选中的第1个下载器才会被默认使用</VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>系统</VCardTitle>
<VCardSubtitle>设置服务使用的域名等信息</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="SystemSettings.APP_DOMAIN"
label="访问域名"
hint="用于通知跳转格式http(s)://domain:port"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn mtype="submit" @click="saveSystemSetting"> 保存 </VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>下载器</VCardTitle>
<VCardSubtitle>只有选中的第1个下载器才会被默认使用</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
@@ -351,14 +306,16 @@ onMounted(() => {
chips
:items="Downloaders"
label="当前使用下载器"
hint="MoviePilot自动添加的下载任务将使用选中的第1个下载器"
hint="启用下载器只有第1个会被默认下载使用"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderSettings.TORRENT_TAG"
label="下载器种子标签"
hint="设置种子标签用于区分MoviePilot添加的下载任务,默认标签为`MOVIEPILOT`"
hint="MoviePilot添加的下载任务标签"
persistent-hint
/>
</VCol>
</VRow>
@@ -366,29 +323,19 @@ onMounted(() => {
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderSettings.DOWNLOADER_MONITOR"
label="监控默认下载器"
hint="监控选中的第1个下载器任务下载完成时自动整理文件到媒体库"
label="下载文件自动整理"
hint="任务下载完成时自动整理文件到媒体库"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol>
<VTabs
v-model="downloaderTab"
stacked
>
<VTab value="qbittorrent">
Qbittorrent
</VTab>
<VTab value="transmission">
Transmission
</VTab>
<VTabs v-model="downloaderTab" stacked>
<VTab value="qbittorrent"> Qbittorrent </VTab>
<VTab value="transmission"> Transmission </VTab>
</VTabs>
<VWindow
v-model="downloaderTab"
class="mt-5 disable-tab-transition"
:touch="false"
>
<VWindow v-model="downloaderTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="qbittorrent">
<VForm>
<VRow>
@@ -396,8 +343,9 @@ onMounted(() => {
<VTextField
v-model="downloaderSettings.QB_HOST"
label="地址"
placeholder="IP:PORT"
hint="格式IP:PORT如启用了HTTPS请使用https://IP:PORT"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -405,7 +353,8 @@ onMounted(() => {
v-model="downloaderSettings.QB_USER"
label="用户名"
placeholder="admin"
hint="QB的登录用户名"
hint="登录使用的用户名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -413,28 +362,32 @@ onMounted(() => {
v-model="downloaderSettings.QB_PASSWORD"
type="password"
label="密码"
hint="QB的登录密码"
hint="登录使用的密码"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="downloaderSettings.QB_CATEGORY"
label="自动分类管理"
hint="开启后下载目录将由QB控制自动下载到分类到目录此时MoviePilot的下载目录设定无效需在QB中提前创建分类"
hint="由下载器自动管理分类和下载目录"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="downloaderSettings.QB_SEQUENTIAL"
label="顺序下载"
hint="开启后QB将按照文件顺序依次下载"
hint="顺序依次下载文件"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="downloaderSettings.QB_FORCE_RESUME"
label="强制继续"
hint="开启后QB将设置为强制继续、强制上传模式(带[F]标识)"
hint="强制继续、强制上传模式"
persistent-hint
/>
</VCol>
</VRow>
@@ -447,8 +400,9 @@ onMounted(() => {
<VTextField
v-model="downloaderSettings.TR_HOST"
label="地址"
placeholder="IP:PORT"
hint="格式IP:PORT如启用了HTTPS请使用https://IP:PORT"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -456,7 +410,8 @@ onMounted(() => {
v-model="downloaderSettings.TR_USER"
label="用户名"
placeholder="admin"
hint="TR的登录用户名"
hint="登录使用的用户名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -464,7 +419,8 @@ onMounted(() => {
v-model="downloaderSettings.TR_PASSWORD"
type="password"
label="密码"
hint="TR的登录密码"
hint="登录使用的密码"
persistent-hint
/>
</VCol>
</VRow>
@@ -478,12 +434,7 @@ onMounted(() => {
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveDownloaderSetting"
>
保存
</VBtn>
<VBtn mtype="submit" @click="saveDownloaderSetting"> 保存 </VBtn>
</div>
</VForm>
</VCardText>
@@ -492,8 +443,11 @@ onMounted(() => {
</VRow>
<VRow>
<VCol cols="12">
<VCard title="媒体服务器">
<VCardSubtitle>只有选中的媒体服务器才会被默认使用</VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>媒体服务器</VCardTitle>
<VCardSubtitle>只有选中的媒体服务器才会被默认使用</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
<VRow>
@@ -504,7 +458,8 @@ onMounted(() => {
chips
:items="MediaServers"
label="当前使用媒体服务器"
hint="媒体服务器用于搜索下载等判断库中是否已存在,以避免重复下载"
hint="启用媒体服务器,入库展示、下载控重等将使用"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -512,7 +467,8 @@ onMounted(() => {
v-model="mediaServerSettings.MEDIASERVER_SYNC_INTERVAL"
:items="syncIntervalItems"
label="同步周期"
hint="设置后数据将定时同步到MoviePilot数据库,以便展示媒体库是否存在标识"
hint="同步媒体库数据到MoviePilot的时间间隔"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -520,31 +476,19 @@ onMounted(() => {
v-model="mediaServerSettings.MEDIASERVER_SYNC_BLACKLIST"
label="媒体库同步黑名单"
placeholder="使用,分隔"
hint="设置不同步数据的媒体库名称,使用,分隔,如:电影,电视剧"
hint="不同步数据的媒体库名称,多个使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol>
<VTabs
v-model="mediaserverTab"
stacked
>
<VTab value="emby">
Emby
</VTab>
<VTab value="jellyfin">
Jellyfin
</VTab>
<VTab value="plex">
Plex
</vtab>
<VTabs v-model="mediaserverTab" stacked>
<VTab value="emby"> Emby </VTab>
<VTab value="jellyfin"> Jellyfin </VTab>
<VTab value="plex"> Plex </VTab>
</VTabs>
<VWindow
v-model="mediaserverTab"
class="mt-5 disable-tab-transition"
:touch="false"
>
<VWindow v-model="mediaserverTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="emby">
<VForm>
<VRow>
@@ -552,8 +496,9 @@ onMounted(() => {
<VTextField
v-model="mediaServerSettings.EMBY_HOST"
label="地址"
placeholder="IP:PORT"
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
placeholder="http(s)://ip:port"
hint="服务端地址,格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -561,14 +506,16 @@ onMounted(() => {
v-model="mediaServerSettings.EMBY_PLAY_HOST"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="格式http(s)://domain:port设置后跳转Emby时将优先使用此地址"
hint="跳转播放页面使用的地址,格式http(s)://domain:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.EMBY_API_KEY"
label="API密钥"
hint="Emby的API密钥Emby设置->高级->API 密钥 中生成"
hint="Emby设置->高级->API密钥中生成的密钥"
persistent-hint
/>
</VCol>
</VRow>
@@ -581,8 +528,9 @@ onMounted(() => {
<VTextField
v-model="mediaServerSettings.JELLYFIN_HOST"
label="地址"
placeholder="IP:PORT"
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
placeholder="http(s)://ip:port"
hint="服务端地址,格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -590,14 +538,16 @@ onMounted(() => {
v-model="mediaServerSettings.JELLYFIN_PLAY_HOST"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="格式http(s)://domain:port设置后跳转Jellyfin时将优先使用此地址"
hint="跳转播放页面使用的地址,格式http(s)://domain:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.JELLYFIN_API_KEY"
label="API密钥"
hint="Jellyfin的API密钥Jellyfin设置->高级->API 密钥 中生成"
hint="Jellyfin设置->高级->API密钥中生成的密钥"
persistent-hint
/>
</VCol>
</VRow>
@@ -610,8 +560,9 @@ onMounted(() => {
<VTextField
v-model="mediaServerSettings.PLEX_HOST"
label="地址"
placeholder="IP:PORT"
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
placeholder="http(s)://ip:port"
hint="服务端地址,格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
@@ -619,14 +570,16 @@ onMounted(() => {
v-model="mediaServerSettings.PLEX_PLAY_HOST"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="格式http(s)://domain:port设置后跳转Plex时将优先使用此地址"
hint="跳转播放页面使用的地址,格式http(s)://domain:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.PLEX_TOKEN"
label="API密钥"
hint="Plex网页Url中的X-Plex-Token通过浏览器F12->网络请求URL中获取"
label="X-Plex-Token"
hint="浏览器F12->网络从Plex请求URL中获取的X-Plex-Token"
persistent-hint
/>
</VCol>
</VRow>
@@ -640,140 +593,7 @@ onMounted(() => {
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveMediaServerSetting"
>
保存
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCard title="媒体库">
<VCardSubtitle>设置下载目录媒体库目录以及整理方式</VCardSubtitle>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.DOWNLOAD_PATH"
label="下载目录"
:rules="[requiredValidator]"
hint="MoviePilot添加的下载任务的默认保存目录必须设置"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.DOWNLOAD_MOVIE_PATH"
label="电影下载目录"
hint="为电影设置单独的下载保存目录,不设置则使用下载目录"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.DOWNLOAD_TV_PATH"
label="电视剧下载目录"
hint="为电视剧设置单独的下载保存目录,不设置则使用下载目录"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.DOWNLOAD_ANIME_PATH"
label="动漫下载目录"
hint="为动漫设置单独的下载保存目录,不设置则使用下载目录"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaSettings.DOWNLOAD_CATEGORY"
label="下载目录自动分类"
hint="开启后,下载任务保存目录将根据二级分类策略自动分类存放到下载目录的二级子目录中,二级分类策略需要编辑配置文件目录下的`category.yml`文件,插件市场有提供文件编辑插件"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="mediaSettings.TRANSFER_TYPE"
:items="transferTypeItems"
label="整理方式"
hint="硬链接需要确保下载目录和媒体库目录不跨盘、不跨共享目录、不分别映射rclone需要手动在容器中完成配置且配置名为`MP`"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="mediaSettings.OVERWRITE_MODE"
:items="overwriteModeItems"
label="覆盖模式"
hint="从不覆盖:不覆盖已存在的文件;按大小覆盖:大文件将覆盖小文件;总是覆盖:总是覆盖已存在的文件;仅保留最新版本:保留最新版本的文件,删除其它版本的文件"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaSettings.SCRAP_METADATA"
label="自动刮削媒体信息"
hint="开启后,整理完成后将自动刮削媒体信息,如海报、简介等"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.LIBRARY_PATH"
label="媒体库目录"
placeholder="多个目录使用,分隔"
:rules="[requiredValidator]"
hint="整理完成后的媒体文件存放的根目录,所有整理场景下未设定目的目录时都将整理到该目录下,必须设置"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.LIBRARY_MOVIE_NAME"
label="电影目录名称"
placeholder="电影"
hint="设置电影的存放一级目录名称,不设置则使用使用`电影`做为目录名称"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.LIBRARY_TV_NAME"
label="电视剧目录名称"
placeholder="电视剧"
hint="设置电视剧的存放一级目录名称,不设置则使用使用`电视剧`做为目录名称"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.LIBRARY_ANIME_NAME"
label="动漫目录名称"
placeholder="动漫"
hint="设置动漫的存放一级目录名称,不设置则使用使用`动漫`做为目录名称"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaSettings.LIBRARY_CATEGORY"
label="媒体库目录自动分类"
hint="开启后,整理完成后的媒体文件将根据二级分类策略自动分类存放到媒体库一级目录的二级子目录中,二级分类策略需要编辑配置文件目录下的`category.yml`文件,插件市场有提供文件编辑插件"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveMediaSetting"
>
保存
</VBtn>
<VBtn mtype="submit" @click="saveMediaServerSetting"> 保存 </VBtn>
</div>
</VForm>
</VCardText>

View File

@@ -20,13 +20,9 @@ const transferExcludeWords = ref('')
// 查询已设置的识别词
async function queryCustomIdentifiers() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/CustomIdentifiers',
)
customIdentifiers.value = result.data?.value.join('\n')
}
catch (error) {
const result: { [key: string]: any } = await api.get('system/setting/CustomIdentifiers')
if (result && result.data && result.data.value) customIdentifiers.value = result.data.value.join('\n')
} catch (error) {
console.log(error)
}
}
@@ -34,13 +30,9 @@ async function queryCustomIdentifiers() {
// 查询已设置的制作组
async function queryCustomReleaseGroups() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/CustomReleaseGroups',
)
customReleaseGroups.value = result.data?.value.join('\n')
}
catch (error) {
const result: { [key: string]: any } = await api.get('system/setting/CustomReleaseGroups')
if (result && result.data && result.data.value) customReleaseGroups.value = result.data.value.join('\n')
} catch (error) {
console.log(error)
}
}
@@ -48,13 +40,9 @@ async function queryCustomReleaseGroups() {
// 查询已设置的自定义占位符
async function queryCustomization() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/Customization',
)
customization.value = result.data?.value.join('\n')
}
catch (error) {
const result: { [key: string]: any } = await api.get('system/setting/Customization')
if (result && result.data && result.data.value) customization.value = result.data?.value.join('\n')
} catch (error) {
console.log(error)
}
}
@@ -62,13 +50,9 @@ async function queryCustomization() {
// 查询已设置的屏蔽词
async function queryTransferExcludeWords() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/TransferExcludeWords',
)
transferExcludeWords.value = result.data?.value.join('\n')
}
catch (error) {
const result: { [key: string]: any } = await api.get('system/setting/TransferExcludeWords')
if (result && result.data && result.data.value) transferExcludeWords.value = result.data?.value.join('\n')
} catch (error) {
console.log(error)
}
}
@@ -82,12 +66,9 @@ async function saveCustomIdentifiers() {
customIdentifiers.value.split('\n'),
)
if (result.success)
$toast.success('自定义识别词保存成功')
else
$toast.error('自定义识别词保存失败!')
}
catch (error) {
if (result.success) $toast.success('自定义识别词保存成功')
else $toast.error('自定义识别词保存失败!')
} catch (error) {
console.log(error)
}
}
@@ -101,12 +82,9 @@ async function saveCustomReleaseGroups() {
customReleaseGroups.value.split('\n'),
)
if (result.success)
$toast.success('自定义制作组/字幕组保存成功')
else
$toast.error('自定义制作组/字幕组保存失败!')
}
catch (error) {
if (result.success) $toast.success('自定义制作组/字幕组保存成功')
else $toast.error('自定义制作组/字幕组保存失败!')
} catch (error) {
console.log(error)
}
}
@@ -120,12 +98,9 @@ async function saveCustomization() {
customization.value.split('\n'),
)
if (result.success)
$toast.success('自定义占位符保存成功')
else
$toast.error('自定义占位符保存失败!')
}
catch (error) {
if (result.success) $toast.success('自定义占位符保存成功')
else $toast.error('自定义占位符保存失败!')
} catch (error) {
console.log(error)
}
}
@@ -139,12 +114,9 @@ async function saveTransferExcludeWords() {
transferExcludeWords.value.split('\n'),
)
if (result.success)
$toast.success('文件整理屏蔽词保存成功')
else
$toast.error('文件整理屏蔽词保存失败!')
}
catch (error) {
if (result.success) $toast.success('文件整理屏蔽词保存成功')
else $toast.error('文件整理屏蔽词保存失败!')
} catch (error) {
console.log(error)
}
}
@@ -160,104 +132,98 @@ onMounted(() => {
<template>
<VRow>
<VCol cols="12">
<VCard title="自定义识别词">
<VCardSubtitle> 添加规则对种子名或者文件名进行预处理以校正识别 </VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>自定义识别词</VCardTitle>
<VCardSubtitle> 添加规则对种子名或者文件名进行预处理以校正识别 </VCardSubtitle>
</VCardItem>
<VCardText>
<VTextarea
v-model="customIdentifiers"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行为一组"
hint="支持正则表达式,特殊字符需要\转义,一行为一组"
persistent-hint
/>
</VCardItem>
<VCardItem>
<VAlert
type="info"
variant="tonal"
title="支持的配置格式(注意空格):"
>
</VCardText>
<VCardText>
<VAlert type="info" variant="tonal" title="支持的配置格式(注意空格):">
<span
v-html="`
v-html="
`
屏蔽词<br>
被替换词 => 替换词<br>
前定位词 <> 后定位词 >> 集偏移量EP<br>
被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量EP<br>
其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别其中s、e为季数和集数可选<br>
`"
`
"
/>
</VAlert>
</VCardItem>
<VCardItem>
<VBtn
type="submit"
@click="saveCustomIdentifiers"
>
保存
</VBtn>
</VCardItem>
</VCardText>
<VCardText>
<VBtn type="submit" @click="saveCustomIdentifiers"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="自定义制作组/字幕组">
<VCardSubtitle> 添加无法识别的制作组/字幕组。 </VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>自定义制作组/字幕组</VCardTitle>
<VCardSubtitle> 添加无法识别的制作组/字幕组。 </VCardSubtitle>
</VCardItem>
<VCardText>
<VTextarea
v-model="customReleaseGroups"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
hint="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
persistent-hint
/>
</VCardItem>
<VCardItem>
<VBtn
type="submit"
@click="saveCustomReleaseGroups"
>
保存
</VBtn>
</VCardItem>
</VCardText>
<VCardText>
<VBtn type="submit" @click="saveCustomReleaseGroups"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="自定义占位符">
<VCardSubtitle> 添加自定义占位符识别正则,重命名格式中添加{customization}使用。 </VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>自定义占位符</VCardTitle>
<VCardSubtitle> 添加自定义占位符识别正则,重命名格式中添加{customization}使用。 </VCardSubtitle>
</VCardItem>
<VCardText>
<VTextarea
v-model="customization"
auto-grow
placeholder="多个匹配对象请换行分隔,支持正则表达式,特殊字符注意转义"
hint="多个匹配对象请换行分隔,支持正则表达式,特殊字符注意转义"
placeholder="支持正则表达式,特殊字符需要\转义,多个匹配对象请换行分隔"
hint="支持正则表达式,特殊字符需要\转义,多个匹配对象请换行分隔"
persistent-hint
/>
</VCardItem>
<VCardItem>
<VBtn
type="submit"
@click="saveCustomization"
>
保存
</VBtn>
</VCardItem>
</VCardText>
<VCardText>
<VBtn type="submit" @click="saveCustomization"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="文件整理屏蔽词">
<VCardSubtitle> 目录名或文件名中包含屏蔽词时不进行整理。 </VCardSubtitle>
<VCard>
<VCardItem>
<VCardTitle>文件整理屏蔽词</VCardTitle>
<VCardSubtitle> 目录名或文件名中包含屏蔽词时不进行整理。 </VCardSubtitle>
</VCardItem>
<VCardText>
<VTextarea
v-model="transferExcludeWords"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
hint="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
persistent-hint
/>
</VCardItem>
<VCardItem>
<VBtn
type="submit"
@click="saveTransferExcludeWords"
>
保存
</VBtn>
</VCardItem>
</VCardText>
<VCardText>
<VBtn type="submit" @click="saveTransferExcludeWords"> 保存 </VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>

View File

@@ -1,9 +1,20 @@
<script lang="ts" setup>
import draggable from 'vuedraggable'
import api from '@/api'
import type { Site } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
import { useDisplay } from 'vuetify'
import { isLength } from 'lodash'
// 显示器宽度
const display = useDisplay()
// APP
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 数据列表
const dataList = ref<Site[]>([])
@@ -11,29 +22,64 @@ const dataList = ref<Site[]>([])
// 是否刷新过
const isRefreshed = ref(false)
// 是否加载中
const loading = ref(false)
// 新增站点对话框
const siteAddDialog = ref(false)
// 获取站点列表数据
async function fetchData() {
try {
loading.value = true
dataList.value = await api.get('site/')
loading.value = false
isRefreshed.value = true
} catch (error) {
console.error(error)
}
}
// 保存站点排序
async function savaSitesPriority() {
// 重新排序
const priorities = dataList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
try {
const result: { [key: string]: any } = await api.post('site/priorities', priorities)
if (result.success) {
fetchData()
}
} catch (error) {
console.error(error)
}
}
// 加载时获取数据
onBeforeMount(fetchData)
onActivated(() => {
if (!loading.value) {
fetchData()
}
})
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<div v-if="dataList.length > 0" class="grid gap-3 grid-site-card">
<div v-for="(data, index) in dataList" :key="index">
<SiteCard :key="data.id" :site="data" @remove="fetchData" @update="fetchData" />
</div>
<div>
<draggable
v-if="dataList.length > 0"
v-model="dataList"
@end="savaSitesPriority"
handle=".cursor-move"
item-key="id"
tag="div"
:component-data="{ 'class': 'grid gap-3 grid-site-card' }"
>
<template #item="{ element }">
<SiteCard :site="element" @remove="fetchData" @update="fetchData" />
</template>
</draggable>
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
@@ -42,7 +88,16 @@ onBeforeMount(fetchData)
error-description="已添加并支持的站点将会在这里显示"
/>
<!-- 新增站点按钮 -->
<VFab icon="mdi-plus" location="bottom end" size="x-large" fixed app appear @click="siteAddDialog = true" />
<VFab
icon="mdi-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="siteAddDialog = true"
:class="{ 'mb-12': appMode }"
/>
<!-- 新增站点弹窗 -->
<SiteAddEditDialog
v-if="siteAddDialog"
@@ -57,10 +112,3 @@ onBeforeMount(fetchData)
@close="siteAddDialog = false"
/>
</template>
<style lang="scss">
.grid-site-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -1,4 +1,4 @@
<script lang='ts' setup>
<script lang="ts" setup>
import type { CalendarOptions, EventSourceInput } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
@@ -8,6 +8,16 @@ import type { Ref } from 'vue'
import type { MediaInfo, Subscribe, TmdbEpisode } from '@/api/types'
import api from '@/api'
import { formatEp, parseDate } from '@/@core/utils/formatters'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
// 进度框
const progressDialog = ref(false)
// 加载中
const loading = ref(false)
// 已加载过
const isLoaded = ref(false)
// 日历属性
const calendarOptions: Ref<CalendarOptions> = ref({
@@ -51,12 +61,9 @@ async function eventsHander(subscribe: Subscribe) {
mediaType: subscribe.type,
len: 1,
}
}
else {
} else {
// 调用API查询集信息
const episodes: TmdbEpisode[] = await api.get(
`tmdb/${subscribe.tmdbid}/${subscribe.season}`,
)
const episodes: TmdbEpisode[] = await api.get(`tmdb/${subscribe.tmdbid}/${subscribe.season}`)
interface EpisodeInfo {
title: string
@@ -78,8 +85,7 @@ async function eventsHander(subscribe: Subscribe) {
if (dictEpisode[air_date]) {
dictEpisode[air_date].subtitle += `,${episode.episode_number}`
dictEpisode[air_date].len++
}
else {
} else {
dictEpisode[air_date] = {
title: subscribe.name,
subtitle: `${episode.episode_number}`,
@@ -100,25 +106,31 @@ async function eventsHander(subscribe: Subscribe) {
// 调用API查询所有订阅
async function getSubscribes() {
if (!isLoaded.value) progressDialog.value = true
try {
// 订阅
loading.value = true
const subscribes: Subscribe[] = await api.get('subscribe/')
const subEvents = await Promise.all(
subscribes.map(async sub => eventsHander(sub)),
)
loading.value = false
const subEvents = await Promise.all(subscribes.map(async sub => eventsHander(sub)))
calendarOptions.value.events = subEvents.flat().filter(event => event.start) as EventSourceInput
}
catch (error) {
isLoaded.value = true
} catch (error) {
console.error(error)
}
progressDialog.value = false
}
// 页面加载时调用API查询所有订阅
onMounted(() => {
getSubscribes()
})
onActivated(() => {
if (!loading.value) {
getSubscribes()
}
})
</script>
<template>
@@ -186,9 +198,10 @@ onMounted(() => {
</div>
</template>
</FullCalendar>
<ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在加载 ..." />
</template>
<style lang='scss'>
<style lang="scss">
.v-application .fc {
--fc-today-bg-color: rgba(var(--v-theme-on-surface), 0.04);
--fc-border-color: rgba(var(--v-border-color), var(--v-border-opacity));
@@ -200,7 +213,7 @@ onMounted(() => {
// 当天背景渐变
.fc-day-today {
background-image: linear-gradient(to bottom, #AF85FD ,rgba(var(--v-theme-on-surface), 0.04));
background-image: linear-gradient(to bottom, #af85fd, rgba(var(--v-theme-on-surface), 0.04));
}
.v-application .fc a {
@@ -295,11 +308,7 @@ onMounted(() => {
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary,
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary:hover,
.v-application
.fc
.fc-toolbar-chunk
.fc-button-group
.fc-button-primary:not(.disabled):active {
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary:not(.disabled):active {
border-color: transparent;
background-color: transparent;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
@@ -323,19 +332,11 @@ onMounted(() => {
text-transform: uppercase;
}
.v-application
.fc
.fc-toolbar-chunk:last-child
.fc-button-group
.fc-button:not(:last-child) {
.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group .fc-button:not(:last-child) {
border-inline-end: 0.0625rem solid rgba(var(--v-theme-primary), var(--v-overlay-scrim-opacity));
}
.v-application
.fc
.fc-toolbar-chunk:last-child
.fc-button-group
.fc-button.fc-button-active {
.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group .fc-button.fc-button-active {
background-color: rgba(var(--v-theme-primary), var(--v-activated-opacity));
color: rgb(var(--v-theme-primary));
}
@@ -371,7 +372,7 @@ onMounted(() => {
padding-inline: 0.25rem;
}
.v-application .fc tbody[role="rowgroup"] > tr > td[role="presentation"] {
.v-application .fc tbody[role='rowgroup'] > tr > td[role='presentation'] {
border: none;
}
@@ -400,9 +401,8 @@ onMounted(() => {
.v-application .fc .fc-popover {
border-radius: 6px;
box-shadow: 0 4px 14px -4px var(--v-shadow-key-umbra-opacity),
0 4px 8px -4px var(--v-shadow-key-penumbra-opacity),
0 4px 8px -4px var(--v-shadow-key-ambient-opacity);
box-shadow: 0 4px 14px -4px var(--v-shadow-key-umbra-opacity), 0 4px 8px -4px var(--v-shadow-key-penumbra-opacity),
0 4px 8px -4px var(--v-shadow-key-ambient-opacity);
}
.v-application .fc .fc-popover .fc-popover-header,
@@ -441,12 +441,7 @@ onMounted(() => {
}
}
.v-theme--dark
.v-application
.fc
.fc-toolbar-chunk
.fc-button-group
.fc-drawerToggler-button {
.v-theme--dark .v-application .fc .fc-toolbar-chunk .fc-button-group .fc-drawerToggler-button {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='rgba(232,232,241,0.68)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M3 12h18M3 6h18M3 18h18'/%3E%3C/svg%3E");
}

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import PullRefresh from 'pull-refresh-vue3'
import { VPullToRefresh } from 'vuetify/labs/VPullToRefresh'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
@@ -7,14 +7,24 @@ import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
import store from '@/store'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// APP
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 输入参数
const props = defineProps({
type: String,
subid: String,
})
// 是否刷新过
const isRefreshed = ref(false)
let isRefreshed = ref(false)
// 数据列表
const dataList = ref<Subscribe[]>([])
@@ -28,24 +38,22 @@ const historyDialog = ref(false)
// 获取订阅列表数据
async function fetchData() {
try {
loading.value = true
dataList.value = await api.get('subscribe/')
loading.value = false
isRefreshed.value = true
} catch (error) {
console.error(error)
}
}
// 加载时获取数据
onBeforeMount(fetchData)
// 刷新状态
const loading = ref(false)
// 下拉刷新
function onRefresh() {
loading.value = true
fetchData()
loading.value = false
async function onRefresh({ done }: { done: any }) {
await fetchData()
done('ok')
}
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
@@ -56,11 +64,29 @@ const filteredDataList = computed(() => {
if (superUser) return dataList.value.filter(data => data.type === props.type)
else return dataList.value.filter(data => data.type === props.type && data.username === userName)
})
onMounted(async () => {
await fetchData()
if (props.subid) {
// 找到这个订阅
const sub = dataList.value.find(sub => sub.id.toString() == props.subid?.toString())
if (sub) {
// 打开编辑弹窗
sub.page_open = true
}
}
})
onActivated(async () => {
if (!loading.value) {
fetchData()
}
})
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<PullRefresh v-model="loading" @refresh="onRefresh">
<VPullToRefresh v-model="loading" @load="onRefresh">
<div v-if="filteredDataList.length > 0" class="mx-3 grid gap-4 grid-subscribe-card p-1">
<SubscribeCard
v-for="data in filteredDataList"
@@ -76,24 +102,25 @@ const filteredDataList = computed(() => {
error-title="没有订阅"
error-description="请通过搜索添加电影电视剧订阅"
/>
</PullRefresh>
</VPullToRefresh>
<!-- 底部操作按钮 -->
<VFab
v-if="store.state.auth.superUser"
icon="mdi-clipboard-edit"
location="bottom end"
location="bottom"
size="x-large"
fixed
app
appear
@click="subscribeEditDialog = true"
:class="{ 'mb-12': appMode }"
/>
<VFab
v-if="store.state.auth.superUser"
icon="mdi-history"
color="info"
location="bottom end"
class="mb-2"
location="bottom"
:class="appMode ? 'mb-28' : 'mb-16'"
size="x-large"
fixed
app
@@ -123,10 +150,3 @@ const filteredDataList = computed(() => {
"
/>
</template>
<style lang="scss">
.grid-subscribe-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -76,13 +76,13 @@ async function fetchData({ done }: { done: any }) {
done('ok')
}
} else {
// 加载一次
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
@@ -97,8 +97,6 @@ async function fetchData({ done }: { done: any }) {
done('ok')
}
}
// 取消加载中
loading.value = false
} catch (error) {
console.error(error)
// 返回加载失败
@@ -129,13 +127,3 @@ async function fetchData({ done }: { done: any }) {
/>
</VInfiniteScroll>
</template>
<style lang="scss">
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>

View File

@@ -0,0 +1,447 @@
<script setup lang="ts">
import api from '@/api'
import type { Plugin, Subscribe } from '@/api/types'
import { SystemNavMenus, UserfulMenus, SettingTabs } from '@/router/menu'
import { NavMenu } from '@/@layouts/types'
import store from '@/store'
// 路由
const router = useRouter()
// 超级用户
const superUser = store.state.auth.superUser
// 当前用户名
const userName = store.state.auth.userName
// 定义事件
const emit = defineEmits(['close'])
// 搜索词
const searchWord = ref<string | null>(null)
// ref
const searchWordInput = ref<HTMLElement | null>(null)
// 近期搜索词条
const recentSearches = ref<string[]>([])
// 保存近期搜索到本地
function saveRecentSearches(keyword: string) {
if (!keyword) return
if (recentSearches.value.includes(keyword)) return
recentSearches.value.unshift(keyword)
localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))
}
// 从本地加载近期搜索
function loadRecentSearches() {
const recentSearchesStr = localStorage.getItem('MP_RecentSearches')
if (recentSearchesStr) {
recentSearches.value = JSON.parse(recentSearchesStr)
// 只保留最近的 5 条
if (recentSearches.value.length > 5) {
recentSearches.value = recentSearches.value.slice(0, 5)
}
}
}
// 所有菜单功能
function getMenus(): NavMenu[] {
let menus: NavMenu[] = []
// 导航菜单
SystemNavMenus.forEach(
item =>
item &&
menus.push({
title: item.full_title ?? item.title,
icon: item.icon,
to: item.to,
header: item.header,
admin: item.admin,
}),
)
// 设置标签页
SettingTabs.forEach(
item =>
item &&
menus.push({
title: '设定 -> ' + item.title,
icon: item.icon,
to: `/setting?tab=${item.tab}`,
header: '',
admin: true,
description: item.description,
}),
)
return menus
}
// 匹配的菜单列表
const matchedMenuItems = computed(() => {
if (!searchWord.value) return []
if (!superUser) return []
const lowerWord = (searchWord.value as string).toLowerCase()
const menuItems = getMenus()
if (menuItems)
return menuItems.filter(
item =>
item.title.toLowerCase().includes(lowerWord) ||
(item.description && item.description.toLowerCase().includes(lowerWord)),
)
return []
})
// 所有插件(已安装)
const pluginItems = ref<Plugin[]>([])
// 获取插件列表数据
async function fetchInstalledPlugins() {
try {
pluginItems.value = await api.get('plugin/', {
params: {
state: 'installed',
},
})
} catch (error) {
console.error(error)
}
}
// 区配的插件列表
const matchedPluginItems = computed(() => {
if (!searchWord.value) return []
if (!superUser) return []
const lowerWord = (searchWord.value as string).toLowerCase()
return pluginItems.value.filter((item: Plugin) => {
if (!item.plugin_name && !item.plugin_desc) return false
return item.plugin_name?.toLowerCase().includes(lowerWord) || item.plugin_desc?.toLowerCase().includes(lowerWord)
})
})
// 所有订阅数据
const SubscribeItems = ref<Subscribe[]>([])
// 获取订阅列表数据
async function fetchSubscribes() {
try {
SubscribeItems.value = await api.get('subscribe/')
} catch (error) {
console.error(error)
}
}
// 匹配的订阅列表
const matchedSubscribeItems = computed(() => {
if (!searchWord.value) return []
const lowerWord = (searchWord.value as string).toLowerCase()
return SubscribeItems.value.filter((item: Subscribe) => {
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
})
})
// 跳转媒体搜索页面
function searchMedia(searchType: string) {
// 搜索类型 media/person
if (!searchWord.value) return
saveRecentSearches(searchWord.value)
router.push({
path: '/browse/media/search',
query: {
title: searchWord.value,
type: searchType,
},
})
emit('close')
}
// 跳转到种子搜索页面
function searchTorrent() {
if (!searchWord.value) return
saveRecentSearches(searchWord.value)
router.push({
path: '/resource',
query: {
keyword: searchWord.value,
area: 'title',
},
})
emit('close')
}
// 跳转到历史记录页面
function searchHistory() {
if (!searchWord.value) return
saveRecentSearches(searchWord.value)
router.push({
path: '/history',
query: {
search: searchWord.value,
},
})
emit('close')
}
// 跳转插件页面
function showPlugin(pluginId: string) {
router.push({
path: `/plugins/`,
query: {
tab: 'installed',
id: pluginId,
},
})
emit('close')
}
// 跳转菜单页面
function goPage(to: string) {
router.push(to)
emit('close')
}
// 跳转订阅页面
function goSubscribe(subscribe: Subscribe) {
if (subscribe.type === '电影') {
router.push({
path: '/subscribe/movie',
query: {
id: subscribe.id,
},
})
} else {
router.push({
path: '/subscribe/tv',
query: {
id: subscribe.id,
},
})
}
emit('close')
}
onMounted(() => {
setTimeout(() => {
searchWordInput.value?.focus()
}, 500)
fetchInstalledPlugins()
fetchSubscribes()
loadRecentSearches()
})
</script>
<template>
<VDialog max-width="40rem" scrollable>
<VCard>
<VCardItem class="pe-12">
<VCombobox
ref="searchWordInput"
v-model="searchWord"
density="compact"
variant="plain"
class="text-high-emphasis"
placeholder="搜索 ..."
@keydown.enter="searchMedia('media')"
>
<template #prepend>
<VIcon icon="ri-search-line" style="opacity: 1" />
</template>
</VCombobox>
</VCardItem>
<DialogCloseBtn inner-class="absolute right-3 top-5 text-high-emphasis" @click="emit('close')" />
<VDivider />
<VCardText class="p-0">
<VList lines="two" v-if="searchWord">
<!-- 搜索结果 -->
<VListSubheader v-if="searchWord"> 媒体 & 资源 </VListSubheader>
<VHover>
<template #default="hover">
<VListItem
prepend-icon="mdi-movie-search"
density="compact"
link
v-bind="hover.props"
@click="searchMedia('media')"
>
<VListItemTitle class="break-words whitespace-break-spaces">
搜索 <span class="font-bold">{{ searchWord }} </span> 相关的电影电视剧 ...
</VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
<VHover>
<template #default="hover">
<VListItem prepend-icon="mdi-account-search" link v-bind="hover.props" @click="searchMedia('person')">
<VListItemTitle class="break-words whitespace-break-spaces">
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的演职人员 ...
</VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
<VHover>
<template #default="hover">
<VListItem prepend-icon="mdi-search-web" link v-bind="hover.props" @click="searchTorrent">
<VListItemTitle class="break-words whitespace-break-spaces">
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的站点资源 ...
</VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
<VHover v-if="superUser">
<template #default="hover">
<VListItem prepend-icon="mdi-history" link v-bind="hover.props" @click="searchHistory">
<VListItemTitle class="break-words whitespace-break-spaces">
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的历史记录 ...
</VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
<VListSubheader v-if="matchedSubscribeItems.length > 0"> 订阅 </VListSubheader>
<VHover
v-if="matchedSubscribeItems.length > 0"
v-for="subscribe in matchedSubscribeItems"
:key="subscribe.id"
>
<template #default="hover">
<VListItem
:prepend-icon="`${subscribe.type === '电影' ? 'mdi-movie-roll' : 'mdi-television-classic'}`"
density="compact"
link
v-bind="hover.props"
@click="goSubscribe(subscribe)"
>
<VListItemTitle>
{{ subscribe.name }}<span v-if="subscribe.season"> {{ subscribe.season }} </span>
</VListItemTitle>
<VListItemSubtitle> {{ subscribe.type }}</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
<VListSubheader v-if="matchedMenuItems.length > 0"> 功能 </VListSubheader>
<VHover v-if="matchedMenuItems.length > 0" v-for="menu in matchedMenuItems" :key="menu.title">
<template #default="hover">
<VListItem
:prepend-icon="menu.icon as string"
density="compact"
link
v-bind="hover.props"
@click="goPage(menu.to as string)"
>
<VListItemTitle>
{{ menu.title }}
</VListItemTitle>
<VListItemSubtitle v-if="menu.description"> {{ menu.description }} </VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
<VListSubheader v-if="matchedPluginItems.length > 0"> 插件 </VListSubheader>
<VHover v-if="matchedPluginItems.length > 0" v-for="plugin in matchedPluginItems" :key="plugin.id">
<template #default="hover">
<VListItem
prepend-icon="mdi-puzzle"
density="compact"
link
v-bind="hover.props"
@click="showPlugin(plugin.id ?? '')"
>
<VListItemTitle> {{ plugin.plugin_name }} </VListItemTitle>
<VListItemSubtitle> {{ plugin.plugin_desc }} </VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
</VList>
<div v-else>
<!-- 默认 -->
<VCardText>
<VRow v-if="recentSearches.length > 0">
<VCol cols="12">
<p class="custom-letter-spacing text-sm text-disabled text-uppercase py-2 px-4 mb-0">最近搜索</p>
<div class="px-3">
<VChip
v-for="(word, index) in recentSearches"
:key="index"
class="me-2 mb-1"
variant="tonal"
@click="searchWord = word"
label
>
{{ word }}
</VChip>
</div>
</VCol>
</VRow>
<VRow v-if="superUser">
<VCol cols="12" md="6">
<p class="custom-letter-spacing text-sm text-disabled text-uppercase py-2 px-4 mb-0">常用功能</p>
<VList lines="one">
<VHover v-for="(menu, index) in UserfulMenus" :key="index">
<template #default="hover">
<VListItem
:prepend-icon="menu.icon"
density="compact"
link
v-bind="hover.props"
@click="goPage(menu.to)"
>
<VListItemTitle>
{{ menu.title }}
</VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
</VList>
</VCol>
<VCol cols="12" md="6">
<p class="custom-letter-spacing text-sm text-disabled text-uppercase py-2 px-4 mb-0">常用插件</p>
<VList lines="one">
<VHover v-for="plugin in pluginItems.slice(0, 5)" :key="plugin.id">
<template #default="hover">
<VListItem
prepend-icon="mdi-puzzle"
density="compact"
link
v-bind="hover.props"
@click="showPlugin(plugin.id ?? '')"
>
<VListItemTitle> {{ plugin.plugin_name }} </VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
</VList>
</VCol>
<VCol cols="12" md="6"> </VCol>
<VCol cols="12" md="6"> </VCol>
</VRow>
</VCardText>
</div>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -46,7 +46,8 @@
"esnext",
"dom",
"dom.iterable",
"scripthost"
"scripthost",
"WebWorker"
],
"skipLibCheck": true,
"types": [
@@ -63,11 +64,13 @@
"src/**/*.vue",
"themeConfig.ts",
"auto-imports.d.ts",
"components.d.ts"
"components.d.ts",
"src/service-worker.ts",
"public/service.js"
],
"exclude": [
"dist",
"node_modules",
"src/@iconify/*"
]
}
}

View File

@@ -4,8 +4,8 @@ import vueJsx from '@vitejs/plugin-vue-jsx'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import vuetify from 'vite-plugin-vuetify'
import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
export default defineConfig({
@@ -13,8 +13,6 @@ export default defineConfig({
plugins: [
vue(),
vueJsx(),
// https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin
vuetify({
styles: {
configFile: 'src/styles/variables/_vuetify.scss',
@@ -29,12 +27,100 @@ export default defineConfig({
vueTemplate: true,
}),
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'script',
manifest: false,
registerType: 'autoUpdate',
strategies: 'injectManifest',
srcDir: 'src',
filename: 'service-worker.ts',
workbox: {
navigateFallbackDenylist: [
/.*\/api\/v\d+\/system\/logging.*/,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg}'],
navigateFallbackDenylist: [/.*\/api\/v\d+\/system\/logging.*/],
},
injectManifest: {
rollupFormat: 'iife',
},
devOptions: {
enabled: true,
type: 'module',
},
manifest: {
'name': 'MoviePilot',
'short_name': 'MoviePilot',
'start_url': './',
'display': 'standalone',
'icons': [
{
'src': './android-chrome-192x192.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './android-chrome-192x192_maskable.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'maskable',
},
{
'src': './android-chrome-512x512.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './android-chrome-512x512_maskable.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'maskable',
},
],
'theme_color': '#28243D',
'background_color': '#28243D',
'shortcuts': [
{
'name': '推荐',
'url': './ranking',
'icons': [
{
'src': './sparkles-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
{
'name': '电影订阅',
'url': './subscribe/movie',
'icons': [
{
'src': './clock-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
{
'name': '电视剧订阅',
'url': './subscribe/tv',
'icons': [
{
'src': './clock-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
{
'name': '设置',
'url': './setting',
'icons': [
{
'src': './cog-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
],
},
}),
@@ -63,8 +149,6 @@ export default defineConfig({
},
optimizeDeps: {
exclude: ['vuetify'],
entries: [
'./src/**/*.vue',
],
entries: ['./src/**/*.vue'],
},
})

Some files were not shown because too many files have changed in this diff Show More