Compare commits

...

258 Commits

Author SHA1 Message Date
jxxghp
6e6be057ca fix ui 2024-02-23 10:45:21 +08:00
jxxghp
af69efa48b feat:插件日志单独查看 2024-02-22 13:32:23 +08:00
jxxghp
c551083fa4 优化插件页面加载速度 2024-02-21 17:50:02 +08:00
jxxghp
9767feed29 更新 package.json 2024-02-18 15:19:52 +08:00
jxxghp
4392818e92 Merge pull request #75 from honue/main 2024-02-18 15:17:53 +08:00
honue
8d22bafeb6 订阅卡片增加剧集详情跳转功能 2024-02-18 15:08:22 +08:00
jxxghp
89ddd1fb78 更新 package.json 2024-02-17 13:19:04 +08:00
jxxghp
24513fa22b Merge pull request #74 from honue/main 2024-02-17 13:18:46 +08:00
honue
cddde0c2a0 fix 2024-02-17 13:09:48 +08:00
jxxghp
9c674e0018 更新 package.json 2024-02-15 21:06:19 +08:00
jxxghp
0c6476d283 更新 AccountSettingSystem.vue 2024-02-15 21:05:58 +08:00
jxxghp
bf0c529a59 fix ui 2024-02-15 20:01:51 +08:00
jxxghp
877bb4d4a2 fix ui 2024-02-15 18:58:22 +08:00
jxxghp
dc4db0b2b3 fix settings 2024-02-15 18:17:36 +08:00
jxxghp
a738d4a3b9 feat:系统设置面板 2024-02-15 15:04:59 +08:00
jxxghp
e9866a04df v1.6.4 2024-02-14 21:07:09 +08:00
jxxghp
4f5193d602 feat:更新Cookie支持两步验证 2024-02-14 21:06:42 +08:00
jxxghp
37b92c55ba feat:文件管理手动刮削 2024-02-10 19:32:49 +08:00
jxxghp
9299f1bcb6 release 2024-02-10 09:48:45 +08:00
jxxghp
7fe12192df add apexcharts 2024-02-10 09:36:54 +08:00
jxxghp
1169644ab3 fix render 2024-02-09 08:43:36 +08:00
jxxghp
6f7770ed43 fix 2024-02-08 20:58:41 +08:00
jxxghp
8059fd6f90 fix 2024-02-08 20:57:09 +08:00
jxxghp
556dbd8d78 fix bug 2024-02-08 20:07:23 +08:00
jxxghp
6695fd8c14 add ace-editor 2024-02-08 19:56:28 +08:00
jxxghp
3ab0229275 fix ui 2024-02-08 13:43:37 +08:00
jxxghp
99467127a0 更新 package.json 2024-02-08 13:28:11 +08:00
jxxghp
90d73b7bd5 Merge pull request #73 from cikezhu/main 2024-02-08 13:27:40 +08:00
叮叮当
2e326e1798 fix 全部日志url反向代理时被serviceWorker拦截 2024-02-08 12:45:52 +08:00
jxxghp
251eac93c7 更新 package.json 2024-02-08 07:11:49 +08:00
jxxghp
c74d70808c Merge pull request #72 from cikezhu/main 2024-02-08 07:10:48 +08:00
叮叮当
e63b2d7152 新窗口打开全部日志 2024-02-08 00:06:52 +08:00
jxxghp
16b29b56a5 更新 package.json 2024-01-29 23:11:57 +08:00
jxxghp
6d79c4fe2f Merge pull request #71 from cikezhu/main 2024-01-29 23:11:34 +08:00
叮叮当
4b1fb60ee3 fix 媒体信息页面person跳转问题 2024-01-29 23:01:22 +08:00
jxxghp
1d2be54f9e 更新 package.json 2024-01-29 11:05:38 +08:00
jxxghp
83547e32db Merge pull request #70 from cikezhu/main 2024-01-29 11:05:10 +08:00
叮叮当
70ddb929f2 fix plugin_icon 2024-01-28 00:47:37 +08:00
叮叮当
8b22961394 支持基于路径的反向代理 2024-01-27 14:47:00 +08:00
jxxghp
c15d42c179 Merge pull request #69 from falling/main 2024-01-24 18:43:37 +08:00
falling
098e473cab 从qbt后台的返回值来更新下载状态 2024-01-21 19:48:47 +08:00
jxxghp
f6f3d9368a fix bug 2024-01-08 13:22:42 +08:00
jxxghp
9558a420e9 fix image proxy 2024-01-08 12:25:06 +08:00
jxxghp
4d3b69ca34 更新 package.json 2024-01-08 11:33:32 +08:00
jxxghp
fdcc4a44c8 Merge pull request #68 from jjjokin/feat-auto-switch-theme 2024-01-08 11:01:26 +08:00
jokin
5de0494538 增加主题自适应选项 2024-01-07 22:17:11 +08:00
jxxghp
2045f833e4 fix bug 2024-01-06 11:07:23 +08:00
jxxghp
cc4f89aac1 fix 2024-01-06 11:02:03 +08:00
jxxghp
1c2f2c17d4 fix plex image 2024-01-06 10:58:35 +08:00
jxxghp
ace7a6621f image proxy 2024-01-05 21:35:33 +08:00
jxxghp
d02fe55a1e fix ui 2024-01-05 20:46:23 +08:00
jxxghp
9b753a8f5b fix 2024-01-05 20:40:44 +08:00
jxxghp
11e82582b8 fix 2024-01-05 17:34:52 +08:00
jxxghp
419358863e feat: dashboard 2024-01-05 17:32:30 +08:00
jxxghp
1d0d7f9975 dashboard cards 2024-01-05 15:59:03 +08:00
jxxghp
c5f564372b Merge pull request #66 from thofx/thofx_fix_ui 2024-01-04 21:38:30 +08:00
thofx
a50f0cd727 fix: 列表使用useDefer渲染 2024-01-04 21:35:35 +08:00
jxxghp
96f6f55138 fix type 2024-01-04 20:50:51 +08:00
jxxghp
6a45c8b358 fix service.js 2024-01-04 20:46:42 +08:00
jxxghp
165937596e fix safari window.open 2024-01-04 08:12:32 +08:00
jxxghp
fb976f043b fix 2024-01-03 21:30:00 +08:00
jxxghp
ecb9c4e51a fix safari 2024-01-03 21:29:40 +08:00
jxxghp
9e8c3b495c 更新 MediaDetailView.vue 2024-01-03 21:11:56 +08:00
jxxghp
24a37fc33c 更新 MediaDetailView.vue 2024-01-03 18:58:16 +08:00
jxxghp
d09a21114d fix 播放跳转 2024-01-03 18:39:39 +08:00
jxxghp
6e2b12501f feat:订阅弹窗开关 2024-01-03 18:11:13 +08:00
jxxghp
2a56e116cf fix 2024-01-03 17:30:49 +08:00
jxxghp
6de4f238d8 feat:size limit 2024-01-03 17:05:01 +08:00
jxxghp
1b426c5957 fix bug 2024-01-03 16:52:35 +08:00
jxxghp
82454a650c feat:dashboard可编辑 2024-01-03 13:09:28 +08:00
jxxghp
227b6bd7ef v1.5.7 2024-01-03 12:46:23 +08:00
jxxghp
9554025daf feat:在线播放 2024-01-03 12:45:44 +08:00
jxxghp
0eb5d607bf v1.5.6 2024-01-01 20:06:37 +08:00
jxxghp
750f4bc276 fix ui 2023-12-30 10:07:02 +08:00
jxxghp
d0aada1d3d 更新 package.json 2023-12-29 15:41:50 +08:00
jxxghp
8a4848387c Merge pull request #65 from thofx/thofx_fix_ui 2023-12-28 20:07:51 +08:00
thofx
6904fc7da3 fix: 文件管理初始化两次的bug 2023-12-28 20:05:50 +08:00
jxxghp
28c55a05e6 Merge pull request #64 from thofx/thofx_fix_ui 2023-12-28 07:04:14 +08:00
jxxghp
562c829267 Merge pull request #63 from honue/main 2023-12-28 07:04:04 +08:00
thofx
b200ed242d fix: 解决历史记录不居中的问题
历史历史错误信息使用tooltip展示
fix:解决当搜索结果过多导致页面卡顿问题
2023-12-27 23:39:12 +08:00
honue
815cfe55df fix 2023-12-27 17:18:58 +08:00
honue
40a1094d74 fix 更新数量为1时不显示角标 2023-12-27 13:39:23 +08:00
honue
346650c091 feat:更改多集展示样式 2023-12-27 13:26:26 +08:00
jxxghp
7f74715f51 fix 2023-12-23 19:29:02 +08:00
jxxghp
b6fcee517d fix 2023-12-23 18:59:43 +08:00
jxxghp
4f62551f6b fix 历史记录路径 2023-12-16 12:10:30 +08:00
jxxghp
3980249271 Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend 2023-12-14 07:10:56 +08:00
jxxghp
e3b11b1130 fix 整理默认路径 2023-12-14 07:08:39 +08:00
jxxghp
f866f23af1 Merge pull request #62 from thsrite/main 2023-12-14 06:30:15 +08:00
thsrite
c793bc24f0 feat 订阅增加保存路径设置 2023-12-12 14:01:35 +08:00
jxxghp
591a46d559 add icon 2023-12-10 13:40:27 +08:00
jxxghp
2852f26702 fix 站点限流设置Bug 2023-12-07 15:44:01 +08:00
jxxghp
fc818fdfd6 v1.4.9 2023-12-06 10:24:43 +08:00
jxxghp
5566ef87f8 feat 插件跳转Github 2023-11-30 16:57:01 +08:00
jxxghp
366fe34d6f fix 2023-11-30 15:25:28 +08:00
jxxghp
37a0e83124 fix 规则导入 2023-11-30 15:18:10 +08:00
jxxghp
061a3f393a fix 2023-11-30 14:10:39 +08:00
jxxghp
1dff22aeab fix 2023-11-30 13:30:51 +08:00
jxxghp
78e6fd4809 fix clipboard 2023-11-30 12:58:25 +08:00
jxxghp
96bbf3d0f2 fix bug 2023-11-30 12:49:55 +08:00
jxxghp
a842eaba4e build 2023-11-30 11:59:52 +08:00
jxxghp
37565bf8e4 feat 优先级规则分享&导入 2023-11-30 11:59:26 +08:00
jxxghp
beb158b387 Merge pull request #59 from jjjokin/feat-history-search-hint 2023-11-29 12:33:03 +08:00
jokin
408eb06f8d feat 添加历史记录搜索提示 2023-11-29 12:17:02 +08:00
jxxghp
abe0e44635 fix typing 2023-11-29 12:08:13 +08:00
jxxghp
cfaf414f1c feat 自动计算图标颜色 2023-11-29 12:05:55 +08:00
jxxghp
f9c4dc616b fix 2023-11-29 09:27:39 +08:00
jxxghp
bf845bab6b feat 插件默认图标与背景色 2023-11-29 08:34:25 +08:00
jxxghp
bae9c85990 fix 2023-11-29 08:11:47 +08:00
jxxghp
56bbb8d0ff fix README.md 2023-11-28 15:08:11 +08:00
jxxghp
60d3565231 build icons 2023-11-28 10:15:24 +08:00
jxxghp
81340fd287 Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend 2023-11-27 08:27:51 +08:00
jxxghp
c10c348c73 feat 插件操作确认 2023-11-27 08:27:49 +08:00
jxxghp
65cb7d9674 更新 PluginAppCard.vue 2023-11-27 07:06:43 +08:00
jxxghp
24f1a10ff7 feat 插件重置 2023-11-26 21:40:27 +08:00
jxxghp
767d11182a add plugin icon 2023-11-26 20:07:13 +08:00
jxxghp
cf363f667e fix plugin ui 2023-11-24 09:56:05 +08:00
jxxghp
0d1046b8c7 v1.4.4 2023-11-20 19:55:52 +08:00
jxxghp
2c05f5779e v1.4.3 2023-11-19 13:49:16 +08:00
jxxghp
9af200f89e feat 支持更多豆瓣详情展示 2023-11-18 21:52:35 +08:00
jxxghp
7e221cfd46 fix douban check subscribe 2023-11-13 20:23:13 +08:00
jxxghp
640882d178 v1.4.2 2023-11-13 12:05:26 +08:00
jxxghp
3a1436abef fix #1100 2023-11-10 21:41:57 +08:00
jxxghp
d431f0490d fix user add 2023-11-10 20:41:01 +08:00
jxxghp
4c2a6c92a6 fix 2023-11-10 12:22:06 +08:00
jxxghp
086c230e9e fix text 2023-11-09 23:26:46 +08:00
jxxghp
27e2ff50f2 fix ui 2023-11-09 12:31:00 +08:00
jxxghp
3134e5596b fix 2023-11-07 09:57:46 +08:00
jxxghp
315274abf9 fix ui 2023-11-06 11:42:13 +08:00
jxxghp
52bbf65fa8 fix #1046 2023-11-05 21:45:42 +08:00
jxxghp
9c018ec63b Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend 2023-11-05 21:43:33 +08:00
jxxghp
bd7e457cdb fix ui 2023-11-05 21:43:27 +08:00
jxxghp
36a0f8515b 更新 package.json 2023-11-05 09:14:17 +08:00
jxxghp
cac10a337d fix 2023-11-04 22:27:56 +08:00
jxxghp
edb53cc58f fix plugin market icon 2023-11-02 12:50:57 +08:00
jxxghp
1dceeecdad fix ui 2023-11-02 11:16:38 +08:00
jxxghp
f8071ada0b feat 插件支持在线图标 2023-11-01 22:03:46 +08:00
jxxghp
21bc8edbd8 feat 在线插件市场 2023-11-01 21:05:31 +08:00
jxxghp
2a8aeb5041 feat 插件市场显示版本号 2023-11-01 18:02:51 +08:00
jxxghp
1a7760cf6d fix build 2023-11-01 17:05:12 +08:00
jxxghp
aee4eed5ac feat 拆分插件图标 2023-11-01 17:02:57 +08:00
jxxghp
87215fb590 add icons 2023-11-01 16:23:50 +08:00
jxxghp
5409126187 add versions 2023-11-01 12:23:08 +08:00
jxxghp
9840782ce5 v1.3.8 2023-10-31 11:48:17 +08:00
jxxghp
d18f42cd6f v1.3.7-1 2023-10-29 18:41:18 +08:00
jxxghp
9372e98459 fix #57 2023-10-29 18:40:37 +08:00
jxxghp
9400f4660d Merge pull request #57 from Shurelol/main 2023-10-29 17:53:05 +08:00
Shurelol
f0d66b8fba feat: 识别测试结果在应用识别词时显示应用详情 2023-10-28 02:56:08 +08:00
jxxghp
78abe72815 Merge pull request #56 from thsrite/main 2023-10-27 17:07:13 +08:00
thsrite
1ce75916ef fix 正在下载显示剩余下载时间 2023-10-27 16:53:09 +08:00
jxxghp
46959d4baa v1.3.7 2023-10-26 17:09:29 +08:00
jxxghp
b24cc44493 Merge pull request #55 from thsrite/main 2023-10-26 16:04:43 +08:00
thsrite
46f6c29e1d feat 云盘文件删除插件 2023-10-26 14:49:09 +08:00
thsrite
5ad75b8420 fix 登录页海报支持自定义tmdb/bing 2023-10-24 10:55:45 +08:00
jxxghp
2030459f20 v1.3.6-1 2023-10-22 08:23:43 +08:00
jxxghp
2855bf812b fix #947 2023-10-22 08:18:01 +08:00
jxxghp
69989893d9 v1.3.6 2023-10-21 14:22:49 +08:00
jxxghp
ffc61f4a31 Merge pull request #54 from thsrite/main 2023-10-20 14:33:15 +08:00
thsrite
dd051f28d2 feat 站点自动登录插件 2023-10-20 12:54:12 +08:00
jxxghp
a3d2def72b fix ui bug 2023-10-20 12:42:54 +08:00
jxxghp
e8552b4385 v1.3.5-1 2023-10-20 07:37:44 +08:00
jxxghp
d73e4853a8 fix ui 2023-10-19 17:06:46 +08:00
jxxghp
7f991da183 fix ui 2023-10-18 21:07:26 +08:00
jxxghp
046d96a012 Merge pull request #52 from thsrite/main 2023-10-17 17:04:33 +08:00
thsrite
9ee6ca43e3 feat MoviePilot更新推送插件 2023-10-17 16:17:05 +08:00
jxxghp
43b1f7e620 v1.3.4-2 2023-10-17 14:04:29 +08:00
jxxghp
ba76f79d85 fix ui 2023-10-17 14:02:54 +08:00
jxxghp
ce47afa698 fix ui 2023-10-16 19:56:49 +08:00
jxxghp
6da110948c v1.3.4 2023-10-16 17:35:23 +08:00
jxxghp
533c564db5 feat 普通用户下载中只显示自己添加的下载 2023-10-16 08:32:02 +08:00
jxxghp
4a65056909 fix 普通用户订阅权限 2023-10-16 08:23:38 +08:00
jxxghp
c52ad73101 fix dialogs 2023-10-16 06:58:09 +08:00
jxxghp
5a3673efc6 更新 package.json 2023-10-14 14:34:16 +08:00
jxxghp
c03ec1d741 fix ui 2023-10-14 14:29:57 +08:00
jxxghp
e62d0809b3 fix ui 2023-10-14 14:15:38 +08:00
jxxghp
7f13597517 feat merge form 2023-10-14 13:48:02 +08:00
jxxghp
c822f1fffd feat 整合站点编辑组件 2023-10-14 09:13:38 +08:00
jxxghp
14ca74a29d Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend 2023-10-13 22:56:07 +08:00
jxxghp
3ee897a350 fix 2023-10-13 22:56:02 +08:00
jxxghp
789aac60c9 更新 package.json 2023-10-13 22:39:49 +08:00
jxxghp
2c73a8f3e1 fix ui 2023-10-13 22:37:04 +08:00
jxxghp
539bc656f8 fix 2023-10-13 22:33:58 +08:00
jxxghp
feda0cad2d feat 默认过滤规则拆分 2023-10-13 21:28:34 +08:00
jxxghp
c723d89739 fix 2023-10-13 17:30:14 +08:00
jxxghp
0a0e7a059a fix 2023-10-13 17:29:23 +08:00
jxxghp
0263fbbee6 fix 2023-10-13 17:26:12 +08:00
jxxghp
e205296e22 feat 订阅实时编辑 2023-10-13 17:24:18 +08:00
jxxghp
261f5a9c68 fix #822 2023-10-13 15:14:26 +08:00
jxxghp
fa097651f4 fix rules 2023-10-13 11:41:27 +08:00
jxxghp
c94d5f7e7d fix bug 2023-10-12 22:45:59 +08:00
jxxghp
e34f18799f fix 2023-10-12 22:25:15 +08:00
jxxghp
1681a311f7 fix 2023-10-12 21:44:48 +08:00
jxxghp
da08d8ec19 fix ui 2023-10-12 21:36:08 +08:00
jxxghp
730178c838 fix 2023-10-12 20:02:24 +08:00
jxxghp
a04450ae98 feat 60fps 2023-10-12 17:06:37 +08:00
jxxghp
2b2fd66a29 fix bug 2023-10-12 16:31:17 +08:00
jxxghp
58fe08ad3d build ui 2023-10-12 16:30:27 +08:00
jxxghp
240d6bede0 fix ui 2023-10-12 16:26:05 +08:00
jxxghp
23d808f8b1 feat 资源页面视图切换 2023-10-12 16:09:15 +08:00
jxxghp
2f293706cb fix 2023-10-12 09:46:04 +08:00
jxxghp
9aaaf0c520 feat 更多订阅设置项 2023-10-12 09:43:55 +08:00
jxxghp
6694e7e929 feat 搜索框聚焦、发现页缓存 2023-10-12 08:13:05 +08:00
jxxghp
d3768cb994 feat switch view button 2023-10-11 21:30:59 +08:00
jxxghp
c59d3e28b9 feat torrents page 2023-10-11 21:21:49 +08:00
jxxghp
914239f434 feat 热门动漫 2023-10-11 16:12:38 +08:00
jxxghp
7a5d04dc53 add rclone 2023-10-11 11:47:45 +08:00
jxxghp
110fe39e72 Merge pull request #50 from thsrite/main
fix customization
2023-10-10 13:57:14 +08:00
thsrite
9689a86151 fix customization 2023-10-10 13:54:57 +08:00
jxxghp
6462ae5956 fix 2023-10-10 09:15:01 +08:00
jxxghp
053963d050 nginx conf 2023-10-10 08:36:09 +08:00
jxxghp
8a95549118 remove win builder 2023-10-10 08:09:29 +08:00
jxxghp
46e8fa551c fix plugin uri 2023-10-10 08:06:34 +08:00
jxxghp
be2034d75b fix build 2023-10-09 21:28:39 +08:00
jxxghp
634fa58048 fix build 2023-10-09 19:21:51 +08:00
jxxghp
cd5c093557 fix build 2023-10-09 19:16:14 +08:00
jxxghp
76cf86385e add pkg 2023-10-09 19:13:31 +08:00
jxxghp
5c5ed5d7ee add express 2023-10-09 18:40:55 +08:00
jxxghp
47e7a37667 Merge pull request #48 from thsrite/customization 2023-10-08 12:22:40 +08:00
thsrite
d642ab42be feat 自定义占位符 2023-10-08 12:13:21 +08:00
jxxghp
b4de1c99d5 repack 2023-10-08 09:19:23 +08:00
jxxghp
53e35eb9ff fix ui 2023-10-08 09:05:37 +08:00
jxxghp
b222098ec5 Update package.json 2023-10-07 05:55:15 -07:00
jxxghp
bb8cf7ed78 Merge pull request #47 from thsrite/main
feat 药丸论坛签到
2023-10-07 20:54:37 +08:00
thsrite
0219ce3a9c Merge remote-tracking branch 'origin/main' into main 2023-10-07 20:50:07 +08:00
thsrite
b82e5d7cba feat 药丸论坛签到 2023-10-07 20:49:59 +08:00
jxxghp
ccee71e638 fix ui 2023-09-29 08:55:33 +08:00
jxxghp
cba0e739eb 更新 package.json 2023-09-28 17:55:17 +08:00
jxxghp
c569cb9cde add icon 2023-09-28 17:11:04 +08:00
jxxghp
fc585a3900 v1.2.7 2023-09-28 16:16:04 +08:00
jxxghp
973f8529c2 Merge pull request #46 from thsrite/main
feat 自动清理媒体库插件icon
2023-09-28 15:46:36 +08:00
thsrite
1ff9dc50fd feat 自动清理媒体库插件icon 2023-09-28 15:12:15 +08:00
jxxghp
065c9053da fix ui 2023-09-28 12:52:44 +08:00
jxxghp
6905be1bcd fix #681 2023-09-28 09:58:39 +08:00
jxxghp
a550f9616c feat 批量整理进度条 2023-09-27 14:26:59 +08:00
jxxghp
bcee3e5373 v1.2.6 2023-09-27 10:19:05 +08:00
jxxghp
d377ced6b6 feat 优先级规则支持动态调整 2023-09-27 09:42:28 +08:00
jxxghp
6e0ceb093c feat 批量重新整理 2023-09-27 08:59:09 +08:00
jxxghp
745f99e52e feat 历史记录批量重新整理 2023-09-27 08:45:55 +08:00
jxxghp
7197034eda fix ui 2023-09-24 19:52:55 +08:00
jxxghp
264748652f fix 2023-09-24 19:47:28 +08:00
jxxghp
48e214564a v1.2.5 2023-09-24 19:30:37 +08:00
jxxghp
5424e7e02a fix ui 2023-09-24 12:26:42 +08:00
jxxghp
0c9c70b067 feat 服务设置 2023-09-24 11:14:27 +08:00
jxxghp
0ff24f4b09 fix torrent ui 2023-09-23 11:55:49 +08:00
jxxghp
cfa75b7643 rename 2023-09-23 08:32:24 +08:00
jxxghp
b72ad1d78d Merge pull request #45 from jianxcao/feature-url-307
feat: 修改url地址,防止307成错误的url地址
2023-09-22 15:34:14 +08:00
jxxghp
5d1f293606 feat SynologyChat 2023-09-22 15:32:51 +08:00
jianxiong.cao
2dc0eca4aa feat: 修改url地址,防止307成错误的url地址 2023-09-22 15:11:52 +08:00
jxxghp
f5808c1c81 fix 光标聚焦 2023-09-22 13:49:00 +08:00
jxxghp
321037477f fix placeholder 2023-09-22 11:03:58 +08:00
jxxghp
43589c66e9 fix bug 2023-09-22 09:20:41 +08:00
jxxghp
435f299a8b fix ui 2023-09-22 07:27:27 +08:00
jxxghp
083db80251 更新 MediaCard.vue 2023-09-21 23:21:04 +08:00
jxxghp
92bf520cf4 fix 2023-09-21 23:06:51 +08:00
jxxghp
ab354f21c4 fix ui 2023-09-21 22:57:54 +08:00
jxxghp
c7a2c045c7 fix ui 2023-09-21 22:50:35 +08:00
jxxghp
d33c8942e4 fix ui 2023-09-21 22:28:18 +08:00
jxxghp
5e630097b9 更新 MediaCard.vue 2023-09-21 21:52:56 +08:00
131 changed files with 7473 additions and 1973 deletions

