Compare commits

...

267 Commits

Author SHA1 Message Date
jxxghp
0a76875f8e chore: update package.json version to 1.9.14-2 2024-08-16 10:10:41 +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
jxxghp
3c019d1376 feat:自定义主题 2024-05-19 14:20:01 +08:00
jxxghp
f676e8423e 更新 package.json 2024-05-18 11:10:41 +08:00
jxxghp
f687d1de01 更新 manifest.json 2024-05-18 11:09:59 +08:00
jxxghp
6fe28bc2ef fix 2024-05-17 14:12:44 +08:00
jxxghp
86b5af3423 去除无用package 2024-05-17 13:59:00 +08:00
jxxghp
8f3dce058c v1.8.9 2024-05-17 12:19:59 +08:00
jxxghp
825b8bb4a5 Merge pull request #128 from hotlcc/develop-20240517-页面优化
仪表板组件拖拽按钮按照hover进行展示
2024-05-17 10:56:16 +08:00
jxxghp
05320d1070 Merge branch 'main' into develop-20240517-页面优化 2024-05-17 10:56:08 +08:00
jxxghp
33d2a396ce 仪表板支持自定义标题 2024-05-17 10:54:19 +08:00
jxxghp
ae4cce8abf 安装到桌面时支持操作按钮 2024-05-17 10:41:35 +08:00
Allen
b85950e4ca 仪表板组件拖拽按钮按照hover进行展示 2024-05-17 10:23:57 +08:00
jxxghp
aecf52551b fix 2024-05-17 07:37:10 +08:00
jxxghp
fc877ed836 fix ui 2024-05-16 20:31:30 +08:00
jxxghp
5580921b7d 站点超时时间设置 2024-05-16 14:42:35 +08:00
jxxghp
6b7d0a0fe2 fix Module Test 2024-05-16 14:18:32 +08:00
jxxghp
f55efbe1e2 feat:种子页面排序 2024-05-16 13:08:55 +08:00
jxxghp
8e6fc3c417 Merge pull request #127 from dh336699/main 2024-05-16 12:25:24 +08:00
hao.dai
7943ab6017 fix: 只有一季以及多季只订阅一季订阅成功无提示问题 2024-05-16 11:29:15 +08:00
jxxghp
81725a58cf Merge pull request #126 from hotlcc/develop-20240516-页面优化 2024-05-16 10:57:33 +08:00
jxxghp
5cbcf46aaa Merge pull request #124 from hotlcc/develop-20240515-页面优化 2024-05-16 10:56:46 +08:00
Allen
49dd3f726a 解决路由回跳缺陷(1、手动退出后重新登录会错误地回到上次丢失认证时记录的路由页面;2、后端接口403时会错误地回到上次丢失认证时记录的路由页面而不是当前页面) 2024-05-16 10:54:10 +08:00
Allen
73f9ebc709 插件仪表板支持自定义子标题 2024-05-15 10:29:34 +08:00
Allen
f6884ba4f9 插件仪表板组件卸载时取消刷新定时器 2024-05-15 10:27:24 +08:00
jxxghp
5d39d0e139 fix subscribe card 2024-05-14 15:54:53 +08:00
jxxghp
6a1463ef17 fix subscribe card 2024-05-14 15:47:29 +08:00
jxxghp
5d00f23cb3 fix bug 2024-05-14 12:19:05 +08:00
jxxghp
6ea106b25d feat:优先级规则支持拖动排序 2024-05-14 11:32:28 +08:00
jxxghp
d501bf7506 feat:仪表板组件支持无边框 2024-05-13 20:23:12 +08:00
jxxghp
1408060053 fix dashboard ui 2024-05-13 12:17:48 +08:00
jxxghp
0c37c01496 feat: add new media cards and components 2024-05-13 07:06:37 +08:00
jxxghp
d2049f7839 fix dashboard refresh 2024-05-12 20:22:20 +08:00
jxxghp
33cdf672b3 fix ui 2024-05-11 17:34:08 +08:00
jxxghp
145c89acc3 release beta 2024-05-11 13:46:55 +08:00
jxxghp
706d7d6dc1 fix apexchats datalabels 2024-05-11 13:46:16 +08:00
jxxghp
2c35d0f897 fix layout ui 2024-05-11 12:53:42 +08:00
jxxghp
f227ae89ec fix 2024-05-10 20:32:07 +08:00
jxxghp
ac43d53884 fix #2045 2024-05-10 20:08:04 +08:00
jxxghp
4b70549bcb fix sort 2024-05-09 19:05:45 +08:00
jxxghp
ea601ae404 fix mobile 2024-05-09 18:54:19 +08:00
jxxghp
201411841c fix versions ui 2024-05-09 18:39:44 +08:00
jxxghp
d857acc58e fix drag handle 2024-05-09 18:30:25 +08:00
jxxghp
d005252f13 fix bug 2024-05-09 15:21:46 +08:00
jxxghp
2065992b17 仪表板组件支持拖动排序 2024-05-09 14:45:12 +08:00
jxxghp
74e96980e6 插件仪表板支持自动刷新 & 仅管理员可见 2024-05-09 08:03:01 +08:00
jxxghp
09110d1ef7 支持插件扩展仪表板 2024-05-08 21:03:00 +08:00
jxxghp
bcf55e63f1 调整热门订阅热度显示样式 2024-05-08 08:08:08 +08:00
jxxghp
dd22b2580e fix nodata svg 2024-05-07 19:30:43 +08:00
jxxghp
62a0e46698 release 2024-05-07 16:10:13 +08:00
jxxghp
14b68135fb fix VDivder 2024-05-07 13:50:49 +08:00
jxxghp
d44b62e489 fix btnui 2024-05-07 13:36:13 +08:00
jxxghp
b0f5c2a493 feat:显示流行度 2024-05-07 12:34:30 +08:00
jxxghp
d6cfbc60a8 更新 PluginCard.vue 2024-05-06 18:43:16 +08:00
jxxghp
fe51f5ced4 更新 SubscribeEditDialog.vue 2024-05-06 18:39:28 +08:00
jxxghp
b257b0453e 更新 SiteAddEditDialog.vue 2024-05-06 18:38:57 +08:00
jxxghp
a88105a086 更新 ReorganizeDialog.vue 2024-05-06 18:38:02 +08:00
jxxghp
2dc792690e Update button styles in PluginCard.vue, ReorganizeDialog.vue, SiteAddEditDialog.vue, and SubscribeEditDialog.vue 2024-05-06 18:26:52 +08:00
jxxghp
aa146b1cdf Update confirmation dialog styles and props in UserProfile.vue, FileList.vue, PluginCard.vue, and dashboard.vue 2024-05-06 18:18:05 +08:00
jxxghp
c44b20bae3 Update confirmation dialog styles and props in UserProfile.vue, FileList.vue, PluginCard.vue, and dashboard.vue 2024-05-06 17:37:47 +08:00
jxxghp
cad8964841 Remove unused styles in setting.vue 2024-05-06 16:41:41 +08:00
jxxghp
ec9a989214 Update card cover size and alignment in PluginAppCard and PluginCard components 2024-05-06 16:14:44 +08:00
jxxghp
7f05932fb9 remove github icon 2024-05-06 13:22:16 +08:00
jxxghp
d51694e1cb fix text 2024-05-06 12:37:34 +08:00
jxxghp
3079483e6b feat:订阅统计共享 2024-05-06 11:37:52 +08:00
jxxghp
bee4264a39 Update styling in setting.vue and PluginCardListView.vue 2024-05-05 20:20:37 +08:00
121 changed files with 7642 additions and 5284 deletions

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@@ -15,7 +15,6 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" /> <link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="manifest.json" crossorigin="use-credentials" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -30,13 +29,14 @@
<meta name="HandheldFriendly" content="True" /> <meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" /> <meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" /> <link rel="stylesheet" type="text/css" href="/loader.css" />
<link rel="preload" href="index.js" as="script">
</head> </head>
<body> <body>
<div id="loading-bg"> <div id="loading-bg">
<div class="loading-logo"> <div class="loading-logo">
<!-- Logo --> <!-- Logo -->
<svg width="100px" height="100px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg" <svg width="10rem" height="10rem" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"> style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
<g transform="matrix(1,0,0,1,-2606,-236)"> <g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)"> <g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
@@ -159,4 +159,4 @@
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,6 @@
{ {
"name": "moviepilot", "name": "moviepilot",
"version": "1.8.6", "version": "1.9.14-2",
"private": true, "private": true,
"bin": "dist/service.js", "bin": "dist/service.js",
"scripts": { "scripts": {
@@ -19,43 +19,37 @@
] ]
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.2.0", "@fullcalendar/core": "^6.1.8",
"@casl/vue": "^2.2.0", "@fullcalendar/daygrid": "^6.1.8",
"@floating-ui/dom": "1.6.3", "@fullcalendar/interaction": "^6.1.7",
"@fullcalendar/list": "^6.1.7",
"@fullcalendar/timegrid": "^6.1.7",
"@fullcalendar/vue3": "^6.1.8",
"@iconify/utils": "^2.1.22", "@iconify/utils": "^2.1.22",
"@vueuse/core": "^10.1.2", "@vueuse/core": "^10.1.2",
"@vueuse/math": "^10.1.2", "@vueuse/math": "^10.1.2",
"ace-builds": "^1.32.6", "ace-builds": "^1.32.6",
"apexcharts-clevision": "^3.28.5", "apexcharts-clevision": "^3.28.5",
"axios": "1.6.8", "axios": "1.6.8",
"axios-mock-adapter": "^1.21.4",
"chart.js": "^4.1.2",
"colorthief": "^2.4.0", "colorthief": "^2.4.0",
"dayjs": "^1.11.10",
"express": "^4.18.2", "express": "^4.18.2",
"express-http-proxy": "^2.0.0", "express-http-proxy": "^2.0.0",
"jwt-decode": "^4.0.0", "lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"postcss-purgecss": "^5.0.0",
"prismjs": "^1.29.0",
"pull-refresh-vue3": "^0.3.1",
"qrcode.vue": "^3.4.1", "qrcode.vue": "^3.4.1",
"roboto-fontface": "^0.10.0",
"sass": "^1.59.3", "sass": "^1.59.3",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"unplugin-vue-define-options": "^1.3.5", "unplugin-vue-define-options": "^1.3.5",
"vite-plugin-pwa": "^0.19.8",
"vue": "^3.3.2", "vue": "^3.3.2",
"vue-chartjs": "^5.2.0",
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "^9.2.2",
"vue-prism-component": "^2.0.0",
"vue-router": "^4.2.0", "vue-router": "^4.2.0",
"vue-toast-notification": "^3", "vue-toast-notification": "^3",
"vue-virtual-scroll-grid": "^1.11.0",
"vue3-ace-editor": "^2.2.4", "vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.4.1", "vue3-apexcharts": "^1.4.1",
"vue3-perfect-scrollbar": "^2.0.0", "vue3-perfect-scrollbar": "^2.0.0",
"vuetify": "3.5.14", "vuedraggable": "^4.1.0",
"vuetify": "3.6.8",
"vuetify-use-dialog": "^0.6.11", "vuetify-use-dialog": "^0.6.11",
"vuex": "^4.1.0", "vuex": "^4.1.0",
"vuex-persistedstate": "^4.1.0", "vuex-persistedstate": "^4.1.0",
@@ -63,12 +57,6 @@
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config-vue": "^0.43.1", "@antfu/eslint-config-vue": "^0.43.1",
"@fullcalendar/core": "^6.1.8",
"@fullcalendar/daygrid": "^6.1.8",
"@fullcalendar/interaction": "^6.1.7",
"@fullcalendar/list": "^6.1.7",
"@fullcalendar/timegrid": "^6.1.7",
"@fullcalendar/vue3": "^6.1.8",
"@iconify-json/mdi": "^1.1.52", "@iconify-json/mdi": "^1.1.52",
"@iconify/tools": "^4.0.4", "@iconify/tools": "^4.0.4",
"@iconify/vue": "4.1.1", "@iconify/vue": "4.1.1",
@@ -82,7 +70,6 @@
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.0.0", "@vitejs/plugin-vue-jsx": "^3.0.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"dayjs": "^1.11.10",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-import-resolver-typescript": "^3.5.1", "eslint-import-resolver-typescript": "^3.5.1",
@@ -92,7 +79,6 @@
"eslint-plugin-sonarjs": "^0.25.1", "eslint-plugin-sonarjs": "^0.25.1",
"eslint-plugin-unicorn": "^52.0.0", "eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-vue": "^9.12.0", "eslint-plugin-vue": "^9.12.0",
"lodash": "^4.17.21",
"postcss": "8", "postcss": "8",
"postcss-html": "^1.5.0", "postcss-html": "^1.5.0",
"stylelint": "16.3.1", "stylelint": "16.3.1",
@@ -105,6 +91,7 @@
"unplugin-vue-components": "^0.26.0", "unplugin-vue-components": "^0.26.0",
"vite": "^5.2.8", "vite": "^5.2.8",
"vite-plugin-pages": "^0.32.1", "vite-plugin-pages": "^0.32.1",
"vite-plugin-pwa": "^0.20.0",
"vite-plugin-vue-layouts": "^0.11.0", "vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.3", "vite-plugin-vuetify": "2.0.3",
"vue-shepherd": "^3.0.0", "vue-shepherd": "^3.0.0",
@@ -114,4 +101,4 @@
"resolutions": { "resolutions": {
"postcss": "8" "postcss": "8"
} }
} }

View File

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

View File

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

View File