View File

@@ -1 +1 @@
VITE_API_BASE_URL=/api/v1/
VITE_API_BASE_URL=api/v1/

View File

@@ -1,4 +1,4 @@
name: Build moviepilot frontend
name: Build Moviepilot-Frontend
on:
workflow_dispatch:
@@ -27,6 +27,13 @@ jobs:
node-version: '18'
cache: 'yarn'
- name: Download Icons
run: |
pwd
curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp -
mv /tmp/MoviePilot-Plugins-main/icons public/plugin_icon
rm -rf /tmp/MoviePilot-Plugins-main
- name: Build frontend
id: build_frontend
run: |

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ dist-ssr
# iconify dist files
src/@iconify/*.js
public/plugin_icon/**

View File

@@ -31,8 +31,8 @@
"volar.preview.port": 3000,
"volar.completion.preferredTagNameCase": "pascal",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit"
},
"eslint.alwaysShowStatus": true,
"eslint.format.enable": true,

View File

@@ -1,35 +1,39 @@
# MoviePilot-Frontend
This template should help get you started developing with Vue 3 in Vite.
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目。
## Recommended IDE Setup
## 推荐的IDE设置
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur).
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (并禁用 Vetur).
## Type Support for `.vue` Imports in TS
## 配置Vite
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates.
请参阅 [Vite 配置参考](https://vitejs.dev/config/).
However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can run `Volar: Switch TS Plugin on/off` from VSCode command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
## 依赖安装
```sh
yarn
```
### Compile and Hot-Reload for Development
### 开发运行
```sh
yarn dev
```
### Type-Check, Compile and Minify for Production
### 编译打包
```sh
yarn build
```
### 静态运行
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件nginx配置参考 `public/nginx.conf`
2. 使用 `node` 命令直接运行`service.js`,默认监听 `3000` 端口,设置环境变量 `NGINX_PORT` 来调整运行端口。
```shell
node dist/service.js
```

View File

@@ -1,7 +1,8 @@
{
"name": "moviepilot",
"version": "1.2.4",
"version": "1.6.7",
"private": true,
"bin": "dist/service.js",
"scripts": {
"dev": "vite --host",
"build": "vite build",
@@ -9,7 +10,13 @@
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
"postinstall": "npm run build:icons"
"postinstall": "npm run build:icons",
"pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe"
},
"pkg": {
"assets": [
"dist/**/*"
]
},
"dependencies": {
"@casl/ability": "^6.2.0",
@@ -17,10 +24,14 @@
"@floating-ui/dom": "1.2.8",
"@vueuse/core": "^10.1.2",
"@vueuse/math": "^10.1.2",
"ace-builds": "^1.32.6",
"apexcharts-clevision": "^3.28.5",
"axios": "1.4.0",
"axios-mock-adapter": "^1.21.4",
"chart.js": "^4.1.2",
"colorthief": "^2.4.0",
"express": "^4.18.2",
"express-http-proxy": "^2.0.0",
"jwt-decode": "^3.1.2",
"nprogress": "^0.2.0",
"postcss-purgecss": "^5.0.0",
@@ -38,6 +49,7 @@
"vue-prism-component": "^2.0.0",
"vue-router": "^4.2.0",
"vue-toast-notification": "^3",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.4.1",
"vue3-perfect-scrollbar": "^1.6.0",
"vuetify": "3.3.5",

86
public/nginx.conf Normal file
View File

@@ -0,0 +1,86 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
sendfile on;
keepalive_timeout 3600;
server {
include mime.types;
default_type application/octet-stream;
listen 3000;
listen [::]:3000;
server_name moviepilot;
location / {
# 主目录
expires off;
add_header Cache-Control "no-cache, no-store, must-revalidate";
root html;
try_files $uri $uri/ /index.html;
}
location /assets {
# 静态资源
expires 7d;
add_header Cache-Control "public";
root html;
}
location ~ ^/api/v1/system/(message|progress/) {
# SSE MIME类型设置
default_type text/event-stream;
# 禁用缓存
add_header Cache-Control no-cache;
add_header X-Accel-Buffering no;
proxy_buffering off;
proxy_cache off;
# 代理设置
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 超时设置
proxy_read_timeout 3600s;
}
location /api {
# 后端API
proxy_pass http://backend_api;
rewrite ^.+mock-server/?(.*)$ /$1 break;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_redirect off;
proxy_set_header Connection "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Nginx-Proxy true;
# 超时设置
proxy_read_timeout 600s;
}
}
upstream backend_api {
# 后端API的地址和端口
server 127.0.0.1:3001;
# 可以添加更多后端服务器作为负载均衡
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

41
public/service.js Normal file
View File

@@ -0,0 +1,41 @@
const path = require('node:path')
const express = require('express')
const proxy = require('express-http-proxy')
const app = express()
const port = process.env.NGINX_PORT || 3000
// 后端 API 地址
const proxyConfig = {
URL: '127.0.0.1',
PORT: process.env.PORT || 3001
}
// 静态文件服务目录
app.use(express.static(__dirname))
// 配置代理中间件将请求转发给后端API
app.use(
'/api',
proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
// 路径加上 /api 前缀
proxyReqPathResolver: (req) => {
return `/api${req.url}`
}
})
);
// 处理根路径的请求
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'))
})
// 处理所有其他请求,重定向到前端入口文件
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'))
})
app.listen(port, () => {
console.log(`Server is running on port ${port}`)
})

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref } from 'vue'
import { useTheme } from 'vuetify'
import type { ThemeSwitcherTheme } from '@layouts/types'
@@ -20,26 +20,30 @@ const {
{ initialValue: savedTheme.value },
)
function changeTheme() {
const nextTheme = getNextThemeName()
globalTheme.name.value = nextTheme
savedTheme.value = nextTheme
localStorage.setItem('theme', nextTheme)
function updateTheme() {
const autoTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
globalTheme.name.value = theme
savedTheme.value = theme
// 修改载入时背景色
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
themeTransition()
}
// Update icon if theme is changed from other sources
// 监听系统主题变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
watch(
() => globalTheme.name.value,
(val) => {
currentThemeName.value = val
},
() => currentThemeName.value,
() => updateTheme(),
)
function changeTheme() {
const nextTheme = getNextThemeName()
currentThemeName.value = nextTheme
localStorage.setItem('theme', nextTheme)
}
// Apply saved theme on page load
// onMounted(() => {
// globalTheme.name.value = savedTheme.value

21
src/@core/utils/dom.ts Normal file
View File

@@ -0,0 +1,21 @@
export function removeEl(selector: string) {
if (selector) {
const el = document.querySelector(selector)
el?.parentNode?.removeChild(el)
}
}
export function useDefer(maxFrameCount = 1) {
const frameCount = ref(0)
const refreshFrameCount = () => {
requestAnimationFrame(() => {
frameCount.value++
if (frameCount.value < maxFrameCount)
refreshFrameCount()
})
}
refreshFrameCount()
return function (showInFrameCount: number) {
return frameCount.value >= showInFrameCount
}
}

View File

@@ -55,7 +55,7 @@ export function formatFileSize(bytes: number) {
if (bytes < 0)
throw new Error('字节数不能为负数。')
const units = ['B', 'K', 'M', 'G', 'T']
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unitIndex = 0
@@ -109,3 +109,41 @@ export function formatBytes(bytes: number, decimals = 2) {
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`
}
// 格式化剧集列表
export function formatEp(nums: number[]): string {
if (!nums.length)
return ''
if (nums.length === 1)
return nums[0].toString()
// 将数组升序排序
nums.sort((a, b) => a - b)
const formattedRanges: string[] = []
let start = nums[0]
let end = nums[0]
for (let i = 1; i < nums.length; i++) {
if (nums[i] === end + 1) {
end = nums[i]
}
else {
if (start === end)
formattedRanges.push(start.toString())
else
formattedRanges.push(`${start.toString()}-${end.toString()}`)
start = end = nums[i]
}
}
if (start === end)
formattedRanges.push(start.toString())
else
formattedRanges.push(`${start.toString()}-${end.toString()}`)
return formattedRanges.join('、')
}

23
src/@core/utils/image.ts Normal file
View File

@@ -0,0 +1,23 @@
import ColorThief from 'colorthief'
// 将 RGB 转换为十六进制
function rgbStringToHex(rgbArray: number[]): string {
if (rgbArray.length !== 3 || rgbArray.some(isNaN))
throw new Error('Invalid RGB string format')
const [r, g, b] = rgbArray
const toHex = (c: number): string => {
const hex = c.toString(16)
return hex.length === 1 ? `0${hex}` : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
// 提取主要颜色
export async function getDominantColor(image: HTMLImageElement): Promise<string> {
const colorThief = new ColorThief()
const dominantColor = colorThief.getColor(image)
return rgbStringToHex(dominantColor)
}

View File

@@ -33,7 +33,7 @@ export function isToday(date: Date) {
)
}
// 计算时间差返回xx天xx小时xx分钟
// 计算时间差返回xx天/xx小时/xx分钟/xx秒
export function calculateTimeDifference(inputTime: string): string {
if (!inputTime)
return ''
@@ -64,6 +64,38 @@ export function calculateTimeDifference(inputTime: string): string {
}
}
// 计算时间差返回xx天xx小时xx分钟
export function calculateTimeDiff(inputTime: string): string {
if (!inputTime)
return ''
// 使用当前时区
const inputDate = new Date(inputTime)
const currentDate = new Date()
const timeDifference = currentDate.getTime() - inputDate.getTime()
const secondsDifference = Math.floor(timeDifference / 1000)
const days = Math.floor(secondsDifference / 86400)
const hours = Math.floor(secondsDifference % 86400 / 3600)
const minutes = Math.floor(secondsDifference % 86400 % 3600 / 60)
const secones = Math.floor(secondsDifference % 60)
if (days > 0)
return `${days}${hours}小时${minutes}分钟`
else if (hours > 0)
return `${hours}小时${minutes}分钟`
else if (minutes > 0)
return `${minutes}分钟`
else if (secones > 0)
return `${secones}`
return ''
}
// 判断一个数组subArray是不是在另一个数组mainArray中
export function isContained(subArray: any[], mainArray: any[]): boolean {
return subArray.every(element => mainArray.includes(element))

View File

@@ -0,0 +1,30 @@
// 请求和获取剪贴板内容
export async function getClipboardContent() {
if (navigator.clipboard && window.isSecureContext) {
return await navigator.clipboard.readText()
}
else {
const input = document.createElement('textarea')
document.body.appendChild(input)
input.select()
document.execCommand('paste')
const content = input.value
document.body.removeChild(input)
return content
}
}
// 将内容复制到剪贴板,兼容非安全域场景
export async function copyToClipboard(content: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content)
}
else {
const input = document.createElement('textarea')
input.value = content
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
}
}

View File

@@ -3,9 +3,15 @@ import { useToast } from 'vue-toast-notification'
import { useTheme } from 'vuetify'
import store from './store'
function setTheme() {
const { global: globalTheme } = useTheme()
let theme = localStorage.getItem('theme') || 'light'
if (theme === 'auto')
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
globalTheme.name.value = theme
}
// 第一时间应用主题
const { global: globalTheme } = useTheme()
globalTheme.name.value = localStorage.getItem('theme') || 'light'
setTheme()
// 提示框
const $toast = useToast()

54
src/ace-config.ts Normal file
View File

@@ -0,0 +1,54 @@
import ace from 'ace-builds'
import modeJsonUrl from 'ace-builds/src-noconflict/mode-json?url'
import modeJavascriptUrl from 'ace-builds/src-noconflict/mode-javascript?url'
import modeHtmlUrl from 'ace-builds/src-noconflict/mode-html?url'
import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
import themeMonokaiUrl from 'ace-builds/src-noconflict/theme-monokai?url'
import workerBaseUrl from 'ace-builds/src-noconflict/worker-base?url'
import workerJsonUrl from 'ace-builds/src-noconflict/worker-json?url'
import workerJavascriptUrl from 'ace-builds/src-noconflict/worker-javascript?url'
import workerHtmlUrl from 'ace-builds/src-noconflict/worker-html?url'
import workerYamlUrl from 'ace-builds/src-noconflict/worker-yaml?url'
import snippetsHtmlUrl from 'ace-builds/src-noconflict/snippets/html?url'
import snippetsJsUrl from 'ace-builds/src-noconflict/snippets/javascript?url'
import snippetsYamlUrl from 'ace-builds/src-noconflict/snippets/yaml?url'
import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
import 'ace-builds/src-noconflict/ext-language_tools'
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
ace.config.setModuleUrl('ace/mode/base', workerBaseUrl)
ace.config.setModuleUrl('ace/mode/json_worker', workerJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript_worker', workerJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html_worker', workerHtmlUrl)
ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
ace.require('ace/ext/language_tools')

View File

@@ -44,6 +44,15 @@ export interface Subscribe {
// 排除
exclude?: string
// 质量
quality?: string
// 分辨率
resolution?: string
// 特效
effect?: string
// 总集数
total_episode?: number
@@ -68,11 +77,14 @@ export interface Subscribe {
// 订阅站点
sites: number[]
// 是否洗版
best_version: number
// 是否洗版数字或者boolean
best_version: any
// 当前优先级
current_priority: number
// 保存目录
save_path: string
}
// 历史记录
@@ -87,7 +99,7 @@ export interface TransferHistory {
// 目的目录
dest?: string
// 转移模式link/copy/move/softlink
// 转移模式link/copy/move/softlink/rclone_copy/rclone_move
mode?: string
// 类型:电影、电视剧
@@ -328,7 +340,7 @@ export interface TmdbEpisode {
guest_stars: Object[]
}
// TMDB人信息
// TMDB人信息
export interface TmdbPerson {
// ID
id?: number
@@ -379,6 +391,34 @@ export interface TmdbPerson {
biography?: string
}
// 豆瓣人物信息
export interface DoubanPerson {
// ID
id?: string
// 名称
name?: string
// 角色
roles?: string[]
// 简介
title?: string
// 详情页面
url?: string
// 饰演
character?: string
// 图片 large/normal
avatar?: { [key: string]: string }
// 别名
latin_name?: string
}
// 站点
export interface Site {
@@ -407,13 +447,13 @@ export interface Site {
ua?: string
// 是否使用代理
proxy?: number
proxy?: any
// 过滤规则
filter?: string
// 是否演染
render?: number
render?: any
// 是否公开站点
public?: number
@@ -469,6 +509,9 @@ export interface DownloadingInfo {
// 媒体信息
media: { [key: string]: any }
// 下载用户
userid?: string
}
// 缺失剧集信息
@@ -500,9 +543,6 @@ export interface Plugin {
// 插件图标
plugin_icon?: string
// 主题色
plugin_color?: string
// 插件版本
plugin_version?: string
@@ -529,6 +569,15 @@ export interface Plugin {
// 是否有详情页面
has_page?: boolean
// 是否有新版本
has_update?: boolean
// 是否本地插件
is_local?: boolean
// 插件仓库地址
repo_url?: string
}
// 种子信息
@@ -602,6 +651,13 @@ export interface TorrentInfo {
// 促销描述
volume_factor: string
// 免费时间
freedate: string
// 剩余免费时间
freedate_diff: string
}
// 识别元数据
@@ -613,6 +669,9 @@ export interface MetaInfo {
// 原字符串
org_string?: string
// 原标题(未经识别词转换)
title?: string
// 副标题
subtitle?: string
@@ -714,6 +773,9 @@ export interface MetaInfo {
// 资源类型+特效
edition: string
// 应用的自定义识别词
apply_words: string[]
}
// 上下文信息
@@ -832,6 +894,7 @@ export interface NotificationSwitch {
wechat: boolean
telegram: boolean
slack: boolean
synologychat: boolean
}
// 环境设置
@@ -861,3 +924,26 @@ export interface FileItem {
children: FileItem[]
modify_time: number
}
// 媒体服务器播放条目
export interface MediaServerPlayItem {
id?: string | number
title: string
subtitle?: string
type?: string
image?: string
link?: string
percent?: number
}
// 媒体服务器媒体库
export interface MediaServerLibrary {
server: string
id?: string | number
name: string
path?: string
type?: string
image?: string
image_list?: string[]
link?: string
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import axios from 'axios'
import List from './filebrowser/List.vue'
import Toolbar from './filebrowser/Toolbar.vue'
import Tree from './filebrowser/Tree.vue'
import List from './filebrowser/List.vue'
import type { EndPoints } from '@/api/types'
// 输入参数
@@ -70,10 +70,12 @@ const storagesArray = computed(() => {
// 方法
function loadingChanged(loading: number) {
if (loading)
if (loading) {
loading++
else if (loading > 0)
}
else if (loading > 0) {
loading--
}
}
function storageChanged(storage: string) {
@@ -92,56 +94,58 @@ function sortChanged(s: string) {
}
// 初始化
onBeforeMount(() => {
onMounted(() => {
activeStorage.value = props.storage ?? 'local'
axiosInstance.value = props.axios ?? axios.create(props.axiosconfig)
})
</script>
<template>
<VCard class="mx-auto" :loading="loading > 0">
<Toolbar
:path="props.path"
:storages="storagesArray"
:storage="activeStorage"
:endpoints="props.endpoints"
:axios="axiosInstance"
@storagechanged="storageChanged"
@pathchanged="pathChanged"
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<VRow no-gutters>
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
<Tree
:path="props.path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
/>
</VCol>
<VDivider v-if="tree" vertical />
<VCol>
<List
:path="props.path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
:sort="sort"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
/>
</VCol>
</VRow>
<VCard class="mx-auto" :loading="loading > 0 || !path">
<div v-if="path">
<Toolbar
:path="path"
:storages="storagesArray"
:storage="activeStorage"
:endpoints="endpoints"
:axios="axiosInstance"
@storagechanged="storageChanged"
@pathchanged="pathChanged"
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<VRow no-gutters>
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
<Tree
:path="path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
/>
</VCol>
<VDivider v-if="tree" vertical />
<VCol>
<List
:path="path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
:sort="sort"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
/>
</VCol>
</VRow>
</div>
</VCard>
</template>

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
width: String,
height: String,
})
// 图片是否加载完成
const imageLoaded = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
}
// 计算图片地址
const getImgUrl = computed(() => {
const image = props.media?.image || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}/0`
})
</script>
<template>
<VHover
v-bind="props"
:height="props.height"
:width="props.width"
>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="ring-gray-500"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': imageLoaded,
}"
@click="goPlay"
>
<template #image>
<VImg
:src="getImgUrl"
aspect-ratio="2/3"
cover
@load="imageLoadHandler"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<VCardText
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 ...">
{{ props.media?.title }}
</h1>
<span class="text-shadow">{{ props.media?.subtitle }}</span>
</VCardText>
</VImg>
</template>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="props.media?.percent"
:model-value="props.media?.percent"
bg-color="success"
color="success"
/>
</div>
</VCard>
</template>
</VHover>
</template>
<style lang="scss">
.text-shadow{
text-shadow:1px 1px #777;
}
</style>

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
import personIcon from '@images/misc/person-icon.png'
import type { DoubanPerson } from '@/api/types'
const personProps = defineProps({
person: Object as PropType<DoubanPerson>,
width: String,
height: String,
})
// 当前人物
const personInfo = ref(personProps.person)
// 人物图片是否加载
const isImageLoaded = ref(false)
// 人物图片地址
function getPersonImage() {
if (!personInfo.value?.avatar)
return personIcon
return personInfo.value?.avatar?.large
}
// 打开人物详情
function goPersonDetail() {
if (!personInfo.value?.id)
return
window.open(`https://movie.douban.com/celebrity/${personInfo.value?.id}/`, '_blank')
}
</script>
<template>
<VHover v-bind="personProps">
<template #default="hover">
<VCard
v-bind="hover.props"
:height="personProps.height"
:width="personProps.width"
class="rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105': hover.isHovering,
}"
@click.stop="goPersonDetail"
>
<div
class="person-card relative transform-gpu cursor-pointer rounded shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700"
>
<div style="padding-bottom: 150%;">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
<VAvatar
size="120"
:class="{
'ring-1 ring-gray-700': isImageLoaded,
}"
>
<VImg
v-img
:src="getPersonImage()"
cover
@load="isImageLoaded = true"
/>
</VAvatar>
</div>
<div class="w-full truncate text-center font-bold">
{{ personInfo?.name }}
</div>
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;">
{{ personInfo?.character }}
</div>
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
</div>
</div>
</div>
</VCard>
</template>
</VHover>
</template>
<style lang="scss">
.person-card {
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
}
.person-card:hover {
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-custom-background)) 60%);
}
</style>