@@ -11,6 +11,13 @@ http {
keepalive_timeout 3600; keepalive_timeout 3600;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_proxied any;
gzip_min_length 256;
gzip_vary on;
gzip_comp_level 6;
server { server {
include mime.types; include mime.types;
@@ -28,9 +35,16 @@ http {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
# 静态资源
expires 1y;
add_header Cache-Control "public, immutable";
root html;
}
location /assets { location /assets {
# 静态资源 # 静态资源
expires 7d; expires 1y;
add_header Cache-Control "public"; add_header Cache-Control "public";
root html; root html;
} }

2
public/robots.txt Normal file
View File

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useTheme } from 'vuetify' import { useDisplay, useTheme } from 'vuetify'
import type { ThemeSwitcherTheme } from '@layouts/types' import type { ThemeSwitcherTheme } from '@layouts/types'
import api from '@/api' import api from '@/api'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils' import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { useToast } from 'vue-toast-notification'
import { VAceEditor } from 'vue3-ace-editor'
// 显示器宽度
const display = useDisplay()
const props = defineProps<{ const props = defineProps<{
themes: ThemeSwitcherTheme[] themes: ThemeSwitcherTheme[]
@@ -13,15 +18,22 @@ const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? themeName) const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
const { const { state: currentThemeName, next: getNextThemeName } = useCycleList(
state: currentThemeName,
next: getNextThemeName,
index: currentThemeIndex,
} = useCycleList(
props.themes.map(t => t.name), props.themes.map(t => t.name),
{ initialValue: savedTheme.value }, { initialValue: savedTheme.value },
) )
const $toast = useToast()
// 自定义CSS弹窗
const cssDialog = ref(false)
// 自定义 CSS
const customCSS = ref('')
// 编辑器主题
const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
// 主题切换动画 // 主题切换动画
function themeTransition() { function themeTransition() {
const x = performance.now() const x = performance.now()
@@ -90,15 +102,16 @@ function updateTheme() {
globalTheme.name.value = theme globalTheme.name.value = theme
savedTheme.value = theme savedTheme.value = theme
themeTransition() themeTransition()
// 保存主题到本地
localStorage.setItem('theme', theme)
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
} }
// 切换主题 // 切换主题
function changeTheme() { function changeTheme(theme: string) {
const nextTheme = getNextThemeName() let nextTheme = theme
if (!theme) nextTheme = getNextThemeName()
currentThemeName.value = nextTheme currentThemeName.value = nextTheme
// 保存主题到本地
localStorage.setItem('theme', nextTheme)
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
// 保存主题到服务端 // 保存主题到服务端
try { try {
api.post('/user/config/theme', nextTheme, { api.post('/user/config/theme', nextTheme, {
@@ -126,17 +139,100 @@ try {
console.error('当前设备不支持监听系统主题变化') console.error('当前设备不支持监听系统主题变化')
} }
// 查询当前主题的图标
const getThemeIcon = computed(() => {
const theme = props.themes.find(t => t.name === currentThemeName.value)
return theme?.icon ?? 'mdi-circle'
})
// 监听设置主题变化 // 监听设置主题变化
watch( watch(
() => currentThemeName.value, () => currentThemeName.value,
() => updateTheme(), () => updateTheme(),
) )
// 获取自定义 CSS
async function getCustomCSS() {
try {
const result: { [key: string]: any } = await api.get('system/setting/UserCustomCSS')
if (result && result.success && result.data?.value) {
customCSS.value = result.data?.value ?? ''
if (customCSS.value) {
const style = document.createElement('style')
style.innerHTML = result.data?.value ?? ''
document.head.appendChild(style)
}
}
} catch (error) {
console.error(error)
}
}
// 保存自定义 CSS
async function saveCustomCSS() {
cssDialog.value = false
try {
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', customCSS.value, {
headers: {
'Content-Type': 'text/plain',
},
})
if (result.success) $toast.success('自定义CSS保存成功')
} catch (e) {
console.error('保存自定义 CSS 到服务端失败')
}
}
onMounted(() => {
getCustomCSS()
})
</script> </script>
<template> <template>
<IconBtn @click="changeTheme"> <VMenu v-if="props.themes">
<VIcon :icon="props.themes[currentThemeIndex].icon" /> <template v-slot:activator="{ props }">
</IconBtn> <IconBtn v-bind="props">
<VIcon :icon="getThemeIcon" />
</IconBtn>
</template>
<VList>
<VListItem v-for="theme in props.themes" :key="theme.name" @click="changeTheme(theme.name)">
<template #prepend>
<VIcon :icon="theme.icon" />
</template>
<VListItemTitle>{{ theme.title }}</VListItemTitle>
</VListItem>
<VListItem @click="cssDialog = true">
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
<VListItemTitle>自定义</VListItemTitle>
</VListItem>
</VList>
</VMenu>
<!-- 自定义 CSS -- -->
<VDialog v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard title="自定义主题风格">
<DialogCloseBtn @click="cssDialog = false" />
<VDivider />
<VAceEditor
v-model:value="customCSS"
lang="css"
:theme="editorTheme"
style="block-size: 100%; min-block-size: 30rem"
/>
<VDivider />
<VCardText class="text-center">
<VBtn @click="saveCustomCSS" class="w-1/2">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
保存
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template> </template>
<style lang="sass"> <style lang="sass">

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
// 👉 IsEmpty // 👉 IsEmpty
export function isEmpty(value: unknown): boolean { export function isEmpty(value: unknown): boolean {
if (value === null || value === undefined || value === '') if (value === null || value === undefined || value === '') return true
return true
return !!(Array.isArray(value) && value.length === 0) return !!(Array.isArray(value) && value.length === 0)
} }
@@ -33,73 +32,6 @@ export function isToday(date: Date) {
) )
} }
/**
* 计算时间差返回xx天/xx小时/xx分钟/xx秒
*
* @deprecated 建议使用:@core/utils/formatters.ts formatDateDifference
*/
export function calculateTimeDifference(inputTime: string): string {
if (!inputTime)
return ''
const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
const currentDate = new Date()
const timeDifference = currentDate.getTime() - inputDate.getTime()
const secondsDifference = Math.floor(timeDifference / 1000)
if (secondsDifference < 60) {
return `${secondsDifference}`
}
else if (secondsDifference < 3600) {
const minutes = Math.floor(secondsDifference / 60)
return `${minutes}分钟`
}
else if (secondsDifference < 86400) {
const hours = Math.floor(secondsDifference / 3600)
return `${hours}小时`
}
else {
const days = Math.floor(secondsDifference / 86400)
return `${days}`
}
}
// 计算时间差返回xx天xx小时xx分钟
export function calculateTimeDiff(inputTime: string): string {
if (!inputTime)
return ''
// 使用当前时区
const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
const currentDate = new Date()
const timeDifference = currentDate.getTime() - inputDate.getTime()
const secondsDifference = Math.floor(timeDifference / 1000)
const days = Math.floor(secondsDifference / 86400)
const hours = Math.floor(secondsDifference % 86400 / 3600)
const minutes = Math.floor(secondsDifference % 86400 % 3600 / 60)
const secones = Math.floor(secondsDifference % 60)
if (days > 0)
return `${days}${hours}小时${minutes}分钟`
else if (hours > 0)
return `${hours}小时${minutes}分钟`
else if (minutes > 0)
return `${minutes}分钟`
else if (secones > 0)
return `${secones}`
return ''
}
// 判断一个数组subArray是不是在另一个数组mainArray中 // 判断一个数组subArray是不是在另一个数组mainArray中
export function isContained(subArray: any[], mainArray: any[]): boolean { export function isContained(subArray: any[], mainArray: any[]): boolean {
return subArray.every(element => mainArray.includes(element)) return subArray.every(element => mainArray.includes(element))
@@ -112,8 +44,7 @@ export function isIntersected(array1: any[], array2: any[]): boolean {
export function isNullOrEmptyObject(obj: any): boolean { export function isNullOrEmptyObject(obj: any): boolean {
// 首先判断是否为 null 或 undefined // 首先判断是否为 null 或 undefined
if (obj === null || obj === undefined) if (obj === null || obj === undefined) return true
return true
// 然后判断是否为空对象 // 然后判断是否为空对象
return !!(typeof obj === 'object' && Object.keys(obj).length === 0) return !!(typeof obj === 'object' && Object.keys(obj).length === 0)
@@ -127,3 +58,10 @@ export function checkPrefersColorSchemeIsDark(): boolean {
return false 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) 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 { .nav-link a {
display: flex; display: flex;
align-items: center; align-items: center;
border-radius: 0 3.125rem 3.125rem 0 !important;
cursor: pointer; cursor: pointer;
margin-inline-end: 1.125em;
padding-inline: 1.375rem 1rem;
} }
} }
</style> </style>

View File

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

View File

@@ -6,19 +6,19 @@ export interface UserConfig {
app: { app: {
title: Lowercase<string> title: Lowercase<string>
logo: VNode logo: VNode
contentWidth: typeof ContentWidth[keyof typeof ContentWidth] contentWidth: (typeof ContentWidth)[keyof typeof ContentWidth]
contentLayoutNav: typeof AppContentLayoutNav[keyof typeof AppContentLayoutNav] contentLayoutNav: (typeof AppContentLayoutNav)[keyof typeof AppContentLayoutNav]
overlayNavFromBreakpoint: number overlayNavFromBreakpoint: number
enableI18n: boolean enableI18n: boolean
isRtl: boolean isRtl: boolean
iconRenderer?: Component iconRenderer?: Component
} }
navbar: { navbar: {
type: typeof NavbarType[keyof typeof NavbarType] type: (typeof NavbarType)[keyof typeof NavbarType]
navbarBlur: boolean navbarBlur: boolean
} }
footer: { footer: {
type:typeof FooterType[keyof typeof FooterType] type: (typeof FooterType)[keyof typeof FooterType]
} }
verticalNav: { verticalNav: {
isVerticalNavCollapsed: boolean isVerticalNavCollapsed: boolean
@@ -120,6 +120,12 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
disable?: boolean disable?: boolean
} }
export interface NavMenu extends NavLink {
header: string
admin: boolean
description?: string
}
// 👉 Vertical nav group // 👉 Vertical nav group
export interface NavGroup extends Partial<AclProperties> { export interface NavGroup extends Partial<AclProperties> {
title: string title: string
@@ -143,7 +149,7 @@ interface I18nLanguage {
// avatar | text | icon // avatar | text | icon
// Thanks: https://stackoverflow.com/a/60617060/10796681 // Thanks: https://stackoverflow.com/a/60617060/10796681
type Notification = { type Notification = {
id:number id: number
title: string title: string
subtitle: string subtitle: string
time: string time: string
@@ -157,5 +163,6 @@ type Notification = {
interface ThemeSwitcherTheme { interface ThemeSwitcherTheme {
name: string name: string
title: string
icon: string icon: string
} }

View File

@@ -1,15 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import api from '@/api'
import store from './store'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils' import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
const { global: globalTheme } = useTheme() const { global: globalTheme } = useTheme()
// 提示框
const $toast = useToast()
// 生效主题 // 生效主题
async function setTheme() { async function setTheme() {
let themeValue = localStorage.getItem('theme') || 'light' let themeValue = localStorage.getItem('theme') || 'light'
@@ -17,47 +11,39 @@ async function setTheme() {
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
} }
// SSE持续接收消息 // ApexCharts 全局配置
function startSSEMessager() { declare global {
const token = store.state.auth.token interface Window {
if (token) { Apex: any
const eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}`)
eventSource.addEventListener('message', event => {
const message = event.data
if (message) $toast.info(message)
})
onBeforeUnmount(() => {
eventSource.close()
})
} }
} }
// 加载用户监控面板配置 if (window.Apex) {
async function loadDashboardConfig() { // 数据标签
const response = await api.get('/user/config/Dashboard') window.Apex.dataLabels = {
if (response && response.data && response.data.value) { formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
const data = JSON.stringify(response.data.value) // 如果有小数点,保留两位小数,否则保留整数
if (data != localStorage.getItem('MP_DASHBOARD')) { const data = w.config.series[seriesIndex]
localStorage.setItem('MP_DASHBOARD', data) return data.toFixed(data % 1 === 0 ? 0 : 1)
} },
} }
} // 图例
window.Apex.legend = {
// 尝试加载用户监控面板配置(本地无配置时才加载) labels: {
async function tryLoadDashboardConfig() { useSeriesColors: true,
if (localStorage.getItem('MP_DASHBOARD')) { },
return }
// 标题
window.Apex.title = {
style: {
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
},
} }
await loadDashboardConfig()
} }
// 页面加载时,加载当前用户数据 // 页面加载时,加载当前用户数据
onBeforeMount(async () => { onBeforeMount(async () => {
setTheme() setTheme()
startSSEMessager()
await tryLoadDashboardConfig()
}) })
</script> </script>

View File

@@ -8,6 +8,10 @@ import modeHtmlUrl from 'ace-builds/src-noconflict/mode-html?url'
import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url' import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
import modeCssUrl from 'ace-builds/src-noconflict/mode-css?url'
import modePythonUrl from 'ace-builds/src-noconflict/mode-python?url'
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url' import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url' import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
@@ -24,6 +28,8 @@ import workerHtmlUrl from 'ace-builds/src-noconflict/worker-html?url'
import workerYamlUrl from 'ace-builds/src-noconflict/worker-yaml?url' import workerYamlUrl from 'ace-builds/src-noconflict/worker-yaml?url'
import workerCssUrl from 'ace-builds/src-noconflict/worker-css?url'
import snippetsHtmlUrl from 'ace-builds/src-noconflict/snippets/html?url' import snippetsHtmlUrl from 'ace-builds/src-noconflict/snippets/html?url'
import snippetsJsUrl from 'ace-builds/src-noconflict/snippets/javascript?url' import snippetsJsUrl from 'ace-builds/src-noconflict/snippets/javascript?url'
@@ -32,12 +38,18 @@ import snippetsYamlUrl from 'ace-builds/src-noconflict/snippets/yaml?url'
import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url' import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
import snippertsCssUrl from 'ace-builds/src-noconflict/snippets/css?url'
import snippetsPythonUrl from 'ace-builds/src-noconflict/snippets/python?url'
import 'ace-builds/src-noconflict/ext-language_tools' import 'ace-builds/src-noconflict/ext-language_tools'
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl) ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl) ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl) ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl) ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
ace.config.setModuleUrl('ace/mode/python', modePythonUrl)
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl) ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl) ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl) ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
@@ -46,9 +58,12 @@ ace.config.setModuleUrl('ace/mode/json_worker', workerJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript_worker', workerJavascriptUrl) ace.config.setModuleUrl('ace/mode/javascript_worker', workerJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html_worker', workerHtmlUrl) ace.config.setModuleUrl('ace/mode/html_worker', workerHtmlUrl)
ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl) ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
ace.config.setModuleUrl('ace/mode/css_worker', workerCssUrl)
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl) ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl) ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl) ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl) ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
ace.config.setModuleUrl('ace/snippets/python', snippetsPythonUrl)
ace.require('ace/ext/language_tools') ace.require('ace/ext/language_tools')

View File

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

View File

@@ -58,9 +58,13 @@ export interface Subscribe {
// 当前优先级 // 当前优先级
current_priority: number current_priority: number
// 保存目录 // 保存目录
save_path: string save_path?: string
// 时间 // 时间
date: string date: string
// 编辑框设置项
show_edit_dialog: boolean
// 编辑框打开状态
page_open?: boolean
} }
// 历史记录 // 历史记录
@@ -332,6 +336,8 @@ export interface Site {
public?: number public?: number
// 备注 // 备注
note?: string note?: string
// 超时时间
timeout?: number
// 流控单位周期 // 流控单位周期
limit_interval?: number limit_interval?: number
// 流控次数 // 流控次数
@@ -441,6 +447,35 @@ export interface Plugin {
history?: { [key: string]: string } history?: { [key: string]: string }
// 添加时间 // 添加时间
add_time?: number add_time?: number
// 页面打开状态
page_open?: boolean
}
// 渲染结构
export interface RenderProps {
component: string
text?: string
html?: string
content?: any
slots?: any
props?: any
events?: any
}
// 仪表板组件
export interface DashboardItem {
// ID
id: string
// 名称
name: string
// 插件的仪表板key
key: string
// 全局配置
attrs: { [key: string]: any }
// col列数
cols: { [key: string]: number }
// 页面元素
elements: RenderProps[]
} }
// 种子信息 // 种子信息
@@ -685,12 +720,7 @@ export interface NotificationSwitch {
slack: boolean slack: boolean
synologychat: boolean synologychat: boolean
vocechat: boolean vocechat: boolean
} webpush: boolean
// 环境设置
export interface Setting {
// 下载目录
DOWNLOAD_PATH: string
} }
// 文件浏览接口 // 文件浏览接口
@@ -711,22 +741,32 @@ export interface EndPoints {
// 文件浏览项目 // 文件浏览项目
export interface FileItem { export interface FileItem {
// 类型 // 类型 dir/file
type: string type: string
// 文件名 // 文件名
name: string name: string
// 文件名不含扩展名 // 文件名不含扩展名
basename: string basename?: string
// 文件路径 // 文件路径
path: 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
} }
// 媒体服务器播放条目 // 媒体服务器播放条目
@@ -790,3 +830,35 @@ export interface Message {
// JSON // JSON
note?: string note?: string
} }
// 系统通知
export interface SystemNotification {
// 通知类型 user/system/plugin
type: string
// 通知标题
title: string
// 通知内容
text: string
// 通知时间
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
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import image from '@images/misc/teamwork.png' import image from '@images/no-data.svg'
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -11,10 +11,7 @@ interface Props {
</script> </script>
<template> <template>
<VEmptyState <VEmptyState :image="image" size="250">
:image="image"
size="250"
>
<template #title> <template #title>
<div class="mt-8 text-2xl"> <div class="mt-8 text-2xl">
{{ props.errorTitle }} {{ props.errorTitle }}
@@ -22,7 +19,7 @@ interface Props {
</template> </template>
<template #text> <template #text>
<div class="text-subtitle"> <div class="text-subtitle mt-3">
{{ props.errorDescription }} {{ props.errorDescription }}
</div> </div>
</template> </template>

View File

@@ -18,8 +18,7 @@ function imageLoadHandler() {
// 跳转播放 // 跳转播放
function goPlay() { function goPlay() {
if (props.media?.link) if (props.media?.link) window.open(props.media?.link, '_blank')
window.open(props.media?.link, '_blank')
} }
// 计算图片地址 // 计算图片地址
@@ -30,11 +29,7 @@ const getImgUrl = computed(() => {
</script> </script>
<template> <template>
<VHover <VHover v-bind="props">
v-bind="props"
:height="props.height"
:width="props.width"
>
<template #default="hover"> <template #default="hover">
<VCard <VCard
v-bind="hover.props" v-bind="hover.props"
@@ -48,12 +43,7 @@ const getImgUrl = computed(() => {
@click="goPlay" @click="goPlay"
> >
<template #image> <template #image>
<VImg <VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler">
:src="getImgUrl"
aspect-ratio="2/3"
cover
@load="imageLoadHandler"
>
<template #placeholder> <template #placeholder>
<div class="w-full h-full"> <div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" /> <VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
@@ -62,7 +52,9 @@ const getImgUrl = computed(() => {
<VCardText <VCardText
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2" class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
> >
<h1 class="mb-1 text-white text-shadow font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ..."> <h1
class="mb-1 text-white text-shadow font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ..."
>
{{ props.media?.title }} {{ props.media?.title }}
</h1> </h1>
<span class="text-shadow">{{ props.media?.subtitle }}</span> <span class="text-shadow">{{ props.media?.subtitle }}</span>
@@ -83,7 +75,7 @@ const getImgUrl = computed(() => {
</template> </template>
<style lang="scss"> <style lang="scss">
.text-shadow{ .text-shadow {
text-shadow:1px 1px #777; text-shadow: 1px 1px #777;
} }
</style> </style>

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

@@ -9,28 +9,26 @@ const props = defineProps({
}) })
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['close', 'changed', 'levelup', 'leveldown']) const emit = defineEmits(['close', 'changed'])
// 按钮点击 // 按钮点击
function onClose() { function onClose() {
emit('close') emit('close')
} }
// 上升优先级
function onLevelUp() {
emit('levelup', props.pri)
}
// 下降优先级
function onLevelDown() {
emit('leveldown', props.pri)
}
// 选项变化 // 选项变化
function filtersChanged(value: string[]) { function filtersChanged(value: string[]) {
emit('changed', props.pri, value) emit('changed', props.pri, value)
} }
// 清洗规则中的换行符和多余空格,并在前后添加空格
const cleanedRules = computed(() => {
return props.rules.map(rule => {
rule = rule ?? ''
return ` ${rule.replace(/[\r\n]/g, '').replace(/\s+/g, '')} `
})
})
// 过滤规则下拉框 // 过滤规则下拉框
const selectFilterOptions = ref<{ [key: string]: string }[]>([ const selectFilterOptions = ref<{ [key: string]: string }[]>([
{ title: '特效字幕', value: ' SPECSUB ' }, { title: '特效字幕', value: ' SPECSUB ' },
@@ -76,18 +74,9 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
<template> <template>
<VCard variant="tonal" :width="props.width" :height="props.height"> <VCard variant="tonal" :width="props.width" :height="props.height">
<span class="absolute top-3 right-14"> <span class="absolute top-3 right-12">
<IconBtn <IconBtn>
v-if="props.pri !== '1'" <VIcon class="cursor-move" icon="mdi-drag" />
@click.stop="onLevelUp"
>
<VIcon icon="mdi-arrow-up" />
</IconBtn>
<IconBtn
v-if="props.pri !== props.maxpri"
@click.stop="onLevelDown"
>
<VIcon icon="mdi-arrow-down" />
</IconBtn> </IconBtn>
</span> </span>
<DialogCloseBtn @click="onClose" /> <DialogCloseBtn @click="onClose" />
@@ -96,8 +85,7 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
<VRow> <VRow>
<VCol> <VCol>
<VSelect <VSelect
:key="props.pri" v-model="cleanedRules"
v-model="props.rules"
variant="underlined" variant="underlined"
:items="selectFilterOptions" :items="selectFilterOptions"
chips chips

View File

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

View File

@@ -58,7 +58,7 @@ const seasonInfos = ref<TmdbSeason[]>([])
const seasonsSelected = ref<TmdbSeason[]>([]) const seasonsSelected = ref<TmdbSeason[]>([])
// 来源角标字典 // 来源角标字典
const sourceIconDict = { const sourceIconDict: { [key: string]: any } = {
themoviedb: tmdbImage, themoviedb: tmdbImage,
douban: doubanImage, douban: doubanImage,
bangumi: bangumiImage, bangumi: bangumiImage,
@@ -66,11 +66,9 @@ const sourceIconDict = {
// 获得mediaid // 获得mediaid
function getMediaId() { function getMediaId() {
return props.media?.tmdb_id if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
? `tmdb:${props.media?.tmdb_id}` else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
: props.media?.douban_id else return `bangumi:${props.media?.bangumi_id}`
? `douban:${props.media?.douban_id}`
: `bangumi:${props.media?.bangumi_id}`
} }
// 订阅弹窗选择的多季 // 订阅弹窗选择的多季
@@ -99,7 +97,6 @@ async function handleAddSubscribe() {
$toast.error(`${props.media?.title} 查询剧集信息失败!`) $toast.error(`${props.media?.title} 查询剧集信息失败!`)
return return
} }
// 检查各季的缺失状态 // 检查各季的缺失状态
await checkSeasonsNotExists() await checkSeasonsNotExists()
if (!tmdbFlag.value) return if (!tmdbFlag.value) return
@@ -176,7 +173,7 @@ function showSubscribeAddToast(result: boolean, title: string, season: number, m
let subname = '订阅' let subname = '订阅'
if (best_version > 0) subname = '洗版订阅' if (best_version > 0) subname = '洗版订阅'
if (result && seasonsSelected.value.length > 1) $toast.success(`${title} 添加${subname}成功!`) if (result) $toast.success(`${title} 添加${subname}成功!`)
else if (!result) $toast.error(`${title} 添加${subname}失败:${message}`) else if (!result) $toast.error(`${title} 添加${subname}失败:${message}`)
} }
@@ -201,8 +198,9 @@ async function removeSubscribe() {
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally {
doneNProgress()
} }
doneNProgress()
} }
// 查询当前媒体是否已订阅 // 查询当前媒体是否已订阅
@@ -273,10 +271,10 @@ async function checkSeasonsNotExists() {
} catch (error) { } catch (error) {
$toast.error(`${props.media?.title}无法识别TMDB媒体信息`) $toast.error(`${props.media?.title}无法识别TMDB媒体信息`)
tmdbFlag.value = false tmdbFlag.value = false
} finally {
// 处理完成
doneNProgress()
} }
// 处理完成
doneNProgress()
} }
// 查询TMDB的所有季信息 // 查询TMDB的所有季信息
@@ -468,7 +466,7 @@ function getYear(airDate: string) {
density="compact" density="compact"
class="absolute bottom-1 right-1" class="absolute bottom-1 right-1"
tile tile
v-if="!hover.isHovering && isImageLoaded" v-if="!hover.isHovering && isImageLoaded && props.media?.source"
> >
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" /> <VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar> </VAvatar>
@@ -479,7 +477,10 @@ function getYear(airDate: string) {
<VBottomSheet v-if="subscribeSeasonDialog" v-model="subscribeSeasonDialog" inset scrollable> <VBottomSheet v-if="subscribeSeasonDialog" v-model="subscribeSeasonDialog" inset scrollable>
<VCard class="rounded-t"> <VCard class="rounded-t">
<DialogCloseBtn @click="subscribeSeasonDialog = false" /> <DialogCloseBtn @click="subscribeSeasonDialog = false" />
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle> <VCardItem>
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
</VCardItem>
<VDivider />
<VCardText> <VCardText>
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic"> <VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item"> <VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">

View File

@@ -149,56 +149,62 @@ const dropdownItems = ref([
</script> </script>
<template> <template>
<VCard :width="props.width" :height="props.height" @click="installPlugin"> <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 bottom-0 right-3">
<div class="me-n3 absolute top-0 right-3"> <IconBtn>
<IconBtn> <VIcon icon="mdi-dots-vertical" />
<VIcon icon="mdi-dots-vertical" class="text-white" /> <VMenu activator="parent" close-on-content-click>
<VMenu activator="parent" close-on-content-click> <VList>
<VList> <VListItem
<VListItem v-for="(item, i) in dropdownItems"
v-for="(item, i) in dropdownItems" v-show="item.show"
v-show="item.show" :key="i"
:key="i" variant="plain"
variant="plain" @click="item.props.click"
@click="item.props.click" >
> <template #prepend>
<template #prepend> <VIcon :icon="item.props.prependIcon" />
<VIcon :icon="item.props.prependIcon" /> </template>
</template> <VListItemTitle v-text="item.title" />
<VListItemTitle v-text="item.title" /> </VListItem>
</VListItem> </VList>
</VList> </VMenu>
</VMenu> </IconBtn>
</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> </div>
<VCardTitle> <div
{{ props.plugin?.plugin_name }} class="relative flex flex-row items-start pa-3 justify-between grow"
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span> :style="{ background: `${backgroundColor}` }"
</VCardTitle> >
<VCardText class="pb-2"> <div
<div>{{ props.plugin?.plugin_desc }}</div> class="absolute inset-0 bg-cover bg-center"
<div> :style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
<VChip v-for="label in pluginLabels" variant="tonal" size="small" class="me-1 my-1" color="info" label> ></div>
{{ label }} <div class="relative flex-1 min-w-0">
</VChip> <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> </div>
</VCardText> <div class="relative flex-shrink-0 self-center">
<VCardText class="flex items-center justify-start pb-2"> <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> <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> <a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }} {{ props.plugin?.plugin_author }}
</a> </a>
@@ -213,22 +219,10 @@ const dropdownItems = ref([
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" /> <ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 --> <!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable> <VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard> <VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" /> <DialogCloseBtn @click="releaseDialog = false" />
<VCardTitle>{{ props.plugin?.plugin_name }} 更新说明</VCardTitle> <VDivider />
<VersionHistory :history="props.plugin?.history" /> <VersionHistory :history="props.plugin?.history" />
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
<style lang="scss" scoped>
.card-cover-blurred::before {
position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
background: rgba(29, 39, 59, 48%);
content: '';
inset: 0;
}
</style>

View File

@@ -108,14 +108,6 @@ async function uninstallPlugin() {
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: '确认', title: '确认',
content: `是否确认卸载插件 ${props.plugin?.plugin_name} ?`, content: `是否确认卸载插件 ${props.plugin?.plugin_name} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
}) })
if (!isConfirmed) return if (!isConfirmed) return
@@ -229,14 +221,6 @@ async function resetPlugin() {
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: '确认', title: '确认',
content: `是否确认重置插件 ${props.plugin?.plugin_name} 的配置数据?`, content: `是否确认重置插件 ${props.plugin?.plugin_name} 的配置数据?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
}) })
if (!isConfirmed) return if (!isConfirmed) return
@@ -381,81 +365,109 @@ const dropdownItems = ref([
// 监听插件状态变化 // 监听插件状态变化
watch( watch(
() => props.plugin?.has_update, () => props.plugin?.has_update,
(newHasUpdate, oldHasUpdate) => { (newHasUpdate, _) => {
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3) const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
}, },
) )
// 监听插件窗口状态变化
watch(
() => props.plugin?.page_open,
(newOpenState, _) => {
if (newOpenState) openPluginDetail()
},
)
</script> </script>
<template> <template>
<!-- 插件卡片 --> <!-- 插件卡片 -->
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail"> <VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
<div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }"> <div class="me-n3 absolute bottom-0 right-3">
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 left-1"> <IconBtn>
<VIcon icon="mdi-new-box" class="text-white" /> <VIcon icon="mdi-dots-vertical" />
</div> <VMenu activator="parent" close-on-content-click>
<div class="me-n3 absolute top-0 right-3"> <VList>
<IconBtn> <VListItem
<VIcon icon="mdi-dots-vertical" class="text-white" /> v-for="(item, i) in dropdownItems"
<VMenu activator="parent" close-on-content-click> v-show="item.show"
<VList> :key="i"
<VListItem variant="plain"
v-for="(item, i) in dropdownItems" :base-color="item.props.color"
v-show="item.show" @click="item.props.click"
:key="i" >
variant="plain" <template #prepend>
:base-color="item.props.color" <VIcon :icon="item.props.prependIcon" />
@click="item.props.click" </template>
> <VListItemTitle v-text="item.title" />
<template #prepend> </VListItem>
<VIcon :icon="item.props.prependIcon" /> </VList>
</template> </VMenu>
<VListItemTitle v-text="item.title" /> </IconBtn>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<VAvatar size="6rem">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div> </div>
<span v-if="props.count" class="absolute bottom-1 right-2 flex items-center"> <div
<VIcon icon="mdi-fire" /> class="relative flex flex-row items-start pa-3 justify-between grow"
<span class="text-sm ms-1">{{ props.count?.toLocaleString() }}</span> :style="{ background: `${backgroundColor}` }"
</span> >
<VCardItem class="py-2"> <div
<VCardTitle class="flex items-center flex-row"> class="absolute inset-0 bg-cover bg-center"
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" /> :style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
{{ props.plugin?.plugin_name />
}}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span> <div class="relative flex-1 min-w-0">
</VCardTitle> <VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
</VCardItem> <VBadge v-if="props.plugin?.state" dot inline color="success" />
<VCardText> {{ props.plugin?.plugin_name }}
{{ props.plugin?.plugin_desc }} <span class="text-sm mt-1 text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="px-2 py-1 text-white text-shadow line-clamp-3">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" class="me-1" />
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
</VCardText> </VCardText>
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
</VCard> </VCard>
<!-- 插件配置页面 --> <!-- 插件配置页面 -->
<VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value"> <VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t"> <VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="pluginConfigDialog" /> <DialogCloseBtn v-model="pluginConfigDialog" />
<VDivider />
<VCardText> <VCardText>
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" /> <FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
</VCardText> </VCardText>
<VCardActions> <VCardActions class="pt-3">
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo"> 查看数据 </VBtn> <VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
查看数据
</VBtn>
<VSpacer /> <VSpacer />
<VBtn variant="tonal" @click="savePluginConf"> 保存 </VBtn> <VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
@@ -464,14 +476,10 @@ watch(
<VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value"> <VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t"> <VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
<DialogCloseBtn v-model="pluginInfoDialog" /> <DialogCloseBtn v-model="pluginInfoDialog" />
<VCardText> <VCardText class="min-h-40">
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" /> <PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
</VCardText> </VCardText>
<VCardActions> <VFab icon="mdi-cog" location="bottom" size="x-large" fixed app appear @click="showPluginConfig" />
<VBtn @click="showPluginConfig"> 配置 </VBtn>
<VSpacer />
<VBtn variant="tonal" @click="pluginInfoDialog = false"> 关闭 </VBtn>
</VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
@@ -480,10 +488,11 @@ watch(
<!-- 更新日志 --> <!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable> <VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard> <VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" /> <DialogCloseBtn @click="releaseDialog = false" />
<VCardTitle>{{ props.plugin?.plugin_name }} 更新说明</VCardTitle> <VDivider />
<VersionHistory :history="props.plugin?.history" /> <VersionHistory :history="props.plugin?.history" />
<VDivider />
<VCardText> <VCardText>
<VBtn @click="updatePlugin" block> <VBtn @click="updatePlugin" block>
<template #prepend> <template #prepend>

View File

@@ -18,26 +18,21 @@ const imageLoadError = ref(false)
// 角标颜色 // 角标颜色
function getChipColor(type: string) { function getChipColor(type: string) {
if (type === '电影') if (type === '电影') return 'border-blue-500 bg-blue-600'
return 'border-blue-500 bg-blue-600' else if (type === '电视剧') return ' bg-indigo-500 border-indigo-600'
else if (type === '电视剧') else return 'border-purple-600 bg-purple-600'
return ' bg-indigo-500 border-indigo-600'
else
return 'border-purple-600 bg-purple-600'
} }
// 计算图片地址 // 计算图片地址
const getImgUrl = computed(() => { const getImgUrl = computed(() => {
if (imageLoadError.value) if (imageLoadError.value) return noImage
return noImage
const image = props.media?.image || '' const image = props.media?.image || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}` return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
}) })
// 跳转播放 // 跳转播放
function goPlay(isHovering = false) { function goPlay(isHovering = false) {
if (props.media?.link && isHovering) if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
window.open(props.media?.link, '_blank')
} }
</script> </script>
@@ -72,24 +67,24 @@ function goPlay(isHovering = false) {
</VImg> </VImg>
<!-- 类型角标 --> <!-- 类型角标 -->
<VChip <VChip
v-show="isImageLoaded" v-show="isImageLoaded"
variant="elevated" variant="elevated"
size="small" size="small"
:class="getChipColor(props.media?.type || '')" :class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold" class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
> >
{{ props.media?.type }} {{ props.media?.type }}
</VChip> </VChip>
<!-- 详情 --> <!-- 详情 -->
<VCardText <VCardText
v-show="hover.isHovering || imageLoadError" v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2" class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
> >
<span class="font-bold">{{ props.media?.subtitle }}</span> <span class="font-bold">{{ props.media?.subtitle }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ..."> <h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }} {{ props.media?.title }}
</h1> </h1>
</VCardText> </VCardText>
</VCard> </VCard>
</template> </template>
</VHover> </VHover>

View File

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

View File

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

View File

@@ -43,16 +43,13 @@ const downloaded = ref<String[]>([])
async function getSiteIcon() { async function getSiteIcon() {
try { try {
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
// 询问并添加下载 // 询问并添加下载
async function handleAddDownload(_site: any = undefined, async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
_media: any = undefined,
_torrent: any = undefined) {
if (!_media || !_torrent || !_site) { if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name _site = torrent.value?.site_name
_media = media.value _media = media.value
@@ -62,18 +59,9 @@ async function handleAddDownload(_site: any = undefined,
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: '确认', title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`, content: `是否确认下载【${_site}${_torrent?.title} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
}) })
if (!isConfirmed) if (!isConfirmed) return
return
addDownload(_media, _torrent) addDownload(_media, _torrent)
} }
@@ -82,22 +70,26 @@ async function handleAddDownload(_site: any = undefined,
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) { async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress() startNProgress()
try { try {
const result: { [key: string]: any } = await api.post('download/', { let result: { [key: string]: any }
media_in: _media,
torrent_in: _torrent,
})
if (result.success) { if (_media) {
result = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
} else {
result = await api.post('download/add', _torrent)
}
if (result && result.success) {
// 添加下载成功 // 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`) $toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
downloaded.value.push(_torrent?.enclosure || '') downloaded.value.push(_torrent?.enclosure || '')
} } else {
else {
// 添加下载失败 // 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`) $toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败${result?.message}`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
doneNProgress() doneNProgress()
@@ -115,14 +107,10 @@ async function downloadTorrentFile() {
// 促销Chip类 // 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) { function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) if (downloadVolume === 0) return 'text-white bg-lime-500'
return 'text-white bg-lime-500' else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (downloadVolume < 1) else if (uploadVolume !== 1) return 'text-white bg-sky-500'
return 'text-white bg-green-500' else return 'text-white bg-gray-500'
else if (uploadVolume !== 1)
return 'text-white bg-sky-500'
else
return 'text-white bg-gray-500'
} }
// 装载时查询站点图标 // 装载时查询站点图标
@@ -138,39 +126,24 @@ onMounted(() => {
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'" :variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
@click="handleAddDownload" @click="handleAddDownload"
> >
<template <template v-if="!showMoreTorrents" #image>
v-if="!showMoreTorrents" <VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
#image
>
<VAvatar
class="absolute right-2 bottom-2 rounded"
variant="flat"
rounded="0"
>
<VImg :src="siteIcon" /> <VImg :src="siteIcon" />
</VAvatar> </VAvatar>
</template> </template>
<VCardItem class="py-1"> <VCardItem class="py-1">
<VCardTitle class="break-words overflow-visible whitespace-break-spaces"> <VCardTitle class="break-words overflow-visible whitespace-break-spaces">
{{ media?.title }} {{ meta?.season_episode }} {{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span> <span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span> <span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VCardTitle> </VCardTitle>
<template #append> <template #append>
<div class="me-n3"> <div class="me-n3">
<IconBtn> <IconBtn>
<VIcon <VIcon icon="mdi-dots-vertical" />
icon="mdi-dots-vertical" <VMenu activator="parent" close-on-content-click>
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem variant="plain" @click="openTorrentDetail()">
variant="plain"
@click="openTorrentDetail()"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-information" /> <VIcon icon="mdi-information" />
</template> </template>
@@ -196,25 +169,11 @@ onMounted(() => {
{{ torrent?.title }} {{ torrent?.title }}
</VCardText> </VCardText>
<VCardText>{{ torrent?.description }}</VCardText> <VCardText>{{ torrent?.description }}</VCardText>
<VCardItem <VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
v-if="torrent?.labels" <VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
class="pb-3 pt-0 pe-12"
>
<VChip
v-if="torrent?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R H&R
</VChip> </VChip>
<VChip <VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
v-if="torrent?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ torrent?.freedate_diff }} {{ torrent?.freedate_diff }}
</VChip> </VChip>
<VChip <VChip
@@ -227,51 +186,24 @@ onMounted(() => {
> >
{{ label }} {{ label }}
</VChip> </VChip>
<VChip <VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
v-if="meta?.edition"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-red-500"
>
{{ meta?.edition }} {{ meta?.edition }}
</VChip> </VChip>
<VChip <VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
v-if="meta?.resource_pix"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-red-500"
>
{{ meta?.resource_pix }} {{ meta?.resource_pix }}
</VChip> </VChip>
<VChip <VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
v-if="meta?.video_encode"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ meta?.video_encode }} {{ meta?.video_encode }}
</VChip> </VChip>
<VChip <VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
v-if="torrent?.size"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-yellow-500"
>
{{ formatFileSize(torrent?.size) }} {{ formatFileSize(torrent?.size) }}
</VChip> </VChip>
<VChip <VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
v-if="meta?.resource_team"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-cyan-500"
>
{{ meta?.resource_team }} {{ meta?.resource_team }}
</VChip> </VChip>
<VChip <VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1" v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class=" :class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)
"
variant="elevated" variant="elevated"
size="small" size="small"
class="me-1 mb-1" class="me-1 mb-1"
@@ -280,10 +212,7 @@ onMounted(() => {
</VChip> </VChip>
</VCardItem> </VCardItem>
<VCardActions> <VCardActions>
<VBtn <VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
v-if="props.more && props.more.length > 0"
@click.stop="showMoreTorrents = !showMoreTorrents"
>
<template #append> <template #append>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" /> <VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</template> </template>
@@ -297,26 +226,12 @@ onMounted(() => {
<VChip <VChip
v-for="(item, index) in props.more" v-for="(item, index) in props.more"
:key="index" :key="index"
@click.stop=" @click.stop="handleAddDownload(item.torrent_info?.site_name, item.media_info, item.torrent_info)"
handleAddDownload(
item.torrent_info?.site_name,
item.media_info,
item.torrent_info,
)
"
> >
<template #append> <template #append>
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
<VBadge <VBadge
color="primary" v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
:content="`↑${item.torrent_info?.seeders}`"
inline
size="small"
/>
<VBadge
v-if="
item.torrent_info?.downloadvolumefactor !== 1
|| item.torrent_info?.uploadvolumefactor !== 1
"
:content="item.torrent_info?.volume_factor" :content="item.torrent_info?.volume_factor"
inline inline
size="small" size="small"

View File

@@ -5,7 +5,7 @@ import { useConfirm } from 'vuetify-use-dialog'
import { formatFileSize } from '@/@core/utils/formatters' import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress' import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { Context, MediaInfo, TorrentInfo } from '@/api/types' import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -40,16 +40,13 @@ const downloaded = ref<String[]>([])
async function getSiteIcon() { async function getSiteIcon() {
try { try {
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
// 询问并添加下载 // 询问并添加下载
async function handleAddDownload(_site: any = undefined, async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
_media: any = undefined,
_torrent: any = undefined) {
if (!_media || !_torrent || !_site) { if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name _site = torrent.value?.site_name
_media = media.value _media = media.value
@@ -59,18 +56,9 @@ async function handleAddDownload(_site: any = undefined,
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: '确认', title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`, content: `是否确认下载【${_site}${_torrent?.title} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
}) })
if (!isConfirmed) if (!isConfirmed) return
return
addDownload(_media, _torrent) addDownload(_media, _torrent)
} }
@@ -79,22 +67,26 @@ async function handleAddDownload(_site: any = undefined,
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) { async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress() startNProgress()
try { try {
const result: { [key: string]: any } = await api.post('download/', { let result: { [key: string]: any }
media_in: _media,
torrent_in: _torrent,
})
if (result.success) { if (_media) {
result = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
} else {
result = await api.post('download/add', _torrent)
}
if (result && result.success) {
// 添加下载成功 // 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`) $toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
downloaded.value.push(_torrent?.enclosure || '') downloaded.value.push(_torrent?.enclosure || '')
} } else {
else {
// 添加下载失败 // 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`) $toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败${result?.message}`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
doneNProgress() doneNProgress()
@@ -112,14 +104,10 @@ async function downloadTorrentFile() {
// 促销Chip类 // 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) { function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) if (downloadVolume === 0) return 'text-white bg-lime-500'
return 'text-white bg-lime-500' else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (downloadVolume < 1) else if (uploadVolume !== 1) return 'text-white bg-sky-500'
return 'text-white bg-green-500' else return 'text-white bg-gray-500'
else if (uploadVolume !== 1)
return 'text-white bg-sky-500'
else
return 'text-white bg-gray-500'
} }
// 装载时查询站点图标 // 装载时查询站点图标
@@ -129,19 +117,9 @@ onMounted(() => {
</script> </script>
<template> <template>
<VListItem <VListItem @click="handleAddDownload" :variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'">
@click="handleAddDownload" <template v-if="!showMoreTorrents" #prepend>
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'" <VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
>
<template
v-if="!showMoreTorrents"
#prepend
>
<VAvatar
class="rounded"
variant="flat"
@click.stop="openTorrentDetail"
>
<VImg :src="siteIcon" /> <VImg :src="siteIcon" />
</VAvatar> </VAvatar>
</template> </template>
@@ -153,25 +131,11 @@ onMounted(() => {
<VListItemSubtitle> <VListItemSubtitle>
{{ torrent?.description }} {{ torrent?.description }}
</VListItemSubtitle> </VListItemSubtitle>
<div <div v-if="torrent?.labels" class="pt-2">
v-if="torrent?.labels" <VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
class="pt-2"
>
<VChip
v-if="torrent?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R H&R
</VChip> </VChip>
<VChip <VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
v-if="torrent?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ torrent?.freedate_diff }} {{ torrent?.freedate_diff }}
</VChip> </VChip>
<VChip <VChip
@@ -184,51 +148,24 @@ onMounted(() => {
> >
{{ label }} {{ label }}
</VChip> </VChip>
<VChip <VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
v-if="meta?.edition"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-red-500"
>
{{ meta?.edition }} {{ meta?.edition }}
</VChip> </VChip>
<VChip <VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
v-if="meta?.resource_pix"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-red-500"
>
{{ meta?.resource_pix }} {{ meta?.resource_pix }}
</VChip> </VChip>
<VChip <VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
v-if="meta?.video_encode"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ meta?.video_encode }} {{ meta?.video_encode }}
</VChip> </VChip>
<VChip <VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
v-if="torrent?.size"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-yellow-500"
>
{{ formatFileSize(torrent?.size) }} {{ formatFileSize(torrent?.size) }}
</VChip> </VChip>
<VChip <VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
v-if="meta?.resource_team"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-cyan-500"
>
{{ meta?.resource_team }} {{ meta?.resource_team }}
</VChip> </VChip>
<VChip <VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1" v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class=" :class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)
"
variant="elevated" variant="elevated"
size="small" size="small"
class="me-1 mb-1" class="me-1 mb-1"
@@ -239,18 +176,10 @@ onMounted(() => {
<template #append> <template #append>
<div class="me-n3"> <div class="me-n3">
<IconBtn> <IconBtn>
<VIcon <VIcon icon="mdi-dots-vertical" />
icon="mdi-dots-vertical" <VMenu activator="parent" close-on-content-click>
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem variant="plain" @click="openTorrentDetail()">
variant="plain"
@click="openTorrentDetail()"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-information" /> <VIcon icon="mdi-information" />
</template> </template>

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

@@ -18,27 +18,15 @@ function handleImport() {
</script> </script>
<template> <template>
<VDialog <VDialog width="40rem" scrollable max-height="85vh">
width="40rem" <VCard :title="props.title" class="rounded-t">
scrollable
max-height="85vh"
>
<VCard
:title="props.title"
class="rounded-t"
>
<DialogCloseBtn @click="emit('close')" /> <DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2"> <VCardText class="pt-2">
<VTextarea v-model="codeString" /> <VTextarea v-model="codeString" />
</VCardText> </VCardText>
<VCardActions> <VCardActions>
<VSpacer /> <VSpacer />
<VBtn <VBtn variant="elevated" @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3"> 导入 </VBtn>
variant="tonal"
@click="handleImport"
>
导入
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>

View File

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

View File

@@ -5,10 +5,14 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
import { numberValidator, requiredValidator } from '@/@validators' import { numberValidator, requiredValidator } from '@/@validators'
import api from '@/api' import api from '@/api'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useConfirm } from 'vuetify-use-dialog'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
// 确认框
const createConfirm = useConfirm()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
siteid: Number, siteid: Number,
@@ -44,7 +48,7 @@ const statusItems = [
// 生成1到50的优先级下拉框选项 // 生成1到50的优先级下拉框选项
const priorityItems = ref( const priorityItems = ref(
Array.from({ length: 50 }, (_, i) => i + 1).map(item => ({ Array.from({ length: 100 }, (_, i) => i + 1).map(item => ({
title: item, title: item,
value: item, value: item,
})), })),
@@ -86,6 +90,13 @@ async function addSite() {
// 调用API删除站点信息 // 调用API删除站点信息
async function deleteSiteInfo() { async function deleteSiteInfo() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认删除站点?`,
})
if (!isConfirmed) return
try { try {
const result: { [key: string]: any } = await api.delete(`site/${siteForm.value?.id}`) const result: { [key: string]: any } = await api.delete(`site/${siteForm.value?.id}`)
if (result.success) emit('remove') if (result.success) emit('remove')
@@ -116,13 +127,14 @@ async function updateSiteInfo() {
</script> </script>
<template> <template>
<VDialog scrollable :close-on-back="false" persistent eager max-width="60rem" :fullscreen="!display.mdAndUp.value"> <VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard <VCard
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`" :title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
class="rounded-t" class="rounded-t"
> >
<DialogCloseBtn @click="emit('close')" /> <DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2"> <VDivider />
<VCardText>
<VForm @submit.prevent="() => {}"> <VForm @submit.prevent="() => {}">
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
@@ -131,51 +143,66 @@ async function updateSiteInfo() {
label="站点地址" label="站点地址"
:rules="[requiredValidator]" :rules="[requiredValidator]"
hint="格式http://www.example.com/" hint="格式http://www.example.com/"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="3"> <VCol cols="6" md="3">
<VSelect <VSelect
v-model="siteForm.pri" v-model="siteForm.pri"
label="优先级" label="优先级"
:items="priorityItems" :items="priorityItems"
:rules="[requiredValidator]" :rules="[requiredValidator]"
hint="站点资源下载优先级,优先级数字越小越优先下载" hint="优先级越小越优先"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="3"> <VCol cols="6" md="3">
<VSelect v-model="siteForm.is_active" :items="statusItems" label="状态" /> <VSelect
v-model="siteForm.is_active"
:items="statusItems"
label="状态"
hint="站点启用/停用"
persistent-hint
/>
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12"> <VCol cols="12" md="9">
<VTextField <VTextField
v-model="siteForm.rss" v-model="siteForm.rss"
label="RSS地址" label="RSS地址"
hint="订阅模式为站点RSS时,将会使用此地址获取站点种子资源,该地址一般会自动获取,也可手动补充" hint="订阅模式为`站点RSS`时使用的订阅链接,如未自动获取手动补充"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="3">
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
</VCol>
<VCol cols="12"> <VCol cols="12">
<VTextarea <VTextarea v-model="siteForm.cookie" label="站点Cookie" hint="站点请求头中的Cookie信息" persistent-hint />
v-model="siteForm.cookie"
label="站点Cookie"
hint="浏览器打开站点首页打开开发人员工具刷新页面后在网络选项中找到首页地址在请求头中获取Cookie信息"
/>
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
<VTextField <VTextField
v-model="siteForm.token" v-model="siteForm.token"
label="请求头Authorization" label="请求头Authorization"
hint="在开发人员工具,网络请求头中获取Authorization,仅个别站点需要" hint="站点请求头中Authorization信息,特殊站点需要"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <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>
<VCol cols="12"> <VCol cols="12">
<VTextField <VTextField
v-model="siteForm.ua" v-model="siteForm.ua"
label="站点User-Agent" label="站点User-Agent"
hint="在开发人员工具网络请求头中获取User-Agent信息需与站点Cookie配套使用" hint="获取Cookie的浏览器对应的User-Agent"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
@@ -185,15 +212,17 @@ async function updateSiteInfo() {
v-model="siteForm.limit_interval" v-model="siteForm.limit_interval"
label="单位周期(秒)" label="单位周期(秒)"
:rules="[numberValidator]" :rules="[numberValidator]"
hint="设定站点限流的单位周期单位为秒0为不限流" hint="限流控制的单位周期时长"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
<VTextField <VTextField
v-model="siteForm.limit_count" v-model="siteForm.limit_count"
label="访问次数" label="周期内访问次数"
:rules="[numberValidator]" :rules="[numberValidator]"
hint="设定单位周期内站点允许的访问次数0为不限制" hint="单位周期内允许的访问次数"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -201,30 +230,46 @@ async function updateSiteInfo() {
v-model="siteForm.limit_seconds" v-model="siteForm.limit_seconds"
label="访问间隔(秒)" label="访问间隔(秒)"
:rules="[numberValidator]" :rules="[numberValidator]"
hint="设定单位周期内每次站点访问需间隔时间单位为秒0为不限制" hint="每次访问需间隔的最小时间"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
<VSwitch v-model="siteForm.proxy" label="代理" hint="站点是否需要代理访问,需要设置好代理服务器信息" /> <VSwitch v-model="siteForm.proxy" label="代理" hint="使用代理服务器访问该站点" persistent-hint />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
<VSwitch <VSwitch v-model="siteForm.render" label="仿真" hint="使用浏览器模拟真实访问该站点" persistent-hint />
v-model="siteForm.render"
label="仿真"
hint="站点是否需要使用浏览器模拟访问,开启可以一定程度上提升连通性,但会大大增加站点请求时间"
/>
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions> <VCardActions class="pt-3">
<VBtn v-if="props.oper === 'add'" @click="emit('close')"> 取消 </VBtn> <VBtn v-if="props.oper !== 'add'" color="error" @click="deleteSiteInfo" variant="outlined" class="me-3">
<VBtn v-else color="error" @click="deleteSiteInfo"> 删除 </VBtn> 删除
</VBtn>
<VSpacer /> <VSpacer />
<VBtn v-if="props.oper === 'add'" color="primary" variant="tonal" @click="addSite"> 新增 </VBtn> <VBtn
<VBtn v-else color="primary" variant="tonal" @click="updateSiteInfo"> 保存 </VBtn> v-if="props.oper === 'add'"
color="primary"
variant="elevated"
@click="addSite"
prepend-icon="mdi-plus"
class="px-5"
>
新增
</VBtn>
<VBtn
v-else
color="primary"
variant="elevated"
@click="updateSiteInfo"
prepend-icon="mdi-content-save"
class="px-5"
>
保存
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>

View File

@@ -2,12 +2,16 @@
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import { numberValidator } from '@/@validators' import { numberValidator } from '@/@validators'
import api from '@/api' import api from '@/api'
import type { Site, Subscribe } from '@/api/types' import type { MediaDirectory, Site, Subscribe } from '@/api/types'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useConfirm } from 'vuetify-use-dialog'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
// 确认框
const createConfirm = useConfirm()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
subid: Number, subid: Number,
@@ -21,6 +25,9 @@ const emit = defineEmits(['remove', 'save', 'close'])
// 站点数据列表 // 站点数据列表
const siteList = ref<Site[]>([]) const siteList = ref<Site[]>([])
// 下载目录列表
const downloadDirectories = ref<MediaDirectory[]>([])
// 站点选择下载框 // 站点选择下载框
const selectSitesOptions = ref<{ [key: number]: string }[]>([]) const selectSitesOptions = ref<{ [key: number]: string }[]>([])
@@ -46,7 +53,7 @@ const subscribeForm = ref<Subscribe>({
last_update: '', last_update: '',
username: '', username: '',
current_priority: 0, current_priority: 0,
save_path: '', save_path: undefined,
date: '', date: '',
show_edit_dialog: false, show_edit_dialog: false,
}) })
@@ -145,6 +152,12 @@ async function getSubscribeInfo() {
// 删除订阅 // 删除订阅
async function removeSubscribe() { async function removeSubscribe() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认取消订阅?`,
})
if (!isConfirmed) return
try { try {
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`) const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
@@ -157,6 +170,25 @@ async function removeSubscribe() {
} }
} }
// 查询下载目录
async function loadDownloadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
// 保存目录下拉框
const targetDirectories = computed(() => {
// 去重后的下载目录
const directories = downloadDirectories.value.map(item => item.path)
return [...new Set(directories)]
})
// 质量选择框数据 // 质量选择框数据
const qualityOptions = ref([ const qualityOptions = ref([
{ {
@@ -242,15 +274,15 @@ const effectOptions = ref([
]) ])
onMounted(() => { onMounted(() => {
loadDownloadDirectories()
getSiteList() getSiteList()
if (props.subid) getSubscribeInfo() if (props.subid) getSubscribeInfo()
if (props.default) queryDefaultSubscribeConfig() if (props.default) queryDefaultSubscribeConfig()
}) })
</script> </script>
<template> <template>
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value"> <VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard <VCard
:title="`${ :title="`${
props.default props.default
@@ -259,7 +291,8 @@ onMounted(() => {
}`" }`"
class="rounded-t" class="rounded-t"
> >
<VCardText class="pt-2"> <VDivider />
<VCardText>
<DialogCloseBtn @click="emit('close')" /> <DialogCloseBtn @click="emit('close')" />
<VForm @submit.prevent="() => {}"> <VForm @submit.prevent="() => {}">
<VRow> <VRow>
@@ -268,7 +301,8 @@ onMounted(() => {
v-if="!props.default" v-if="!props.default"
v-model="subscribeForm.keyword" v-model="subscribeForm.keyword"
label="搜索关键词" label="搜索关键词"
hint="定搜索关键词将使用此关键词搜索站点资源否则自动使用themoviedb中的名称搜索" hint="定搜索站点时使用的关键词"
persistent-hint
/> />
</VCol> </VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2"> <VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
@@ -276,7 +310,8 @@ onMounted(() => {
v-model="subscribeForm.total_episode" v-model="subscribeForm.total_episode"
label="总集数" label="总集数"
:rules="[numberValidator]" :rules="[numberValidator]"
hint="设定剧集总集数以应对themoviedb中剧集信息未维护完整导致提前结束订阅的情况" hint="剧集总集数"
persistent-hint
/> />
</VCol> </VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2"> <VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
@@ -284,19 +319,38 @@ onMounted(() => {
v-model="subscribeForm.start_episode" v-model="subscribeForm.start_episode"
label="开始集数" label="开始集数"
:rules="[numberValidator]" :rules="[numberValidator]"
hint="只订阅下载此集数及之后的剧集" hint="开始订阅集数"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
<VSelect v-model="subscribeForm.quality" label="质量" :items="qualityOptions" /> <VSelect
v-model="subscribeForm.quality"
label="质量"
:items="qualityOptions"
hint="订阅资源质量"
persistent-hint
/>
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
<VSelect v-model="subscribeForm.resolution" label="分辨率" :items="resolutionOptions" /> <VSelect
v-model="subscribeForm.resolution"
label="分辨率"
:items="resolutionOptions"
hint="订阅资源分辨率"
persistent-hint
/>
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
<VSelect v-model="subscribeForm.effect" label="特效" :items="effectOptions" /> <VSelect
v-model="subscribeForm.effect"
label="特效"
:items="effectOptions"
hint="订阅资源特效"
persistent-hint
/>
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
@@ -304,14 +358,16 @@ onMounted(() => {
<VTextField <VTextField
v-model="subscribeForm.include" v-model="subscribeForm.include"
label="包含(关键字、正则式)" label="包含(关键字、正则式)"
hint="支持正则表达式,多个关键字用 | 分隔表示或" hint="包含规则,支持正则表达式"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
<VTextField <VTextField
v-model="subscribeForm.exclude" v-model="subscribeForm.exclude"
label="排除(关键字、正则式)" label="排除(关键字、正则式)"
hint="支持正则表达式,多个关键字用 | 分隔表示或" hint="排除规则,支持正则表达式"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -321,16 +377,19 @@ onMounted(() => {
chips chips
label="订阅站点" label="订阅站点"
multiple multiple
hint="订阅选中的订阅站点,不选则订阅所有可订阅站点" hint="订阅的站点范围,不选使用系统设置"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12"> <VCol cols="12">
<VTextField <VCombobox
v-model="subscribeForm.save_path" v-model="subscribeForm.save_path"
:items="targetDirectories"
label="保存路径" label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录" hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
@@ -339,31 +398,40 @@ onMounted(() => {
<VSwitch <VSwitch
v-model="subscribeForm.best_version" v-model="subscribeForm.best_version"
label="洗版" label="洗版"
hint="开启后不管媒体库是否存在,均会根据洗版优先级进行过滤下载,直到下载到了最高优先级的资源为止" hint="根据洗版优先级进行洗版订阅"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
<VSwitch <VSwitch
v-model="subscribeForm.search_imdbid" v-model="subscribeForm.search_imdbid"
label="使用 ImdbID 搜索" label="使用 ImdbID 搜索"
hint="开启后将使用 ImdbID 搜索资源,搜索结果更精确,但不是所有站点都支持" hint="开使用 ImdbID 精确搜索资源"
persistent-hint
/> />
</VCol> </VCol>
<VCol v-if="props.default" cols="12" md="4"> <VCol v-if="props.default" cols="12" md="4">
<VSwitch <VSwitch
v-model="subscribeForm.show_edit_dialog" v-model="subscribeForm.show_edit_dialog"
label="订阅时编辑更多规则" label="订阅时编辑更多规则"
hint="开启后将在添加订阅后弹出编辑订阅对话框,方便用户编辑订阅规则" hint="添加订阅时显示此编辑订阅对话框"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions class="pt-3">
<VCardActions> <VBtn v-if="!props.default" color="error" @click="removeSubscribe" variant="outlined" class="me-3">
<VBtn v-if="!props.default" color="error" @click="removeSubscribe"> 取消订阅 </VBtn> 取消订阅
</VBtn>
<VSpacer /> <VSpacer />
<VBtn variant="tonal" @click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"> <VBtn
variant="elevated"
@click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
prepend-icon="mdi-content-save"
class="px-5"
>
保存 保存
</VBtn> </VBtn>
</VCardActions> </VCardActions>

View File

@@ -134,9 +134,10 @@ const dropdownItems = ref([
<template> <template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value"> <VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%"> <VCard class="mx-auto" width="100%">
<VCardItem class="pb-0"> <VCardItem>
<VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle> <VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle>
</VCardItem> </VCardItem>
<VDivider />
<DialogCloseBtn <DialogCloseBtn
@click=" @click="
() => { () => {

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

View File

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

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import api from '@/api'
import { FileItem } from '@/api/types'
import { VTreeview } from 'vuetify/labs/VTreeview'
// 输入变量为默认路径
const props = defineProps({
root: {
type: String,
default: '/',
required: true,
},
})
// 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

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

View File

@@ -110,6 +110,7 @@ onMounted(() => {
} }
" "
/> />
<VDivider />
<VList v-if="items.length > 0" lines="three"> <VList v-if="items.length > 0" lines="three">
<template v-for="(item, i) in items" :key="i"> <template v-for="(item, i) in items" :key="i">
<VListItem @click="selectMedia(item)"> <VListItem @click="selectMedia(item)">

View File

@@ -8,17 +8,16 @@ const props = defineProps({
</script> </script>
<template> <template>
<VCardItem> <VCardText>
<VList> <VList>
<VListItem <VListItem v-for="(value, key) in props.history" :key="key">
v-for="(value, key) in props.history" <VListItemTitle class="font-bold text-lg">
:key="key" {{ key }}
> </VListItemTitle>
<VListItemTitle>{{ key }}</VListItemTitle>
<div class="text-gray-500"> <div class="text-gray-500">
{{ value }} {{ value }}
</div> </div>
</VListItem> </VListItem>
</VList> </VList>
</VCardItem> </VCardText>
</template> </template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { RenderProps } from '@/api/types'
import { type PropType } from 'vue'
// 输入参数
const elementProps = defineProps({
config: Object as PropType<RenderProps>,
})
</script>
<template>
<Component :is="elementProps.config?.component" v-if="!elementProps.config?.html" v-bind="elementProps.config?.props">
{{ elementProps.config?.text }}
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
<slot :name="name" v-bind="_props">
<DashboardRender v-for="(slotItem, slotIndex) in content || []" :key="slotIndex" :config="slotItem" />
</slot>
</template>
<DashboardRender
v-for="(innerItem, innerIndex) in elementProps.config?.content || []"
:key="innerIndex"
:config="innerItem"
/>
</Component>
<Component
:is="elementProps.config?.component"
v-if="elementProps.config?.html"
v-bind="elementProps.config?.props"
v-html="elementProps.config?.html"
/>
</template>

View File

@@ -1,15 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { RenderProps } from '@/api/types'
import { type PropType, ref } from 'vue' import { type PropType, ref } from 'vue'
// 组件接口
interface RenderProps {
component: string
text: string
html: string
content?: any
props?: any
}
// 输入参数 // 输入参数
const elementProps = defineProps({ const elementProps = defineProps({
config: Object as PropType<RenderProps>, config: Object as PropType<RenderProps>,
@@ -17,13 +9,15 @@ const elementProps = defineProps({
}) })
// 配置元素 // 配置元素
const formItem = ref<RenderProps>(elementProps.config ?? { const formItem = ref<RenderProps>(
component: 'div', elementProps.config ?? {
text: '', component: 'div',
html: '', text: '',
props: {}, html: '',
content: [], props: {},
}) content: [],
},
)
// 配置数据 // 配置数据
const formData = ref<any>(elementProps.form || {}) const formData = ref<any>(elementProps.form || {})
@@ -37,53 +31,27 @@ const formData = ref<any>(elementProps.form || {})
v-model:value="formData[formItem.props?.modelvalue]" v-model:value="formData[formItem.props?.modelvalue]"
> >
{{ formItem.text }} {{ formItem.text }}
<template <template v-for="(innerItem, innerIndex) in formItem.content || []" :key="innerIndex">
v-for="(innerItem, innerIndex) in (formItem.content || [])"
:key="innerIndex"
>
<FormRender <FormRender
v-if="!!innerItem.props?.modelvalue" v-if="!!innerItem.props?.modelvalue"
v-model:value="formData[innerItem.props?.modelvalue]" v-model:value="formData[innerItem.props?.modelvalue]"
:config="innerItem" :config="innerItem"
:form="formData" :form="formData"
/> />
<FormRender <FormRender v-else v-model="formData[innerItem.props?.model]" :config="innerItem" :form="formData" />
v-else
v-model="formData[innerItem.props?.model]"
:config="innerItem"
:form="formData"
/>
</template> </template>
</Component> </Component>
<Component <Component :is="formItem.component" v-else-if="formItem.html" v-bind="formItem.props" v-html="formItem.html" />
:is="formItem.component" <Component :is="formItem.component" v-else v-bind="formItem.props" v-model="formData[formItem.props?.model]">
v-else-if="formItem.html"
v-bind="formItem.props"
v-html="formItem.html"
/>
<Component
:is="formItem.component"
v-else
v-bind="formItem.props"
v-model="formData[formItem.props?.model]"
>
{{ formItem.text }} {{ formItem.text }}
<template <template v-for="(innerItem, innerIndex) in formItem.content || []" :key="innerIndex">
v-for="(innerItem, innerIndex) in (formItem.content || [])"
:key="innerIndex"
>
<FormRender <FormRender
v-if="!!innerItem.props?.modelvalue" v-if="!!innerItem.props?.modelvalue"
v-model:value="formData[innerItem.props?.modelvalue]" v-model:value="formData[innerItem.props?.modelvalue]"
:config="innerItem" :config="innerItem"
:form="formData" :form="formData"
/> />
<FormRender <FormRender v-else v-model="formData[innerItem.props?.model]" :config="innerItem" :form="formData" />
v-else
v-model="formData[innerItem.props?.model]"
:config="innerItem"
:form="formData"
/>
</template> </template>
</Component> </Component>
</template> </template>

View File

@@ -3,21 +3,11 @@ import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api' import api from '@/api'
import { type PropType } from 'vue' import { type PropType } from 'vue'
import ProgressDialog from '../dialog/ProgressDialog.vue' import ProgressDialog from '../dialog/ProgressDialog.vue'
import { RenderProps } from '@/api/types'
// 定议外部事件 // 定议外部事件
const emit = defineEmits(['action']) const emit = defineEmits(['action'])
// 组件接口
interface RenderProps {
component: string
text: string
html: string
content?: any
slots?: any
props?: any
events?: any
}
// 输入参数 // 输入参数
const elementProps = defineProps({ const elementProps = defineProps({
config: Object as PropType<RenderProps>, config: Object as PropType<RenderProps>,

View File

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

View File

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

View File

@@ -60,22 +60,17 @@ async function getResourceList() {
try { try {
resourceDataList.value = await api.get(`site/resource/${props.site}`) resourceDataList.value = await api.get(`site/resource/${props.site}`)
resourceLoading.value = false resourceLoading.value = false
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
// 促销Chip类 // 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) { function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) if (downloadVolume === 0) return 'text-white bg-lime-500'
return 'text-white bg-lime-500' else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (downloadVolume < 1) else if (uploadVolume !== 1) return 'text-white bg-sky-500'
return 'text-white bg-green-500' else return 'text-white bg-gray-500'
else if (uploadVolume !== 1)
return 'text-white bg-sky-500'
else
return 'text-white bg-gray-500'
} }
// 添加下载 // 添加下载
@@ -83,18 +78,9 @@ async function addDownload(_torrent: any) {
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: '确认', title: '确认',
content: `是否确认下载【${_torrent.site_name}${_torrent?.title} ?`, content: `是否确认下载【${_torrent.site_name}${_torrent?.title} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
}) })
if (!isConfirmed) if (!isConfirmed) return
return
startNProgress() startNProgress()
try { try {
@@ -103,13 +89,11 @@ async function addDownload(_torrent: any) {
if (result.success) { if (result.success) {
// 添加下载成功 // 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`) $toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
} } else {
else {
// 添加下载失败 // 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`) $toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
doneNProgress() doneNProgress()
@@ -146,21 +130,10 @@ onMounted(() => {
<div class="text-sm my-1"> <div class="text-sm my-1">
{{ item.description }} {{ item.description }}
</div> </div>
<VChip <VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
v-if="item.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R H&R
</VChip> </VChip>
<VChip <VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
v-if="item.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ item.freedate_diff }} {{ item.freedate_diff }}
</VChip> </VChip>
<VChip <VChip
@@ -175,9 +148,7 @@ onMounted(() => {
</VChip> </VChip>
<VChip <VChip
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1" v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
:class=" :class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)
"
variant="elevated" variant="elevated"
size="small" size="small"
class="me-1 mb-1" class="me-1 mb-1"
@@ -206,18 +177,10 @@ onMounted(() => {
<template #item.actions="{ item }"> <template #item.actions="{ item }">
<div class="me-n3"> <div class="me-n3">
<IconBtn> <IconBtn>
<VIcon <VIcon icon="mdi-dots-vertical" />
icon="mdi-dots-vertical" <VMenu activator="parent" close-on-content-click>
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem variant="plain" @click="openTorrentDetail(item.page_url || '')">
variant="plain"
@click="openTorrentDetail(item.page_url || '')"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-information" /> <VIcon icon="mdi-information" />
</template> </template>
@@ -238,8 +201,6 @@ onMounted(() => {
</IconBtn> </IconBtn>
</div> </div>
</template> </template>
<template #no-data> <template #no-data> 没有数据 </template>
没有数据
</template>
</VDataTable> </VDataTable>
</template> </template>

View File

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

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> <template>
<div class="h-100 d-flex align-center justify-space-between" /> <div v-if="appMode" class="w-100" style="block-size: calc(3.5rem + env(safe-area-inset-bottom))">
<VBottomNavigation
grow
horizontal
color="primary"
class="footer-nav border-t"
style="block-size: calc(3.5rem + env(safe-area-inset-bottom))"
>
<VBtn to="/dashboard" :ripple="false">
<VIcon v-if="activeState.home" size="28">mdi-home</VIcon>
<VIcon v-else size="28">mdi-home-outline</VIcon>
</VBtn>
<VBtn to="/ranking" :ripple="false">
<VIcon v-if="activeState.ranking" size="28">mdi-star</VIcon>
<VIcon v-else size="28">mdi-star-outline</VIcon>
</VBtn>
<VBtn to="/subscribe/movie" :ripple="false">
<VIcon v-if="activeState.movie" size="28">mdi-movie-open</VIcon>
<VIcon v-else size="28">mdi-movie-open-outline</VIcon>
</VBtn>
<VBtn to="/subscribe/tv" :ripple="false">
<VIcon v-if="activeState.tv" size="28">mdi-television-play</VIcon>
<VIcon v-else size="28">mdi-television</VIcon>
</VBtn>
<VBtn to="/apps" :ripple="false">
<VIcon v-if="activeState.apps" size="28">mdi-dots-horizontal-circle</VIcon>
<VIcon v-else size="28">mdi-dots-horizontal</VIcon>
</VBtn>
</VBottomNavigation>
</div>
</template> </template>
<style lang="scss">
.footer-nav {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
background-color: rgb(var(--v-theme-surface), 0.8);
padding-block-end: env(safe-area-inset-bottom);
}
.footer-nav .v-btn--variant-text .v-btn__overlay {
background-color: transparent !important;
}
</style>
}

View File

@@ -2,25 +2,29 @@
import type { ThemeSwitcherTheme } from '@layouts/types' import type { ThemeSwitcherTheme } from '@layouts/types'
const themes: ThemeSwitcherTheme[] = [ const themes: ThemeSwitcherTheme[] = [
{
name: 'auto',
title: '跟随系统',
icon: 'mdi-laptop',
},
{ {
name: 'light', name: 'light',
title: '明亮',
icon: 'mdi-weather-sunny', icon: 'mdi-weather-sunny',
}, },
{ {
name: 'dark', name: 'dark',
title: '暗黑',
icon: 'mdi-weather-night', icon: 'mdi-weather-night',
}, },
{ {
name: 'purple', name: 'purple',
title: '紫韵幽兰',
icon: 'mdi-brightness-4', icon: 'mdi-brightness-4',
}, },
{
name: 'auto',
icon: 'mdi-brightness-auto',
},
] ]
</script> </script>
<template> <template>
<ThemeSwitcher :themes="themes" /> <ThemeSwitcher class="ms-2" :themes="themes" />
</template> </template>

View File

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

View File

@@ -8,6 +8,7 @@ import MessageView from '@/views/system/MessageView.vue'
import store from '@/store' import store from '@/store'
import api from '@/api' import api from '@/api'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { getQueryValue } from '@/@core/utils'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
@@ -75,6 +76,29 @@ async function sendMessage() {
onMounted(() => { onMounted(() => {
scrollMessageToEnd() scrollMessageToEnd()
const shortcut = getQueryValue('shortcut')
if (shortcut) {
switch (shortcut) {
case 'nameTest':
nameTestDialog.value = true
break
case 'netTest':
netTestDialog.value = true
break
case 'logging':
loggingDialog.value = true
break
case 'ruleTest':
ruleTestDialog.value = true
break
case 'systemTest':
systemTestDialog.value = true
break
case 'message':
messageDialog.value = true
break
}
}
}) })
</script> </script>
@@ -91,7 +115,7 @@ onMounted(() => {
> >
<!-- Menu Activator --> <!-- Menu Activator -->
<template #activator="{ props }"> <template #activator="{ props }">
<IconBtn class="me-2" v-bind="props"> <IconBtn class="ms-2" v-bind="props">
<VIcon icon="mdi-checkbox-multiple-blank-outline" /> <VIcon icon="mdi-checkbox-multiple-blank-outline" />
</IconBtn> </IconBtn>
</template> </template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import store from '@/store'
import { formatDateDifference } from '@core/utils/formatters'
import { SystemNotification } from '@/api/types'
// 是否有新消息
const hasNewMessage = ref(false)
// 通知列表
const notificationList = ref<SystemNotification[]>([])
// 事件源
let eventSource: EventSource | null = null
// 弹窗
const appsMenu = ref(false)
// SSE持续接收消息
function startSSEMessager() {
const token = store.state.auth.token
if (token) {
eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}`)
eventSource.addEventListener('message', event => {
if (event.data) {
const noti: SystemNotification = JSON.parse(event.data)
notificationList.value.unshift(noti)
hasNewMessage.value = true
// TODO 在顶部显示消息汽泡
}
})
}
}
// 页面加载时,加载当前用户数据
onBeforeMount(async () => {
startSSEMessager()
})
// 页面卸载时,关闭事件源
onBeforeUnmount(() => {
if (eventSource) eventSource.close()
})
</script>
<template>
<VMenu v-model="appsMenu" width="400" transition="scale-transition" close-on-content-click>
<!-- Menu Activator -->
<template #activator="{ props }">
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
<IconBtn>
<VIcon icon="mdi-bell-outline" />
</IconBtn>
</VBadge>
<IconBtn v-else v-bind="props">
<VIcon icon="mdi-bell-outline" />
</IconBtn>
</template>
<!-- Menu Content -->
<VCard>
<VCardItem class="border-b">
<VCardTitle>通知</VCardTitle>
<template #append>
<VTooltip text="设为已读">
<template #activator="{ props }">
<IconBtn
v-bind="props"
@click="
() => {
hasNewMessage = false
appsMenu = false
}
"
>
<VIcon icon="mdi-email-mark-as-unread" />
</IconBtn>
</template>
</VTooltip>
</template>
</VCardItem>
<VList lines="two" v-if="notificationList.length > 0" max-height="600">
<VListItem v-for="(item, i) in notificationList" :key="i">
<template #prepend>
<VAvatar rounded>
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot-happy" size="large"></VIcon>
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
</VAvatar>
</template>
<VListItemTitle class="overflow-visiable break-words whitespace-break-spaces">
{{ item.title }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ item.text }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
</VListItem>
</VList>
<VList v-else>
<VListItem>
<VListItemTitle class="text-center">暂无通知</VListItemTitle>
</VListItem>
</VList>
</VCard>
</VMenu>
</template>

View File

@@ -6,6 +6,9 @@ import router from '@/router'
import avatar1 from '@images/avatars/avatar-1.png' import avatar1 from '@images/avatars/avatar-1.png'
import api from '@/api' import api from '@/api'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue' import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify'
const display = useDisplay()
// Vuex Store // Vuex Store
const store = useStore() const store = useStore()
@@ -22,8 +25,7 @@ const progressDialog = ref(false)
// 执行注销操作 // 执行注销操作
function logout() { function logout() {
// 清除登录状态信息 // 清除登录状态信息
store.dispatch('auth/clearToken') store.dispatch('auth/logout')
// 重定向到登录页面或其他适当的页面 // 重定向到登录页面或其他适当的页面
router.push('/login') router.push('/login')
} }
@@ -34,14 +36,6 @@ async function restart() {
const confirmed = await createConfirm({ const confirmed = await createConfirm({
title: '确认', title: '确认',
content: '确认重启系统吗?', content: '确认重启系统吗?',
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '30rem',
},
cancellationButtonProps: {
variant: 'tonal',
},
}) })
if (confirmed) { if (confirmed) {
@@ -65,14 +59,24 @@ async function restart() {
} }
} }
// 是否精简模式
const isCompactMode = ref(localStorage.getItem('MP_APPMODE') != '0')
// 从Vuex Store中获取信息 // 从Vuex Store中获取信息
const superUser = store.state.auth.superUser const superUser = store.state.auth.superUser
const userName = store.state.auth.userName const userName = store.state.auth.userName
const avatar = store.state.auth.avatar const avatar = store.state.auth.avatar
// 监听精简模式切换
watch(isCompactMode, value => {
localStorage.setItem('MP_APPMODE', value ? '1' : '0')
//刷新页面
location.reload()
})
</script> </script>
<template> <template>
<VAvatar class="cursor-pointer" color="primary" variant="tonal"> <VAvatar class="cursor-pointer ms-3" color="primary" variant="tonal">
<VImg :src="avatar ?? avatar1" /> <VImg :src="avatar ?? avatar1" />
<!-- SECTION Menu --> <!-- SECTION Menu -->
@@ -93,45 +97,52 @@ const avatar = store.state.auth.avatar
</VListItemTitle> </VListItemTitle>
<VListItemSubtitle>{{ userName }}</VListItemSubtitle> <VListItemSubtitle>{{ userName }}</VListItemSubtitle>
</VListItem> </VListItem>
<!-- Divider -->
<VDivider v-if="display.mdAndDown.value" class="my-2" />
<!-- 👉 AppMode -->
<VListItem v-if="display.mdAndDown.value">
<template #prepend>
<VSwitch class="me-2" v-model="isCompactMode"></VSwitch>
</template>
<VListItemTitle>App模式</VListItemTitle>
</VListItem>
<VDivider class="my-2" /> <VDivider class="my-2" />
<!-- 👉 Profile --> <!-- 👉 Profile -->
<VListItem v-if="superUser" link to="setting"> <VListItem v-if="superUser" link @click="router.push('/setting?tab=account')">
<template #prepend> <template #prepend>
<VIcon class="me-2" icon="mdi-account-outline" size="22" /> <VIcon class="me-2" icon="mdi-account-outline" size="22" />
</template> </template>
<VListItemTitle>设定</VListItemTitle> <VListItemTitle>设定</VListItemTitle>
</VListItem> </VListItem>
<!-- 👉 FAQ -->
<VListItem href="https://wiki.movie-pilot.org" target="_blank">
<template #prepend>
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
</template>
<VListItemTitle>帮助</VListItemTitle>
</VListItem>
<!-- Divider --> <!-- Divider -->
<VDivider class="my-2" /> <VDivider v-if="superUser" class="my-2" />
<!-- 👉 restart --> <!-- 👉 restart -->
<VListItem v-if="superUser" @click="restart"> <VListItem v-if="superUser" @click="restart">
<template #prepend> <template #prepend>
<VIcon class="me-2" icon="mdi-restart" size="22" /> <VIcon class="me-2" icon="mdi-restart" size="22" />
</template> </template>
<VListItemTitle>重启</VListItemTitle> <VListItemTitle>重启</VListItemTitle>
</VListItem> </VListItem>
<!-- 👉 FAQ -->
<VListItem href="https://github.com/jxxghp/MoviePilot/blob/main/README.md" target="_blank">
<template #prepend>
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
</template>
<VListItemTitle>帮助</VListItemTitle>
</VListItem>
<!-- 👉 Logout --> <!-- 👉 Logout -->
<VListItem @click="logout"> <VListItem @click="logout">
<template #prepend> <VBtn color="error" block>
<VIcon class="me-2" icon="mdi-logout" size="22" /> <template #append> <VIcon size="small" icon="mdi-logout" /> </template>
</template> 退出登录
</VBtn>
<VListItemTitle>注销</VListItemTitle>
</VListItem> </VListItem>
</VList> </VList>
</VMenu> </VMenu>

View File

@@ -1,38 +1,51 @@
import { VAceEditor } from 'vue3-ace-editor' import '@/@core/utils/compatibility'
import { createApp } from 'vue'
import '@/@iconify/icons-bundle'
import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import './ace-config' import './ace-config'
import VueApexCharts from 'vue3-apexcharts' import '@/@iconify/icons-bundle'
import { removeEl } from './@core/utils/dom' import '@/plugins/webfontloader'
import App from '@/App.vue' import App from '@/App.vue'
import vuetify from '@/plugins/vuetify' import vuetify from '@/plugins/vuetify'
import { loadFonts } from '@/plugins/webfontloader'
import router from '@/router' import router from '@/router'
import store from '@/store' import store from '@/store'
import { VAceEditor } from 'vue3-ace-editor'
import { createApp } from 'vue'
import { removeEl } from './@core/utils/dom'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
import { VTreeview } from 'vuetify/labs/VTreeview'
import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import VueApexCharts from 'vue3-apexcharts'
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import MediaCard from './components/cards/MediaCard.vue'
import PosterCard from './components/cards/PosterCard.vue'
import BackdropCard from './components/cards/BackdropCard.vue'
import PersonCard from './components/cards/PersonCard.vue'
import MediaInfoCard from './components/cards/MediaInfoCard.vue'
import TorrentCard from './components/cards/TorrentCard.vue'
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
import PathField from './components/input/PathField.vue'
import '@core/scss/template/index.scss' import '@core/scss/template/index.scss'
import '@layouts/styles/index.scss' import '@layouts/styles/index.scss'
import '@styles/styles.scss' import '@styles/styles.scss'
import 'vue-toast-notification/dist/theme-bootstrap.css' import 'vue-toast-notification/dist/theme-bootstrap.css'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'; import 'vue3-perfect-scrollbar/style.css'
import 'vue3-perfect-scrollbar/style.css';
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import { fixArrayAt } from '@/@core/utils/compatibility'
// 修复低版本Safari等浏览器数组不支持at函数的问题
fixArrayAt()
// 加载字体
loadFonts()
// 创建Vue实例 // 创建Vue实例
const app = createApp(App) const app = createApp(App)
// 注册全局组件 // 注册全局组件
app.component('VAceEditor', VAceEditor) app
.component('VAceEditor', VAceEditor)
.component('VApexChart', VueApexCharts) .component('VApexChart', VueApexCharts)
.component('VDialogCloseBtn', DialogCloseBtn) .component('VDialogCloseBtn', DialogCloseBtn)
.component('VMediaCard', MediaCard)
.component('VPosterCard', PosterCard)
.component('VBackdropCard', BackdropCard)
.component('VPersonCard', PersonCard)
.component('VMediaInfoCard', MediaInfoCard)
.component('VTorrentCard', TorrentCard)
.component('VMediaIdSelector', MediaIdSelector)
.component('VTreeview', VTreeview)
.component('VPathField', PathField)
// 注册插件 // 注册插件
app app
@@ -42,7 +55,27 @@ app
.use(ToastPlugin, { .use(ToastPlugin, {
position: 'bottom-right', position: 'bottom-right',
}) })
.use(VuetifyUseDialog) .use(VuetifyUseDialog, {
confirmDialog: {
dialogProps: {
maxWidth: '40rem',
},
confirmationButtonProps: {
variant: 'elevated',
color: 'primary',
class: 'me-3 px-5',
'prepend-icon': 'mdi-check',
},
cancellationButtonProps: {
variant: 'outlined',
color: 'secondary',
class: 'me-3',
},
confirmationText: '确认',
cancellationText: '取消',
},
})
.use(PerfectScrollbarPlugin) .use(PerfectScrollbarPlugin)
.use(VueApexCharts)
.mount('#app') .mount('#app')
.$nextTick(() => removeEl('#loading-bg')) .$nextTick(() => removeEl('#loading-bg'))

71
src/pages/appcenter.vue Normal file
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 v-if="title" class="mt-3 md:flex md:items-center md:justify-between">
<div class="min-w-0 flex-1 mx-0"> <div class="min-w-0 flex-1 mx-0">
<h2 <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" data-testid="page-header"
> >
<span class="text-moviepilot">{{ title }}</span> <span class="text-moviepilot">{{ title }}</span>

View File

@@ -1,40 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue' import draggable from 'vuedraggable'
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
import AnalyticsSpeed from '@/views/dashboard/AnalyticsSpeed.vue'
import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'
import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
import api from '@/api' import api from '@/api'
import { isNullOrEmptyObject } from '@/@core/utils' import { isNullOrEmptyObject } from '@/@core/utils'
import { DashboardItem } from '@/api/types'
import store from '@/store'
import DashboardElement from '@/components/misc/DashboardElement.vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
// 显示器宽度 // APP
const display = useDisplay() const display = useDisplay()
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 仪表盘配置 // 从Vuex Store中获取superuser信息
const dashboard_names = { const superUser = store.state.auth.superUser
storage: '存储空间',
mediaStatistic: '媒体统计',
weeklyOverview: '最近入库',
speed: '实时速率',
scheduler: '后台任务',
cpu: 'CPU',
memory: '内存',
library: '我的媒体库',
playing: '继续观看',
latest: '最近添加',
}
// 弹窗 // 是否拉升高度
const dialog = ref(false) const isElevated = ref(true)
// 从localStorage中获取数据 // 是否发送请求的总开关
const default_config = { const isRequest = ref(true)
// 计算属性,控制是否拉升高度
const elevatedConf = controlledComputed(
() => isElevated.value,
() => ({
class: { 'match-height': isElevated.value },
}),
)
// 所有组件刷新定时器的句柄
const refreshTimers = ref<{ [key: string]: NodeJS.Timeout }>({})
// 仪表板启用配置
const enableConfig = ref<{ [key: string]: boolean }>({
mediaStatistic: true, mediaStatistic: true,
scheduler: false, scheduler: false,
speed: false, speed: false,
@@ -45,87 +45,345 @@ const default_config = {
library: true, library: true,
playing: true, playing: true,
latest: true, latest: true,
})
// 仪表板顺序配置
const orderConfig = ref<{ id: string; key: string }[]>([])
// 仪表板配置
const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'storage',
name: '存储空间',
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
elements: [],
},
{
id: 'mediaStatistic',
name: '媒体统计',
key: '',
attrs: {},
cols: { cols: 12, md: 8 },
elements: [],
},
{
id: 'weeklyOverview',
name: '最近入库',
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
elements: [],
},
{
id: 'speed',
name: '实时速率',
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
elements: [],
},
{
id: 'scheduler',
name: '后台任务',
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
elements: [],
},
{
id: 'cpu',
name: 'CPU',
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
elements: [],
},
{
id: 'memory',
name: '内存',
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
elements: [],
},
{
id: 'library',
name: '我的媒体库',
key: '',
attrs: {},
cols: { cols: 12 },
elements: [],
},
{
id: 'playing',
name: '继续观看',
key: '',
attrs: {},
cols: { cols: 12 },
elements: [],
},
{
id: 'latest',
name: '最近添加',
key: '',
attrs: {},
cols: { cols: 12 },
elements: [],
},
])
// 插件的仪表板元信息
const pluginDashboardMeta = ref<any[]>([])
// 插件仪表板的刷新状态
const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
// 弹窗
const dialog = ref(false)
// 加载用户监控面板配置(本地无配置时才加载)
async function loadDashboardConfig() {
// 显示配置
const local_enable = localStorage.getItem('MP_DASHBOARD')
if (local_enable) {
enableConfig.value = JSON.parse(local_enable)
} else {
const response = await api.get('/user/config/Dashboard')
if (response && response.data && response.data.value) {
enableConfig.value = response.data.value
localStorage.setItem('MP_DASHBOARD', JSON.stringify(response.data.value))
}
}
// 顺序配置
const local_order = localStorage.getItem('MP_DASHBOARD_ORDER')
if (local_order) {
orderConfig.value = JSON.parse(local_order)
} else {
const response2 = await api.get('/user/config/DashboardOrder')
if (response2 && response2.data && response2.data.value) {
orderConfig.value = response2.data.value
localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify(orderConfig.value))
}
}
// 是否拉升高度
const local_elevated = localStorage.getItem('MP_DASHBOARD_ELEVATED')
if (local_elevated) isElevated.value = local_elevated === 'true'
// 排序
if (orderConfig.value) {
sortDashboardConfigs()
}
} }
// 初始化默认值 // 按order的顺序对dashboardConfigs进行排序
const config = ref(JSON.parse(localStorage.getItem('MP_DASHBOARD') || '{}')) function sortDashboardConfigs() {
if (isNullOrEmptyObject(config.value)) { dashboardConfigs.value.sort((a, b) => {
config.value = default_config const aIndex = orderConfig.value.findIndex(
(item: { id: string; key: string }) => item.id === a.id && item.key === a.key,
)
const bIndex = orderConfig.value.findIndex(
(item: { id: string; key: string }) => item.id === b.id && item.key === b.key,
)
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
})
} }
// 设置项目 // 设置项目
function setDashboardConfig() { async function saveDashboardConfig() {
const data = JSON.stringify(config.value) // 启用配置
const data = JSON.stringify(enableConfig.value)
localStorage.setItem('MP_DASHBOARD', data) localStorage.setItem('MP_DASHBOARD', data)
// 顺序配置从dashboardConfigs中提取
const order = JSON.stringify(dashboardConfigs.value.map(item => ({ id: item.id, key: item.key })))
localStorage.setItem('MP_DASHBOARD_ORDER', order)
// 是否拉升高度
localStorage.setItem('MP_DASHBOARD_ELEVATED', isElevated.value.toString())
// 保存到服务端 // 保存到服务端
api.post('/user/config/Dashboard', data, { try {
headers: { await api.post('/user/config/Dashboard', data, {
'Content-Type': 'application/json', headers: {
}, 'Content-Type': 'application/json',
}) },
})
await api.post('/user/config/DashboardOrder', order, {
headers: {
'Content-Type': 'application/json',
},
})
} catch (error) {
console.error(error)
}
// 保存后重新获取插件仪表板
getPluginDashboardMeta()
dialog.value = false dialog.value = false
} }
// 构造插件仪表板主ID
function buildPluginDashboardId(plugin_id: string, key: string) {
if (!key) return plugin_id
return plugin_id + ':' + key
}
// 调用API获取所有插件的仪表板元信息
async function getPluginDashboardMeta() {
// 只有超级用户才能获取
if (!superUser) return
pluginDashboardMeta.value = await api.get('/plugin/dashboard/meta')
try {
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
// 下载插件仪表板配置
pluginDashboardMeta.value.forEach(async (pluginDashboard: { id: string; key: string }) => {
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
// 初始化插件仪表板的刷新状态
pluginDashboardRefreshStatus.value[pluginDashboardId] = true
await getPluginDashboard(pluginDashboard.id, pluginDashboard.key)
})
}
} catch (error) {
console.error(error)
}
}
// 获取一个插件的仪表板配置项
async function getPluginDashboard(id: string, key: string) {
try {
const url = key ? `/plugin/dashboard/${id}/${key}` : `/plugin/dashboard/${id}`
api.get(url).then((res: any) => {
if (res) {
// 名称替换为元信息的名称
const meta = pluginDashboardMeta.value.find(
(item: { id: string; key: string }) => item.id === id && item.key === key,
)
if (meta) res.name = meta.name
// 保存到仪表板配置中,如果已经存在则替换
const index = dashboardConfigs.value.findIndex(
(item: { id: string; key: string }) => item.id === id && item.key === key,
)
if (index !== -1) {
dashboardConfigs.value[index] = res
} else {
dashboardConfigs.value.push(res)
// 排序
sortDashboardConfigs()
}
const pluginDashboardId = buildPluginDashboardId(id, key)
// 定时刷新
if (
res.attrs?.refresh &&
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
enableConfig.value[pluginDashboardId] &&
isRequest.value
) {
// 清除之前的定时器
if (refreshTimers.value[pluginDashboardId]) {
clearTimeout(refreshTimers.value[pluginDashboardId])
}
// 设置新的定时器
let timer = setTimeout(() => {
getPluginDashboard(id, key)
}, res.attrs.refresh * 1000)
refreshTimers.value[pluginDashboardId] = timer
}
}
})
} catch (error) {
console.error(error)
}
}
// 拖动排序结束
function dragOrderEnd() {
// 保存数据
saveDashboardConfig()
}
onBeforeMount(async () => {
await loadDashboardConfig()
getPluginDashboardMeta()
})
onActivated(async () => {
isRequest.value = true
})
onDeactivated(() => {
isRequest.value = false
})
</script> </script>
<template> <template>
<!-- 仪表板 -->
<draggable
v-model="dashboardConfigs"
@end="dragOrderEnd"
handle=".cursor-move"
item-key="id"
tag="VRow"
:component-data="elevatedConf"
>
<template #item="{ element }">
<VCol v-if="enableConfig[buildPluginDashboardId(element.id, element.key)] && element.cols" v-bind:="element.cols">
<DashboardElement
:config="element"
:allow-refresh="isRequest"
v-model:refreshStatus="pluginDashboardRefreshStatus[buildPluginDashboardId(element.id, element.key)]"
/>
</VCol>
</template>
</draggable>
<!-- 底部操作按钮 --> <!-- 底部操作按钮 -->
<VFab icon="mdi-view-dashboard-edit" location="bottom end" size="x-large" fixed app appear @click="dialog = true" /> <VFab
<VRow class="match-height"> icon="mdi-view-dashboard-edit"
<VCol v-if="config.storage" cols="12" md="4"> location="bottom"
<AnalyticsStorage /> size="x-large"
</VCol> fixed
app
appear
@click="dialog = true"
:class="{ 'mb-12': appMode }"
/>
<VCol v-if="config.mediaStatistic" cols="12" md="8">
<AnalyticsMediaStatistic />
</VCol>
<VCol v-if="config.weeklyOverview" cols="12" md="4">
<AnalyticsWeeklyOverview />
</VCol>
<VCol v-if="config.speed" cols="12" md="4">
<AnalyticsSpeed />
</VCol>
<VCol v-if="config.scheduler" cols="12" md="4">
<AnalyticsScheduler />
</VCol>
<VCol v-if="config.cpu" cols="12" md="6">
<AnalyticsCpu />
</VCol>
<VCol v-if="config.memory" cols="12" md="6">
<AnalyticsMemory />
</VCol>
<VCol v-if="config.library" cols="12">
<MediaServerLibrary />
</VCol>
<VCol v-if="config.playing" cols="12">
<MediaServerPlaying />
</VCol>
<VCol v-if="config.latest" cols="12">
<MediaServerLatest />
</VCol>
</VRow>
<!-- 弹窗根据配置生成选项 --> <!-- 弹窗根据配置生成选项 -->
<VDialog v-model="dialog" max-width="40rem" scrollable :fullscreen="!display.mdAndUp.value"> <VDialog v-model="dialog" max-width="35rem" scrollable>
<VCard title="设置仪表板"> <VCard>
<VCardItem>
<VCardTitle>设置仪表板</VCardTitle>
</VCardItem>
<VDivider />
<VCardText> <VCardText>
<VRow> <VRow>
<VCol v-for="(item, key) in dashboard_names" :key="key" cols="12" md="4" sm="4"> <VCol
<VCheckbox v-model="config[key]" :label="dashboard_names[key]" /> v-for="item in dashboardConfigs"
:key="buildPluginDashboardId(item.id, item.key)"
cols="6"
md="4"
sm="4"
>
<VCheckbox
v-model="enableConfig[buildPluginDashboardId(item.id, item.key)]"
:label="item.attrs?.title ?? item.name"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="isElevated" label="自适应组件高度" />
</VCol> </VCol>
</VRow> </VRow>
</VCardText> </VCardText>
<VCardActions> <VDivider />
<VBtn color="primary" @click="dialog = false"> 取消 </VBtn> <VCardText class="pt-5 text-end">
<VSpacer /> <VSpacer />
<VBtn color="primary" variant="tonal" @click="setDashboardConfig"> 保存 </VBtn> <VBtn variant="outlined" color="secondary" class="me-4" @click="dialog = false"> 关闭 </VBtn>
</VCardActions> <VBtn @click="saveDashboardConfig">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
保存
</VBtn>
</VCardText>
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
<script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
</script>
<template>
<div>
<SubscribeListView type="电影" />
</div>
</template>

View File

@@ -1,9 +0,0 @@
<script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
</script>
<template>
<div>
<SubscribeListView type="电视剧" />
</div>
</template>

39
src/pages/subscribe.vue Normal file
View File

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

View File

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

View File

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

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

View File

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

View File

@@ -4,13 +4,28 @@ import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils' import { hexToRgb } from '@layouts/utils'
import api from '@/api' import api from '@/api'
// 输入参数
const props = defineProps({
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
})
const vuetifyTheme = useTheme() const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.colors) const currentTheme = controlledComputed(
const variableTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.variables) () => vuetifyTheme.name.value,
() => vuetifyTheme.current.value.colors,
)
const variableTheme = controlledComputed(
() => vuetifyTheme.name.value,
() => vuetifyTheme.current.value.variables,
)
// 定时器 // 定时器
let refreshTimer: NodeJS.Timer | null = null let refreshTimer: NodeJS.Timeout | null = null
// 时间序列 // 时间序列
const series = ref([ const series = ref([
@@ -22,83 +37,87 @@ const series = ref([
// 当前值 // 当前值
const current = ref(0) const current = ref(0)
const chartOptions = controlledComputed(() => vuetifyTheme.name.value, () => { const chartOptions = controlledComputed(
return { () => vuetifyTheme.name.value,
chart: { () => {
parentHeightOffset: 0, return {
toolbar: { show: false }, chart: {
animations: { enabled: false }, parentHeightOffset: 0,
}, toolbar: { show: false },
tooltip: { enabled: false }, animations: { enabled: false },
grid: { },
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${variableTheme.value['border-opacity']})`, tooltip: { enabled: false },
strokeDashArray: 6, grid: {
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
variableTheme.value['border-opacity']
})`,
strokeDashArray: 6,
xaxis: {
lines: { show: false },
},
yaxis: {
lines: { show: true },
},
padding: {
top: -10,
left: -7,
right: 5,
bottom: 5,
},
},
stroke: {
width: 3,
lineCap: 'butt',
curve: 'smooth',
},
colors: [currentTheme.value.primary],
markers: {
size: 6,
offsetY: 4,
offsetX: -2,
strokeWidth: 3,
colors: ['transparent'],
strokeColors: 'transparent',
discrete: [
{
size: 5.5,
seriesIndex: 0,
strokeColor: currentTheme.value.primary,
fillColor: currentTheme.value.surface,
},
],
hover: { size: 7 },
},
xaxis: { xaxis: {
lines: { show: false }, labels: { show: false },
axisTicks: { show: false },
axisBorder: { show: false },
}, },
yaxis: { yaxis: {
lines: { show: true }, labels: { show: false },
max: 100,
}, },
padding: { }
top: -10, },
left: -7, )
right: 5,
bottom: 5,
},
},
stroke: {
width: 3,
lineCap: 'butt',
curve: 'smooth',
},
colors: [currentTheme.value.primary],
markers: {
size: 6,
offsetY: 4,
offsetX: -2,
strokeWidth: 3,
colors: ['transparent'],
strokeColors: 'transparent',
discrete: [
{
size: 5.5,
seriesIndex: 0,
strokeColor: currentTheme.value.primary,
fillColor: currentTheme.value.surface,
},
],
hover: { size: 7 },
},
xaxis: {
labels: { show: false },
axisTicks: { show: false },
axisBorder: { show: false },
},
yaxis: {
labels: { show: false },
max: 100,
},
}
})
// 调用API接口获取最新CPU使用率 // 调用API接口获取最新CPU使用率
async function getCpuUsage() { async function getCpuUsage() {
if (!props.allowRefresh) return
try { try {
// 请求数据 // 请求数据
current.value = await api.get('dashboard/cpu') ?? 0 current.value = (await api.get('dashboard/cpu')) ?? 0
// 添加到序列 // 添加到序列
series.value[0].data.push(current.value) series.value[0].data.push(current.value)
// 序列超过30条记录时清掉前面的 // 序列超过30条记录时清掉前面的
if (series.value[0].data.length > 30) if (series.value[0].data.length > 30) series.value[0].data.shift()
series.value[0].data.shift() } catch (e) {
}
catch (e) {
console.log(e) console.log(e)
} }
} }
onMounted(() => { onMounted(() => {
getCpuUsage()// 启动定时器 getCpuUsage() // 启动定时器
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
getCpuUsage() getCpuUsage()
}, 2000) }, 2000)
@@ -114,21 +133,21 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<VCard> <VHover>
<VCardText> <template #default="hover">
<h6 class="text-h6"> <VCard v-bind="hover.props">
CPU <VCardItem>
</h6> <template #append>
<VueApexCharts <VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
type="line" </template>
:options="chartOptions" <VCardTitle>CPU</VCardTitle>
:series="series" </VCardItem>
:height="150" <VCardText>
/> <VueApexCharts type="line" :options="chartOptions" :series="series" :height="150" />
<p class="text-center font-weight-medium mb-0"> <p class="text-center font-weight-medium mb-0">当前{{ current }}%</p>
当前{{ current }}% </VCardText>
</p> </VCard>
</VCardText> </template>
</VCard> </VHover>
</template> </template>

View File

@@ -2,14 +2,7 @@
import api from '@/api' import api from '@/api'
import type { MediaStatistic } from '@/api/types' import type { MediaStatistic } from '@/api/types'
const statistics = ref([ const statistics = ref<{ [key: string]: string }[]>([])
{
title: '',
stats: '',
icon: '',
color: '',
},
])
// 调用API加载媒体统计数据 // 调用API加载媒体统计数据
async function loadMediaStatistic() { async function loadMediaStatistic() {
@@ -42,8 +35,7 @@ async function loadMediaStatistic() {
color: 'info', color: 'info',
}, },
] ]
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -54,43 +46,37 @@ onMounted(() => {
</script> </script>
<template> <template>
<VCard> <VHover>
<VCardItem> <template #default="hover">
<VCardTitle>媒体统计</VCardTitle> <VCard v-bind="hover.props">
</VCardItem> <VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>媒体统计</VCardTitle>
</VCardItem>
<VCardText> <VCardText>
<VRow> <VRow>
<VCol <VCol v-for="item in statistics" :key="item.title" cols="6" sm="3">
v-for="item in statistics" <div class="d-flex align-center">
:key="item.title" <div class="me-3">
cols="6" <VAvatar :color="item.color" rounded size="42" class="elevation-1">
sm="3" <VIcon size="24" :icon="item.icon" />
> </VAvatar>
<div class="d-flex align-center"> </div>
<div class="me-3">
<VAvatar
:color="item.color"
rounded
size="42"
class="elevation-1"
>
<VIcon
size="24"
:icon="item.icon"
/>
</VAvatar>
</div>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<span class="text-caption"> <span class="text-caption">
{{ item.title }} {{ item.title }}
</span> </span>
<span class="text-h6">{{ item.stats }}</span> <span class="text-h6">{{ item.stats }}</span>
</div> </div>
</div> </div>
</VCol> </VCol>
</VRow> </VRow>
</VCardText> </VCardText>
</VCard> </VCard>
</template>
</VHover>
</template> </template>

View File

@@ -5,13 +5,28 @@ import { hexToRgb } from '@layouts/utils'
import api from '@/api' import api from '@/api'
import { formatBytes } from '@/@core/utils/formatters' import { formatBytes } from '@/@core/utils/formatters'
// 输入参数
const props = defineProps({
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
})
const vuetifyTheme = useTheme() const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.colors) const currentTheme = controlledComputed(
const variableTheme = controlledComputed(() => vuetifyTheme.name.value, () => vuetifyTheme.current.value.variables) () => vuetifyTheme.name.value,
() => vuetifyTheme.current.value.colors,
)
const variableTheme = controlledComputed(
() => vuetifyTheme.name.value,
() => vuetifyTheme.current.value.variables,
)
// 定时器 // 定时器
let refreshTimer: NodeJS.Timer | null = null let refreshTimer: NodeJS.Timeout | null = null
// 时间序列 // 时间序列
const series = ref([ const series = ref([
@@ -25,79 +40,83 @@ const usedMemory = ref(0)
// 内存使用百分比 // 内存使用百分比
const memoryUsage = ref(0) const memoryUsage = ref(0)
const chartOptions = controlledComputed(() => vuetifyTheme.name.value, () => { const chartOptions = controlledComputed(
return { () => vuetifyTheme.name.value,
chart: { () => {
parentHeightOffset: 0, return {
toolbar: { show: false }, chart: {
animations: { enabled: false }, parentHeightOffset: 0,
}, toolbar: { show: false },
tooltip: { enabled: false }, animations: { enabled: false },
grid: { },
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${variableTheme.value['border-opacity']})`, tooltip: { enabled: false },
strokeDashArray: 6, grid: {
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
variableTheme.value['border-opacity']
})`,
strokeDashArray: 6,
xaxis: {
lines: { show: false },
},
yaxis: {
lines: { show: true },
},
padding: {
top: -10,
left: -7,
right: 5,
bottom: 5,
},
},
stroke: {
width: 3,
lineCap: 'butt',
curve: 'smooth',
},
colors: [currentTheme.value.primary],
markers: {
size: 6,
offsetY: 4,
offsetX: -2,
strokeWidth: 3,
colors: ['transparent'],
strokeColors: 'transparent',
discrete: [
{
size: 5.5,
seriesIndex: 0,
strokeColor: currentTheme.value.primary,
fillColor: currentTheme.value.surface,
},
],
hover: { size: 7 },
},
dataLabels: {
enabled: false,
},
xaxis: { xaxis: {
lines: { show: false }, labels: { show: false },
axisTicks: { show: false },
axisBorder: { show: false },
}, },
yaxis: { yaxis: {
lines: { show: true }, labels: { show: false },
max: 100,
}, },
padding: { }
top: -10, },
left: -7, )
right: 5,
bottom: 5,
},
},
stroke: {
width: 3,
lineCap: 'butt',
curve: 'smooth',
},
colors: [currentTheme.value.primary],
markers: {
size: 6,
offsetY: 4,
offsetX: -2,
strokeWidth: 3,
colors: ['transparent'],
strokeColors: 'transparent',
discrete: [
{
size: 5.5,
seriesIndex: 0,
strokeColor: currentTheme.value.primary,
fillColor: currentTheme.value.surface,
},
],
hover: { size: 7 },
},
dataLabels: {
enabled: false,
},
xaxis: {
labels: { show: false },
axisTicks: { show: false },
axisBorder: { show: false },
},
yaxis: {
labels: { show: false },
max: 100,
},
}
})
// 调用API接口获取最新内存使用量 // 调用API接口获取最新内存使用量
async function getMemorgUsage() { async function getMemorgUsage() {
if (!props.allowRefresh) return
try { try {
// 请求数据 // 请求数据
[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory') ;[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')
series.value[0].data.push(memoryUsage.value) series.value[0].data.push(memoryUsage.value)
// 序列超过30条记录时清掉前面的 // 序列超过30条记录时清掉前面的
if (series.value[0].data.length > 30) if (series.value[0].data.length > 30) series.value[0].data.shift()
series.value[0].data.shift() } catch (e) {
}
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -120,21 +139,21 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<VCard> <VHover>
<VCardText> <template #default="hover">
<h6 class="text-h6"> <VCard v-bind="hover.props">
内存 <VCardItem>
</h6> <template #append>
<VueApexCharts <VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
type="area" </template>
:options="chartOptions" <VCardTitle>内存</VCardTitle>
:series="series" </VCardItem>
:height="150" <VCardText>
/> <VueApexCharts type="area" :options="chartOptions" :series="series" :height="150" />
<p class="text-center font-weight-medium mb-0"> <p class="text-center font-weight-medium mb-0">当前{{ formatBytes(usedMemory) }}</p>
当前{{ formatBytes(usedMemory) }} </VCardText>
</p> </VCard>
</VCardText> </template>
</VCard> </VHover>
</template> </template>

View File

@@ -10,7 +10,7 @@ const headers = ['进程ID', '进程名称', '运行时间', '内存占用']
const processList = ref<Process[]>([]) const processList = ref<Process[]>([])
// 定时器 // 定时器
let refreshTimer: NodeJS.Timer | null = null let refreshTimer: NodeJS.Timeout | null = null
// 调用API加载数据 // 调用API加载数据
async function loadProcessList() { async function loadProcessList() {
@@ -18,8 +18,7 @@ async function loadProcessList() {
const res: Process[] = await api.get('dashboard/processes') const res: Process[] = await api.get('dashboard/processes')
processList.value = res processList.value = res
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -43,47 +42,32 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<VCard title="系统进程"> <VCard>
<VTable <VCardItem>
item-key="fullName" <template #append>
class="table-rounded" <VIcon class="cursor-move">mdi-drag</VIcon>
hide-default-footer </template>
disable-sort <VCardTitle>系统进程</VCardTitle>
> </VCardItem>
<VTable item-key="fullName" class="table-rounded" hide-default-footer disable-sort>
<thead> <thead>
<tr> <tr>
<th <th v-for="header in headers" :id="header" :key="header">
v-for="header in headers"
:id="header"
:key="header"
>
{{ header }} {{ header }}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr v-for="row in processList" :key="row.pid">
v-for="row in processList" <td class="text-sm" v-text="row.pid" />
:key="row.pid"
>
<td
class="text-sm"
v-text="row.pid"
/>
<!-- name --> <!-- name -->
<td> <td>
<h6 class="text-sm font-weight-medium"> <h6 class="text-sm font-weight-medium">
{{ row.name }} {{ row.name }}
</h6> </h6>
</td> </td>
<td <td class="text-sm" v-text="formatSeconds(row.run_time)" />
class="text-sm" <td class="text-sm" v-text="`${row.memory} MB`" />
v-text="formatSeconds(row.run_time)"
/>
<td
class="text-sm"
v-text="`${row.memory} MB`"
/>
</tr> </tr>
</tbody> </tbody>
</VTable> </VTable>

View File

@@ -2,20 +2,31 @@
import api from '@/api' import api from '@/api'
import type { ScheduleInfo } from '@/api/types' import type { ScheduleInfo } from '@/api/types'
// 输入参数
const props = defineProps({
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
})
// 定时服务列表 // 定时服务列表
const schedulerList = ref<ScheduleInfo[]>([]) const schedulerList = ref<ScheduleInfo[]>([])
// 定时器 // 定时器
let refreshTimer: NodeJS.Timer | null = null let refreshTimer: NodeJS.Timeout | null = null
// 调用API加载定时服务列表 // 调用API加载定时服务列表
async function loadSchedulerList() { async function loadSchedulerList() {
if (!props.allowRefresh) {
return
}
try { try {
const res: ScheduleInfo[] = await api.get('dashboard/schedule') const res: ScheduleInfo[] = await api.get('dashboard/schedule')
schedulerList.value = res schedulerList.value = res
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -39,55 +50,49 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<VCard> <VHover>
<VCardItem> <template #default="hover">
<VCardTitle>后台任务</VCardTitle> <VCard v-bind="hover.props">
</VCardItem> <VCardItem>
<VCardText>
<VList
class="card-list"
height="250"
>
<VListItem
v-for="item in schedulerList"
:key="item.id"
>
<template #prepend>
<VAvatar
size="40"
variant="tonal"
color=""
class="me-3"
>
{{ item.name[0] }}
</VAvatar>
</template>
<VListItemTitle class="mb-1">
<span class="text-sm font-weight-medium">{{ item.name }}</span>
</VListItemTitle>
<VListItemSubtitle class="text-xs">
{{ item.next_run }}
</VListItemSubtitle>
<template #append> <template #append>
<div> <VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
<h4 class="font-weight-medium">
{{ item.status }}
</h4>
</div>
</template> </template>
</VListItem> <VCardTitle>后台任务</VCardTitle>
<VListItem v-if="schedulerList.length === 0"> </VCardItem>
<VListItemTitle class="text-center">
没有后台服务 <VCardText>
</VListItemTitle> <VList class="card-list" height="250">
</VListItem> <VListItem v-for="item in schedulerList" :key="item.id">
</VList> <template #prepend>
</VCardText> <VAvatar size="40" variant="tonal" color="" class="me-3">
</VCard> {{ item.name[0] }}
</VAvatar>
</template>
<VListItemTitle class="mb-1">
<span class="text-sm font-weight-medium">{{ item.name }}</span>
</VListItemTitle>
<VListItemSubtitle class="text-xs">
{{ item.next_run }}
</VListItemSubtitle>
<template #append>
<div>
<h4 class="font-weight-medium">
{{ item.status }}
</h4>
</div>
</template>
</VListItem>
<VListItem v-if="schedulerList.length === 0">
<VListItemTitle class="text-center"> 没有后台服务 </VListItemTitle>
</VListItem>
</VList>
</VCardText>
</VCard>
</template>
</VHover>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -3,8 +3,17 @@ import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import type { DownloaderInfo } from '@/api/types' import type { DownloaderInfo } from '@/api/types'
// 输入参数
const props = defineProps({
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
})
// 定时器 // 定时器
let refreshTimer: NodeJS.Timer | null = null let refreshTimer: NodeJS.Timeout | null = null
// 下载器信息 // 下载器信息
const downloadInfo = ref<DownloaderInfo>({ const downloadInfo = ref<DownloaderInfo>({
@@ -35,6 +44,10 @@ const infoItems = ref([
// 调用API查询下载器数据 // 调用API查询下载器数据
async function loadDownloaderInfo() { async function loadDownloaderInfo() {
if (!props.allowRefresh) {
return
}
try { try {
const res: DownloaderInfo = await api.get('dashboard/downloader') const res: DownloaderInfo = await api.get('dashboard/downloader')
@@ -56,8 +69,7 @@ async function loadDownloaderInfo() {
amount: formatFileSize(res.free_space), amount: formatFileSize(res.free_space),
}, },
] ]
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -81,47 +93,44 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<VCard> <VHover>
<VCardItem> <template #default="hover">
<VCardTitle>实时速率</VCardTitle> <VCard v-bind="hover.props">
</VCardItem> <VCardItem>
<VCardText class="pt-4">
<div>
<p class="text-h5 me-2">
{{ formatFileSize(downloadInfo.upload_speed) }}/s
</p>
<p class="text-h4 me-2">
{{ formatFileSize(downloadInfo.download_speed) }}/s
</p>
</div>
<VList class="card-list mt-9">
<VListItem
v-for="item in infoItems"
:key="item.title"
>
<template #prepend>
<VIcon
rounded
:icon="item.avatar"
/>
</template>
<VListItemTitle class="text-sm font-weight-medium mb-1">
{{ item.title }}
</VListItemTitle>
<template #append> <template #append>
<div> <VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
<h6 class="text-sm font-weight-medium mb-2">
{{ item.amount }}
</h6>
</div>
</template> </template>
</VListItem> <VCardTitle>实时速率</VCardTitle>
</VList> </VCardItem>
</VCardText>
</VCard> <VCardText class="pt-4">
<div>
<p class="text-h5 me-2">{{ formatFileSize(downloadInfo.upload_speed) }}/s</p>
<p class="text-h4 me-2">{{ formatFileSize(downloadInfo.download_speed) }}/s</p>
</div>
<VList class="card-list mt-9">
<VListItem v-for="item in infoItems" :key="item.title">
<template #prepend>
<VIcon rounded :icon="item.avatar" />
</template>
<VListItemTitle class="text-sm font-weight-medium mb-1">
{{ item.title }}
</VListItemTitle>
<template #append>
<div>
<h6 class="text-sm font-weight-medium mb-2">
{{ item.amount }}
</h6>
</div>
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
</template>
</VHover>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -8,9 +8,7 @@ import triangleLight from '@images/misc/triangle-light.png'
const { global } = useTheme() const { global } = useTheme()
const triangleBg = computed(() => const triangleBg = computed(() => (global.name.value === 'light' ? triangleLight : triangleDark))
global.name.value === 'light' ? triangleLight : triangleDark,
)
// 总存储空间 // 总存储空间
const storage = ref(0) const storage = ref(0)
@@ -30,8 +28,7 @@ async function getStorage() {
storage.value = res.total_storage storage.value = res.total_storage
used.value = res.used_storage used.value = res.used_storage
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -42,42 +39,36 @@ onMounted(() => {
</script> </script>
<template> <template>
<VCard <VHover>
title="存储空间" <template #default="hover">
subtitle="" <VCard v-bind="hover.props">
class="position-relative" <!-- Triangle Background -->
> <VImg :src="triangleBg" class="triangle-bg flip-in-rtl" />
<VCardText> <VCardItem>
<h5 class="text-2xl font-weight-medium text-primary"> <template #append>
{{ formatFileSize(storage) }} <VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</h5> </template>
<p class="mt-2"> <VCardTitle>存储空间</VCardTitle>
已使用 {{ usedPercent }}% 🚀 </VCardItem>
</p> <VCardText>
<p class="mt-1"> <h5 class="text-2xl font-weight-medium text-primary">
<VProgressLinear {{ formatFileSize(storage) }}
:model-value="usedPercent" </h5>
color="primary" <p class="mt-2">已使用 {{ usedPercent }}% 🚀</p>
/> <p class="mt-1">
</p> <VProgressLinear :model-value="usedPercent" color="primary" />
</VCardText> </p>
</VCardText>
<!-- Triangle Background --> <!-- Trophy -->
<VImg <VImg :src="trophy" class="trophy" />
:src="triangleBg" </VCard>
class="triangle-bg flip-in-rtl" </template>
/> </VHover>
<!-- Trophy -->
<VImg
:src="trophy"
class="trophy"
/>
</VCard>
</template> </template>
<style lang="scss"> <style lang="scss">
@use "@layouts/styles/mixins" as layoutsMixins; @use '@layouts/styles/mixins' as layoutsMixins;
.v-card .triangle-bg { .v-card .triangle-bg {
position: absolute; position: absolute;

View File

@@ -80,8 +80,13 @@ const options = controlledComputed(
fontSize: '12px', fontSize: '12px',
}, },
formatter: (value: number) => formatter: (value: number) => {
value > 999 ? (value / 1000).toFixed(0) : value, if (value > 999) {
return (value / 1000).toFixed(1) + 'k'
} else {
return value.toString()
}
},
}, },
}, },
} }
@@ -100,8 +105,7 @@ async function getWeeklyData() {
const res: number[] = await api.get('dashboard/transfer') const res: number[] = await api.get('dashboard/transfer')
series.value = [{ data: res }] series.value = [{ data: res }]
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -112,33 +116,29 @@ onMounted(() => {
</script> </script>
<template> <template>
<VCard> <VHover>
<VCardItem> <template #default="hover">
<VCardTitle>最近入库</VCardTitle> <VCard v-bind="hover.props">
</VCardItem> <VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>最近入库</VCardTitle>
</VCardItem>
<VCardText> <VCardText>
<VueApexCharts <VueApexCharts type="bar" :options="options" :series="series" :height="160" />
type="bar"
:options="options"
:series="series"
:height="160"
/>
<div class="d-flex align-center mb-3"> <div class="d-flex align-center mb-3">
<h5 class="text-h5 me-4"> <h5 class="text-h5 me-4">
{{ totalCount }} {{ totalCount }}
</h5> </h5>
<p>最近一周入库了 {{ totalCount }} 部影片 😎</p> <p>最近一周入库了 {{ totalCount }} 部影片 😎</p>
</div> </div>
<VBtn <VBtn v-if="superUser" block to="/history"> 查看详情 </VBtn>
v-if="superUser" </VCardText>
block </VCard>
to="/history" </template>
> </VHover>
查看详情
</VBtn>
</VCardText>
</VCard>
</template> </template>

View File

@@ -10,8 +10,7 @@ const latestList = ref<MediaServerPlayItem[]>([])
async function loadLatest() { async function loadLatest() {
try { try {
latestList.value = await api.get('mediaserver/latest') latestList.value = await api.get('mediaserver/latest')
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -22,27 +21,20 @@ onMounted(() => {
</script> </script>
<template> <template>
<VCard> <VHover>
<VCardItem> <template #default="hover">
<VCardTitle>最近添加</VCardTitle> <VCard v-bind="hover.props">
</VCardItem> <VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle >最近添加</VCardTitle>
</VCardItem>
<div <div v-if="latestList.length > 0" class="grid gap-4 grid-media-card mx-3 mb-3" tabindex="0">
v-if="latestList.length > 0" <PosterCard v-for="data in latestList" :key="data.id" :media="data" />
class="grid gap-4 grid-media-card mx-3 mb-3" </div>
tabindex="0" </VCard>
> </template>
<PosterCard </VHover>
v-for="data in latestList"
:key="data.id"
:media="data"
/>
</div>
</VCard>
</template> </template>
<style lang="scss">
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
</style>

View File

@@ -10,8 +10,7 @@ const libraryList = ref<MediaServerPlayItem[]>([])
async function loadLibrary() { async function loadLibrary() {
try { try {
libraryList.value = await api.get('mediaserver/library') libraryList.value = await api.get('mediaserver/library')
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -22,29 +21,20 @@ onMounted(() => {
</script> </script>
<template> <template>
<VCard> <VHover>
<VCardItem> <template #default="hover">
<VCardTitle>我的媒体库</VCardTitle> <VCard v-bind="hover.props">
</VCardItem> <VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle >我的媒体库</VCardTitle>
</VCardItem>
<div <div v-if="libraryList.length > 0" class="grid gap-4 grid-backdrop-card mx-3" tabindex="0">
v-if="libraryList.length > 0" <LibraryCard v-for="data in libraryList" :key="data.id" :media="data" height="10rem" />
class="grid gap-4 grid-backdrop-card mx-3" </div>
tabindex="0" </VCard>
> </template>
<LibraryCard </VHover>
v-for="data in libraryList"
:key="data.id"
:media="data"
height="10rem"
/>
</div>
</VCard>
</template> </template>
<style lang="scss">
.grid-backdrop-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -10,8 +10,7 @@ const playingList = ref<MediaServerPlayItem[]>([])
async function loadPlayingList() { async function loadPlayingList() {
try { try {
playingList.value = await api.get('mediaserver/playing') playingList.value = await api.get('mediaserver/playing')
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -22,29 +21,20 @@ onMounted(() => {
</script> </script>
<template> <template>
<VCard> <VHover>
<VCardItem> <template #default="hover">
<VCardTitle>继续观看</VCardTitle> <VCard v-bind="hover.props">
</VCardItem> <VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>继续观看</VCardTitle>
</VCardItem>
<div <div v-if="playingList.length > 0" class="grid gap-4 grid-backdrop-card mx-3" tabindex="0">
v-if="playingList.length > 0" <BackdropCard v-for="data in playingList" :key="data.id" :media="data" height="10rem" />
class="grid gap-4 grid-backdrop-card mx-3" </div>
tabindex="0" </VCard>
> </template>
<BackdropCard </VHover>
v-for="data in playingList"
:key="data.id"
:media="data"
height="10rem"
/>
</div>
</VCard>
</template> </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> </template>
<style lang="scss"> <style lang="scss">
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
</style> </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"> <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"> <template v-if="mediaDetail.backdrop_path || mediaDetail.poster_path">
<div class="vue-media-back absolute left-0 top-0 w-full h-96"> <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>
<div class="vue-media-back absolute left-0 top-0 w-full h-96" /> <div class="vue-media-back absolute left-0 top-0 w-full h-96" />
</template> </template>
@@ -861,6 +861,7 @@ onBeforeMount(() => {
/> />
<!-- 订阅编辑弹窗 --> <!-- 订阅编辑弹窗 -->
<SubscribeEditDialog <SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog" v-model="subscribeEditDialog"
:subid="subscribeId" :subid="subscribeId"
@close="subscribeEditDialog = false" @close="subscribeEditDialog = false"

View File

@@ -124,9 +124,3 @@ async function fetchData({ done }: { done: any }) {
/> />
</VInfiniteScroll> </VInfiniteScroll>
</template> </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 _ from 'lodash'
import type { Context } from '@/api/types' import type { Context } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue' import TorrentCard from '@/components/cards/TorrentCard.vue'
import { useDefer } from '@/@core/utils/dom'
interface SearchTorrent extends Context { interface SearchTorrent extends Context {
more?: Array<Context> more?: Array<Context>
@@ -47,8 +46,10 @@ const editionFilterOptions = ref<Array<string>>([])
// 获取分辨率过滤选项 // 获取分辨率过滤选项
const resolutionFilterOptions = 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[]>>() const groupedDataList = ref<Map<string, Context[]>>()
@@ -71,8 +72,35 @@ function initOptions(data: Context) {
// 对季过滤选项进行排序 // 对季过滤选项进行排序
const sortSeasonFilterOptions = computed(() => { const sortSeasonFilterOptions = computed(() => {
return seasonFilterOptions.value.sort((a, b) => { 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 groupedDataList.value = groupMap
}) })
let defer = (_: number) => true // 只监听filterForm和groupedDataList的变化。因为displayDataList的变化不需要清空列表
watch([filterForm, groupedDataList], filterData)
// 计算过滤后的列表 function filterData() {
watchEffect(() => {
// 清空列表 // 清空列表
dataList.value = [] dataList = []
// 匹配过滤函数 displayDataList.value = []
// 匹配过滤函数filter中有任一值包含value则返回true
const match = (filter: Array<string>, value: string | undefined) => const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value)) filter.length === 0 || (value && filter.includes(value))
@@ -135,12 +164,23 @@ watchEffect(() => {
const firstData = _.cloneDeepWith(matchData[0]) as SearchTorrent const firstData = _.cloneDeepWith(matchData[0]) as SearchTorrent
if (matchData.length > 1) firstData.more = matchData.slice(1) 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> </script>
<template> <template>
@@ -225,16 +265,12 @@ watchEffect(() => {
</VCol> </VCol>
</VRow> </VRow>
</VCard> </VCard>
<div class="grid gap-3 grid-torrent-card items-start"> <VInfiniteScroll mode="intersect" side="end" :items="displayDataList" class="overflow-hidden"
<div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`"> @load="loadMore">
<TorrentCard v-if="defer(index)" :torrent="item" :more="item.more" /> <template #loading />
</div> <template #empty />
</div> <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> </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> <script lang="ts" setup>
import type { Context } from '@/api/types' import type { Context } from '@/api/types'
import TorrentItem from '@/components/cards/TorrentItem.vue' 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({ const props = defineProps({
@@ -27,6 +36,16 @@ const filterForm = reactive({
resolution: [] as string[], 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')
// 数据列表 // 数据列表
const dataList = ref<Array<Context>>([]) const dataList = ref<Array<Context>>([])
@@ -60,7 +79,54 @@ function initOptions(data: Context) {
optionValue(resolutionFilterOptions.value, meta_info?.resource_pix) optionValue(resolutionFilterOptions.value, meta_info?.resource_pix)
} }
let defer = (_: number) => true // 对季过滤选项进行排序
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
if (sortField.value === 'default') {
dataList.value = list.sort((a, b) => b.torrent_info.pri_order - a.torrent_info.pri_order)
} else if (sortField.value === 'site') {
dataList.value = list.sort((a, b) => (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || ''))
} else if (sortField.value === 'size') {
dataList.value = list.sort((a, b) => b.torrent_info.size - a.torrent_info.size)
} else if (sortField.value === 'seeder') {
dataList.value = list.sort((a, b) => b.torrent_info.seeders - a.torrent_info.seeders)
}
})
// 计算过滤后的列表 // 计算过滤后的列表
watchEffect(() => { watchEffect(() => {
@@ -70,32 +136,31 @@ watchEffect(() => {
const match = (filter: Array<string>, value: string | undefined) => const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value)) filter.length === 0 || (value && filter.includes(value))
props.items?.forEach((data) => { props.items?.forEach(data => {
const { meta_info, torrent_info } = data const { meta_info, torrent_info } = data
if ( if (
// 站点过滤 // 站点过滤
match(filterForm.site, torrent_info.site_name) match(filterForm.site, torrent_info.site_name) &&
// 促销状态过滤 // 促销状态过滤
&& match(filterForm.freeState, torrent_info.volume_factor) match(filterForm.freeState, torrent_info.volume_factor) &&
// 季过滤 // 季过滤
&& match(filterForm.season, meta_info.season_episode) match(filterForm.season, meta_info.season_episode) &&
// 制作组过滤 // 制作组过滤
&& match(filterForm.releaseGroup, meta_info.resource_team) match(filterForm.releaseGroup, meta_info.resource_team) &&
// 视频编码过滤 // 视频编码过滤
&& match(filterForm.videoCode, meta_info.video_encode) match(filterForm.videoCode, meta_info.video_encode) &&
// 分辨率过滤 // 分辨率过滤
&& match(filterForm.resolution, meta_info.resource_pix) match(filterForm.resolution, meta_info.resource_pix) &&
// 质量过滤 // 质量过滤
&& match(filterForm.edition, meta_info.edition) match(filterForm.edition, meta_info.edition)
) )
dataList.value.push(data) dataList.value.push(data)
}) })
defer = useDefer(dataList.value.length)
}) })
// 初始化过滤选项 // 初始化过滤选项
onMounted(() => { onMounted(() => {
props.items?.forEach((item) => { props.items?.forEach(item => {
initOptions(item) initOptions(item)
}) })
}) })
@@ -104,22 +169,37 @@ onMounted(() => {
<template> <template>
<VRow> <VRow>
<VCol> <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> <VListItem>
<VListItemTitle>没有附合当前过滤条件的资源</VListItemTitle> <VListItemTitle>没有附合当前过滤条件的资源</VListItemTitle>
</VListItem> </VListItem>
</VList> </VList>
<VList v-if="dataList.length !== 0" lines="three" class="rounded p-0"> <VList v-if="dataList.length !== 0" lines="three" class="rounded p-0 torrent-list-vscroll shadow-lg">
<div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`"> <VVirtualScroll :items="dataList" :style="listStyle">
<TorrentItem v-if="defer(index)" :torrent="item" /> <template #default="{ item }">
</div> <TorrentItem :torrent="item" :key="`${item.torrent_info.page_url}`" />
</template>
</VVirtualScroll>
</VList> </VList>
</VCol> </VCol>
<VCol xl="2" md="3" class="d-none d-md-block"> <VCol xl="2" md="3" v-if="display.mdAndUp.value">
<VList lines="one" class="rounded"> <VList lines="one" class="rounded shadow-lg" :style="listStyle">
<VListSubheader v-if="siteFilterOptions.length > 0"> <VListSubheader> 排序 </VListSubheader>
站点 <VListItem>
</VListSubheader> <VChipGroup column v-model="sortField">
<VChip :color="sortField == 'default' ? 'primary' : ''" filter variant="outlined" value="default">
默认
</VChip>
<VChip :color="sortField == 'site' ? 'primary' : ''" filter variant="outlined" value="site"> 站点 </VChip>
<VChip :color="sortField == 'size' ? 'primary' : ''" filter variant="outlined" value="size">
文件大小
</VChip>
<VChip :color="sortField == 'seeder' ? 'primary' : ''" filter variant="outlined" value="seeder">
做种数
</VChip>
</VChipGroup>
</VListItem>
<VListSubheader v-if="siteFilterOptions.length > 0"> 站点 </VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.site" column multiple> <VChipGroup v-model="filterForm.site" column multiple>
<VChip <VChip
@@ -134,9 +214,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="editionFilterOptions.length > 0"> <VListSubheader v-if="editionFilterOptions.length > 0"> 质量 </VListSubheader>
质量
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.edition" column multiple> <VChipGroup v-model="filterForm.edition" column multiple>
<VChip <VChip
@@ -151,9 +229,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="resolutionFilterOptions.length > 0"> <VListSubheader v-if="resolutionFilterOptions.length > 0"> 分辨率 </VListSubheader>
分辨率
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.resolution" column multiple> <VChipGroup v-model="filterForm.resolution" column multiple>
<VChip <VChip
@@ -168,9 +244,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="releaseGroupFilterOptions.length > 0"> <VListSubheader v-if="releaseGroupFilterOptions.length > 0"> 制作组 </VListSubheader>
制作组
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.releaseGroup" column multiple> <VChipGroup v-model="filterForm.releaseGroup" column multiple>
<VChip <VChip
@@ -185,9 +259,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="videoCodeFilterOptions.length > 0"> <VListSubheader v-if="videoCodeFilterOptions.length > 0"> 视频编码 </VListSubheader>
视频编码
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.videoCode" column multiple> <VChipGroup v-model="filterForm.videoCode" column multiple>
<VChip <VChip
@@ -202,9 +274,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="freeStateFilterOptions.length > 0"> <VListSubheader v-if="freeStateFilterOptions.length > 0"> 促销状态 </VListSubheader>
促销状态
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.freeState" column multiple> <VChipGroup v-model="filterForm.freeState" column multiple>
<VChip <VChip
@@ -219,13 +289,11 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="seasonFilterOptions.length > 0"> <VListSubheader v-if="seasonFilterOptions.length > 0"> 季集 </VListSubheader>
季集
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.season" column multiple> <VChipGroup v-model="filterForm.season" column multiple>
<VChip <VChip
v-for="season in seasonFilterOptions" v-for="season in sortSeasonFilterOptions"
:key="season" :key="season"
:color="filterForm.season.includes(season) ? 'primary' : ''" :color="filterForm.season.includes(season) ? 'primary' : ''"
filter filter

View File

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

View File

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

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