View File

@@ -17,12 +17,17 @@ function getPercentage() {
// 速度
function getSpeedText() {
return `${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s`
return `${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${props.info?.left_time}`
}
// 下载状态
const isDownloading = ref(props.info?.state === 'downloading')
// 监听props.info?.state的变化
watch(() => props.info?.state, (newValue) => {
isDownloading.value = newValue === 'downloading';
});
// 图片是否加载完成
const imageLoaded = ref(false)

View File

@@ -2,19 +2,30 @@
// 输入参数
const props = defineProps({
pri: String,
maxpri: String,
rules: Array as PropType<string[]>,
width: String,
height: String,
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed'])
const emit = defineEmits(['close', 'changed', 'levelup', 'leveldown'])
// 按钮点击
function onClose() {
emit('close')
}
// 上升优先级
function onLevelUp() {
emit('levelup', props.pri)
}
// 下降优先级
function onLevelDown() {
emit('leveldown', props.pri)
}
// 选项变化
function filtersChanged(value: string[]) {
emit('changed', props.pri, value)
@@ -24,6 +35,11 @@ function filtersChanged(value: string[]) {
const selectFilterOptions = ref<{ [key: string]: string }[]>([
{ title: '特效字幕', value: ' SPECSUB ' },
{ title: '中文字幕', value: ' CNSUB ' },
{ title: '国语配音', value: ' CNVOI ' },
{ title: '排除: 国语配音', value: ' !CNVOI ' },
{ title: '粤语配音', value: ' HKVOI ' },
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
{ title: '促销: 免费', value: ' FREE ' },
{ title: '分辨率: 4K', value: ' 4K ' },
{ title: '分辨率: 1080P', value: ' 1080P ' },
{ title: '分辨率: 720P', value: ' 720P ' },
@@ -38,22 +54,41 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
{ title: '排除: REMUX', value: ' !REMUX ' },
{ title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
{ title: '质量: 60fps', value: ' 60FPS ' },
{ title: '排除: 60fps', value: ' !60FPS ' },
{ title: '编码: H265', value: ' H265 ' },
{ title: '排除: H265', value: ' !H265 ' },
{ title: '编码: H264', value: ' H264 ' },
{ title: '排除: H264', value: ' !H264 ' },
{ title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
{ title: '效果: HDR', value: ' HDR ' },
{ title: '排除: HDR', value: ' !HDR ' },
{ title: '国语配音', value: ' CNVOI ' },
{ title: '排除: 国语配音', value: ' !CNVOI ' },
{ title: '促销: 免费', value: ' FREE ' },
{ title: '效果: SDR', value: ' SDR ' },
{ title: '排除: SDR', value: ' !SDR ' },
{ title: '效果: 3D', value: ' 3D ' },
{ title: '排除: 3D', value: ' !3D ' },
])
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height">
<span class="absolute top-3 right-14">
<IconBtn
v-if="props.pri !== '1'"
@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>
</span>
<DialogCloseBtn @click="onClose" />
<VCardItem>
<VCardTitle>优先级 {{ props.pri }}</VCardTitle>

View File

@@ -0,0 +1,202 @@
<script lang="ts" setup>
import type { MediaServerLibrary } from '@/api/types'
import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerLibrary>,
width: String,
height: String,
})
// canvas
const canvasRef = ref<HTMLCanvasElement>()
// 图片地址
const imgUrl = ref('')
// 图片是否加载完成
const imageLoaded = ref(false)
// 图片是否加载错误
const imageError = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 图片加载错误
function imageErrorHandler() {
imageError.value = true
}
// 默认图片
function getDefaultImage() {
if (props.media?.server === 'plex')
return plex
else if (props.media?.server === 'emby')
return emby
else if (props.media?.server === 'jellyfin')
return jellyfin
else
return plex
}
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
}
// 生成图片代理路径
function getImgUrl(url: string) {
if (!url)
return getDefaultImage()
else
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(url)}/0`
}
// 根据多张图片生成媒体库封面
async function drawImages(imageList: string[]) {
// 图片
const IMAGES = imageList
if (IMAGES.length === 0)
return getDefaultImage()
// 为所有图片添加system/img前缀
for (let i = 0; i < IMAGES.length; i++)
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(IMAGES[i])}/0`
// canvas
const canvas = canvasRef.value
if (!canvas)
return getDefaultImage()
// 画布参数
const POSTER_WIDTH = (canvas.width - 32) / 4
const POSTER_HEIGHT = canvas.height * 0.75 - 8
const MARGIN_WIDTH = 4
const MARGIN_HEIGHT = 4
const REFLECTION_HEIGHT = POSTER_HEIGHT / 2
const REFLECTION_SHOW_HEIGHT = canvas.height / 4
// 获取画布上下文
const ctx = canvas.getContext('2d')
if (!ctx)
return getDefaultImage()
// 设置背景色为黑色
ctx.fillStyle = '#000000'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 绘制图片
async function drawImageWithReflection(imgSrc: string, index: number) {
if (!canvas)
return
if (!ctx)
return
const img = new Image()
img.setAttribute('crossorigin', 'anonymous')
img.src = imgSrc
await new Promise(resolve => img.onload = resolve)
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
const y = MARGIN_HEIGHT
ctx.drawImage(img, x, y, POSTER_WIDTH, POSTER_HEIGHT)
ctx.save()
ctx.translate(0, canvas.height)
ctx.scale(1, -1)
ctx.drawImage(
img,
0,
0,
img.width,
img.height,
x,
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
POSTER_WIDTH,
REFLECTION_HEIGHT,
)
const gradient = ctx.createLinearGradient(
0,
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
0,
REFLECTION_HEIGHT,
)
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)')
ctx.fillStyle = gradient
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_SHOW_HEIGHT)
ctx.restore()
}
// 绘制多张图片
const loopCount = Math.min(4, IMAGES.length)
for (let i = 0; i < loopCount; i++)
await drawImageWithReflection(IMAGES[i], i + 1)
// 转换为图片地址
return canvas.toDataURL('image/png')
}
onMounted(async () => {
if (props.media?.image_list && props.media?.image_list.length > 0)
imgUrl.value = await drawImages(props.media?.image_list || [])
else
imgUrl.value = getImgUrl(props.media?.image || '')
})
</script>
<template>
<VHover
v-bind="props"
:height="props.height"
:width="props.width"
>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
@click="goPlay"
>
<template #image>
<canvas ref="canvasRef" class="w-full h-full hidden" />
<VImg
:src="imgUrl"
aspect-ratio="2/3"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1 class="mb-1 text-white font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.name }}
</h1>
</VCardText>
</VImg>
</template>
</VCard>
</template>
</VHover>
</template>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import type { PropType, Ref } from 'vue'
import { useToast } from 'vue-toast-notification'
import SubscribeEditForm from '../form/SubscribeEditForm.vue'
import { formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
@@ -15,6 +16,11 @@ const props = defineProps({
height: String,
})
// 订阅规则
const subscribeRules = ref({
show_edit_dialog: false,
})
// 提示框
const $toast = useToast()
@@ -33,12 +39,18 @@ const isSubscribed = ref(false)
// 本地存在状态
const isExists = ref(false)
// 各季缺失状态0-已存在 1-部分缺失 2-全部缺失,没有数据也是已存在
// 各季缺失状态0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
const seasonsNotExisted = ref<{ [key: number]: number }>({})
// 订阅季弹窗
const subscribeSeasonDialog = ref(false)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
// 季详情
const seasonInfos = ref<TmdbSeason[]>([])
@@ -86,6 +98,7 @@ async function handleAddSubscribe() {
}
else {
// 弹出季选择列表,支持多选
seasonsSelected.value = []
subscribeSeasonDialog.value = true
}
}
@@ -112,7 +125,7 @@ async function addSubscribe(season = 0) {
// 全部存在时洗版
best_version = !seasonsNotExisted.value[season] ? 1 : 0
// 请求API
const result: { [key: string]: any } = await api.post('subscribe', {
const result: { [key: string]: any } = await api.post('subscribe/', {
name: props.media?.title,
type: props.media?.type,
year: props.media?.year,
@@ -136,6 +149,12 @@ async function addSubscribe(season = 0) {
result.message,
best_version,
)
// 弹出订阅编辑弹窗
if (result.success && seasonsSelected.value.length <= 1 && subscribeRules.value.show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
}
}
catch (error) {
console.error(error)
@@ -156,9 +175,9 @@ function showSubscribeAddToast(result: boolean,
if (best_version > 0)
subname = '洗版订阅'
if (result)
if (result && seasonsSelected.value.length > 1)
$toast.success(`${title} 添加${subname}成功!`)
else
else if (!result)
$toast.error(`${title} 添加${subname}失败:${message}`)
}
@@ -206,10 +225,10 @@ async function handleCheckSubscribe() {
}
}
// 查询当前媒体是否已存在
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
const result: { [key: string]: any } = await api.get('media/exists', {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
title: props.media?.title,
@@ -237,6 +256,7 @@ async function checkSubscribe(season = 0) {
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season,
title: props.media?.title,
},
})
@@ -254,10 +274,10 @@ async function checkSeasonsNotExists() {
// 开始处理
startNProgress()
try {
const result: NotExistMediaInfo[] = await api.post('download/notexists', props.media)
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
if (result) {
result.forEach((item) => {
// 0-已存在 1-部分缺失 2-全部缺失
// 0-已入库 1-部分缺失 2-全部缺失
let state = 0
if (item.episodes.length === 0)
state = 2
@@ -287,6 +307,20 @@ async function getMediaSeasons() {
}
}
// 查询订阅弹窗规则
async function querySubscribeRules() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/DefaultFilterRules',
)
if (result.data?.value)
subscribeRules.value = result.data?.value
}
catch (error) {
console.log(error)
}
}
// 爱心订阅按钮响应
function handleSubscribe() {
if (isSubscribed.value)
@@ -313,14 +347,14 @@ function getExistColor(season: number) {
function getExistText(season: number) {
const state = seasonsNotExisted.value[season]
if (!state)
return '已存在'
return '已入库'
if (state === 1)
return '部分缺失'
else if (state === 2)
return '缺失'
else
return '已存在'
return '已入库'
}
// 打开详情页
@@ -358,6 +392,7 @@ function handleSearch() {
onBeforeMount(() => {
handleCheckSubscribe()
handleCheckExists()
querySubscribeRules()
})
// 计算图片地址
@@ -378,6 +413,21 @@ function getSeasonPoster(posterPath: string) {
return ''
return `https://image.tmdb.org/t/p/w500${posterPath}`
}
// 将yyyy-mm-dd转换为yyyy年mm月dd日
function formatAirDate(airDate: string) {
if (!airDate)
return ''
const date = new Date(airDate)
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
// 从yyyy-mm-dd中提取年份
function getYear(airDate: string) {
if (!airDate)
return ''
const date = new Date(airDate)
return date.getFullYear()
}
</script>
<template>
@@ -465,18 +515,24 @@ function getSeasonPoster(posterPath: string) {
inset
scrollable
>
<VCard title="选择订阅季">
<VCard class="rounded-t">
<DialogCloseBtn @click="subscribeSeasonDialog = false" />
<VCardText class="p-0">
<VCardTitle class="pe-10">
订阅 - {{ props.media?.title }}
</VCardTitle>
<VCardText>
<VList
v-model="seasonsSelected"
multiple
v-model:selected="seasonsSelected"
lines="three"
select-strategy="classic"
>
<VListItem v-for="(item, i) in seasonInfos" :key="i">
<VListItem
v-for="(item, i) in seasonInfos" :key="i"
:value="item"
>
<template #prepend>
<VImg
height="80"
height="90"
width="60"
:src="getSeasonPoster(item.poster_path || '')"
aspect-ratio="2/3"
@@ -493,10 +549,20 @@ function getSeasonPoster(posterPath: string) {
<VListItemTitle>
{{ item.season_number }}
</VListItemTitle>
<VListItemSubtitle class="mt-1">
评分{{ item.vote_average }}上映日期{{ item.air_date }}
<VListItemSubtitle class="mt-1 me-2">
<VChip
v-if="item.vote_average"
color="primary"
size="small"
class="mb-1"
>
<VIcon icon="mdi-star" /> {{ item.vote_average }}
</VChip>
{{ getYear(item.air_date || '') }} {{ item.episode_count }}
</VListItemSubtitle>
<VListItemSubtitle>
{{ media?.title }} {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播
</VListItemSubtitle>
<VListItemSubtitle v-html="item.overview" />
<VListItemSubtitle>
<VChip
v-if="seasonsNotExisted"
@@ -507,9 +573,9 @@ function getSeasonPoster(posterPath: string) {
{{ getExistText(item.season_number || 0) }}
</VChip>
</VListItemSubtitle>
<template #append>
<template #append="{ isSelected }">
<VListItemAction start>
<VSwitch v-model="seasonsSelected" :value="item.season_number" />
<VSwitch :model-value="isSelected" />
</VListItemAction>
</template>
</VListItem>
@@ -526,6 +592,14 @@ function getSeasonPoster(posterPath: string) {
</div>
</VCard>
</VBottomSheet>
<!-- 订阅编辑弹窗 -->
<SubscribeEditForm
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="() => { subscribeEditDialog = false; handleCheckSubscribe(); }"
/>
</template>
<style lang="scss">

View File

@@ -143,5 +143,33 @@ function openTmdbPage(type: string, tmdbId: number) {
识别失败无法识别到有效信息
</VAlert>
</VCol>
<VExpansionPanels
v-show="context?.meta_info?.title !== context?.meta_info.org_string"
>
<VExpansionPanel>
<VExpansionPanelTitle>
识别词应用详情
</VExpansionPanelTitle>
<VExpansionPanelText>
<VChip
variant="elevated"
class="me-1 mb-1 break-all"
color="primary"
>
{{ context?.meta_info.org_string }}
</VChip>
<VChip
v-for="(val, key) in context?.meta_info.apply_words"
:key="key"
:val="val"
variant="outlined"
color="info"
class="me-1 mb-1 break-all"
>
{{ val }}
</VChip>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</div>
</template>

View File

@@ -2,6 +2,8 @@
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { Plugin } from '@/api/types'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
// 输入参数
const props = defineProps({
@@ -13,19 +15,55 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['install'])
// 背景颜色
const backgroundColor = ref('#28A9E1')
// 图片对象
const imageRef = ref<any>()
// 提示框
const $toast = useToast()
// 进度框
const progressDialog = ref(false)
// 进度框文本
const progressText = ref('正在安装插件...')
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
// 从图片中提取背景色
backgroundColor.value = await getDominantColor(imageElement)
}
// 安装插件
async function installPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在安装 ${props.plugin?.plugin_name} ${props?.plugin?.plugin_version} 插件...`
const result: { [key: string]: any } = await api.get(
`plugin/install/${props.plugin?.id}`,
{
params: {
repo_url: props.plugin?.repo_url,
force: props.plugin?.has_update,
},
},
)
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
@@ -33,13 +71,63 @@ async function installPlugin() {
emit('install')
}
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}}`)
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}`)
}
}
catch (error) {
console.error(error)
}
}
// 计算图标路径
const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value)
return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}/1`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
// 访问插件页面
function visitPluginPage() {
// 将raw.githubusercontent.com转换为项目地址
let repoUrl = props.plugin?.repo_url
if (repoUrl) {
if (repoUrl.includes('raw.githubusercontent.com')) {
if (!repoUrl.endsWith('/'))
repoUrl += '/'
if (repoUrl.split('/').length < 6)
repoUrl = `${repoUrl}main/`
try {
const [user, repo] = repoUrl.split('/').slice(-4, -2)
repoUrl = `https://github.com/${user}/${repo}`
}
catch (error) {
return
}
}
}
else {
repoUrl = props.plugin?.author_url
}
window.open(repoUrl, '_blank')
}
// 弹出菜单
const dropdownItems = ref([
{
title: '查看详情',
value: 1,
props: {
prependIcon: 'mdi-information-outline',
click: visitPluginPage,
},
},
])
</script>
<template>
@@ -50,17 +138,51 @@ async function installPlugin() {
>
<div
class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${props.plugin?.plugin_color}` }"
:style="{ background: `${backgroundColor}` }"
>
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" class="text-white" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
:key="i"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<div
v-if="props.plugin?.has_update"
class="me-n3 absolute top-0 left-1"
>
<VIcon
icon="mdi-new-box"
class="text-white"
/>
</div>
<VAvatar
size="8rem"
:class="{ shadow: isImageLoaded }"
>
<VImg
:src="`/plugin/${props.plugin?.plugin_icon}`"
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="isImageLoaded = true"
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
@@ -76,9 +198,29 @@ async function installPlugin() {
@click.stop
>
{{ props.plugin?.plugin_author }}
</a>
</a><br>
版本{{ props.plugin?.plugin_version }}
</VCardText>
</VCard>
<!-- 安装插件进度框 -->
<VDialog
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>

View File

@@ -1,10 +1,14 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import api from '@/api'
import type { Plugin } from '@/api/types'
import FormRender from '@/components/render/FormRender.vue'
import PageRender from '@/components/render/PageRender.vue'
import { isNullOrEmptyObject } from '@core/utils'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
import store from '@/store'
// 输入参数
const props = defineProps({
@@ -16,9 +20,18 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
// 背景颜色
const backgroundColor = ref('#28A9E1')
// 图片对象
const imageRef = ref<any>()
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 本身是否可见
const isVisible = ref(true)
@@ -31,17 +44,44 @@ const pluginConfigForm = ref({})
// 插件表单配置项
let pluginFormItems = reactive([])
// 插件详情页面
// 插件数据页面
const pluginInfoDialog = ref(false)
// 插件详情页面配置项
// 插件数据页面配置项
let pluginPageItems = reactive([])
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
// 从图片中提取背景色
backgroundColor.value = await getDominantColor(imageElement)
}
// 调用API卸载插件
async function uninstallPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认卸载插件 ${props.plugin?.plugin_name} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
})
if (!isConfirmed)
return
try {
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
if (result.success) {
@@ -74,7 +114,7 @@ async function loadPluginForm() {
}
}
// 调用API读取详情页面
// 调用API读取数据页面
async function loadPluginPage() {
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
@@ -117,9 +157,9 @@ async function savePluginConf() {
}
}
// 显示插件详情
// 显示插件数据
async function showPluginInfo() {
// 加载详情
// 加载数据
await loadPluginPage()
pluginConfigDialog.value = false
pluginInfoDialog.value = true
@@ -136,10 +176,67 @@ async function showPluginConfig() {
pluginConfigDialog.value = true
}
// 计算图标路径
const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value)
return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}/1`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
// 重置插件
async function resetPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认重置插件 ${props.plugin?.plugin_name} 的配置数据?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
})
if (!isConfirmed)
return
try {
const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 数据已重置`)
// 通知父组件刷新
emit('save')
}
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 重置失败:${result.message}}`)
}
}
catch (error) {
console.error(error)
}
}
// 访问作者主页
function visitAuthorPage() {
window.open(props.plugin?.author_url, '_blank')
}
// 查看日志URL
function openLoggerWindow() {
const token = store.state.auth.token
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
}
// 弹出菜单
const dropdownItems = ref([
{
title: '查看详情',
title: '查看数据',
value: 1,
show: props.plugin?.has_page,
props: {
@@ -148,7 +245,7 @@ const dropdownItems = ref([
},
},
{
title: '置',
title: '置',
value: 2,
show: true,
props: {
@@ -157,15 +254,45 @@ const dropdownItems = ref([
},
},
{
title: '卸载',
title: '重置',
value: 3,
show: true,
props: {
prependIcon: 'mdi-cancel',
color: 'warning',
click: resetPlugin,
},
},
{
title: '卸载',
value: 4,
show: true,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
click: uninstallPlugin,
},
},
{
title: '查看日志',
value: 5,
show: true,
props: {
prependIcon: 'mdi-file-document-outline',
click: () => {
openLoggerWindow()
},
},
},
{
title: '作者主页',
value: 5,
show: true,
props: {
prependIcon: 'mdi-home-circle-outline',
click: visitAuthorPage,
},
},
])
</script>
@@ -184,7 +311,7 @@ const dropdownItems = ref([
>
<div
class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${props.plugin?.plugin_color}` }"
:style="{ background: `${backgroundColor}` }"
>
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
@@ -213,20 +340,22 @@ const dropdownItems = ref([
</div>
<VAvatar
size="8rem"
:class="{ shadow: isImageLoaded }"
>
<VImg
:src="`/plugin/${props.plugin?.plugin_icon}`"
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
<VCardItem class="py-2">
<VCardTitle class="flex items-center flex-row">
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" />
{{ props.plugin?.plugin_name }}
{{ props.plugin?.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
</VCardItem>
<VCardText>
@@ -236,11 +365,13 @@ const dropdownItems = ref([
<!-- 插件配置页面 -->
<VDialog
v-model="pluginConfigDialog"
max-width="50rem"
scrollable
persistent
max-width="60rem"
>
<VCard :title="`${props.plugin?.plugin_name} - 配置`">
<VCard
:title="`${props.plugin?.plugin_name} - 配置`"
class="rounded-t"
>
<DialogCloseBtn @click="pluginConfigDialog = false" />
<VCardText>
<FormRender
@@ -252,24 +383,29 @@ const dropdownItems = ref([
</VCardText>
<VCardActions>
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo">
查看详情
查看数据
</VBtn>
<VSpacer />
<VBtn @click="savePluginConf">
<VBtn
variant="tonal"
@click="savePluginConf"
>
保存
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 插件详情页面 -->
<!-- 插件数据页面 -->
<VDialog
v-model="pluginInfoDialog"
max-width="62.5rem"
scrollable
persistent
max-width="80rem"
>
<VCard :title="`${props.plugin?.plugin_name}`">
<VCard
:title="`${props.plugin?.plugin_name}`"
class="rounded-t"
>
<DialogCloseBtn @click="pluginInfoDialog = false" />
<VCardText>
<PageRender
@@ -279,11 +415,16 @@ const dropdownItems = ref([
/>
</VCardText>
<VCardActions>
<VBtn @click="showPluginConfig">
<VBtn
@click="showPluginConfig"
>
配置
</VBtn>
<VSpacer />
<VBtn @click="pluginInfoDialog = false">
<VBtn
variant="tonal"
@click="pluginInfoDialog = false"
>
关闭
</VBtn>
</VCardActions>

View File

@@ -0,0 +1,102 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
width: String,
height: String,
})
// 图片加载状态
const isImageLoaded = ref(false)
// 图片加载失败
const imageLoadError = ref(false)
// 角标颜色
function getChipColor(type: string) {
if (type === '电影')
return 'border-blue-500 bg-blue-600'
else if (type === '电视剧')
return ' bg-indigo-500 border-indigo-600'
else
return 'border-purple-600 bg-purple-600'
}
// 计算图片地址
const getImgUrl = computed(() => {
if (imageLoadError.value)
return noImage
const image = props.media?.image || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}/0`
})
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
}
</script>
<template>
<VHover v-bind="props">
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500 rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goPlay"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 详情 -->
<VCardText
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"
>
<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 ...">
{{ props.media?.title }}
</h1>
</VCardText>
</VImg>
</VCard>
</template>
</VHover>
</template>
<style lang="scss">
.on-hover img {
@apply brightness-50;
}
</style>

View File

@@ -1,8 +1,9 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import SiteAddEditForm from '../form/SiteAddEditForm.vue'
import { formatFileSize } from '@core/utils/formatters'
import { numberValidator, requiredValidator } from '@/@validators'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Site, TorrentInfo } from '@/api/types'
import ExistIcon from '@core/components/ExistIcon.vue'
@@ -15,7 +16,7 @@ const cardProps = defineProps({
})
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'update'])
const emit = defineEmits(['update', 'remove'])
// 密码输入
const isPasswordVisible = ref(false)
@@ -32,9 +33,6 @@ const testButtonText = ref('测试')
// 测试按钮可用性
const testButtonDisable = ref(false)
// 更新按钮文字
const updateButtonText = ref('更新')
// 更新按钮可用性
const updateButtonDisable = ref(false)
@@ -42,11 +40,17 @@ const updateButtonDisable = ref(false)
const siteCookieDialog = ref(false)
// 站点编辑弹窗
const siteInfoDialog = ref(false)
const siteEditDialog = ref(false)
// 资源浏览弹窗
const resourceDialog = ref(false)
// 进度条
const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 资源浏览表头
const resourceHeaders = [
{ title: '标题', key: 'title', sortable: false },
@@ -76,29 +80,9 @@ const resourceItemsPerPage = ref(25)
const userPwForm = ref({
username: '',
password: '',
code: '',
})
// 状态下拉项
const statusItems = [
{ title: '启用', value: true },
{ title: '停用', value: false },
]
// 生成1到50的优先级下拉框选项
const priorityItems = ref(
Array.from({ length: 50 }, (_, i) => i + 1).map(item => ({
title: item,
value: item,
})),
)
// 站点编辑表单数据
const siteForm = reactive<any>(cardProps.site ?? {})
// 类型转换
siteForm.proxy = siteForm.proxy === 1
siteForm.render = siteForm.render === 1
// 打开种子详情页面
function openTorrentDetail(page_url: string) {
window.open(page_url, '_blank')
@@ -144,11 +128,6 @@ async function handleSiteUpdate() {
siteCookieDialog.value = true
}
// 打开站点编辑弹窗
async function handleSiteInfo() {
siteInfoDialog.value = true
}
// 打开资源浏览弹窗
async function handleResourceBrowse() {
resourceDialog.value = true
@@ -163,15 +142,18 @@ async function updateSiteCookie() {
// 更新按钮状态
siteCookieDialog.value = false
updateButtonText.value = '更新中 ...'
updateButtonDisable.value = true
progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
const result: { [key: string]: any } = await api.get(
`site/cookie/${cardProps.site?.id}`,
{
params: {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
},
},
)
@@ -181,7 +163,7 @@ async function updateSiteCookie() {
else
$toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
updateButtonText.value = '更新'
progressDialog.value = false
updateButtonDisable.value = false
}
catch (error) {
@@ -189,42 +171,6 @@ async function updateSiteCookie() {
}
}
// 调用API删除站点信息
async function deleteSiteInfo() {
try {
siteInfoDialog.value = false
const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`)
if (result.success) {
$toast.success(`${cardProps.site?.name} 删除成功!`)
emit('remove')
}
else { $toast.error(`${cardProps.site?.name} 删除失败:${result.message}`) }
}
catch (error) {
$toast.error(`${cardProps.site?.name} 删除失败!`)
console.error(error)
}
}
// 调用API更新站点信息
async function updateSiteInfo() {
try {
// 更新按钮状态
siteInfoDialog.value = false
const result: { [key: string]: any } = await api.put('site', siteForm)
if (result.success) {
$toast.success(`${cardProps.site?.name} 更新成功!`)
emit('update')
}
else { $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`) }
}
catch (error) {
$toast.error(`${cardProps.site?.name} 更新失败!`)
console.error(error)
}
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0)
@@ -264,9 +210,9 @@ onMounted(() => {
<VCard
:height="cardProps.height"
:width="cardProps.width"
:flat="!siteForm.is_active"
:flat="!cardProps.site?.is_active"
class="overflow-hidden"
@click="handleSiteInfo"
@click="siteEditDialog = true"
>
<template #image>
<VAvatar
@@ -278,17 +224,19 @@ onMounted(() => {
</VAvatar>
</template>
<VCardItem>
<VCardTitle class="font-bold" @click.stop="openSitePage">
{{ cardProps.site?.name }}
<VCardTitle class="font-bold">
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
</VCardTitle>
<VCardSubtitle>{{ cardProps.site?.url }}</VCardSubtitle>
<VCardSubtitle>
{{ cardProps.site?.url }}
</VCardSubtitle>
</VCardItem>
<ExistIcon v-if="siteForm.is_active" />
<ExistIcon v-if="cardProps.site?.is_active" />
<VCardText class="py-2">
<VTooltip
v-if="siteForm.render"
v-if="cardProps.site?.render === 1"
text="浏览器仿真"
>
<template #activator="{ props }">
@@ -302,7 +250,7 @@ onMounted(() => {
</VTooltip>
<VTooltip
v-if="siteForm.proxy"
v-if="cardProps.site?.proxy === 1"
text="代理"
>
<template #activator="{ props }">
@@ -316,7 +264,7 @@ onMounted(() => {
</VTooltip>
<VTooltip
v-if="siteForm.limit_interval"
v-if="cardProps.site?.limit_interval"
text="流控"
>
<template #activator="{ props }">
@@ -330,7 +278,7 @@ onMounted(() => {
</VTooltip>
<VTooltip
v-if="siteForm.filter"
v-if="cardProps.site?.filter"
text="过滤"
>
<template #activator="{ props }">
@@ -358,7 +306,7 @@ onMounted(() => {
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
{{ updateButtonText }}
更新
</VBtn>
<VBtn
:disabled="testButtonDisable"
@@ -389,7 +337,7 @@ onMounted(() => {
<VRow>
<VCol
cols="12"
md="6"
md="4"
>
<VTextField
v-model="userPwForm.username"
@@ -399,7 +347,7 @@ onMounted(() => {
</VCol>
<VCol
cols="12"
md="6"
md="4"
>
<VTextField
v-model="userPwForm.password"
@@ -413,149 +361,37 @@ onMounted(() => {
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="userPwForm.code"
label="两步验证"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn @click="updateSiteCookie">
<VBtn
variant="tonal"
@click="updateSiteCookie"
>
开始更新
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 站点编辑弹窗 -->
<VDialog
v-model="siteInfoDialog"
max-width="50rem"
persistent
scrollable
>
<!-- Dialog Content -->
<VCard :title="`编辑站点 - ${cardProps.site?.name}`">
<VCardText class="pt-2">
<DialogCloseBtn @click="siteInfoDialog = false" />
<VForm @submit.prevent="() => {}">
<VRow>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="siteForm.url"
label="站点地址"
:rules="[requiredValidator]"
/>
</VCol>
<VCol
cols="12"
md="3"
>
<VSelect
v-model="siteForm.pri"
label="优先级"
:items="priorityItems"
:rules="[requiredValidator]"
/>
</VCol>
<VCol
cols="12"
md="3"
>
<VSelect
v-model="siteForm.is_active"
:items="statusItems"
label="状态"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VTextField
v-model="siteForm.rss"
label="RSS地址"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="siteForm.limit_interval"
label="单位周期(秒)"
:rules="[numberValidator]"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="siteForm.limit_seconds"
label="访问次数"
:rules="[numberValidator]"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="siteForm.limit_seconds"
label="访问间隔(秒)"
:rules="[numberValidator]"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="6"
>
<VSwitch
v-model="siteForm.proxy"
label="代理"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<VSwitch
v-model="siteForm.render"
label="仿真"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn color="error" @click="deleteSiteInfo">
删除
</VBtn>
<VSpacer />
<VBtn @click="updateSiteInfo">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<SiteAddEditForm
v-model="siteEditDialog"
:siteid="cardProps.site?.id"
@save="siteEditDialog = false; emit('update')"
@remove="emit('remove')"
@close="siteEditDialog = false"
/>
<!-- 站点资源弹窗 -->
<VDialog
v-model="resourceDialog"
@@ -587,6 +423,23 @@ onMounted(() => {
<div class="text-sm my-1">
{{ item.raw.description }}
</div>
<VChip
v-if="item.raw?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="item.raw?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ item.raw?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.raw?.labels"
:key="index"
@@ -647,13 +500,14 @@ onMounted(() => {
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="item.raw.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile(item.raw.enclosure)"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子</VListItemTitle>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
@@ -667,6 +521,24 @@ onMounted(() => {
</VCardText>
</VCard>
</VDialog>
<VDialog
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">

View File

@@ -1,10 +1,11 @@
<script lang="ts" setup>
<script lang='ts' setup>
import { useToast } from 'vue-toast-notification'
import SubscribeEditForm from '../form/SubscribeEditForm.vue'
import { calculateTimeDifference } from '@/@core/utils'
import { formatSeason } from '@/@core/utils/formatters'
import { numberValidator } from '@/@validators'
import api from '@/api'
import type { Site, Subscribe } from '@/api/types'
import type { Subscribe } from '@/api/types'
import router from '@/router'
// 输入参数
const props = defineProps({
@@ -21,19 +22,7 @@ const $toast = useToast()
const imageLoaded = ref(false)
// 订阅弹窗
const subscribeInfoDialog = ref(false)
// 站点数据列表
const siteList = ref<Site[]>([])
// 站点选择下载框
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
// 订阅编辑表单
const subscribeForm = reactive<any>(props.media ?? {})
// 类型转换
subscribeForm.best_version = subscribeForm.best_version === 1
const subscribeEditDialog = ref(false)
// 上一次更新时间
const lastUpdateText = ref(
@@ -67,7 +56,7 @@ function getPercentage() {
return Math.round(
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0))
/ (props.media?.total_episode ?? 1))
* 100,
* 100,
)
}
@@ -114,58 +103,9 @@ async function searchSubscribe() {
}
}
// 调用API修改订阅
async function updateSubscribeInfo() {
subscribeInfoDialog.value = false
try {
const result: { [key: string]: any } = await api.put('subscribe', subscribeForm)
// 提示
if (result.success) {
$toast.success(`${props.media?.name} 更新成功!`)
// 通知父组件刷新
emit('remove')
}
else { $toast.error(`${props.media?.name} 更新失败:${result.message}`) }
}
catch (e) {
console.log(e)
}
}
// 获取站点列表数据
async function loadSites() {
try {
const data: Site[] = await api.get('site/rss')
// 过滤站点,只有启用的站点才显示
siteList.value = data.filter(item => item.is_active)
}
catch (error) {
console.error(error)
}
}
// 获取站点列表选择框数据
async function getSiteList() {
// 加载订阅站点列表
if (!siteList.value.length)
await loadSites()
const maps = siteList.value.map((item) => {
return {
title: item.name,
value: item.id,
}
})
selectSitesOptions.value = maps.flat()
}
// 编辑订阅响应
async function editSubscribeDialog() {
await getSiteList()
subscribeInfoDialog.value = true
subscribeEditDialog.value = true
}
// 弹出菜单
@@ -187,8 +127,28 @@ const dropdownItems = ref([
},
},
{
title: '取消订阅',
title: '查看详情',
value: 3,
props: {
prependIcon: 'mdi-open-in-new',
click: () => {
router.push({
path: '/media',
query: {
mediaid: `${
props.media?.tmdbid
? `tmdb:${props.media?.tmdbid}`
: `douban:${props.media?.doubanid}`
}`,
type: props.media?.type,
},
})
},
},
},
{
title: '取消订阅',
value: 4,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
@@ -201,7 +161,7 @@ const dropdownItems = ref([
<template>
<VCard
:key="props.media?.id"
:class="`${subscribeForm.best_version ? 'outline-dashed outline-1' : ''}`"
:class="`${props.media?.best_version ? 'outline-dashed outline-1' : ''}`"
@click="editSubscribeDialog"
>
<template #image>
@@ -223,7 +183,7 @@ const dropdownItems = ref([
</template>
<VCardTitle :class="getTextClass()">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : "") }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</VCardTitle>
<template #append>
<div class="me-n3">
@@ -313,7 +273,8 @@ const dropdownItems = ref([
<VIcon
icon="mdi-download"
class="me-1"
/> {{ lastUpdateText }}
/>
{{ lastUpdateText }}
</VCardText>
<VProgressLinear
v-if="getPercentage() > 0"
@@ -323,100 +284,11 @@ const dropdownItems = ref([
/>
</VCard>
<!-- 订阅编辑弹窗 -->
<VDialog
v-model="subscribeInfoDialog"
max-width="50rem"
persistent
scrollable
>
<!-- Dialog Content -->
<VCard :title="`订阅 - ${props.media?.name}`">
<VCardText class="pt-2">
<VForm @submit.prevent="() => {}">
<VRow>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="subscribeForm.keyword"
label="搜索关键词"
/>
</VCol>
<VCol
v-if="props.media?.type === '电视剧'"
cols="12"
md="3"
>
<VTextField
v-model="subscribeForm.total_episode"
label="总集数"
:rules="[numberValidator]"
/>
</VCol>
<VCol
v-if="props.media?.type === '电视剧'"
cols="12"
md="3"
>
<VTextField
v-model="subscribeForm.start_episode"
label="开始集数"
:rules="[numberValidator]"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect
v-model="subscribeForm.sites"
:items="selectSitesOptions"
chips
label="订阅站点"
multiple
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn @click="subscribeInfoDialog = false">
取消
</VBtn>
<VSpacer />
<VBtn @click="updateSubscribeInfo">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<SubscribeEditForm
v-model="subscribeEditDialog"
:subid="props.media?.id"
@remove="() => { emit('remove');subscribeEditDialog = false; }"
@save="() => { emit('save');subscribeEditDialog = false; }"
@close="subscribeEditDialog = false"
/>
</template>

View File

@@ -20,9 +20,11 @@ const keyword = ref('')
// 加载中
const loading = ref(false)
// ref
const tmdbKeyword = ref<HTMLElement | null>(null)
// 选中条目
function selectMedia(item: TmdbItem) {
console.log(item)
emit('update:modelValue', item.tmdbid)
emit('close')
}
@@ -68,6 +70,14 @@ async function searchMedias() {
console.error(e)
}
}
// 加载时聚焦搜索框
onMounted(() => {
// 500ms后聚焦
setTimeout(() => {
tmdbKeyword.value?.focus()
}, 500)
})
</script>
<template>
@@ -77,11 +87,15 @@ async function searchMedias() {
>
<VToolbar flat class="p-0">
<VTextField
ref="tmdbKeyword"
v-model="keyword"
label="输入名称搜索"
class="mx-3"
variant="underlined"
single-line
placeholder="电影或电视剧名称"
variant="solo"
append-inner-icon="mdi-magnify"
flat
class="mx-1"
:loading="loading"
@click:append-inner="searchMedias"
@keydown.enter="searchMedias"

View File

@@ -64,6 +64,9 @@ async function handleAddDownload(_site: any = undefined,
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
})
if (!isConfirmed)
@@ -76,7 +79,7 @@ async function handleAddDownload(_site: any = undefined,
async function addDownload(_media: any, _torrent: any) {
startNProgress()
try {
const result: { [key: string]: any } = await api.post('download', {
const result: { [key: string]: any } = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
@@ -122,26 +125,6 @@ function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
onMounted(() => {
getSiteIcon()
})
// 弹出菜单
const dropdownItems = ref([
{
title: '查看详情',
value: 1,
props: {
prependIcon: 'mdi-information',
click: openTorrentDetail,
},
},
{
title: '下载种子',
value: 2,
props: {
prependIcon: 'mdi-download',
click: downloadTorrentFile,
},
},
])
</script>
<template>
@@ -180,15 +163,23 @@ const dropdownItems = ref([
>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
:key="i"
variant="plain"
@click="item.props.click"
@click="openTorrentDetail()"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
<VIcon icon="mdi-information" />
</template>
<VListItemTitle v-text="item.title" />
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
@@ -204,6 +195,23 @@ const dropdownItems = ref([
v-if="torrent?.labels"
class="pb-3 pt-0 pe-12"
>
<VChip
v-if="torrent?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="torrent?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"

View File

@@ -0,0 +1,268 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { Context } from '@/api/types'
// 输入参数
const props = defineProps({
torrent: Object as PropType<Context>,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 更多来源界面
const showMoreTorrents = ref(false)
// 种子信息
const torrent = ref(props.torrent?.torrent_info)
// 媒体信息
const media = ref(props.torrent?.media_info)
// 识别元数据
const meta = ref(props.torrent?.meta_info)
// 站点图标
const siteIcon = ref('')
// 查询站点图标
async function getSiteIcon() {
try {
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
}
catch (error) {
console.error(error)
}
}
// 询问并添加下载
async function handleAddDownload(_site: any = undefined,
_media: any = undefined,
_torrent: any = undefined) {
if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name
_media = media.value
_torrent = torrent.value
}
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
})
if (!isConfirmed)
return
addDownload(_media, _torrent)
}
// 添加下载
async function addDownload(_media: any, _torrent: any) {
startNProgress()
try {
const result: { [key: string]: any } = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
if (result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
}
else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
}
}
catch (error) {
console.error(error)
}
doneNProgress()
}
// 打开种子详情页面
function openTorrentDetail() {
window.open(torrent.value?.page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile() {
window.open(torrent.value?.enclosure, '_blank')
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0)
return 'text-white bg-lime-500'
else if (downloadVolume < 1)
return 'text-white bg-green-500'
else if (uploadVolume !== 1)
return 'text-white bg-sky-500'
else
return 'text-white bg-gray-500'
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
})
</script>
<template>
<VListItem @click="handleAddDownload">
<template
v-if="!showMoreTorrents"
#prepend
>
<VAvatar
class="rounded"
variant="flat"
@click.stop="openTorrentDetail"
>
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
{{ torrent?.title }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
<VListItemSubtitle>
{{ torrent?.description }}
</VListItemSubtitle>
<div
v-if="torrent?.labels"
class="pt-2"
>
<VChip
v-if="torrent?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="torrent?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="meta?.edition"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-red-500"
>
{{ meta?.edition }}
</VChip>
<VChip
v-if="meta?.resource_pix"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-red-500"
>
{{ meta?.resource_pix }}
</VChip>
<VChip
v-if="meta?.video_encode"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ meta?.video_encode }}
</VChip>
<VChip
v-if="torrent?.size"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-yellow-500"
>
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip
v-if="meta?.resource_team"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-cyan-500"
>
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="
getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)
"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</div>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="openTorrentDetail()"
>
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
</template>

View File

@@ -4,13 +4,12 @@ import type { PropType } from 'vue'
import { useConfirm } from 'vuetify-use-dialog'
import axios from 'axios'
import { useToast } from 'vue-toast-notification'
import { numberValidator } from '@/@validators'
import ReorganizeForm from '../form/ReorganizeForm.vue'
import { formatBytes } from '@core/utils/formatters'
import type { Context, EndPoints, FileItem } from '@/api/types'
import store from '@/store'
import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import TmdbSelectorCard from '@/components/cards/TmdbSelectorCard.vue'
// 输入参数
const inProps = defineProps({
@@ -32,6 +31,15 @@ const $toast = useToast()
// 是否正在加载
const loading = ref(true)
// 识别进度条
const progressDialog = ref(false)
// 识别进度文本
const progressText = ref('请稍候 ...')
// 识别进度
const progressValue = ref(0)
// 确认框
const createConfirm = useConfirm()
@@ -53,57 +61,18 @@ const renamePopper = ref(false)
// 整理弹窗
const transferPopper = ref(false)
// 整理进度条
const progressDialog = ref(false)
// 整理进度文本
const progressText = ref('请稍候 ...')
// 整理进度
const progressValue = ref(0)
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 新名称
const newName = ref('')
// 当前名称
const currentItem = ref<FileItem>()
// 文件转移表单
const transferForm = reactive({
path: '',
target: '',
tmdbid: null,
season: null,
type_name: '',
transfer_type: '',
episode_format: '',
episode_detail: '',
episode_part: '',
episode_offset: null,
min_filesize: 0,
})
// 识别结果
const nameTestResult = ref<Context>()
// 识别结果对话框
const nameTestDialog = ref(false)
// TMDB选择对话框
const tmdbSelectorDialog = ref(false)
// 生成1到50季的下拉框选项
const seasonItems = ref(
Array.from({ length: 51 }, (_, i) => i).map(item => ({
title: `${item}`,
value: item,
})),
)
// 目录过滤
const dirs = computed(() =>
items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)),
@@ -158,6 +127,9 @@ async function deleteItem(item: FileItem) {
dialogProps: {
maxWidth: '50rem',
},
cancellationButtonProps: {
variant: 'tonal',
},
})
if (confirmed) {
@@ -245,41 +217,6 @@ function showTransfer(item: FileItem) {
transferPopper.value = true
}
// 整理文件
async function transfer() {
transferForm.path = currentItem.value?.path ?? ''
// 开始整理文件
try {
// 关闭弹窗
transferPopper.value = false
// 显示进度条
progressDialog.value = true
// 开始监听进度
startLoadingProgress()
// 异步调API结束后关闭进度条
api.post('transfer/manual', {}, {
params: transferForm,
}).then((res: any) => {
// 关闭进度条
progressDialog.value = false
// 停止监听进度
stopLoadingProgress()
// 显示结果
if (res.success) {
$toast.success(`${currentItem.value?.name} 整理完成!`)
// 重新加载
load()
}
else {
$toast.error(`${currentItem.value?.name} 整理失败:${res.message}`)
}
})
}
catch (e) {
console.log(e)
}
}
// 将文件修改时间timestape转换为本地时间
function formatTime(timestape: number) {
return new Date(timestape * 1000).toLocaleString()
@@ -307,29 +244,6 @@ watch(
},
)
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer?token=${token}`,
)
progressEventSource.value.onmessage = (event) => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
}
}
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
}
// 调用API识别
async function recognize(path: string) {
try {
@@ -353,6 +267,28 @@ async function recognize(path: string) {
}
}
// 调用API刮削
async function scrape(path: string) {
try {
// 显示进度条
progressDialog.value = true
progressText.value = `正在刮削 ${path} ...`
const result: { [key: string]: any } = await api.get('media/scrape', {
params: {
path,
},
})
// 关闭进度条
progressDialog.value = false
if (!result.success)
$toast.error(result.message)
else
$toast.success(`${path}削刮完成!`)
}
catch (error) {
console.error(error)
}
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -365,8 +301,17 @@ const dropdownItems = ref([
},
},
}, {
title: '重命名',
title: '刮削',
value: 2,
props: {
prependIcon: 'mdi-auto-fix',
click: (_item: FileItem) => {
scrape(_item.path || '')
},
},
}, {
title: '重命名',
value: 3,
props: {
prependIcon: 'mdi-rename',
click: showRenmae,
@@ -374,7 +319,7 @@ const dropdownItems = ref([
},
{
title: '整理',
value: 3,
value: 4,
props: {
prependIcon: 'mdi-folder-arrow-right',
click: showTransfer,
@@ -382,7 +327,7 @@ const dropdownItems = ref([
},
{
title: '删除',
value: 4,
value: 5,
props: {
prependIcon: 'mdi-delete-outline',
color: 'error',
@@ -431,111 +376,133 @@ onMounted(() => {
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList v-if="dirs.length" subheader>
<VListSubheader>目录</VListSubheader>
<VListItem
<VHover
v-for="(item, index) in dirs"
:key="index"
class="px-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon icon="mdi-folder-outline" />
</template>
<VListItemTitle v-text="item.name" />
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
<template #default="hover">
<VListItem
v-bind="hover.props"
class="px-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon icon="mdi-folder-outline" />
</template>
<VListItemTitle v-text="item.name" />
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span v-show="hover.isHovering" class="flex">
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="scrape(item.path)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</span>
</template>
</VListItem>
</template>
</VListItem>
</VHover>
</VList>
<VDivider v-if="dirs.length && files.length" />
<VList v-if="files.length" subheader>
<VListSubheader>文件</VListSubheader>
<VListItem
<VHover
v-for="(item, index) in files"
:key="index"
class="pl-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon v-if="inProps.icons" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
</template>
<template #default="hover">
<VListItem
v-bind="hover.props"
class="pl-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon v-if="inProps.icons" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle> {{ formatBytes(item.size) }}</VListItemSubtitle>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle> {{ formatBytes(item.size) }}</VListItemSubtitle>
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span v-show="hover.isHovering" class="flex">
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="scrape(item.path)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</span>
</template>
</VListItem>
</template>
</VListItem>
</VHover>
</VList>
</VCardText>
<VCardText
@@ -586,23 +553,19 @@ onMounted(() => {
v-model="renamePopper"
max-width="50rem"
>
<template #activator="{ props }">
<IconBtn title="重命名" v-bind="props">
<VIcon icon="mdi-rename-outline" />
</IconBtn>
</template>
<VCard title="重命名">
<VCardText>
<VTextField v-model="newName" label="名称" />
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn depressed @click="renamePopper = false">
取消
</VBtn>
<VSpacer />
<VBtn
:disabled="!newName"
depressed
variant="tonal"
@click="rename"
>
重命名
@@ -611,178 +574,44 @@ onMounted(() => {
</VCard>
</VDialog>
<!-- 文件整理弹窗 -->
<VDialog
<ReorganizeForm
v-model="transferPopper"
max-width="50rem"
scrollable
>
<template #activator="{ props }">
<IconBtn title="整理" v-bind="props">
<VIcon icon="mdi-folder-arrow-right-outline" />
</IconBtn>
</template>
<VCard :title="`文件整理 - ${currentItem?.name}`">
<VCardText class="pt-2">
<VForm @submit.prevent="() => {}">
<VRow>
<VCol
cols="12"
md="8"
>
<VTextField
v-model="transferForm.target"
label="目的路径"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
:items="[
{ title: '默认', value: '' },
{ title: '移动', value: 'move' },
{ title: '复制', value: 'copy' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
]"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VSelect
v-model="transferForm.type_name"
label="类型"
:items="[{ title: '请选择', value: '' }, { title: '电影', value: '电影' }, { title: '电视剧', value: '电视剧' }]"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="transferForm.tmdbid"
label="TMDBID"
placeholder="留空自动识别"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
@click:append-inner="tmdbSelectorDialog = true"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VSelect
v-show="transferForm.type_name === '电视剧'"
v-model.number="transferForm.season"
label="季"
:items="seasonItems"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="8">
<VTextField
v-model="transferForm.episode_format"
label="集数定位"
placeholder="使用{ep}定位集数"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_detail"
label="指定集数"
placeholder="起始集,终止集如1或1,2"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_part"
label="指定Part"
placeholder="如part1"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model.number="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model.number="transferForm.min_filesize"
label="最小文件大小MB"
:rules="[numberValidator]"
placeholder="0"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn depressed @click="transferPopper = false">
取消
</VBtn>
<VSpacer />
<VBtn
@click="transfer"
>
开始整理
</VBtn>
</VCardActions>
</VCard>
</VDialog>
:path="currentItem?.path"
@done="transferPopper = false; load()"
@close="transferPopper = false"
/>
<!-- 手动整理进度框 -->
<vDialog
<VDialog
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<vCard
<VCard
color="primary"
>
<vCardText class="text-center">
<VCardText class="text-center">
{{ progressText }}
<vProgressLinear
<VProgressLinear
v-if="progressValue"
color="white"
class="mb-0 mt-1"
:model-value="progressValue"
/>
</vCardText>
</vCard>
</vDialog>
</VCardText>
</VCard>
</VDialog>
<!-- 识别结果对话框 -->
<vDialog
<VDialog
v-model="nameTestDialog"
width="50rem"
>
<vCard>
<VCard>
<DialogCloseBtn @click="nameTestDialog = false" />
<VCardItem>
<MediaInfoCard :context="nameTestResult" />
</VCardItem>
</vCard>
</vDialog>
<!-- TMDB ID搜索框 -->
<vDialog
v-model="tmdbSelectorDialog"
width="40rem"
scrollable
>
<TmdbSelectorCard
v-model="transferForm.tmdbid"
@close="tmdbSelectorDialog = false"
/>
</vDialog>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>

View File

@@ -171,6 +171,7 @@ const sortIcon = computed(() => {
<VBtn
:disabled="!newFolderName"
depressed
variant="tonal"
@click="mkdir"
>
新建

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
// 输入参数
const props = defineProps({
title: String,
})
// 定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 代码
const codeString = ref('')
// 导入
function handleImport() {
emit('update:modelValue', codeString.value)
emit('close')
}
</script>
<template>
<VCard
:title="props.title"
class="rounded-t"
>
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VTextarea v-model="codeString" />
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="handleImport"
>
导入
</VBtn>
</VCardActions>
</VCard>
</template>

View File

@@ -0,0 +1,307 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import TmdbSelectorCard from '../cards/TmdbSelectorCard.vue'
import store from '@/store'
import api from '@/api'
import { numberValidator } from '@/@validators'
// 输入参数
const props = defineProps({
path: String,
target: String,
logids: Array<number>,
})
// 定义事件
const emit = defineEmits(['done', 'close'])
// 生成1到50季的下拉框选项
const seasonItems = ref(
Array.from({ length: 51 }, (_, i) => i).map(item => ({
title: `${item}`,
value: item,
})),
)
// 提示框
const $toast = useToast()
// TMDB选择对话框
const tmdbSelectorDialog = ref(false)
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 整理进度条
const progressDialog = ref(false)
// 整理进度文本
const progressText = ref('请稍候 ...')
// 整理进度
const progressValue = ref(0)
// 文件转移表单
const transferForm = reactive({
logid: 0,
path: '',
target: props.target ?? '',
tmdbid: null,
season: null,
type_name: '',
transfer_type: '',
episode_format: '',
episode_detail: '',
episode_part: '',
episode_offset: null,
min_filesize: 0,
})
watchEffect(() => {
transferForm.path = props.path ?? ''
transferForm.target = props.target ?? ''
})
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer?token=${token}`,
)
progressEventSource.value.onmessage = (event) => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
}
}
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
}
// 整理文件
// eslint-disable-next-line sonarjs/cognitive-complexity
async function transfer() {
if (!props.logids && !props.path)
return
// 显示进度条
progressDialog.value = true
// 开始监听进度
startLoadingProgress()
if (props.path) {
// 文件整理
try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, {
params: transferForm,
})
// 显示结果
if (result.success)
$toast.success(`${props.path} 整理完成!`)
else
$toast.error(`${props.path} 整理失败:${result.message}`)
}
catch (e) {
console.log(e)
}
}
else if (props.logids) {
// 日志整理
for (const logid of props.logids) {
transferForm.logid = logid
try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, {
params: transferForm,
})
if (!result.success)
$toast.error(`历史记录 ${logid} 重新整理失败:${result.message}`)
}
catch (e) {
console.log(e)
}
}
}
// 停止监听进度
stopLoadingProgress()
// 关闭进度条
progressDialog.value = false
// 重新加载
emit('done')
}
</script>
<template>
<VDialog
scrollable
max-width="60rem"
>
<VCard
:title="`${props.path ? `整理 - ${props.path}` : `整理 - 共 ${props.logids?.length} 条记录`}`"
class="rounded-t"
>
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VForm @submit.prevent="() => {}">
<VRow>
<VCol
cols="12"
md="8"
>
<VTextField
v-model="transferForm.target"
label="目的路径"
placeholder="留空自动"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
:items="[
{ title: '默认', value: '' },
{ title: '移动', value: 'move' },
{ title: '复制', value: 'copy' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
{ title: 'Rclone复制', value: 'rclone_copy' },
{ title: 'Rclone移动', value: 'rclone_move' },
]"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VSelect
v-model="transferForm.type_name"
label="类型"
:items="[{ title: '自动', value: '' }, { title: '电影', value: '电影' }, { title: '电视剧', value: '电视剧' }]"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="transferForm.tmdbid"
:disabled="transferForm.type_name === ''"
label="TMDBID"
placeholder="留空自动识别"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
@click:append-inner="tmdbSelectorDialog = true"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VSelect
v-show="transferForm.type_name === '电视剧'"
v-model.number="transferForm.season"
label="季"
:items="seasonItems"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="8">
<VTextField
v-model="transferForm.episode_format"
label="集数定位"
placeholder="使用{ep}定位集数"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_detail"
label="指定集数"
placeholder="起始集,终止集如1或1,2"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_part"
label="指定Part"
placeholder="如part1"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model.number="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model.number="transferForm.min_filesize"
label="最小文件大小MB"
:rules="[numberValidator]"
placeholder="0"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn depressed @click="emit('close')">
取消
</VBtn>
<VSpacer />
<VBtn
variant="tonal"
@click="transfer"
>
开始整理
</VBtn>
</VCardActions>
</VCard>
<!-- 手动整理进度框 -->
<VDialog
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
v-if="progressValue"
color="white"
class="mb-0 mt-1"
:model-value="progressValue"
/>
</VCardText>
</VCard>
</VDialog>
<!-- TMDB ID搜索框 -->
<VDialog
v-model="tmdbSelectorDialog"
width="40rem"
scrollable
>
<TmdbSelectorCard
v-model="transferForm.tmdbid"
@close="tmdbSelectorDialog = false"
/>
</VDialog>
</VDialog>
</template>

View File

@@ -0,0 +1,274 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import type { Site } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { numberValidator, requiredValidator } from '@/@validators'
import api from '@/api'
// 输入参数
const props = defineProps({
siteid: Number,
oper: String,
})
// 注册事件
const emit = defineEmits(['save', 'remove', 'close'])
// 站点编辑表单数据
const siteForm = ref<Site>({
id: props.siteid ?? 0,
url: '',
rss: '',
cookie: '',
ua: '',
pri: 0,
is_active: true,
limit_interval: 0,
limit_seconds: 0,
name: '',
domain: '',
})
// 提示框
const $toast = useToast()
// 状态下拉项
const statusItems = [
{ title: '启用', value: true },
{ title: '停用', value: false },
]
// 生成1到50的优先级下拉框选项
const priorityItems = ref(
Array.from({ length: 50 }, (_, i) => i + 1).map(item => ({
title: item,
value: item,
})),
)
// 监控输入参数
watchEffect(async () => {
if (props.siteid)
fetchSiteInfo()
})
// 查询站点信息
async function fetchSiteInfo() {
try {
siteForm.value = await api.get(`site/${props.siteid}`)
siteForm.value.proxy = siteForm.value.proxy === 1
siteForm.value.render = siteForm.value.render === 1
}
catch (error) {
console.error(error)
}
}
// 调用API 新增站点
async function addSite() {
if (!siteForm.value?.url)
return
startNProgress()
try {
const result: { [key: string]: string } = await api.post('site/', siteForm.value)
if (result.success) {
$toast.success('新增站点成功')
emit('save')
}
else { $toast.error(`新增站点失败:${result.message}`) }
}
catch (error) {
console.error(error)
}
doneNProgress()
}
// 调用API删除站点信息
async function deleteSiteInfo() {
try {
const result: { [key: string]: any } = await api.delete(`site/${siteForm.value?.id}`)
if (result.success)
emit('remove')
else $toast.error(`${siteForm.value?.name} 删除失败:${result.message}`)
}
catch (error) {
$toast.error(`${siteForm.value?.name} 删除失败!`)
console.error(error)
}
}
// 调用API更新站点信息
async function updateSiteInfo() {
startNProgress()
try {
const result: { [key: string]: any } = await api.put('site/', siteForm.value)
if (result.success) {
$toast.success(`${siteForm.value?.name} 更新成功!`)
emit('save')
}
else { $toast.error(`${siteForm.value?.name} 更新失败:${result.message}`) }
}
catch (error) {
$toast.error(`${siteForm.value?.name} 更新失败!`)
console.error(error)
}
doneNProgress()
}
</script>
<template>
<VDialog
scrollable
max-width="60rem"
>
<VCard
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
class="rounded-t"
>
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VForm @submit.prevent="() => {}">
<VRow>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="siteForm.url"
label="站点地址"
:rules="[requiredValidator]"
/>
</VCol>
<VCol
cols="12"
md="3"
>
<VSelect
v-model="siteForm.pri"
label="优先级"
:items="priorityItems"
:rules="[requiredValidator]"
/>
</VCol>
<VCol
cols="12"
md="3"
>
<VSelect
v-model="siteForm.is_active"
:items="statusItems"
label="状态"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VTextField
v-model="siteForm.rss"
label="RSS地址"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="siteForm.limit_interval"
label="单位周期(秒)"
:rules="[numberValidator]"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="siteForm.limit_count"
label="访问次数"
:rules="[numberValidator]"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="siteForm.limit_seconds"
label="访问间隔(秒)"
:rules="[numberValidator]"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="6"
>
<VSwitch
v-model="siteForm.proxy"
label="代理"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<VSwitch
v-model="siteForm.render"
label="仿真"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn
v-if="props.oper === 'add'"
@click="emit('close')"
>
取消
</VBtn>
<VBtn
v-else
color="error"
@click="deleteSiteInfo"
>
删除
</VBtn>
<VSpacer />
<VBtn
v-if="props.oper === 'add'"
color="primary"
variant="tonal"
@click="addSite"
>
新增
</VBtn>
<VBtn
v-else
color="primary"
variant="tonal"
@click="updateSiteInfo"
>
保存
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,364 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { numberValidator } from '@/@validators'
import api from '@/api'
import type { Site, Subscribe } from '@/api/types'
// 输入参数
const props = defineProps({
subid: Number,
})
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save', 'close'])
// 站点数据列表
const siteList = ref<Site[]>([])
// 站点选择下载框
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
// 订阅编辑表单
const subscribeForm = ref<Subscribe>({
id: props.subid ?? 0,
keyword: '',
quality: '',
resolution: '',
effect: '',
include: '',
exclude: '',
total_episode: 0,
start_episode: 0,
best_version: 0,
sites: [],
type: '',
name: '',
year: '',
tmdbid: 0,
state: '',
last_update: '',
username: '',
current_priority: 0,
save_path: '',
})
// 提示框
const $toast = useToast()
// 调用API修改订阅
async function updateSubscribeInfo() {
try {
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
// 提示
if (result.success) {
$toast.success(`${subscribeForm.value.name} 更新成功!`)
// 通知父组件刷新
emit('save')
}
else { $toast.error(`${subscribeForm.value.name} 更新失败:${result.message}`) }
}
catch (e) {
console.log(e)
}
}
// 获取站点列表数据
async function loadSites() {
try {
const data: Site[] = await api.get('site/rss')
// 过滤站点,只有启用的站点才显示
siteList.value = data.filter(item => item.is_active)
}
catch (error) {
console.error(error)
}
}
// 获取站点列表选择框数据
async function getSiteList() {
// 加载订阅站点列表
if (!siteList.value.length)
await loadSites()
const maps = siteList.value.map((item) => {
return {
title: item.name,
value: item.id,
}
})
selectSitesOptions.value = maps.flat()
}
// 获取订阅信息
async function getSubscribeInfo() {
try {
const result: Subscribe = await api.get(
`subscribe/${props.subid}`,
)
subscribeForm.value = result
subscribeForm.value.best_version = subscribeForm.value.best_version === 1
}
catch (e) {
console.log(e)
}
}
// 删除订阅
async function removeSubscribe() {
try {
const result: { [key: string]: any } = await api.delete(
`subscribe/${props.subid}`,
)
if (result.success) {
// 通知父组件刷新
emit('remove')
}
}
catch (e) {
console.log(e)
}
}
// 质量选择框数据
const qualityOptions = ref([
{
title: '全部',
value: '',
},
{
title: '蓝光原盘',
value: 'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD',
},
{
title: 'Remux',
value: 'Remux',
},
{
title: 'BluRay',
value: 'Blu-?Ray',
},
{
title: 'UHD',
value: 'UHD|UltraHD',
},
{
title: 'WEB-DL',
value: 'WEB-?DL|WEB-?RIP',
},
{
title: 'HDTV',
value: 'HDTV',
},
{
title: 'H265',
value: '[Hx].?265|HEVC',
},
{
title: 'H264',
value: '[Hx].?264|AVC',
},
])
// 分辨率选择框数据
const resolutionOptions = ref([
{
title: '全部',
value: '',
},
{
title: '4k',
value: '4K|2160p|x2160',
},
{
title: '1080p',
value: '1080[pi]|x1080',
},
{
title: '720p',
value: '720[pi]|x720',
},
])
// 特效选择框数据
const effectOptions = ref([
{
title: '全部',
value: '',
},
{
title: '杜比视界',
value: 'Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+',
},
{
title: '杜比全景声',
value: 'Dolby[\\s.]*\\+?Atmos|Atmos',
},
{
title: 'HDR',
value: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
},
{
title: 'SDR',
value: '[\\s.]+SDR[\\s.]+',
},
])
watchEffect(() => {
if (props.subid) {
getSiteList()
getSubscribeInfo()
}
})
</script>
<template>
<VDialog
scrollable
max-width="60rem"
>
<VCard
:title="`编辑订阅 - ${subscribeForm.name} ${subscribeForm.season ? `第 ${subscribeForm.season} 季` : ''}`"
class="rounded-t"
>
<VCardText class="pt-2">
<DialogCloseBtn @click="emit('close')" />
<VForm @submit.prevent="() => {}">
<VRow>
<VCol
cols="12"
md="8"
>
<VTextField
v-model="subscribeForm.keyword"
label="搜索关键词"
/>
</VCol>
<VCol
v-if="subscribeForm.type === '电视剧'"
cols="12"
md="2"
>
<VTextField
v-model="subscribeForm.total_episode"
label="总集数"
:rules="[numberValidator]"
/>
</VCol>
<VCol
v-if="subscribeForm.type === '电视剧'"
cols="12"
md="2"
>
<VTextField
v-model="subscribeForm.start_episode"
label="开始集数"
:rules="[numberValidator]"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VSelect
v-model="subscribeForm.quality"
label="质量"
:items="qualityOptions"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VSelect
v-model="subscribeForm.resolution"
label="分辨率"
:items="resolutionOptions"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VSelect
v-model="subscribeForm.effect"
label="特效"
:items="effectOptions"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VSelect
v-model="subscribeForm.sites"
:items="selectSitesOptions"
chips
label="订阅站点"
multiple
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
>
<VTextField
v-model="subscribeForm.save_path"
label="保存路径"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn color="error" @click="removeSubscribe">
取消订阅
</VBtn>
<VSpacer />
<VBtn
variant="tonal"
@click="updateSubscribeInfo"
>
保存
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -5,6 +5,7 @@ import { type PropType, ref } from 'vue'
interface RenderProps {
component: string
text: string
html: string
content?: any
props?: any
}
@@ -16,9 +17,10 @@ const elementProps = defineProps({
})
// 配置元素
const formItem = ref<RenderProps>(elementProps.config || {
const formItem = ref<RenderProps>(elementProps.config ?? {
component: 'div',
text: '',
html: '',
props: {},
content: [],
})
@@ -30,16 +32,58 @@ const formData = ref<any>(elementProps.form || {})
<template>
<Component
:is="formItem.component"
v-if="!formItem.html && !!formItem.props?.modelvalue"
v-bind="formItem.props"
v-model="formData[formItem.props?.model || '']"
v-model:value="formData[formItem.props?.modelvalue]"
>
{{ formItem.text }}
<FormRender
<template
v-for="(innerItem, innerIndex) in (formItem.content || [])"
:key="innerIndex"
v-model="formData[innerItem.props?.model || '']"
:config="innerItem"
:form="formData"
/>
>
<FormRender
v-if="!!innerItem.props?.modelvalue"
v-model:value="formData[innerItem.props?.modelvalue]"
:config="innerItem"
:form="formData"
/>
<FormRender
v-else
v-model="formData[innerItem.props?.model]"
:config="innerItem"
:form="formData"
/>
</template>
</Component>
<Component
:is="formItem.component"
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 }}
<template
v-for="(innerItem, innerIndex) in (formItem.content || [])"
:key="innerIndex"
>
<FormRender
v-if="!!innerItem.props?.modelvalue"
v-model:value="formData[innerItem.props?.modelvalue]"
:config="innerItem"
:form="formData"
/>
<FormRender
v-else
v-model="formData[innerItem.props?.model]"
:config="innerItem"
:form="formData"
/>
</template>
</Component>
</template>

View File

@@ -13,11 +13,10 @@ interface RenderProps {
// 输入参数
const elementProps = defineProps({
config: Object as PropType<RenderProps>,
handler: Boolean,
})
// 配置元素
const formItem = ref<RenderProps>(elementProps.config || {
const formItem = ref<RenderProps>(elementProps.config ?? {
component: 'div',
text: '',
html: '',

View File

@@ -91,7 +91,6 @@ const superUser = store.state.auth.superUser
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '电影',
icon: 'mdi-movie-check-outline',
@@ -99,7 +98,6 @@ const superUser = store.state.auth.superUser
}"
/>
<VerticalNavLink
v-if="superUser"
:item="{
title: '电视剧',
icon: 'mdi-television-classic',

View File

@@ -14,6 +14,10 @@ const themes: ThemeSwitcherTheme[] = [
name: 'purple',
icon: 'mdi-brightness-4',
},
{
name: 'auto',
icon: 'mdi-brightness-auto',
},
]
</script>

View File

@@ -8,6 +8,9 @@ const searchWord = ref<string>('')
// 搜索弹窗
const searchDialog = ref(false)
// ref
const searchWordInput = ref<HTMLElement | null>(null)
// Search
function search() {
if (!searchWord.value)
@@ -21,6 +24,14 @@ function search() {
},
})
}
// 打开搜索弹窗
function openSearchDialog() {
searchDialog.value = true
nextTick(() => {
searchWordInput.value?.focus()
})
}
</script>
<template>
@@ -34,23 +45,16 @@ function search() {
max-width="50rem"
transition="dialog-top-transition"
>
<!-- Dialog Activator -->
<template #activator="{ props }">
<IconBtn
class="d-lg-none"
v-bind="props"
>
<VIcon icon="mdi-magnify" />
</IconBtn>
</template>
<!-- Dialog Content -->
<VCard title="搜索">
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
ref="searchWordInput"
v-model="searchWord"
label="电影、电视剧名称"
@keydown.enter="search"
/>
</VCol>
</VRow>
@@ -59,8 +63,8 @@ function search() {
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="search"
@keydown.enter="search"
>
搜索
</VBtn>
@@ -68,7 +72,13 @@ function search() {
</VCard>
</VDialog>
</div>
<!-- 👉 Search Icon -->
<IconBtn
class="d-lg-none"
@click="openSearchDialog"
>
<VIcon icon="mdi-magnify" />
</IconBtn>
<!-- 👉 Search Textfield -->
<span class="w-1/5">
<VTextField

View File

@@ -3,6 +3,7 @@ import NameTestView from '@/views/system/NameTestView.vue'
import NetTestView from '@/views/system/NetTestView.vue'
import LoggingView from '@/views/system/LoggingView.vue'
import RuleTestView from '@/views/system/RuleTestView.vue'
import store from '@/store'
// App捷径
const appsMenu = ref(false)
@@ -18,6 +19,12 @@ const loggingDialog = ref(false)
// 过滤规则弹窗
const ruleTestDialog = ref(false)
// 拼接全部日志url
function allLoggingUrl() {
const token = store.state.auth.token
return `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1`
}
</script>
<template>
@@ -171,8 +178,19 @@ const ruleTestDialog = ref(false)
class="w-full lg:w-4/5"
scrollable
>
<VCard title="实时日志">
<VCard>
<DialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
<VCardTitle class="inline-flex">
实时日志
<a class="mx-2 inline-flex items-center justify-center" :href="allLoggingUrl()" target="_blank">
<div class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700">
<VIcon icon="mdi-open-in-new" />
<span class="ms-1">在新窗口中打开</span>
</div>
</a>
</VCardTitle>
</VCardItem>
<VCardText>
<LoggingView />
</VCardText>

View File

@@ -38,6 +38,9 @@ async function restart() {
dialogProps: {
maxWidth: '30rem',
},
cancellationButtonProps: {
variant: 'tonal',
},
})
if (confirmed) {
@@ -121,6 +124,25 @@ const avatar = store.state.auth.avatar
<VListItemTitle>设定</VListItemTitle>
</VListItem>
<!-- Divider -->
<VDivider class="my-2" />
<!-- 👉 restart -->
<VListItem
v-if="superUser"
@click="restart"
>
<template #prepend>
<VIcon
class="me-2"
icon="mdi-restart"
size="22"
/>
</template>
<VListItemTitle>重启</VListItemTitle>
</VListItem>
<!-- 👉 FAQ -->
<VListItem
href="https://github.com/jxxghp/MoviePilot/blob/main/README.md"
@@ -137,22 +159,6 @@ const avatar = store.state.auth.avatar
<VListItemTitle>帮助</VListItemTitle>
</VListItem>
<!-- Divider -->
<VDivider class="my-2" />
<!-- 👉 restart -->
<VListItem @click="restart">
<template #prepend>
<VIcon
class="me-2"
icon="mdi-restart"
size="22"
/>
</template>
<VListItemTitle>重启</VListItemTitle>
</VListItem>
<!-- 👉 Logout -->
<VListItem @click="logout">
<template #prepend>
@@ -170,21 +176,21 @@ const avatar = store.state.auth.avatar
<!-- !SECTION -->
</VAvatar>
<!-- 重启进度框 -->
<vDialog
<VDialog
v-model="progressDialog"
width="25rem"
>
<vCard
<VCard
color="primary"
>
<vCardText class="text-center">
<VCardText class="text-center">
正在重启 ...
<vProgressLinear
<VProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</vCardText>
</vCard>
</vDialog>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,7 +1,11 @@
import { VAceEditor } from 'vue3-ace-editor'
import { createApp } from 'vue'
import '@/@iconify/icons-bundle'
import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import './ace-config'
import VueApexCharts from 'vue3-apexcharts'
import { removeEl } from './@core/utils/dom'
import App from '@/App.vue'
import vuetify from '@/plugins/vuetify'
import { loadFonts } from '@/plugins/webfontloader'
@@ -10,15 +14,18 @@ import store from '@/store'
import '@core/scss/template/index.scss'
import '@layouts/styles/index.scss'
import '@styles/styles.scss'
import 'vue-toast-notification/dist/theme-default.css'
import { removeEl } from '@/util'
import 'vue-toast-notification/dist/theme-bootstrap.css'
loadFonts()
// Create vue app
// 创建Vue实例
const app = createApp(App)
// Use plugins Mount vue app
// 注册全局组件
app.component('VAceEditor', VAceEditor)
.component('VApexChart', VueApexCharts)
// 注册插件
app
.use(vuetify)
.use(router)

View File

@@ -13,6 +13,9 @@ const route = useRoute()
// 标题
const title = route.query?.title?.toString()
// 类型
const type = route.query?.type?.toString()
// 计算API路径
function getApiPath(paths: string[] | string) {
if (Array.isArray(paths))
@@ -34,6 +37,7 @@ function getApiPath(paths: string[] | string) {
<PersonCardListView
:apipath="getApiPath(props.paths || '')"
:params="route.query"
:type="type"
/>
</div>
</template>

View File

@@ -6,11 +6,57 @@ 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'
// 仪表盘配置
const dashboard_names = {
storage: '存储空间',
mediaStatistic: '媒体统计',
weeklyOverview: '最近入库',
speed: '实时速率',
scheduler: '后台任务',
cpu: 'CPU',
memory: '内存',
library: '我的媒体库',
playing: '继续观看',
latest: '最近添加',
}
// 弹窗
const dialog = ref(false)
// 从localStorage中获取数据
const default_config = {
mediaStatistic: true,
scheduler: false,
speed: false,
storage: true,
weeklyOverview: false,
cpu: false,
memory: false,
library: true,
playing: true,
latest: true,
}
const config = ref(JSON.parse(localStorage.getItem('MP_DASHBOARD') || '{}'))
if (Object.keys(config.value).length === 0) {
config.value = default_config
localStorage.setItem('MP_DASHBOARD', JSON.stringify(config.value))
}
// 设置项目
function setDashboardConfig() {
localStorage.setItem('MP_DASHBOARD', JSON.stringify(config.value))
dialog.value = false
}
</script>
<template>
<VRow class="match-height">
<VCol
v-if="config.storage"
cols="12"
md="4"
>
@@ -18,6 +64,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.mediaStatistic"
cols="12"
md="8"
>
@@ -25,6 +72,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.weeklyOverview"
cols="12"
md="4"
>
@@ -32,6 +80,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.speed"
cols="12"
md="4"
>
@@ -39,6 +88,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.scheduler"
cols="12"
md="4"
>
@@ -46,6 +96,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.cpu"
cols="12"
md="6"
>
@@ -53,10 +104,75 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</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>
<!-- 底部操作按钮 -->
<span class="fixed right-5 bottom-5">
<VBtn icon="mdi-view-dashboard-edit" class="me-2" color="primary" size="x-large" @click="dialog = true" />
</span>
<!-- 弹窗根据配置生成选项 -->
<VDialog
v-model="dialog"
max-width="600"
scrollable
>
<VCard title="设置仪表板">
<VCardText>
<VRow>
<VCol
v-for="(item, key) in dashboard_names"
:key="key"
cols="12"
md="4"
>
<VCheckbox
v-model="config[key]"
:label="dashboard_names[key]"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn
color="primary"
@click="dialog = false"
>
取消
</VBtn>
<VSpacer />
<VBtn
color="primary"
@click="setDashboardConfig"
>
保存
</VBtn>
</VCardActions>
</VCard>
</vdialog>
</template>

View File

@@ -33,7 +33,7 @@ const isImageLoaded = ref(false)
// 获取背景图片
async function fetchBackgroundImage() {
api
.get('/login/tmdb')
.get('/login/wallpaper')
.then((response: any) => {
backgroundImageUrl.value = response.message
})

View File

@@ -28,6 +28,24 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
title="热门电视剧"
/>
<MediaCardSlideView
apipath="douban/movie_hot"
linkurl="/browse/douban/movie_hot?title=热门电影"
title="热门电影"
/>
<MediaCardSlideView
apipath="douban/tv_hot"
linkurl="/browse/douban/tv_hot?title=热门电视剧"
title="热门电视剧"
/>
<MediaCardSlideView
apipath="douban/tv_animation"
linkurl="/browse/douban/tv_animation?title=热门动漫"
title="热门动漫"
/>
<MediaCardSlideView
apipath="douban/movies"
linkurl="/browse/douban/movies?title=最新电影"

View File

@@ -1,5 +1,10 @@
<script setup lang="ts">
import NoDataFound from '@/components/NoDataFound.vue'
import api from '@/api'
import type { Context } from '@/api/types'
import store from '@/store'
import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
import TorrentRowListView from '@/views/discover/TorrentRowListView.vue'
// 路由参数
const route = useRoute()
@@ -12,14 +17,130 @@ const type = route.query?.type?.toString() ?? ''
// 搜索字段
const area = route.query?.area?.toString() ?? ''
// 视图类型从localStorage中读取
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
// 数据列表
const dataList = ref<Array<Context>>([])
// 是否刷新过
const isRefreshed = ref(false)
// 加载进度文本
const progressText = ref('')
// 加载进度
const progressValue = ref(0)
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '正在搜索,请稍候...'
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/search?token=${token}`,
)
progressEventSource.value.onmessage = (event) => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
}
}
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
}
// 设置视图类型
function setViewType(type: string) {
localStorage.setItem('MPTorrentsViewType', type)
viewType.value = type
}
// 获取搜索列表数据
async function fetchData() {
try {
if (!keyword) {
// 查询上次搜索结果
dataList.value = await api.get('search/last')
}
else {
startLoadingProgress()
// 优先按TMDBID精确查询
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:')) {
dataList.value = await api.get(`search/media/${keyword}`, {
params: {
mtype: type,
area,
},
})
}
else {
// 按标题模糊查询
dataList.value = await api.get(`search/title/${keyword}`)
}
stopLoadingProgress()
}
// 标记已刷新
isRefreshed.value = true
}
catch (error) {
console.error(error)
return Promise.reject(error)
}
}
// 加载数据
onMounted(() => {
fetchData()
})
</script>
<template>
<div>
<div v-if="!isRefreshed" class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center">
<VProgressCircular v-if="!keyword" size="48" indeterminate color="primary" />
<VProgressCircular v-if="keyword" class="mb-3" color="primary" :model-value="progressValue" size="64" />
<span>{{ progressText }}</span>
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
error-title="没有资源"
error-description="没有搜索到符合条件的资源"
/>
<div v-if="dataList.length > 0">
<TorrentRowListView
v-if="viewType === 'list'"
:items="dataList"
/>
<TorrentCardListView
:keyword="keyword"
:type="type"
:area="area"
v-else
:items="dataList"
/>
</div>
<!-- 视图切换 -->
<span v-if="dataList.length > 0" class="fixed right-5 bottom-5">
<VBtn
v-if="viewType === 'list'"
size="x-large"
icon="mdi-view-grid"
color="primary"
@click="setViewType('card')"
/>
<VBtn
v-else
size="x-large"
icon="mdi-view-list"
color="primary"
@click="setViewType('list')"
/>
</span>
</template>

View File

@@ -2,12 +2,13 @@
import { useRoute } from 'vue-router'
import AccountSettingAccount from '@/views/setting/AccountSettingAccount.vue'
import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
import AccountSettingWords from '@/views/setting/AccountSettingWords.vue'
import AccountSettingAbout from '@/views/setting/AccountSettingAbout.vue'
import AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue'
import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'
import AccountSettingService from '@/views/setting/AccountSettingService.vue'
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
const route = useRoute()
@@ -20,6 +21,11 @@ const tabs = [
icon: 'mdi-account',
tab: 'account',
},
{
title: '系统',
icon: 'mdi-cog',
tab: 'system',
},
{
title: '站点',
icon: 'mdi-web',
@@ -36,9 +42,9 @@ const tabs = [
tab: 'subscribe',
},
{
title: '规则',
icon: 'mdi-filter-cog',
tab: 'filter',
title: '服务',
icon: 'mdi-list-box',
tab: 'service',
},
{
title: '通知',
@@ -60,7 +66,10 @@ const tabs = [
<template>
<div>
<VTabs v-model="activeTab" show-arrows>
<VTabs
v-model="activeTab"
show-arrows
>
<VTab v-for="item in tabs" :key="item.icon" :value="item.tab">
<VIcon size="20" start :icon="item.icon" />
{{ item.title }}
@@ -68,15 +77,26 @@ const tabs = [
</VTabs>
<VDivider />
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition">
<!-- Account -->
<VWindow
v-model="activeTab"
class="mt-5 disable-tab-transition"
:touch="false"
>
<!-- 用户 -->
<VWindowItem value="account">
<transition name="fade-slide" appear>
<AccountSettingAccount />
</transition>
</VWindowItem>
<!-- 用户 -->
<!-- 系统 -->
<VWindowItem value="system">
<transition name="fade-slide" appear>
<AccountSettingSystem />
</transition>
</VWindowItem>
<!-- 站点 -->
<VWindowItem value="site">
<transition name="fade-slide" appear>
<AccountSettingSite />
@@ -97,10 +117,10 @@ const tabs = [
</transition>
</VWindowItem>
<!-- Notification -->
<VWindowItem value="filter">
<!-- 服务 -->
<VWindowItem value="service">
<transition name="fade-slide" appear>
<AccountSettingRule />
<AccountSettingService />
</transition>
</VWindowItem>

View File

@@ -31,7 +31,7 @@ export default {
elevation: 0,
},
VList: {
activeColor: 'primary',
color: 'primary',
},
VPagination: {
activeColor: 'primary',

View File

@@ -1,4 +1,4 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
import { configureNProgress, doneNProgress, startNProgress } from '@/api/nprogress'
import store from '@/store'
@@ -7,7 +7,7 @@ configureNProgress()
// Router
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: createWebHashHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) {
// 如果页面有缓存那么恢复其位置, 否则始终滚动到顶部
if (to.meta.keepAlive && savedPosition)
@@ -103,6 +103,7 @@ const router = createRouter({
component: () => import('../pages/browse.vue'),
props: true,
meta: {
keepAlive: true,
requiresAuth: true,
},
},
@@ -111,6 +112,7 @@ const router = createRouter({
component: () => import('../pages/credits.vue'),
props: true,
meta: {
keepAlive: true,
requiresAuth: true,
},
},

View File

@@ -1,6 +0,0 @@
export function removeEl(selector: string) {
if (selector) {
const el = document.querySelector(selector)
el?.parentNode?.removeChild(el)
}
}

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