Compare commits

..

172 Commits

Author SHA1 Message Date
jxxghp
1fc964ec16 add app mode 2024-06-13 17:30:50 +08:00
jxxghp
7f2f7b100b 更新 AccountSettingNotification.vue 2024-06-13 07:11:03 +08:00
jxxghp
8292140f1f 更新 package.json 2024-06-13 07:07:45 +08:00
jxxghp
c26e610a23 更新 MediaDetailView.vue 2024-06-13 07:06:49 +08:00
jxxghp
c96cfe81ab Merge pull request #154 from Mattoids/main 2024-06-11 18:43:06 +08:00
liufei
bb1cc0b60e 修复 套件版本无法添加用户的问题 2024-06-11 17:59:49 +08:00
jxxghp
1e74073344 更新 SearchBarView.vue 2024-06-10 17:05:11 +08:00
jxxghp
d83d1dd888 fix 榜单 & 订阅弹窗 & 订阅重置 2024-06-10 09:36:42 +08:00
jxxghp
e34573e72f fix webpush仅限管理员 2024-06-08 12:35:05 +08:00
jxxghp
9d3f4879ef feat:增加域名设置 2024-06-08 10:56:29 +08:00
jxxghp
6317277a70 v1.9.4-1 2024-06-08 07:46:20 +08:00
jxxghp
a1130ec60b feat:捷径根据参数自动打开 2024-06-08 07:45:45 +08:00
jxxghp
a1a3ccf6fb fix 2024-06-07 20:24:02 +08:00
jxxghp
aedb8bee9c fix service worker 2024-06-07 20:22:59 +08:00
jxxghp
6620d1c8fe fix service-worker 2024-06-07 08:34:09 +08:00
jxxghp
0ecc7dfead remove defer 2024-06-06 14:07:25 +08:00
jxxghp
9f5859ee93 feat:订阅重置 2024-06-06 07:57:45 +08:00
jxxghp
d559e1717c fix service worker 2024-06-05 22:21:27 +08:00
jxxghp
e649be58a2 add webpush switch 2024-06-05 18:42:39 +08:00
jxxghp
157c37c862 add service worker 2024-06-05 18:12:07 +08:00
jxxghp
da910ac670 Merge pull request #151 from hotlcc/develop-20240604-2 2024-06-04 17:53:02 +08:00
Allen
3831363815 删除和整理场景路由参数未改变,reloadPage不会生效,需要fetchData刷新数据 2024-06-04 17:41:09 +08:00
jxxghp
94a6ea13bd rollback 2024-06-04 16:18:06 +08:00
jxxghp
06c1ad0f69 更新 main.ts 2024-06-04 16:14:25 +08:00
jxxghp
d6873781e8 更新 SearchBarView.vue 2024-06-04 15:46:36 +08:00
jxxghp
ab6c9647a7 Merge pull request #149 from hotlcc/develop-20240604-1 2024-06-04 15:42:36 +08:00
Allen
59b0350993 针对异形屏做了优化 2024-06-04 15:28:17 +08:00
jxxghp
df0be4c070 更新 package.json 2024-06-04 14:02:59 +08:00
jxxghp
87f3ef4353 Merge pull request #148 from hotlcc/develop-20240604-1 2024-06-04 14:00:49 +08:00
Allen
2611bbaea4 弹窗 VDialog 在低版本 iOS Safari 浏览器下宽度异常问题处理 2024-06-04 13:54:48 +08:00
jxxghp
7c0d8cf792 Merge pull request #147 from hotlcc/develop-20240604-1 2024-06-04 12:54:09 +08:00
Allen
2d17baccd2 低版本safari主菜单样式兼容性处理 2024-06-04 12:33:18 +08:00
jxxghp
fe31723726 fix #145 2024-06-04 11:41:38 +08:00
jxxghp
bb10b22421 fix bug 2024-06-04 08:01:10 +08:00
jxxghp
6445f3a634 Merge pull request #144 from falling/main 2024-06-03 21:11:33 +08:00
falling
d1f28d9c94 资源搜索里的季集下拉列表,从字符串排序改成按季集排序 2024-06-03 21:01:12 +08:00
jxxghp
1e5366123c feat:近期搜索记忆 2024-06-03 16:35:32 +08:00
jxxghp
7feff7c90b fix 2024-06-03 11:45:15 +08:00
jxxghp
429b3bc045 Merge pull request #142 from hotlcc/develop-20240603
Develop 20240603
2024-06-03 11:36:01 +08:00
Allen
e76f1b89da fix number 2024-06-03 11:33:53 +08:00
Allen
f25e8595c3 fix number 2024-06-03 11:17:54 +08:00
jxxghp
6977ce55a3 Merge pull request #141 from hotlcc/develop-20240603
Develop 20240603
2024-06-03 11:09:51 +08:00
Allen
222e0e5ff2 fix encodeURIComponent 2024-06-03 11:04:17 +08:00
Allen
6996d9bbe2 历史记录页面搜索关键字、页码、页大小参数路优化,方便外部定位,同时为了解决支持kbar后路由参数和搜索框内容不一致的问题 2024-06-03 11:00:00 +08:00
jxxghp
f70e08adac Merge pull request #140 from hotlcc/develop-20240603
Develop 20240603
2024-06-03 10:47:04 +08:00
jxxghp
223ecc0e6b fix dialog persistent-hint 2024-06-03 10:43:28 +08:00
Allen
43f36f556c kbar支持历史记录 2024-06-03 10:16:01 +08:00
jxxghp
4579e00283 fix persistent-hint 2024-06-03 10:14:03 +08:00
Allen
b5e9b14048 站点卡片代理和仿真图标顺序与配置界面保存一致 2024-06-03 09:21:03 +08:00
Allen
2288e72c5f 站点卡片有代理等图标时高度保持一致 2024-06-03 09:19:52 +08:00
jxxghp
4882cc0417 release v1.9.3 2024-06-03 08:21:15 +08:00
jxxghp
499d3d0424 fix ui 2024-06-03 08:09:03 +08:00
jxxghp
d6b17debb4 fix loading banner 2024-06-02 21:27:24 +08:00
jxxghp
8f970e0008 feat:支持直接搜索站点资源 2024-06-02 21:10:02 +08:00
jxxghp
18d778a1cc feat:聚合搜索支持订阅 2024-06-02 19:50:28 +08:00
jxxghp
d667c4e45d v1.9.3 2024-06-02 18:50:55 +08:00
jxxghp
b7f8ffd56f fix 聚合搜索 2024-06-02 18:45:50 +08:00
jxxghp
c20f9d527f fix icon 2024-06-02 15:41:09 +08:00
jxxghp
b859d00cb9 fix 聚合搜索 2024-06-02 14:58:58 +08:00
jxxghp
a2d28ad360 feat:聚合搜索(working...) 2024-06-02 11:13:03 +08:00
jxxghp
c6702fbc18 更新 package.json 2024-06-01 22:31:44 +08:00
jxxghp
5018f96786 fix VFab 2024-06-01 22:07:19 +08:00
jxxghp
f29f408b67 feat:目录选择组件 2024-05-31 20:35:57 +08:00
jxxghp
a475a3b851 fix DirectoryTreeInput 2024-05-31 18:31:45 +08:00
jxxghp
9335f79c30 add DirectoryTreeInput 2024-05-31 15:06:58 +08:00
jxxghp
9dab691649 Merge pull request #139 from hotlcc/develop-20240531
vuetify升级至3.6.8后,设定中tab的选中样式改变,修复为原版选中样式
2024-05-31 14:10:36 +08:00
Allen
16abc65f49 vuetify升级至3.6.8后,设定中tab的选中样式改变,修复为原版选中样式 2024-05-31 13:00:35 +08:00
jxxghp
23ac80886d upgrade vuetify to 3.6.8 2024-05-31 11:46:27 +08:00
jxxghp
b242e757e0 add kbar 2024-05-31 11:26:19 +08:00
jxxghp
a69965a605 Merge pull request #138 from hotlcc/develop-20240531 2024-05-31 11:25:14 +08:00
Allen
3321427eb4 修正tab路由参数为query,解决原先采用params路由参数时导致主菜单选中状态不同步的问题 2024-05-31 11:18:44 +08:00
jxxghp
3ffe354770 v1.9.2-3 2024-05-31 08:04:48 +08:00
jxxghp
52e0d3a4bc fix tab route 2024-05-31 08:04:27 +08:00
jxxghp
e865a5ca62 Merge pull request #136 from hotlcc/develop-20240530 2024-05-30 15:24:06 +08:00
Allen
528a4ddb03 完善设定tab精确路由 2024-05-30 15:16:49 +08:00
jxxghp
36f3b649c6 Merge pull request #135 from hotlcc/develop-20240530 2024-05-30 11:59:54 +08:00
Allen
ce91c0cc30 await接口请求后才重新获取插件仪表板,解决仪表板调整配置保存时出现重复插件请求的问题 2024-05-30 11:36:13 +08:00
jxxghp
e31e9e3520 更新 dashboard.vue 2024-05-29 15:30:04 +08:00
jxxghp
df313ebe7f fix 2024-05-29 15:26:59 +08:00
jxxghp
e1cf36e952 v1.9.2-2 2024-05-29 15:17:10 +08:00
jxxghp
493194652c 仪表板组件高度拉齐开关 && 无边框组件背景不拉平 && 修复多次定时问题 2024-05-29 15:15:15 +08:00
jxxghp
5030e75c2c fix ui 2024-05-29 09:17:22 +08:00
jxxghp
3c70eac7ca fix 种子剧集过滤 2024-05-27 09:12:46 +08:00
jxxghp
f9b22962a4 fix hover 2024-05-27 08:48:25 +08:00
jxxghp
7ce0c21b0c fix 2024-05-26 18:38:41 +08:00
jxxghp
7a7a8c923f fix 2024-05-26 18:15:41 +08:00
jxxghp
d5d5e28f7e fix 文件管理路径 2024-05-26 18:14:31 +08:00
jxxghp
b22ac27075 fix 2024-05-26 17:55:38 +08:00
jxxghp
3cb5f4bdfe fix 默认下载路径 2024-05-26 17:41:41 +08:00
jxxghp
d355e4575d fix #2179 根据路径自动匹配刮削开关 2024-05-26 09:37:42 +08:00
jxxghp
bdbb118e55 fix https://github.com/jxxghp/MoviePilot-Frontend/issues/131 2024-05-26 08:09:47 +08:00
jxxghp
9a174d99db Update MediaDirectoryCard.vue 2024-05-25 07:07:13 +08:00
jxxghp
9c8725066c Merge pull request #130 from hotlcc/develop-20240524-插件支持多仪表板组件 2024-05-24 15:48:16 +08:00
Allen
9f0f3de864 一个插件支持透出多个仪表板控件,并兼容历史 2024-05-24 14:56:33 +08:00
jxxghp
ac84ed2d6a v1.9.1-1 2024-05-24 11:20:16 +08:00
jxxghp
9d7e15f4df feat:同盘优先选项 2024-05-24 11:18:30 +08:00
jxxghp
c3563f4501 v1.9.1 2024-05-24 09:00:42 +08:00
jxxghp
a543202edc feat:订阅保存路径支持下拉选择 2024-05-24 08:16:10 +08:00
jxxghp
52cf517a91 站点拖动排序 2024-05-23 19:39:33 +08:00
jxxghp
11b649dc8c fix 手动整理选择目录
fix https://github.com/jxxghp/MoviePilot/issues/2145
2024-05-23 12:39:35 +08:00
jxxghp
19663bacb1 更新 TransferHistoryView.vue 2024-05-23 10:34:38 +08:00
jxxghp
41c276d0e0 更新 AccountSettingDirectory.vue 2024-05-23 09:17:11 +08:00
jxxghp
6bb73add28 release-beta 2024-05-23 08:42:00 +08:00
jxxghp
2c16b6c078 fix manual_transfer 2024-05-23 08:09:48 +08:00
jxxghp
5ddc955805 feat:目录设置UI 2024-05-22 18:01:53 +08:00
jxxghp
6a3afa4240 fix dashboard refresh 2024-05-21 20:20:51 +08:00
jxxghp
deabd7b83c fix ui 2024-05-21 10:51:10 +08:00
jxxghp
422e5858ef fix ui 2024-05-19 14:30:20 +08:00
jxxghp
3c019d1376 feat:自定义主题 2024-05-19 14:20:01 +08:00
jxxghp
f676e8423e 更新 package.json 2024-05-18 11:10:41 +08:00
jxxghp
f687d1de01 更新 manifest.json 2024-05-18 11:09:59 +08:00
jxxghp
6fe28bc2ef fix 2024-05-17 14:12:44 +08:00
jxxghp
86b5af3423 去除无用package 2024-05-17 13:59:00 +08:00
jxxghp
8f3dce058c v1.8.9 2024-05-17 12:19:59 +08:00
jxxghp
825b8bb4a5 Merge pull request #128 from hotlcc/develop-20240517-页面优化
仪表板组件拖拽按钮按照hover进行展示
2024-05-17 10:56:16 +08:00
jxxghp
05320d1070 Merge branch 'main' into develop-20240517-页面优化 2024-05-17 10:56:08 +08:00
jxxghp
33d2a396ce 仪表板支持自定义标题 2024-05-17 10:54:19 +08:00
jxxghp
ae4cce8abf 安装到桌面时支持操作按钮 2024-05-17 10:41:35 +08:00
Allen
b85950e4ca 仪表板组件拖拽按钮按照hover进行展示 2024-05-17 10:23:57 +08:00
jxxghp
aecf52551b fix 2024-05-17 07:37:10 +08:00
jxxghp
fc877ed836 fix ui 2024-05-16 20:31:30 +08:00
jxxghp
5580921b7d 站点超时时间设置 2024-05-16 14:42:35 +08:00
jxxghp
6b7d0a0fe2 fix Module Test 2024-05-16 14:18:32 +08:00
jxxghp
f55efbe1e2 feat:种子页面排序 2024-05-16 13:08:55 +08:00
jxxghp
8e6fc3c417 Merge pull request #127 from dh336699/main 2024-05-16 12:25:24 +08:00
hao.dai
7943ab6017 fix: 只有一季以及多季只订阅一季订阅成功无提示问题 2024-05-16 11:29:15 +08:00
jxxghp
81725a58cf Merge pull request #126 from hotlcc/develop-20240516-页面优化 2024-05-16 10:57:33 +08:00
jxxghp
5cbcf46aaa Merge pull request #124 from hotlcc/develop-20240515-页面优化 2024-05-16 10:56:46 +08:00
Allen
49dd3f726a 解决路由回跳缺陷(1、手动退出后重新登录会错误地回到上次丢失认证时记录的路由页面;2、后端接口403时会错误地回到上次丢失认证时记录的路由页面而不是当前页面) 2024-05-16 10:54:10 +08:00
Allen
73f9ebc709 插件仪表板支持自定义子标题 2024-05-15 10:29:34 +08:00
Allen
f6884ba4f9 插件仪表板组件卸载时取消刷新定时器 2024-05-15 10:27:24 +08:00
jxxghp
5d39d0e139 fix subscribe card 2024-05-14 15:54:53 +08:00
jxxghp
6a1463ef17 fix subscribe card 2024-05-14 15:47:29 +08:00
jxxghp
5d00f23cb3 fix bug 2024-05-14 12:19:05 +08:00
jxxghp
6ea106b25d feat:优先级规则支持拖动排序 2024-05-14 11:32:28 +08:00
jxxghp
d501bf7506 feat:仪表板组件支持无边框 2024-05-13 20:23:12 +08:00
jxxghp
1408060053 fix dashboard ui 2024-05-13 12:17:48 +08:00
jxxghp
0c37c01496 feat: add new media cards and components 2024-05-13 07:06:37 +08:00
jxxghp
d2049f7839 fix dashboard refresh 2024-05-12 20:22:20 +08:00
jxxghp
33cdf672b3 fix ui 2024-05-11 17:34:08 +08:00
jxxghp
145c89acc3 release beta 2024-05-11 13:46:55 +08:00
jxxghp
706d7d6dc1 fix apexchats datalabels 2024-05-11 13:46:16 +08:00
jxxghp
2c35d0f897 fix layout ui 2024-05-11 12:53:42 +08:00
jxxghp
f227ae89ec fix 2024-05-10 20:32:07 +08:00
jxxghp
ac43d53884 fix #2045 2024-05-10 20:08:04 +08:00
jxxghp
4b70549bcb fix sort 2024-05-09 19:05:45 +08:00
jxxghp
ea601ae404 fix mobile 2024-05-09 18:54:19 +08:00
jxxghp
201411841c fix versions ui 2024-05-09 18:39:44 +08:00
jxxghp
d857acc58e fix drag handle 2024-05-09 18:30:25 +08:00
jxxghp
d005252f13 fix bug 2024-05-09 15:21:46 +08:00
jxxghp
2065992b17 仪表板组件支持拖动排序 2024-05-09 14:45:12 +08:00
jxxghp
74e96980e6 插件仪表板支持自动刷新 & 仅管理员可见 2024-05-09 08:03:01 +08:00
jxxghp
09110d1ef7 支持插件扩展仪表板 2024-05-08 21:03:00 +08:00
jxxghp
bcf55e63f1 调整热门订阅热度显示样式 2024-05-08 08:08:08 +08:00
jxxghp
dd22b2580e fix nodata svg 2024-05-07 19:30:43 +08:00
jxxghp
62a0e46698 release 2024-05-07 16:10:13 +08:00
jxxghp
14b68135fb fix VDivder 2024-05-07 13:50:49 +08:00
jxxghp
d44b62e489 fix btnui 2024-05-07 13:36:13 +08:00
jxxghp
b0f5c2a493 feat:显示流行度 2024-05-07 12:34:30 +08:00
jxxghp
d6cfbc60a8 更新 PluginCard.vue 2024-05-06 18:43:16 +08:00
jxxghp
fe51f5ced4 更新 SubscribeEditDialog.vue 2024-05-06 18:39:28 +08:00
jxxghp
b257b0453e 更新 SiteAddEditDialog.vue 2024-05-06 18:38:57 +08:00
jxxghp
a88105a086 更新 ReorganizeDialog.vue 2024-05-06 18:38:02 +08:00
jxxghp
2dc792690e Update button styles in PluginCard.vue, ReorganizeDialog.vue, SiteAddEditDialog.vue, and SubscribeEditDialog.vue 2024-05-06 18:26:52 +08:00
jxxghp
aa146b1cdf Update confirmation dialog styles and props in UserProfile.vue, FileList.vue, PluginCard.vue, and dashboard.vue 2024-05-06 18:18:05 +08:00
jxxghp
c44b20bae3 Update confirmation dialog styles and props in UserProfile.vue, FileList.vue, PluginCard.vue, and dashboard.vue 2024-05-06 17:37:47 +08:00
jxxghp
cad8964841 Remove unused styles in setting.vue 2024-05-06 16:41:41 +08:00
jxxghp
ec9a989214 Update card cover size and alignment in PluginAppCard and PluginCard components 2024-05-06 16:14:44 +08:00
jxxghp
7f05932fb9 remove github icon 2024-05-06 13:22:16 +08:00
jxxghp
d51694e1cb fix text 2024-05-06 12:37:34 +08:00
jxxghp
3079483e6b feat:订阅统计共享 2024-05-06 11:37:52 +08:00
jxxghp
bee4264a39 Update styling in setting.vue and PluginCardListView.vue 2024-05-05 20:20:37 +08:00
107 changed files with 6062 additions and 4539 deletions

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@@ -15,7 +15,6 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" /> <link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="manifest.json" crossorigin="use-credentials" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -30,6 +29,7 @@
<meta name="HandheldFriendly" content="True" /> <meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" /> <meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" /> <link rel="stylesheet" type="text/css" href="/loader.css" />
<link rel="preload" href="index.js" as="script">
</head> </head>
<body> <body>
@@ -159,4 +159,4 @@
</script> </script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

2
public/robots.txt Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,9 +58,13 @@ export interface Subscribe {
// 当前优先级 // 当前优先级
current_priority: number current_priority: number
// 保存目录 // 保存目录
save_path: string save_path?: string
// 时间 // 时间
date: string date: string
// 编辑框设置项
show_edit_dialog: boolean
// 编辑框打开状态
page_open?: boolean
} }
// 历史记录 // 历史记录
@@ -332,6 +336,8 @@ export interface Site {
public?: number public?: number
// 备注 // 备注
note?: string note?: string
// 超时时间
timeout?: number
// 流控单位周期 // 流控单位周期
limit_interval?: number limit_interval?: number
// 流控次数 // 流控次数
@@ -441,6 +447,35 @@ export interface Plugin {
history?: { [key: string]: string } history?: { [key: string]: string }
// 添加时间 // 添加时间
add_time?: number add_time?: number
// 页面打开状态
page_open?: boolean
}
// 渲染结构
export interface RenderProps {
component: string
text?: string
html?: string
content?: any
slots?: any
props?: any
events?: any
}
// 仪表板组件
export interface DashboardItem {
// ID
id: string
// 名称
name: string
// 插件的仪表板key
key: string
// 全局配置
attrs: { [key: string]: any }
// col列数
cols: { [key: string]: number }
// 页面元素
elements: RenderProps[]
} }
// 种子信息 // 种子信息
@@ -685,12 +720,7 @@ export interface NotificationSwitch {
slack: boolean slack: boolean
synologychat: boolean synologychat: boolean
vocechat: boolean vocechat: boolean
} webpush: boolean
// 环境设置
export interface Setting {
// 下载目录
DOWNLOAD_PATH: string
} }
// 文件浏览接口 // 文件浏览接口
@@ -711,7 +741,7 @@ export interface EndPoints {
// 文件浏览项目 // 文件浏览项目
export interface FileItem { export interface FileItem {
// 类型 // 类型 dir/file
type: string type: string
// 文件名 // 文件名
name: string name: string
@@ -790,3 +820,35 @@ export interface Message {
// JSON // JSON
note?: string note?: string
} }
// 系统通知
export interface SystemNotification {
// 通知类型 user/system/plugin
type: string
// 通知标题
title: string
// 通知内容
text: string
// 通知时间
date: string
}
// 下载目录/媒体库目录
export interface MediaDirectory {
// 类型 download/library
type?: string
// 别名
name?: string
// 路径
path?: string
// 媒体类型 电影/电视剧
media_type?: string
// 媒体类别 动画电影/国产剧
category?: string
// 刮削媒体信息
scrape?: boolean
// 自动二级分类,未指定类别时自动分类
auto_category?: boolean
// 优先级
priority?: number
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

View File

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

View File

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

View File

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

View File

@@ -9,23 +9,13 @@ const props = defineProps({
}) })
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['close', 'changed', 'levelup', 'leveldown']) const emit = defineEmits(['close', 'changed'])
// 按钮点击 // 按钮点击
function onClose() { function onClose() {
emit('close') emit('close')
} }
// 上升优先级
function onLevelUp() {
emit('levelup', props.pri)
}
// 下降优先级
function onLevelDown() {
emit('leveldown', props.pri)
}
// 选项变化 // 选项变化
function filtersChanged(value: string[]) { function filtersChanged(value: string[]) {
emit('changed', props.pri, value) emit('changed', props.pri, value)
@@ -76,18 +66,9 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
<template> <template>
<VCard variant="tonal" :width="props.width" :height="props.height"> <VCard variant="tonal" :width="props.width" :height="props.height">
<span class="absolute top-3 right-14"> <span class="absolute top-3 right-12">
<IconBtn <IconBtn>
v-if="props.pri !== '1'" <VIcon class="cursor-move" icon="mdi-drag" />
@click.stop="onLevelUp"
>
<VIcon icon="mdi-arrow-up" />
</IconBtn>
<IconBtn
v-if="props.pri !== props.maxpri"
@click.stop="onLevelDown"
>
<VIcon icon="mdi-arrow-down" />
</IconBtn> </IconBtn>
</span> </span>
<DialogCloseBtn @click="onClose" /> <DialogCloseBtn @click="onClose" />
@@ -96,7 +77,6 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
<VRow> <VRow>
<VCol> <VCol>
<VSelect <VSelect
:key="props.pri"
v-model="props.rules" v-model="props.rules"
variant="underlined" variant="underlined"
:items="selectFilterOptions" :items="selectFilterOptions"

View File

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

View File

@@ -149,7 +149,7 @@ const dropdownItems = ref([
</script> </script>
<template> <template>
<VCard :width="props.width" :height="props.height" @click="installPlugin"> <VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
<div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }"> <div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
<div class="me-n3 absolute top-0 right-3"> <div class="me-n3 absolute top-0 right-3">
<IconBtn> <IconBtn>
@@ -196,7 +196,7 @@ const dropdownItems = ref([
</VChip> </VChip>
</div> </div>
</VCardText> </VCardText>
<VCardText class="flex items-center justify-start pb-2"> <VCardText class="flex align-self-baseline pb-2 w-full align-end">
<span> <span>
<VIcon icon="mdi-account" class="me-1" /> <VIcon icon="mdi-account" class="me-1" />
<a :href="props.plugin?.author_url" target="_blank" @click.stop> <a :href="props.plugin?.author_url" target="_blank" @click.stop>
@@ -213,9 +213,9 @@ const dropdownItems = ref([
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" /> <ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 --> <!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable> <VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard> <VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" /> <DialogCloseBtn @click="releaseDialog = false" />
<VCardTitle>{{ props.plugin?.plugin_name }} 更新说明</VCardTitle> <VDivider />
<VersionHistory :history="props.plugin?.history" /> <VersionHistory :history="props.plugin?.history" />
</VCard> </VCard>
</VDialog> </VDialog>

View File

@@ -108,14 +108,6 @@ async function uninstallPlugin() {
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: '确认', title: '确认',
content: `是否确认卸载插件 ${props.plugin?.plugin_name} ?`, content: `是否确认卸载插件 ${props.plugin?.plugin_name} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
}) })
if (!isConfirmed) return if (!isConfirmed) return
@@ -229,14 +221,6 @@ async function resetPlugin() {
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: '确认', title: '确认',
content: `是否确认重置插件 ${props.plugin?.plugin_name} 的配置数据?`, content: `是否确认重置插件 ${props.plugin?.plugin_name} 的配置数据?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
}) })
if (!isConfirmed) return if (!isConfirmed) return
@@ -381,16 +365,24 @@ const dropdownItems = ref([
// 监听插件状态变化 // 监听插件状态变化
watch( watch(
() => props.plugin?.has_update, () => props.plugin?.has_update,
(newHasUpdate, oldHasUpdate) => { (newHasUpdate, _) => {
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3) const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
}, },
) )
// 监听插件窗口状态变化
watch(
() => props.plugin?.page_open,
(newOpenState, _) => {
if (newOpenState) openPluginDetail()
},
)
</script> </script>
<template> <template>
<!-- 插件卡片 --> <!-- 插件卡片 -->
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail"> <VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
<div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }"> <div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 left-1"> <div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 left-1">
<VIcon icon="mdi-new-box" class="text-white" /> <VIcon icon="mdi-new-box" class="text-white" />
@@ -429,33 +421,38 @@ watch(
/> />
</VAvatar> </VAvatar>
</div> </div>
<span v-if="props.count" class="absolute bottom-1 right-2 flex items-center">
<VIcon icon="mdi-fire" />
<span class="text-sm ms-1">{{ props.count?.toLocaleString() }}</span>
</span>
<VCardItem class="py-2"> <VCardItem class="py-2">
<VCardTitle class="flex items-center flex-row"> <VCardTitle class="flex items-center flex-row">
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" /> <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">v{{ props.plugin?.plugin_version }}</span> <span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle> </VCardTitle>
</VCardItem> </VCardItem>
<VCardText> <VCardText class="pb-1">
{{ props.plugin?.plugin_desc }} {{ props.plugin?.plugin_desc }}
</VCardText> </VCardText>
<VCardText class="flex justify-end align-self-baseline p-1 w-full align-end">
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-fire" />
<span class="text-sm ms-1">{{ props.count?.toLocaleString() }}</span>
</span>
</VCardText>
</VCard> </VCard>
<!-- 插件配置页面 --> <!-- 插件配置页面 -->
<VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value"> <VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t"> <VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="pluginConfigDialog" /> <DialogCloseBtn v-model="pluginConfigDialog" />
<VDivider />
<VCardText> <VCardText>
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" /> <FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
</VCardText> </VCardText>
<VCardActions> <VCardActions class="pt-3">
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo"> 查看数据 </VBtn> <VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
查看数据
</VBtn>
<VSpacer /> <VSpacer />
<VBtn variant="tonal" @click="savePluginConf"> 保存 </VBtn> <VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
@@ -464,14 +461,10 @@ watch(
<VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value"> <VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t"> <VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
<DialogCloseBtn v-model="pluginInfoDialog" /> <DialogCloseBtn v-model="pluginInfoDialog" />
<VCardText> <VCardText class="min-h-40">
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" /> <PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
</VCardText> </VCardText>
<VCardActions> <VFab icon="mdi-cog" location="bottom" size="x-large" fixed app appear @click="showPluginConfig" />
<VBtn @click="showPluginConfig"> 配置 </VBtn>
<VSpacer />
<VBtn variant="tonal" @click="pluginInfoDialog = false"> 关闭 </VBtn>
</VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
@@ -480,10 +473,11 @@ watch(
<!-- 更新日志 --> <!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable> <VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard> <VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" /> <DialogCloseBtn @click="releaseDialog = false" />
<VCardTitle>{{ props.plugin?.plugin_name }} 更新说明</VCardTitle> <VDivider />
<VersionHistory :history="props.plugin?.history" /> <VersionHistory :history="props.plugin?.history" />
<VDivider />
<VCardText> <VCardText>
<VBtn @click="updatePlugin" block> <VBtn @click="updatePlugin" block>
<template #prepend> <template #prepend>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script lang='ts' setup> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue' import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import { formatDateDifference } from '@/@core/utils/formatters' import { formatDateDifference } from '@/@core/utils/formatters'
import { formatSeason } from '@/@core/utils/formatters' import { formatSeason } from '@/@core/utils/formatters'
@@ -15,6 +16,9 @@ const props = defineProps({
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['remove', 'save']) const emit = defineEmits(['remove', 'save'])
// 确认框
const createConfirm = useConfirm()
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
@@ -25,11 +29,7 @@ const imageLoaded = ref(false)
const subscribeEditDialog = ref(false) const subscribeEditDialog = ref(false)
// 上一次更新时间 // 上一次更新时间
const lastUpdateText = ref( const lastUpdateText = ref(props.media && props.media.last_update ? formatDateDifference(props.media.last_update) : '')
props.media && props.media.last_update
? formatDateDifference(props.media.last_update)
: '',
)
// 图片加载完成响应 // 图片加载完成响应
function imageLoadHandler() { function imageLoadHandler() {
@@ -38,23 +38,17 @@ function imageLoadHandler() {
// 根据 type 返回不同的图标 // 根据 type 返回不同的图标
function getIcon() { function getIcon() {
if (props.media?.type === '电影') if (props.media?.type === '电影') return 'mdi-movie'
return 'mdi-movie' else if (props.media?.type === '电视剧') return 'mdi-television-classic'
else if (props.media?.type === '电视剧') else return 'mdi-help-circle'
return 'mdi-television-classic'
else
return 'mdi-help-circle'
} }
// 计算百分比 // 计算百分比
function getPercentage() { function getPercentage() {
if (props.media?.total_episode === 0) if (props.media?.total_episode === 0) return 0
return 0
return Math.round( return Math.round(
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0)) (((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0)) / (props.media?.total_episode ?? 1)) * 100,
/ (props.media?.total_episode ?? 1))
* 100,
) )
} }
@@ -71,16 +65,13 @@ function getTextClass() {
// 删除订阅 // 删除订阅
async function removeSubscribe() { async function removeSubscribe() {
try { try {
const result: { [key: string]: any } = await api.delete( const result: { [key: string]: any } = await api.delete(`subscribe/${props.media?.id}`)
`subscribe/${props.media?.id}`,
)
if (result.success) { if (result.success) {
// 通知父组件刷新 // 通知父组件刷新
emit('remove') emit('remove')
} }
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -88,15 +79,32 @@ async function removeSubscribe() {
// 搜索订阅 // 搜索订阅
async function searchSubscribe() { async function searchSubscribe() {
try { try {
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get(`subscribe/search/${props.media?.id}`)
`subscribe/search/${props.media?.id}`,
)
// 提示 // 提示
if (result.success) if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
$toast.success(`${props.media?.name} 提交搜索请求成功!`) } catch (e) {
console.log(e)
} }
catch (e) { }
// 重置订阅
async function resetSubscribe() {
// 确认
try {
const isConfirmed = await createConfirm({
title: '确认',
content: `重置后 ${props.media?.name} 已下载记录将被清除,未入库的剧集将会重新下载,是否确认?`,
})
if (!isConfirmed) return
// 重置
const result: { [key: string]: any } = await api.get(`subscribe/reset/${props.media?.id}`)
// 提示
if (result.success) {
$toast.success(`${props.media?.name} 重置成功!`)
emit('save')
} else $toast.error(`${props.media?.name} 重置失败:${result.message}`)
} catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -133,11 +141,7 @@ const dropdownItems = ref([
router.push({ router.push({
path: '/media', path: '/media',
query: { query: {
mediaid: `${ mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
props.media?.tmdbid
? `tmdb:${props.media?.tmdbid}`
: `douban:${props.media?.doubanid}`
}`,
type: props.media?.type, type: props.media?.type,
}, },
}) })
@@ -145,8 +149,18 @@ const dropdownItems = ref([
}, },
}, },
{ {
title: '取消订阅', title: '重置',
value: 4, value: 4,
props: {
prependIcon: 'mdi-restore-alert',
click: resetSubscribe,
color: 'warning',
},
show: props.media?.type === '电视剧',
},
{
title: '取消订阅',
value: 5,
props: { props: {
prependIcon: 'mdi-trash-can-outline', prependIcon: 'mdi-trash-can-outline',
color: 'error', color: 'error',
@@ -154,140 +168,120 @@ const dropdownItems = ref([
}, },
}, },
]) ])
// 监听插件窗口状态变化
watch(
() => props.media?.page_open,
(newOpenState, _) => {
if (newOpenState) editSubscribeDialog()
},
)
</script> </script>
<template> <template>
<VCard <VHover>
:key="props.media?.id" <template #default="hover">
:class="`${props.media?.best_version ? 'outline-dashed outline-1' : ''}`" <VCard
@click="editSubscribeDialog" v-bind="hover.props"
> :key="props.media?.id"
<template #image> class="flex flex-col"
<VImg :class="{
:src="props.media?.backdrop || props.media?.poster" 'outline-dashed outline-1': props.media?.best_version,
aspect-ratio="2/3" 'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
cover }"
class="brightness-50" @click="editSubscribeDialog"
@load="imageLoadHandler"
/>
</template>
<VCardItem>
<template #prepend>
<VIcon
size="1.9rem"
:color="getTextColor()"
:icon="getIcon()"
/>
</template>
<VCardTitle :class="getTextClass()">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</VCardTitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon
icon="mdi-dots-vertical"
:color="getTextColor()"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VCardItem>
<VCardText>
<p
class="clamp-text mb-0"
:class="getTextClass()"
> >
{{ props.media?.description }} <template #image>
</p> <VImg
</VCardText> :src="props.media?.backdrop || props.media?.poster"
aspect-ratio="2/3"
<VCardText class="d-flex justify-space-between align-center flex-wrap"> cover
<div class="d-flex align-center"> class="brightness-50"
<IconBtn @load="imageLoadHandler"
icon="mdi-star" />
:color="getTextColor()" </template>
class="me-1" <VCardItem>
/> <template #prepend>
<span <VIcon size="1.9rem" :color="getTextColor()" :icon="getIcon()" />
class="text-subtitle-2 me-4" </template>
:class="getTextClass()" <VCardTitle :class="getTextClass()">
>{{ {{ props.media?.name }}
props.media?.vote {{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
}}</span> </VCardTitle>
<IconBtn <template #append>
v-if="props.media?.total_episode" <div class="me-n3">
v-bind="props" <IconBtn>
icon="mdi-progress-clock" <VIcon icon="mdi-dots-vertical" :color="getTextColor()" />
:color="getTextColor()" <VMenu activator="parent" close-on-content-click>
class="me-1" <VList>
/> <template v-for="(item, i) in dropdownItems" :key="i">
<span <VListItem
v-if="props.media?.season" v-if="item.show !== false"
class="text-subtitle-2 me-4" variant="plain"
:class="getTextClass()" :base-color="item.props.color"
>{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} / @click="item.props.click"
{{ props.media?.total_episode }}</span> >
<IconBtn <template #prepend>
v-if="props.media?.username" <VIcon :icon="item.props.prependIcon" />
icon="mdi-account" </template>
:color="getTextColor()" <VListItemTitle v-text="item.title" />
class="me-1" </VListItem>
/> </template>
<span </VList>
v-if="props.media?.username" </VMenu>
class="text-subtitle-2 me-4" </IconBtn>
:class="getTextClass()" </div>
> </template>
{{ props.media?.username }} </VCardItem>
</span> <VCardText>
</div> <p class="clamp-text mb-0" :class="getTextClass()">
</VCardText> {{ props.media?.description }}
<VCardText </p>
v-if="lastUpdateText" </VCardText>
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300" <VCardText class="d-flex justify-space-between align-center flex-wrap">
> <div class="d-flex align-center">
<VIcon <IconBtn
icon="mdi-download" v-if="props.media?.total_episode"
class="me-1" v-bind="props"
/> icon="mdi-progress-clock"
{{ lastUpdateText }} :color="getTextColor()"
</VCardText> class="me-1"
<VProgressLinear />
v-if="getPercentage() > 0" <span v-if="props.media?.season" class="text-subtitle-2 me-4" :class="getTextClass()"
:model-value="getPercentage()" >{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
bg-color="success" {{ props.media?.total_episode }}</span
color="success" >
/> <IconBtn v-if="props.media?.username" icon="mdi-account" :color="getTextColor()" class="me-1" />
</VCard> <span v-if="props.media?.username" class="text-subtitle-2 me-4" :class="getTextClass()">
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<VProgressLinear v-if="getPercentage() > 0" :model-value="getPercentage()" bg-color="success" color="success" />
</VCard>
</template>
</VHover>
<!-- 订阅编辑弹窗 --> <!-- 订阅编辑弹窗 -->
<SubscribeEditDialog <SubscribeEditDialog
v-if="subscribeEditDialog" v-if="subscribeEditDialog"
v-model="subscribeEditDialog" v-model="subscribeEditDialog"
:subid="props.media?.id" :subid="props.media?.id"
@remove="() => { emit('remove');subscribeEditDialog = false; }" @remove="
@save="() => { emit('save');subscribeEditDialog = false; }" () => {
emit('remove')
subscribeEditDialog = false
}
"
@save="
() => {
emit('save')
subscribeEditDialog = false
}
"
@close="subscribeEditDialog = false" @close="subscribeEditDialog = false"
/> />
</template> </template>

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import api from '@/api'
import { numberValidator } from '@/@validators' import { numberValidator } from '@/@validators'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue' import ProgressDialog from './ProgressDialog.vue'
import { MediaDirectory } from '@/api/types'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
@@ -20,9 +21,9 @@ const props = defineProps({
// 定义事件 // 定义事件
const emit = defineEmits(['done', 'close']) const emit = defineEmits(['done', 'close'])
// 生成1到50季的下拉框选项 // 生成1到100季的下拉框选项
const seasonItems = ref( const seasonItems = ref(
Array.from({ length: 51 }, (_, i) => i).map(item => ({ Array.from({ length: 101 }, (_, i) => i).map(item => ({
title: `${item}`, title: `${item}`,
value: item, value: item,
})), })),
@@ -53,7 +54,7 @@ const progressValue = ref(0)
const transferForm = reactive({ const transferForm = reactive({
logid: 0, logid: 0,
path: '', path: '',
target: props.target ?? '', target: props.target ?? null,
tmdbid: null, tmdbid: null,
doubanid: null, doubanid: null,
season: null, season: null,
@@ -64,11 +65,32 @@ const transferForm = reactive({
episode_part: '', episode_part: '',
episode_offset: null, episode_offset: null,
min_filesize: 0, min_filesize: 0,
scrape: false,
}) })
// 所有媒体库目录
const libraryDirectories = ref<MediaDirectory[]>([])
// 目的目录下拉框
const targetDirectories = computed(() => {
const directories = libraryDirectories.value.map(item => item.path)
return [...new Set(directories)]
})
// 监听输入变化
watchEffect(() => { watchEffect(() => {
transferForm.path = props.path ?? '' transferForm.path = props.path ?? ''
transferForm.target = props.target ?? '' transferForm.target = props.target ?? null
})
// 监听目的路径变化,自动查询目录的刮削配置
watch(transferForm, async () => {
if (transferForm.target) {
const directory = libraryDirectories.value.find(item => item.path === transferForm.target)
if (directory) {
transferForm.scrape = directory.scrape ?? false
}
}
}) })
// 使用SSE监听加载进度 // 使用SSE监听加载进度
@@ -157,27 +179,43 @@ async function loadSystemSettings() {
} }
} }
// 查询媒体库目录
async function loadLibraryDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/LibraryDirectories')
if (result.success && result.data?.value) {
libraryDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
onMounted(() => { onMounted(() => {
loadSystemSettings() loadSystemSettings()
loadLibraryDirectories()
}) })
</script> </script>
<template> <template>
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value"> <VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard <VCard
:title="`${props.path ? `整理 - ${props.path}` : `整理 - 共 ${props.logids?.length} 条记录`}`" :title="`${props.path ? `整理 - ${props.path}` : `整理 - 共 ${props.logids?.length} 条记录`}`"
class="rounded-t" class="rounded-t"
> >
<DialogCloseBtn @click="emit('close')" /> <DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2"> <VDivider />
<VCardText>
<VForm @submit.prevent="() => {}"> <VForm @submit.prevent="() => {}">
<VRow> <VRow>
<VCol cols="12" md="8"> <VCol cols="12" md="8">
<VTextField <VCombobox
v-model="transferForm.target" v-model="transferForm.target"
:items="targetDirectories"
label="目的路径" label="目的路径"
placeholder="留空自动" placeholder="留空自动"
hint="留空将自动整理到媒体库目录" hint="整理目的路径,留空将自动匹配"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -193,6 +231,8 @@ onMounted(() => {
{ title: 'Rclone复制', value: 'rclone_copy' }, { title: 'Rclone复制', value: 'rclone_copy' },
{ title: 'Rclone移动', value: 'rclone_move' }, { title: 'Rclone移动', value: 'rclone_move' },
]" ]"
hint="文件操作整理方式"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
@@ -206,6 +246,8 @@ onMounted(() => {
{ title: '电影', value: '电影' }, { title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' }, { title: '电视剧', value: '电视剧' },
]" ]"
hint="文件的媒体类型"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -217,7 +259,8 @@ onMounted(() => {
placeholder="留空自动识别" placeholder="留空自动识别"
:rules="[numberValidator]" :rules="[numberValidator]"
append-inner-icon="mdi-magnify" append-inner-icon="mdi-magnify"
hint="点击图标按名称搜索,留空自动重新识别" hint="按名称查询媒体编号,留空自动识别"
persistent-hint
@click:append-inner="mediaSelectorDialog = true" @click:append-inner="mediaSelectorDialog = true"
/> />
<VTextField <VTextField
@@ -228,7 +271,8 @@ onMounted(() => {
placeholder="留空自动识别" placeholder="留空自动识别"
:rules="[numberValidator]" :rules="[numberValidator]"
append-inner-icon="mdi-magnify" append-inner-icon="mdi-magnify"
hint="点击图标按名称搜索,留空自动重新识别" hint="按名称查询媒体编号,留空自动识别"
persistent-hint
@click:append-inner="mediaSelectorDialog = true" @click:append-inner="mediaSelectorDialog = true"
/> />
</VCol> </VCol>
@@ -238,6 +282,8 @@ onMounted(() => {
v-model.number="transferForm.season" v-model.number="transferForm.season"
label="季" label="季"
:items="seasonItems" :items="seasonItems"
hint="指定季数"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
@@ -247,7 +293,8 @@ onMounted(() => {
v-model="transferForm.episode_format" v-model="transferForm.episode_format"
label="集数定位" label="集数定位"
placeholder="使用{ep}定位集数" placeholder="使用{ep}定位集数"
hint="使用{ep}定位文件名中的集数部分,其余相同部分直接填写,不同部分使用{a}进行忽略,例如:{a}葬送的芙莉莲_Sousou no Frieren 第{ep}话{b}" hint="使用{ep}定位文件名中的集数部分以辅助识别"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -255,7 +302,8 @@ onMounted(() => {
v-model="transferForm.episode_detail" v-model="transferForm.episode_detail"
label="指定集数" label="指定集数"
placeholder="起始集,终止集如1或1,2" placeholder="起始集,终止集如1或1,2"
hint="直接指定集数或范围,格式:起始集,终止集,如1或1,2" hint="指定集数或范围如1或1,2"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -263,7 +311,8 @@ onMounted(() => {
v-model="transferForm.episode_part" v-model="transferForm.episode_part"
label="指定Part" label="指定Part"
placeholder="如part1" placeholder="如part1"
hint="指定集数的Part如part1" hint="指定Part如part1"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -271,7 +320,8 @@ onMounted(() => {
v-model.number="transferForm.episode_offset" v-model.number="transferForm.episode_offset"
label="集数偏移" label="集数偏移"
placeholder="如-10" placeholder="如-10"
hint="集数进行偏移运算,如-10表示文件名中的集数减10为整理后集数" hint="集数偏移运算,如-10或EP*2"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -280,16 +330,26 @@ onMounted(() => {
label="最小文件大小MB" label="最小文件大小MB"
:rules="[numberValidator]" :rules="[numberValidator]"
placeholder="0" placeholder="0"
hint="最小文件大小,小于此大小的文件将被忽略不进行整理" hint="只整理大于最小文件大小的文件"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="transferForm.scrape"
label="刮削元数据"
hint="整理完成后自动刮削元数据"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions> <VCardActions class="pt-3">
<VBtn depressed @click="emit('close')"> 取消 </VBtn>
<VSpacer /> <VSpacer />
<VBtn variant="tonal" @click="transfer"> 开始整理 </VBtn> <VBtn variant="elevated" @click="transfer" prepend-icon="mdi-arrow-right-bold" class="px-5"> 开始整理 </VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
<!-- 手动整理进度框 --> <!-- 手动整理进度框 -->

View File

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

View File

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

View File

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

View File

@@ -117,14 +117,6 @@ async function deleteItem(item: FileItem) {
const confirmed = await createConfirm({ const confirmed = await createConfirm({
title: '确认', title: '确认',
content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.basename}`, content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.basename}`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
cancellationButtonProps: {
variant: 'tonal',
},
}) })
if (confirmed) { if (confirmed) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,35 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
const display = useDisplay()
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') == '1' && display.mdAndDown.value
})
</script>
<template> <template>
<div class="h-100 d-flex align-center justify-space-between" /> <div v-if="appMode" class="w-100" style="block-size: 3.5rem">
<VBottomNavigation grow horizontal color="primary">
<VBtn to="/dashboard">
<VIcon size="28">mdi-home-outline</VIcon>
<span>首页</span>
</VBtn>
<VBtn to="/ranking">
<VIcon size="28">mdi-star-check-outline</VIcon>
<span>推荐</span>
</VBtn>
<VBtn to="/subscribe-movie?tab=mysub">
<VIcon size="28">mdi-movie-roll</VIcon>
<span><span></span>电影</span>
</VBtn>
<VBtn to="/subscribe-tv?tab=mysub">
<VIcon size="28">mdi-television-classic</VIcon>
<span>电视剧</span>
</VBtn>
<VBtn to="/apps">
<VIcon size="28">mdi-dots-horizontal</VIcon>
<span>更多</span>
</VBtn>
</VBottomNavigation>
</div>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { NavMenu } from '@/@layouts/types'
import { SystemNavMenus } from '@/router/menu'
import store from '@/store'
// 从Vuex Store中获取superuser信息
const superUser = store.state.auth.superUser
// 根据分类获取菜单列表
const getMenuList = () => {
return SystemNavMenus.filter((item: NavMenu) => !item.admin || superUser)
}
</script>
<template>
<div class="ps ps--active-y mx-3" tabindex="0">
<VRow class="ma-0 mt-n1">
<VCol cols="6" md="4" lg="3" class="text-center cursor-pointer shortcut-icon" v-for="item in getMenuList()">
<VCard class="pa-4" :to="item.to" variant="flat">
<VAvatar size="64" variant="text">
<VIcon size="48" :icon="item.icon" color="primary" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">{{ item.full_title || item.title }}</h6>
</VCard>
</VCol>
</VRow>
</div>
</template>

View File

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

View File

@@ -8,6 +8,7 @@ import router from '@/router'
import logo from '@images/logo.png' import logo from '@images/logo.png'
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils' import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
const { global: globalTheme } = useTheme() const { global: globalTheme } = useTheme()
@@ -70,25 +71,6 @@ const fetchOTP = debounce(async () => {
}) })
}, 500) }, 500)
// 加载用户监控面板配置
async function loadDashboardConfig() {
const response = await api.get('/user/config/Dashboard')
if (response && response.data && response.data.value) {
const data = JSON.stringify(response.data.value)
if (data != localStorage.getItem('MP_DASHBOARD')) {
localStorage.setItem('MP_DASHBOARD', data)
}
}
}
// 尝试加载用户监控面板配置(本地无配置时才加载)
async function tryLoadDashboardConfig() {
if (localStorage.getItem('MP_DASHBOARD')) {
return
}
await loadDashboardConfig()
}
// 获取用户主题配置 // 获取用户主题配置
async function fetchThemeConfig() { async function fetchThemeConfig() {
const response = await api.get('/user/config/theme') const response = await api.get('/user/config/theme')
@@ -108,13 +90,39 @@ async function setTheme() {
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background) localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
} }
async function afterLogin() { // 订阅推送通知
async function subscribeForPushNotifications() {
if ('serviceWorker' in navigator && 'PushManager' in window) {
const registration = await navigator.serviceWorker.ready
// 获取订阅信息
const subscription = await registration.pushManager.getSubscription().then(function (subscription) {
if (subscription === null) {
const convertedVapidKey = urlBase64ToUint8Array(import.meta.env.VITE_PUBLIC_VAPID_KEY)
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey,
})
} else {
return subscription
}
})
// 发送订阅请求
try {
await api.post('/message/webpush/subscribe', subscription)
} catch (e) {
console.log(e)
}
}
}
// 登录后处理
async function afterLogin(superuser: boolean) {
// 生效主题配置 // 生效主题配置
await setTheme() await setTheme()
// 尝试加载用户监控面板配置(本地无配置时才加载)
await tryLoadDashboardConfig()
// 跳转到首页或回原始页面 // 跳转到首页或回原始页面
router.push(store.state.auth.originalPath ?? '/') router.push(store.state.auth.originalPath ?? '/')
// 订阅推送通知
if (superuser) await subscribeForPushNotifications()
} }
// 登录获取token事件 // 登录获取token事件
@@ -155,7 +163,7 @@ function login() {
store.dispatch('auth/updateAvatar', avatar) store.dispatch('auth/updateAvatar', avatar)
// 登录后处理 // 登录后处理
afterLogin() afterLogin(superuser)
}) })
.catch((error: any) => { .catch((error: any) => {
// 登录失败,显示错误提示 // 登录失败,显示错误提示

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue' import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import router from '@/router'
import { SubscribeMovieTabs } from '@/router/menu'
const route = useRoute()
// 订阅ID参数
const subId = ref(route.query.id as string)
// 当前标签
const activeTab = ref(route.query.tab)
// 跳转tab
function jumpTab(tab: string) {
router.push('/subscribe-movie?tab=' + tab)
}
</script> </script>
<template> <template>
<div> <div>
<SubscribeListView type="电影" /> <VTabs v-model="activeTab">
<VTab v-for="item in SubscribeMovieTabs" :value="item.tab" @click="jumpTab(item.tab)">
<span class="mx-5">{{ item.title }}</span>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="mysub">
<transition name="fade-slide" appear>
<SubscribeListView type="电影" :subid="subId" />
</transition>
</VWindowItem>
<VWindowItem value="popular">
<transition name="fade-slide" appear>
<SubscribePopularView type="电影" :subid="subId" />
</transition>
</VWindowItem>
</VWindow>
</div> </div>
</template> </template>

View File

@@ -1,9 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue' import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import router from '@/router'
import { SubscribeTvTabs } from '@/router/menu'
const route = useRoute()
const activeTab = ref(route.query.tab)
// 订阅ID参数
const subId = ref(route.query.id as string)
// 跳转tab
function jumpTab(tab: string) {
router.push('/subscribe-tv?tab=' + tab)
}
</script> </script>
<template> <template>
<div> <div>
<SubscribeListView type="电视剧" /> <VTabs v-model="activeTab">
<VTab v-for="item in SubscribeTvTabs" :value="item.tab" @click="jumpTab(item.tab)">
<span class="mx-5">{{ item.title }}</span>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="mysub">
<transition name="fade-slide" appear>
<SubscribeListView type="电视剧" :subid="subId" />
</transition>
</VWindowItem>
<VWindowItem value="popular">
<transition name="fade-slide" appear>
<SubscribePopularView type="电视剧" :subid="subId" />
</transition>
</VWindowItem>
</VWindow>
</div> </div>
</template> </template>

View File

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

View File

@@ -10,8 +10,7 @@ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) { scrollBehavior(to, from, savedPosition) {
// 如果页面有缓存那么恢复其位置, 否则始终滚动到顶部 // 如果页面有缓存那么恢复其位置, 否则始终滚动到顶部
if (to.meta.keepAlive && savedPosition) if (to.meta.keepAlive && savedPosition) return savedPosition
return savedPosition
return { top: 0 } return { top: 0 }
}, },
routes: [ routes: [
@@ -21,14 +20,14 @@ const router = createRouter({
component: () => import('../layouts/default.vue'), component: () => import('../layouts/default.vue'),
children: [ children: [
{ {
path: 'dashboard', path: '/dashboard',
component: () => import('../pages/dashboard.vue'), component: () => import('../pages/dashboard.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
}, },
{ {
path: 'ranking', path: '/ranking',
component: () => import('../pages/ranking.vue'), component: () => import('../pages/ranking.vue'),
meta: { meta: {
keepAlive: true, keepAlive: true,
@@ -36,63 +35,63 @@ const router = createRouter({
}, },
}, },
{ {
path: 'resource', path: '/resource',
component: () => import('../pages/resource.vue'), component: () => import('../pages/resource.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
}, },
{ {
path: 'subscribe-movie', path: '/subscribe-movie',
component: () => import('../pages/subscribe-movie.vue'), component: () => import('../pages/subscribe-movie.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
}, },
{ {
path: 'subscribe-tv', path: '/subscribe-tv',
component: () => import('../pages/subscribe-tv.vue'), component: () => import('../pages/subscribe-tv.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
}, },
{ {
path: 'calendar', path: '/calendar',
component: () => import('../pages/calendar.vue'), component: () => import('../pages/calendar.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
}, },
{ {
path: 'downloading', path: '/downloading',
component: () => import('../pages/downloading.vue'), component: () => import('../pages/downloading.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
}, },
{ {
path: 'history', path: '/history',
component: () => import('../pages/history.vue'), component: () => import('../pages/history.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
}, },
{ {
path: 'site', path: '/site',
component: () => import('../pages/site.vue'), component: () => import('../pages/site.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
}, },
{ {
path: 'plugins', path: '/plugins',
component: () => import('../pages/plugin.vue'), component: () => import('../pages/plugin.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
}, },
{ {
path: 'setting', path: '/setting',
component: () => import('../pages/setting.vue'), component: () => import('../pages/setting.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
@@ -138,6 +137,13 @@ const router = createRouter({
requiresAuth: true, requiresAuth: true,
}, },
}, },
{
path: '/apps',
component: () => import('../pages/appcenter.vue'),
meta: {
requiresAuth: true,
},
},
], ],
}, },
{ {
@@ -159,13 +165,13 @@ const router = createRouter({
// 路由导航守卫 // 路由导航守卫
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
// 总是记录非login路由
if (to.fullPath != '/login') store.state.auth.originalPath = to.fullPath
const isAuthenticated = store.state.auth.token !== null const isAuthenticated = store.state.auth.token !== null
if (to.meta.requiresAuth && !isAuthenticated) { if (to.meta.requiresAuth && !isAuthenticated) {
store.state.auth.originalPath = to.fullPath
next('/login') next('/login')
} } else {
else {
startNProgress() startNProgress()
next() next()
} }

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

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

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

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

View File

@@ -130,3 +130,61 @@
.v-toast { .v-toast {
z-index: 2500 !important; z-index: 2500 !important;
} }
.v-divider {
border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity));
opacity:0.75;
}
.apexcharts-title-text {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
}
.grid-site-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
.grid-backdrop-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
.grid-torrent-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;
}
.grid-plugin-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
.grid-downloading-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
.grid-directory-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
.grid-filterrule-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
.grid-subscribe-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;
}
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import api from '@/api'
import type { DownloaderInfo } from '@/api/types' import type { DownloaderInfo } from '@/api/types'
// 定时器 // 定时器
let refreshTimer: NodeJS.Timer | null = null let refreshTimer: NodeJS.Timeout | null = null
// 下载器信息 // 下载器信息
const downloadInfo = ref<DownloaderInfo>({ const downloadInfo = ref<DownloaderInfo>({
@@ -56,8 +56,7 @@ async function loadDownloaderInfo() {
amount: formatFileSize(res.free_space), amount: formatFileSize(res.free_space),
}, },
] ]
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -81,47 +80,44 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<VCard> <VHover>
<VCardItem> <template #default="hover">
<VCardTitle>实时速率</VCardTitle> <VCard v-bind="hover.props">
</VCardItem> <VCardItem>
<VCardText class="pt-4">
<div>
<p class="text-h5 me-2">
{{ formatFileSize(downloadInfo.upload_speed) }}/s
</p>
<p class="text-h4 me-2">
{{ formatFileSize(downloadInfo.download_speed) }}/s
</p>
</div>
<VList class="card-list mt-9">
<VListItem
v-for="item in infoItems"
:key="item.title"
>
<template #prepend>
<VIcon
rounded
:icon="item.avatar"
/>
</template>
<VListItemTitle class="text-sm font-weight-medium mb-1">
{{ item.title }}
</VListItemTitle>
<template #append> <template #append>
<div> <VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
<h6 class="text-sm font-weight-medium mb-2">
{{ item.amount }}
</h6>
</div>
</template> </template>
</VListItem> <VCardTitle>实时速率</VCardTitle>
</VList> </VCardItem>
</VCardText>
</VCard> <VCardText class="pt-4">
<div>
<p class="text-h5 me-2">{{ formatFileSize(downloadInfo.upload_speed) }}/s</p>
<p class="text-h4 me-2">{{ formatFileSize(downloadInfo.download_speed) }}/s</p>
</div>
<VList class="card-list mt-9">
<VListItem v-for="item in infoItems" :key="item.title">
<template #prepend>
<VIcon rounded :icon="item.avatar" />
</template>
<VListItemTitle class="text-sm font-weight-medium mb-1">
{{ item.title }}
</VListItemTitle>
<template #append>
<div>
<h6 class="text-sm font-weight-medium mb-2">
{{ item.amount }}
</h6>
</div>
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
</template>
</VHover>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
import _ from 'lodash' import _ from 'lodash'
import type { Context } from '@/api/types' import type { Context } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue' import TorrentCard from '@/components/cards/TorrentCard.vue'
import { useDefer } from '@/@core/utils/dom'
interface SearchTorrent extends Context { interface SearchTorrent extends Context {
more?: Array<Context> more?: Array<Context>
@@ -71,8 +70,35 @@ function initOptions(data: Context) {
// 对季过滤选项进行排序 // 对季过滤选项进行排序
const sortSeasonFilterOptions = computed(() => { const sortSeasonFilterOptions = computed(() => {
return seasonFilterOptions.value.sort((a, b) => { return seasonFilterOptions.value.sort((a, b) => {
// 按字符串升序排序 // 按季,集降序排序
return a.localeCompare(b, 'zh-Hans-CN', { sensitivity: 'accent' }) const parseSeasonEpisode = (str: string) => {
const seasonRangeMatch = str.match(/S(\d+)(?:-S(\d+))?/)
const episodeRangeMatch = str.match(/E(\d+)(?:-E(\d+))?/)
return {
seasonStart: seasonRangeMatch?.[1] ? parseInt(seasonRangeMatch[1]) : 0,
seasonEnd: seasonRangeMatch?.[2] ? parseInt(seasonRangeMatch[2]) : 0,
episodeStart: episodeRangeMatch?.[1] ? parseInt(episodeRangeMatch[1]) : 0,
episodeEnd: episodeRangeMatch?.[2] ? parseInt(episodeRangeMatch[2]) : 0,
}
}
const parsedA = parseSeasonEpisode(a)
const parsedB = parseSeasonEpisode(b)
// 先按季降序排序
if (parsedB.seasonStart !== parsedA.seasonStart) {
return parsedB.seasonStart - parsedA.seasonStart
}
if (parsedB.seasonEnd !== parsedA.seasonEnd) {
return parsedB.seasonEnd - parsedA.seasonEnd
}
// 按集降序排序
if (parsedB.episodeStart !== parsedA.episodeStart) {
return parsedB.episodeStart - parsedA.episodeStart
}
if (parsedB.episodeEnd !== parsedA.episodeEnd) {
return parsedB.episodeEnd - parsedA.episodeEnd
}
// 兜底
return b.localeCompare(a)
}) })
}) })
@@ -99,15 +125,13 @@ onMounted(() => {
groupedDataList.value = groupMap groupedDataList.value = groupMap
}) })
let defer = (_: number) => true
// 计算过滤后的列表 // 计算过滤后的列表
watchEffect(() => { watchEffect(() => {
// 清空列表 // 清空列表
dataList.value = [] dataList.value = []
// 匹配过滤函数 // 匹配过滤函数filter中有任一值包含value则返回true
const match = (filter: Array<string>, value: string | undefined) => const match = (filter: Array<string>, value: string | undefined): boolean =>
filter.length === 0 || (value && filter.includes(value)) filter.length === 0 || filter.includes(value ?? '') || filter.some(v => value?.includes(v) ?? false)
groupedDataList.value?.forEach(value => { groupedDataList.value?.forEach(value => {
if (value.length > 0) { if (value.length > 0) {
@@ -139,7 +163,6 @@ watchEffect(() => {
} }
} }
}) })
defer = useDefer(dataList.value.length)
}) })
</script> </script>
@@ -227,14 +250,7 @@ watchEffect(() => {
</VCard> </VCard>
<div class="grid gap-3 grid-torrent-card items-start"> <div class="grid gap-3 grid-torrent-card items-start">
<div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`"> <div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`">
<TorrentCard v-if="defer(index)" :torrent="item" :more="item.more" /> <TorrentCard :torrent="item" :more="item.more" />
</div> </div>
</div> </div>
</template> </template>
<style lang="scss">
.grid-torrent-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Context } from '@/api/types' import type { Context } from '@/api/types'
import TorrentItem from '@/components/cards/TorrentItem.vue' import TorrentItem from '@/components/cards/TorrentItem.vue'
import { useDefer } from '@/@core/utils/dom'
// 定义输入参数 // 定义输入参数
const props = defineProps({ const props = defineProps({
@@ -27,6 +26,9 @@ const filterForm = reactive({
resolution: [] as string[], resolution: [] as string[],
}) })
// 排序字段
const sortField = ref('default')
// 数据列表 // 数据列表
const dataList = ref<Array<Context>>([]) const dataList = ref<Array<Context>>([])
@@ -60,7 +62,19 @@ function initOptions(data: Context) {
optionValue(resolutionFilterOptions.value, meta_info?.resource_pix) optionValue(resolutionFilterOptions.value, meta_info?.resource_pix)
} }
let defer = (_: number) => true // 排序
watchEffect(() => {
const list = dataList.value
if (sortField.value === 'default') {
dataList.value = list.sort((a, b) => b.torrent_info.pri_order - a.torrent_info.pri_order)
} else if (sortField.value === 'site') {
dataList.value = list.sort((a, b) => (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || ''))
} else if (sortField.value === 'size') {
dataList.value = list.sort((a, b) => b.torrent_info.size - a.torrent_info.size)
} else if (sortField.value === 'seeder') {
dataList.value = list.sort((a, b) => b.torrent_info.seeders - a.torrent_info.seeders)
}
})
// 计算过滤后的列表 // 计算过滤后的列表
watchEffect(() => { watchEffect(() => {
@@ -70,32 +84,31 @@ watchEffect(() => {
const match = (filter: Array<string>, value: string | undefined) => const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value)) filter.length === 0 || (value && filter.includes(value))
props.items?.forEach((data) => { props.items?.forEach(data => {
const { meta_info, torrent_info } = data const { meta_info, torrent_info } = data
if ( if (
// 站点过滤 // 站点过滤
match(filterForm.site, torrent_info.site_name) match(filterForm.site, torrent_info.site_name) &&
// 促销状态过滤 // 促销状态过滤
&& match(filterForm.freeState, torrent_info.volume_factor) match(filterForm.freeState, torrent_info.volume_factor) &&
// 季过滤 // 季过滤
&& match(filterForm.season, meta_info.season_episode) match(filterForm.season, meta_info.season_episode) &&
// 制作组过滤 // 制作组过滤
&& match(filterForm.releaseGroup, meta_info.resource_team) match(filterForm.releaseGroup, meta_info.resource_team) &&
// 视频编码过滤 // 视频编码过滤
&& match(filterForm.videoCode, meta_info.video_encode) match(filterForm.videoCode, meta_info.video_encode) &&
// 分辨率过滤 // 分辨率过滤
&& match(filterForm.resolution, meta_info.resource_pix) match(filterForm.resolution, meta_info.resource_pix) &&
// 质量过滤 // 质量过滤
&& match(filterForm.edition, meta_info.edition) match(filterForm.edition, meta_info.edition)
) )
dataList.value.push(data) dataList.value.push(data)
}) })
defer = useDefer(dataList.value.length)
}) })
// 初始化过滤选项 // 初始化过滤选项
onMounted(() => { onMounted(() => {
props.items?.forEach((item) => { props.items?.forEach(item => {
initOptions(item) initOptions(item)
}) })
}) })
@@ -104,22 +117,37 @@ onMounted(() => {
<template> <template>
<VRow> <VRow>
<VCol> <VCol>
<VList v-if="dataList.length === 0" lines="three" class="rounded p-0"> <VList v-if="dataList.length === 0" lines="three" class="rounded p-0 shadow-lg">
<VListItem> <VListItem>
<VListItemTitle>没有附合当前过滤条件的资源</VListItemTitle> <VListItemTitle>没有附合当前过滤条件的资源</VListItemTitle>
</VListItem> </VListItem>
</VList> </VList>
<VList v-if="dataList.length !== 0" lines="three" class="rounded p-0"> <VList v-if="dataList.length !== 0" lines="three" class="rounded p-0 torrent-list-vscroll shadow-lg">
<div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`"> <VVirtualScroll :items="dataList">
<TorrentItem v-if="defer(index)" :torrent="item" /> <template #default="{ item }">
</div> <TorrentItem :torrent="item" :key="`${item.torrent_info.title}_${item.torrent_info.site}`" />
</template>
</VVirtualScroll>
</VList> </VList>
</VCol> </VCol>
<VCol xl="2" md="3" class="d-none d-md-block"> <VCol xl="2" md="3" class="d-none d-md-block">
<VList lines="one" class="rounded"> <VList lines="one" class="rounded torrent-list-vscroll shadow-lg">
<VListSubheader v-if="siteFilterOptions.length > 0"> <VListSubheader> 排序 </VListSubheader>
站点 <VListItem>
</VListSubheader> <VChipGroup column v-model="sortField">
<VChip :color="sortField == 'default' ? 'primary' : ''" filter variant="outlined" value="default">
默认
</VChip>
<VChip :color="sortField == 'site' ? 'primary' : ''" filter variant="outlined" value="site"> 站点 </VChip>
<VChip :color="sortField == 'size' ? 'primary' : ''" filter variant="outlined" value="size">
文件大小
</VChip>
<VChip :color="sortField == 'seeder' ? 'primary' : ''" filter variant="outlined" value="seeder">
做种数
</VChip>
</VChipGroup>
</VListItem>
<VListSubheader v-if="siteFilterOptions.length > 0"> 站点 </VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.site" column multiple> <VChipGroup v-model="filterForm.site" column multiple>
<VChip <VChip
@@ -134,9 +162,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="editionFilterOptions.length > 0"> <VListSubheader v-if="editionFilterOptions.length > 0"> 质量 </VListSubheader>
质量
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.edition" column multiple> <VChipGroup v-model="filterForm.edition" column multiple>
<VChip <VChip
@@ -151,9 +177,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="resolutionFilterOptions.length > 0"> <VListSubheader v-if="resolutionFilterOptions.length > 0"> 分辨率 </VListSubheader>
分辨率
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.resolution" column multiple> <VChipGroup v-model="filterForm.resolution" column multiple>
<VChip <VChip
@@ -168,9 +192,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="releaseGroupFilterOptions.length > 0"> <VListSubheader v-if="releaseGroupFilterOptions.length > 0"> 制作组 </VListSubheader>
制作组
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.releaseGroup" column multiple> <VChipGroup v-model="filterForm.releaseGroup" column multiple>
<VChip <VChip
@@ -185,9 +207,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="videoCodeFilterOptions.length > 0"> <VListSubheader v-if="videoCodeFilterOptions.length > 0"> 视频编码 </VListSubheader>
视频编码
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.videoCode" column multiple> <VChipGroup v-model="filterForm.videoCode" column multiple>
<VChip <VChip
@@ -202,9 +222,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="freeStateFilterOptions.length > 0"> <VListSubheader v-if="freeStateFilterOptions.length > 0"> 促销状态 </VListSubheader>
促销状态
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.freeState" column multiple> <VChipGroup v-model="filterForm.freeState" column multiple>
<VChip <VChip
@@ -219,9 +237,7 @@ onMounted(() => {
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VListItem> </VListItem>
<VListSubheader v-if="seasonFilterOptions.length > 0"> <VListSubheader v-if="seasonFilterOptions.length > 0"> 季集 </VListSubheader>
季集
</VListSubheader>
<VListItem> <VListItem>
<VChipGroup v-model="filterForm.season" column multiple> <VChipGroup v-model="filterForm.season" column multiple>
<VChip <VChip
@@ -240,3 +256,15 @@ onMounted(() => {
</VCol> </VCol>
</VRow> </VRow>
</template> </template>
<style lang="scss">
.torrent-list-vscroll {
block-size: calc(100vh - 6rem);
overflow-y: auto;
}
@media (width <= 768px) {
.orrent-list-vscroll {
block-size: calc(100vh - 10rem);
}
}
</style>

View File

@@ -8,30 +8,24 @@ import PluginCard from '@/components/cards/PluginCard.vue'
import noImage from '@images/logos/plugin.png' import noImage from '@images/logos/plugin.png'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { isNullOrEmptyObject } from '@/@core/utils' import { isNullOrEmptyObject } from '@/@core/utils'
import { useDefer } from '@/@core/utils/dom' import router from '@/router'
import { PluginTabs } from '@/router/menu'
const route = useRoute() const route = useRoute()
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
// 延迟加载 // APP
let deferApp = (_: number) => true const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') == '1' && display.mdAndDown.value
})
// 当前标签 // 当前标签
const activeTab = ref(route.params.tab) const activeTab = ref(route.query.tab)
// 标签页 // 插件ID参数
const tabs = [ const pluginId = ref(route.query.id)
{
title: '我的插件',
tab: 'myplugin',
},
{
title: '插件市场',
tab: 'pluginmarket',
},
]
// 当前排序字段 // 当前排序字段
const activeSort = ref(null) const activeSort = ref(null)
@@ -287,8 +281,6 @@ const sortedUninstalledList = computed(() => {
} }
}) })
deferApp = useDefer(ret_list.length)
if (isNullOrEmptyObject(PluginStatistics.value)) return ret_list if (isNullOrEmptyObject(PluginStatistics.value)) return ret_list
// 数据排序 // 数据排序
if (!activeSort.value || activeSort.value === 'count') { if (!activeSort.value || activeSort.value === 'count') {
@@ -320,30 +312,40 @@ function handleRepoUrl(url: string | undefined) {
return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '') return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')
} }
// 跳转tab
function jumpTab(tab: string) {
router.push('/plugins?tab=' + tab)
}
// 加载时获取数据 // 加载时获取数据
onBeforeMount(async () => { onBeforeMount(async () => {
await refreshData() await refreshData()
getPluginStatistics() getPluginStatistics()
if (activeTab.value != 'market' && pluginId.value) {
// 找到这个插件
const plugin = dataList.value.find(item => item.id === pluginId.value)
if (plugin) {
plugin.page_open = true
}
}
}) })
</script> </script>
<template> <template>
<div> <div>
<VTabs v-model="activeTab"> <VTabs v-model="activeTab">
<VTab v-for="item in tabs" :value="item.tab"> <VTab v-for="item in PluginTabs" :value="item.tab" @click="jumpTab(item.tab)">
<span class="mx-5">{{ item.title }}</span> <span class="mx-5">{{ item.title }}</span>
</VTab> </VTab>
</VTabs> </VTabs>
<VDivider />
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false"> <VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<!-- 我的插件 --> <!-- 我的插件 -->
<VWindowItem value="myplugin"> <VWindowItem value="installed">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<LoadingBanner v-if="!isRefreshed" class="mt-12" /> <LoadingBanner v-if="!isRefreshed" class="mt-12" />
<div v-if="dataList.length > 0" class="grid gap-4 grid-plugin-card"> <div v-if="dataList.length > 0" class="grid gap-4 grid-plugin-card items-start">
<template v-for="(data, index) in dataList" :key="`${data.id}_v${data.plugin_version}`"> <template v-for="(data, index) in dataList" :key="`${data.id}_v${data.plugin_version}`">
<PluginCard <PluginCard
:count="PluginStatistics[data.id || '0']" :count="PluginStatistics[data.id || '0']"
@@ -365,7 +367,7 @@ onBeforeMount(async () => {
</transition> </transition>
</VWindowItem> </VWindowItem>
<!-- 插件市场 --> <!-- 插件市场 -->
<VWindowItem value="pluginmarket"> <VWindowItem value="market">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<LoadingBanner v-if="!isAppMarketLoaded" class="mt-12" /> <LoadingBanner v-if="!isAppMarketLoaded" class="mt-12" />
@@ -413,14 +415,9 @@ onBeforeMount(async () => {
</VCol> </VCol>
</VRow> </VRow>
</div> </div>
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card"> <div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card items-start">
<template v-for="(data, index) in sortedUninstalledList" :key="`${data.id}_v${data.plugin_version}`"> <template v-for="(data, index) in sortedUninstalledList" :key="`${data.id}_v${data.plugin_version}`">
<PluginAppCard <PluginAppCard :plugin="data" :count="PluginStatistics[data.id || '0']" @install="pluginInstalled" />
v-if="deferApp(index)"
:plugin="data"
:count="PluginStatistics[data.id || '0']"
@install="pluginInstalled"
/>
</template> </template>
</div> </div>
<NoDataFound <NoDataFound
@@ -439,13 +436,14 @@ onBeforeMount(async () => {
<VFab <VFab
icon="mdi-magnify" icon="mdi-magnify"
color="info" color="info"
location="bottom end" location="bottom"
class="mb-2" class="mb-2"
size="x-large" size="x-large"
fixed fixed
app app
appear appear
@click="SearchDialog = true" @click="SearchDialog = true"
:class="{ 'mb-12': appMode }"
/> />
<VDialog <VDialog
v-if="SearchDialog" v-if="SearchDialog"
@@ -518,10 +516,3 @@ onBeforeMount(async () => {
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
<style lang="scss">
.grid-plugin-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from '@/api' import api from '@/api'
import { MediaDirectory } from '@/api/types'
import FileBrowser from '@/components/FileBrowser.vue' import FileBrowser from '@/components/FileBrowser.vue'
const endpoints = { const endpoints = {
@@ -29,42 +30,65 @@ const endpoints = {
}, },
} }
// 读取下载目录 // 当前目录
const path: Ref<string | undefined> = ref() const path: Ref<string | undefined> = ref()
// 调用API加载当前系统环境设置 // 下载目录列表
function loadSystemSettings(): Promise<string> { const downloadDirectories = ref<MediaDirectory[]>([])
return new Promise((resolve, reject) => {
api
.get('system/env')
.then((result: any) => {
let path = '/'
if (result.success)
path = result.data?.DOWNLOAD_PATH || '/'
if (!path.endsWith('/')) // 计算公共路径
path += '/' function findCommonPath(paths: string[]): string {
let commonPath = '/'
if (!paths || paths.length === 0) {
commonPath = '/'
} else if (paths.length === 1) {
commonPath = paths[0]
commonPath = commonPath.replace(/\\/g, '/')
} else {
const normalizedPaths = paths.map(path => path.replace(/\\/g, '/'))
const splitPaths = normalizedPaths.map(path => path.split('/'))
let commonParts: string[] = []
for (let i = 0; i < splitPaths[0].length; i++) {
const part = splitPaths[0][i]
if (splitPaths.every(pathParts => pathParts[i] === part)) {
commonParts.push(part)
} else {
break
}
}
commonPath = commonParts.join('/')
}
resolve(path) if (!commonPath.endsWith('/')) {
}) commonPath += '/'
.catch(error => reject(error)) }
})
if (commonPath.includes(':')) {
commonPath = commonPath.replace('/', '\\')
}
return commonPath
} }
// 查询下载目录
async function loadDownloadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value
path.value = findCommonPath(downloadDirectories.value.map(item => item.path) as string[])
}
} catch (error) {
console.log(error)
}
}
// 目录变化
function pathChanged(_path: string) { function pathChanged(_path: string) {
path.value = _path path.value = _path
} }
onMounted(() => { onBeforeMount(loadDownloadDirectories)
loadSystemSettings()
.then((res) => {
path.value = res
})
.catch((error) => {
console.error(error)
path.value = '/'
})
})
</script> </script>
<template> <template>

View File

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

View File

@@ -85,10 +85,8 @@ async function loadAccountInfo() {
const user: User = await api.get('user/current') const user: User = await api.get('user/current')
console.log(user) console.log(user)
accountInfo.value = user accountInfo.value = user
if (!accountInfo.value.avatar) if (!accountInfo.value.avatar) accountInfo.value.avatar = avatar1
accountInfo.value.avatar = avatar1 } catch (error) {
}
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -105,12 +103,9 @@ async function saveAccountInfo() {
} }
try { try {
const result: { [key: string]: any } = await api.put('user/', accountInfo.value) const result: { [key: string]: any } = await api.put('user/', accountInfo.value)
if (result.success) if (result.success) $toast.success('用户信息保存成功!')
$toast.success('用户信息保存成功!') else $toast.error(`用户信息保存失败:${result.message}`)
else } catch (error) {
$toast.error(`用户信息保存失败:${result.message}`)
}
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -121,8 +116,7 @@ async function loadAllUsers() {
const result: User[] = await api.get('/user/') const result: User[] = await api.get('/user/')
allUsers.value = result allUsers.value = result
} } catch (error) {
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -134,12 +128,10 @@ async function deleteUser(user: User) {
if (result.success) { if (result.success) {
$toast.success('用户删除成功!') $toast.success('用户删除成功!')
loadAllUsers() loadAllUsers()
} } else {
else {
$toast.error(`用户删除失败:${result.message}`) $toast.error(`用户删除失败:${result.message}`)
} }
} } catch (error) {
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -153,12 +145,10 @@ async function deactivateUser(user: User) {
if (result.success) { if (result.success) {
$toast.success('用户冻结成功!') $toast.success('用户冻结成功!')
loadAllUsers() loadAllUsers()
} } else {
else {
$toast.error(`用户冻结失败:${result.message}`) $toast.error(`用户冻结失败:${result.message}`)
} }
} } catch (error) {
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -170,17 +160,15 @@ async function addUser() {
return return
} }
try { try {
const result: { [key: string]: any } = await api.post('user', userForm) const result: { [key: string]: any } = await api.post('user/', userForm)
if (result.success) { if (result.success) {
$toast.success('用户新增成功!') $toast.success('用户新增成功!')
loadAllUsers() loadAllUsers()
addUserDialog.value = false addUserDialog.value = false
} } else {
else {
$toast.error(`用户新增失败:${result.message}`) $toast.error(`用户新增失败:${result.message}`)
} }
} } catch (error) {
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -194,12 +182,10 @@ async function getOtpUri() {
secret.value = result.data.secret secret.value = result.data.secret
qrCode.value = result.data.uri qrCode.value = result.data.uri
otpDialog.value = true otpDialog.value = true
} } else {
else {
$toast.error(`获取otp uri失败${result.message}`) $toast.error(`获取otp uri失败${result.message}`)
} }
} } catch (error) {
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -211,12 +197,10 @@ async function disableOtp() {
if (result.success) { if (result.success) {
accountInfo.value.is_otp = false accountInfo.value.is_otp = false
$toast.success('关闭登录双重验证成功!') $toast.success('关闭登录双重验证成功!')
} } else {
else {
$toast.error(`关闭otp失败${result.message}`) $toast.error(`关闭otp失败${result.message}`)
} }
} } catch (error) {
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -228,18 +212,19 @@ async function judgeOtpPassword() {
return return
} }
try { try {
const result: { [key: string]: any } = await api.post('user/otp/judge', { uri: otpUri.value, otpPassword: otpPassword.value }) const result: { [key: string]: any } = await api.post('user/otp/judge', {
uri: otpUri.value,
otpPassword: otpPassword.value,
})
if (result.success) { if (result.success) {
$toast.success('开启登录双重验证成功!') $toast.success('开启登录双重验证成功!')
otpDialog.value = false otpDialog.value = false
accountInfo.value.is_otp = true accountInfo.value.is_otp = true
} } else {
else {
$toast.error(`开启otp失败${result.message}`) $toast.error(`开启otp失败${result.message}`)
} }
} } catch (error) {
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -258,23 +243,13 @@ onMounted(() => {
<VCard title="个人信息"> <VCard title="个人信息">
<VCardText class="d-flex"> <VCardText class="d-flex">
<!-- 👉 Avatar --> <!-- 👉 Avatar -->
<VAvatar <VAvatar rounded="lg" size="100" class="me-6" :image="accountInfo.avatar" />
rounded="lg"
size="100"
class="me-6"
:image="accountInfo.avatar"
/>
<!-- 👉 Upload Photo --> <!-- 👉 Upload Photo -->
<form class="d-flex flex-column justify-center gap-5"> <form class="d-flex flex-column justify-center gap-5">
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<VBtn <VBtn color="primary" @click="refInputEl?.click()">
color="primary" <VIcon icon="mdi-cloud-upload-outline" />
@click="refInputEl?.click()"
>
<VIcon
icon="mdi-cloud-upload-outline"
/>
<span class="d-none d-sm-block ms-2">上传头像</span> <span class="d-none d-sm-block ms-2">上传头像</span>
</VBtn> </VBtn>
@@ -285,17 +260,10 @@ onMounted(() => {
accept=".jpeg,.png,.jpg,GIF" accept=".jpeg,.png,.jpg,GIF"
hidden hidden
@input="changeAvatar" @input="changeAvatar"
> />
<VBtn <VBtn type="reset" color="error" variant="tonal" @click="resetAvatar">
type="reset" <VIcon icon="mdi-refresh" />
color="error"
variant="tonal"
@click="resetAvatar"
>
<VIcon
icon="mdi-refresh"
/>
<span class="d-none d-sm-block ms-2">重置</span> <span class="d-none d-sm-block ms-2">重置</span>
</VBtn> </VBtn>
@@ -304,16 +272,12 @@ onMounted(() => {
variant="tonal" variant="tonal"
@click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()" @click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()"
> >
<VIcon <VIcon icon="mdi-account-key" />
icon="mdi-account-key" <span class="d-none d-sm-block ms-2">{{ accountInfo.is_otp ? '关闭验证' : '双重验证' }}</span>
/>
<span class="d-none d-sm-block ms-2">{{ accountInfo.is_otp ? "关闭验证" : "双重验证" }}</span>
</VBtn> </VBtn>
</div> </div>
<p class="text-body-1 mb-0"> <p class="text-body-1 mb-0">允许 JPGGIF PNG 格式 最大尺寸 800K</p>
允许 JPGGIF PNG 格式 最大尺寸 800K
</p>
</form> </form>
</VCardText> </VCardText>
@@ -324,33 +288,16 @@ onMounted(() => {
<VForm class="mt-6"> <VForm class="mt-6">
<VRow> <VRow>
<!-- 👉 Name --> <!-- 👉 Name -->
<VCol <VCol md="6" cols="12">
md="6" <VTextField v-model="accountInfo.name" readonly label="用户名" />
cols="12"
>
<VTextField
v-model="accountInfo.name"
readonly
label="用户名"
/>
</VCol> </VCol>
<!-- 👉 Email --> <!-- 👉 Email -->
<VCol <VCol cols="12" md="6">
cols="12" <VTextField v-model="accountInfo.email" label="邮箱" type="email" />
md="6"
>
<VTextField
v-model="accountInfo.email"
label="邮箱"
type="email"
/>
</VCol> </VCol>
<VCol <VCol cols="12" md="6">
cols="12"
md="6"
>
<!-- 👉 new password --> <!-- 👉 new password -->
<VTextField <VTextField
v-model="newPassword" v-model="newPassword"
@@ -362,32 +309,20 @@ onMounted(() => {
/> />
</VCol> </VCol>
<VCol <VCol cols="12" md="6">
cols="12"
md="6"
>
<!-- 👉 confirm password --> <!-- 👉 confirm password -->
<VTextField <VTextField
v-model="confirmPassword" v-model="confirmPassword"
:type="isConfirmPasswordVisible ? 'text' : 'password'" :type="isConfirmPasswordVisible ? 'text' : 'password'"
:append-inner-icon=" :append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
label="确认新密码" label="确认新密码"
@click:append-inner=" @click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
isConfirmPasswordVisible = !isConfirmPasswordVisible
"
/> />
</VCol> </VCol>
<!-- 👉 Form Actions --> <!-- 👉 Form Actions -->
<VCol <VCol cols="12" class="d-flex flex-wrap gap-4">
cols="12" <VBtn @click="saveAccountInfo"> 保存 </VBtn>
class="d-flex flex-wrap gap-4"
>
<VBtn @click="saveAccountInfo">
保存
</VBtn>
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
@@ -395,10 +330,7 @@ onMounted(() => {
</VCard> </VCard>
</VCol> </VCol>
<VCol <VCol v-if="accountInfo.is_superuser" cols="12">
v-if="accountInfo.is_superuser"
cols="12"
>
<!-- 👉 Accounts --> <!-- 👉 Accounts -->
<VCard title="所有用户"> <VCard title="所有用户">
<template #append> <template #append>
@@ -409,76 +341,38 @@ onMounted(() => {
<VTable class="text-no-wrap"> <VTable class="text-no-wrap">
<thead> <thead>
<tr> <tr>
<th scope="col"> <th scope="col">用户名</th>
用户名 <th scope="col">邮箱</th>
</th> <th scope="col">状态</th>
<th scope="col"> <th scope="col">管理员</th>
邮箱 <th scope="col" class="w-5" />
</th>
<th scope="col">
状态
</th>
<th scope="col">
管理员
</th>
<th
scope="col"
class="w-5"
/>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr v-for="user in allUsers" :key="user.name">
v-for="user in allUsers"
:key="user.name"
>
<td> <td>
{{ user.name }} {{ user.name }}
</td> </td>
<td>{{ user.email }}</td> <td>{{ user.email }}</td>
<td> <td>
<VChip <VChip v-if="user.is_active" color="success" text-color="white"> 激活 </VChip>
v-if="user.is_active" <VChip v-else color="error" text-color="white"> 冻结 </VChip>
color="success"
text-color="white"
>
激活
</VChip>
<VChip
v-else
color="error"
text-color="white"
>
冻结
</VChip>
</td> </td>
<td>{{ user.is_superuser ? "是" : "否" }}</td> <td>{{ user.is_superuser ? '是' : '否' }}</td>
<td> <td>
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name !== user.name"> <IconBtn v-show="accountInfo.is_superuser && accountInfo.name !== user.name">
<VIcon icon="mdi-dots-vertical" /> <VIcon icon="mdi-dots-vertical" />
<VMenu <VMenu activator="parent" close-on-content-click>
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem variant="plain" @click="deactivateUser(user)">
variant="plain"
@click="deactivateUser(user)"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-lock" /> <VIcon icon="mdi-lock" />
</template> </template>
<VListItemTitle> <VListItemTitle>
{{ {{ user.is_active ? '冻结' : '解冻' }}
user.is_active ? "冻结" : "解冻"
}}
</VListItemTitle> </VListItemTitle>
</VListItem> </VListItem>
<VListItem <VListItem variant="plain" base-color="error" @click="deleteUser(user)">
variant="plain"
base-color="error"
@click="deleteUser(user)"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-delete" /> <VIcon icon="mdi-delete" />
</template> </template>
@@ -495,85 +389,50 @@ onMounted(() => {
</VCol> </VCol>
</VRow> </VRow>
<!-- =弹窗 --> <!-- =弹窗 -->
<VDialog <VDialog v-model="addUserDialog" max-width="50rem" persistent z-index="1010">
v-model="addUserDialog"
max-width="50rem"
persistent
z-index="1010"
>
<!-- Dialog Content --> <!-- Dialog Content -->
<VCard title="新增用户"> <VCard title="新增用户">
<VCardText> <VCardText>
<VForm @submit.prevent="() => {}"> <VForm @submit.prevent="() => {}">
<VRow> <VRow>
<VCol <VCol cols="12" md="6">
cols="12" <VTextField v-model="userForm.name" label="用户名" :rules="[requiredValidator]" />
md="6"
>
<VTextField
v-model="userForm.name"
label="用户名"
:rules="[requiredValidator]"
/>
</VCol> </VCol>
<VCol <VCol cols="12" md="6">
cols="12"
md="6"
>
<VTextField <VTextField
v-model="userForm.password" v-model="userForm.password"
label="密码" label="密码"
:rules="[requiredValidator]" :rules="[requiredValidator]"
:type="isPasswordVisible ? 'text' : 'password'" :type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon=" :append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
@click:append-inner="isPasswordVisible = !isPasswordVisible" @click:append-inner="isPasswordVisible = !isPasswordVisible"
/> />
</VCol> </VCol>
<VCol <VCol cols="12" md="6">
cols="12" <VTextField v-model="userForm.email" :rules="[requiredValidator]" label="邮箱" />
md="6"
>
<VTextField
v-model="userForm.email"
:rules="[requiredValidator]"
label="邮箱"
/>
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions> <VCardActions>
<VBtn @click="addUserDialog = false"> <VBtn @click="addUserDialog = false"> 取消 </VBtn>
取消
</VBtn>
<VSpacer /> <VSpacer />
<VBtn @click="addUser"> <VBtn @click="addUser"> 确定 </VBtn>
确定
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
<!-- 双重验证弹窗 --> <!-- 双重验证弹窗 -->
<VDialog <VDialog v-model="otpDialog" max-width="45rem" persistent z-index="1010">
v-model="otpDialog"
max-width="45rem"
persistent
z-index="1010"
>
<!-- 开启双重验证弹窗内容 --> <!-- 开启双重验证弹窗内容 -->
<VCard> <VCard>
<DialogCloseBtn @click="otpDialog = false" /> <DialogCloseBtn @click="otpDialog = false" />
<VCardText> <VCardText>
<h4 class="text-h4 text-center mb-6 mt-5"> <h4 class="text-h4 text-center mb-6 mt-5">登录双重验证</h4>
登录双重验证 <h5 class="text-h5 font-weight-medium mb-2">身份验证器</h5>
</h4><h5 class="text-h5 font-weight-medium mb-2">
身份验证器
</h5>
<p class="mb-6"> <p class="mb-6">
使用像Google AuthenticatorMicrosoft AuthenticatorAuthy或1Password这样的身份验证器应用程序扫描二维码它将为您生成一个6位数的代码供您在下方输入 使用像Google AuthenticatorMicrosoft
AuthenticatorAuthy或1Password这样的身份验证器应用程序扫描二维码它将为您生成一个6位数的代码供您在下方输入
</p> </p>
<div class="my-6"> <div class="my-6">
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" /> <QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
@@ -597,14 +456,12 @@ onMounted(() => {
variant="outlined" variant="outlined"
/> />
<div class="d-flex justify-end flex-wrap gap-4"> <div class="d-flex justify-end flex-wrap gap-4">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false"> <VBtn variant="outlined" color="secondary" @click="otpDialog = false"> 取消 </VBtn>
取消
</VBtn>
<VBtn @click="judgeOtpPassword"> <VBtn @click="judgeOtpPassword">
确定 <template #prepend>
<template #append>
<VIcon icon="mdi-check" /> <VIcon icon="mdi-check" />
</template> </template>
确定
</VBtn> </VBtn>
</div> </div>
</VForm> </VForm>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import draggable from 'vuedraggable'
import api from '@/api' import api from '@/api'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue' import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import type { Site } from '@/api/types' import type { Site } from '@/api/types'
@@ -170,42 +171,12 @@ async function saveSelectedSites() {
} }
} }
// 上调优先级 // 根据列表的拖动顺序更新优先级
function onLevelUp(pri: string) { function dragOrderEnd() {
// 找到当前卡片 filterCards.value = filterCards.value.map((card, index) => {
const card = filterCards.value.find(card => card.pri === pri) card.pri = (index + 1).toString()
if (!card) return return card
})
// 找到当前卡片的上一张卡片
const prevCard = filterCards.value.find(card => card.pri === (parseInt(pri) - 1).toString())
if (!prevCard) return
// 交换两张卡片的优先级
const temp = card.pri
card.pri = prevCard.pri
prevCard.pri = temp
// 卡片重新按优先级排序
filterCards.value.sort((a, b) => parseInt(a.pri) - parseInt(b.pri))
}
// 下调优先级
function onLevelDown(pri: string) {
// 找到当前卡片
const card = filterCards.value.find(card => card.pri === pri)
if (!card) return
// 找到当前卡片的下一张卡片
const nextCard = filterCards.value.find(card => card.pri === (parseInt(pri) + 1).toString())
if (!nextCard) return
// 交换两张卡片的优先级
const temp = card.pri
card.pri = nextCard.pri
nextCard.pri = temp
// 卡片重新按优先级排序
filterCards.value.sort((a, b) => parseInt(a.pri) - parseInt(b.pri))
} }
// 查询包含与排除规则 // 查询包含与排除规则
@@ -309,8 +280,11 @@ onMounted(() => {
<template> <template>
<VRow> <VRow>
<VCol cols="12"> <VCol cols="12">
<VCard title="媒体数据源"> <VCard>
<VCardSubtitle> 设定搜索时展示哪些源的媒体信息</VCardSubtitle> <VCardItem>
<VCardTitle>媒体数据源</VCardTitle>
<VCardSubtitle>设定搜索时展示哪些源的媒体信息</VCardSubtitle>
</VCardItem>
<VCardText> <VCardText>
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
@@ -320,22 +294,24 @@ onMounted(() => {
chips chips
:items="mediaSourcesDict" :items="mediaSourcesDict"
label="当前使用数据源" label="当前使用数据源"
hint="选中多项时会同时展示来自不同数据源的搜索结果,选择的数据源顺序将会影响搜索结果的排序" hint="搜索媒体信息时使用的数据源以及排序"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
</VCardText> </VCardText>
<VCardText>
<VCardItem>
<VBtn type="submit" @click="saveMediaSourceSetting"> 保存 </VBtn> <VBtn type="submit" @click="saveMediaSourceSetting"> 保存 </VBtn>
</VCardItem> </VCardText>
</VCard> </VCard>
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<VCard title="搜索站点"> <VCard>
<VCardSubtitle> 只有选中的站点才会在搜索中使用</VCardSubtitle>
<VCardItem> <VCardItem>
<VCardTitle>搜索站点</VCardTitle>
<VCardSubtitle> 只有选中的站点才会在搜索中使用</VCardSubtitle>
</VCardItem>
<VCardText>
<VChipGroup v-model="selectedSites" column multiple> <VChipGroup v-model="selectedSites" column multiple>
<VChip <VChip
v-for="site in allSites" v-for="site in allSites"
@@ -348,63 +324,73 @@ onMounted(() => {
{{ site.name }} {{ site.name }}
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VCardItem> </VCardText>
<VCardText>
<VCardItem>
<VBtn type="submit" @click="saveSelectedSites"> 保存 </VBtn> <VBtn type="submit" @click="saveSelectedSites"> 保存 </VBtn>
</VCardItem> </VCardText>
</VCard> </VCard>
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<VCard title="搜索优先级"> <VCard>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="shareRules">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="importCodeDialog = true">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在搜索时默认使用的优先级排序未在优先级中的资源将不在搜索结果中显示 </VCardSubtitle>
<VCardItem> <VCardItem>
<div class="grid gap-3 grid-filterrule-card"> <template #append>
<FilterRuleCard <IconBtn>
v-for="(card, index) in filterCards" <VIcon icon="mdi-dots-vertical" />
:key="index" <VMenu activator="parent" close-on-content-click>
:pri="card.pri" <VList>
:maxpri="filterCards.length.toString()" <VListItem variant="plain" @click="shareRules">
:rules="card.rules" <template #prepend>
@changed="updateFilterCardValue" <VIcon icon="mdi-share" />
@close="filterCardClose(card.pri)" </template>
@leveldown="onLevelDown" <VListItemTitle>分享</VListItemTitle>
@levelup="onLevelUp" </VListItem>
/> <VListItem variant="plain" @click="importCodeDialog = true">
</div> <template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardTitle>搜索优先级</VCardTitle>
<VCardSubtitle>设置在搜索时默认使用的优先级排序未在优先级中的资源将不在搜索结果中显示</VCardSubtitle>
</VCardItem> </VCardItem>
<VCardItem> <VCardText>
<draggable
v-model="filterCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="filterCards.length.toString()"
:rules="element.rules"
@changed="updateFilterCardValue"
@close="filterCardClose(element.pri)"
/>
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveCustomFilters()"> 保存 </VBtn> <VBtn type="submit" class="me-2" @click="saveCustomFilters()"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addFilterCard()"> <VBtn color="success" variant="tonal" @click="addFilterCard()">
<VIcon icon="mdi-plus" /> <VIcon icon="mdi-plus" />
</VBtn> </VBtn>
</VCardItem> </VCardText>
</VCard> </VCard>
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<VCard title="默认过滤规则"> <VCard>
<VCardSubtitle> 设置在搜索时默认使用的过滤规则 </VCardSubtitle> <VCardItem>
<VCardTitle>默认过滤规则</VCardTitle>
<VCardSubtitle>设置在搜索时默认使用的过滤规则</VCardSubtitle>
</VCardItem>
<VCardText> <VCardText>
<VForm> <VForm>
<VRow> <VRow>
@@ -413,7 +399,8 @@ onMounted(() => {
v-model="defaultFilterRules.include" v-model="defaultFilterRules.include"
type="text" type="text"
label="包含(关键字、正则式)" label="包含(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或" hint="包含规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
@@ -421,7 +408,8 @@ onMounted(() => {
v-model="defaultFilterRules.exclude" v-model="defaultFilterRules.exclude"
type="text" type="text"
label="排除(关键字、正则式)" label="排除(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或" hint="排除规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
@@ -431,23 +419,25 @@ onMounted(() => {
label="最小做种数" label="最小做种数"
placeholder="0" placeholder="0"
hint="小于该值的资源将被过滤掉0表示不过滤" hint="小于该值的资源将被过滤掉0表示不过滤"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
<VTextField <VTextField
v-model="defaultFilterRules.min_seeders_time" v-model="defaultFilterRules.min_seeders_time"
type="text" type="text"
label="最少做种数生效发布时间(分钟)" label="最少做种数生效发布时间(分钟)"
placeholder="0" placeholder="0"
hint="发布时间距现在大于该值的资源将生效最小做种数规则0表示不生效" hint="发布时间距当前时间大于该值的资源将生效最小做种数规则0表示不生效"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardItem> <VCardText>
<VBtn type="submit" @click="saveDefaultFilter"> 保存 </VBtn> <VBtn type="submit" @click="saveDefaultFilter"> 保存 </VBtn>
</VCardItem> </VCardText>
</VCard> </VCard>
</VCol> </VCol>
</VRow> </VRow>
@@ -455,10 +445,3 @@ onMounted(() => {
<ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" /> <ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
</VDialog> </VDialog>
</template> </template>
<style lang="scss">
.grid-filterrule-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script lang='ts' setup> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import draggable from 'vuedraggable'
import api from '@/api' import api from '@/api'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue' import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import type { Site } from '@/api/types' import type { Site } from '@/api/types'
@@ -42,7 +43,7 @@ const defaultFilterRules = ref({
movie_size: '', movie_size: '',
tv_size: '', tv_size: '',
min_seeders: 0, min_seeders: 0,
min_seeders_time: 0 min_seeders_time: 0,
}) })
// 订阅模式选择项 // 订阅模式选择项
@@ -80,8 +81,7 @@ async function querySelectedRssSites() {
const result: { [key: string]: any } = await api.get('system/setting/RssSites') const result: { [key: string]: any } = await api.get('system/setting/RssSites')
selectedRssSites.value = result.data?.value ?? [] selectedRssSites.value = result.data?.value ?? []
} } catch (error) {
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -89,31 +89,23 @@ async function querySelectedRssSites() {
// 保存用户选中的订阅站点 // 保存用户选中的订阅站点
async function saveSelectedRssSites() { async function saveSelectedRssSites() {
try { try {
const result1: { [key: string]: any } = await api.post( const result1: { [key: string]: any } = await api.post('system/setting/RssSites', selectedRssSites.value)
'system/setting/RssSites',
selectedRssSites.value)
const result2: { [key: string]: any } = await api.post( const result2: { [key: string]: any } = await api.post(
'system/setting/SUBSCRIBE_SEARCH', 'system/setting/SUBSCRIBE_SEARCH',
enableIntervalSearch.value ? 'True' : 'False', enableIntervalSearch.value ? 'True' : 'False',
) )
const result3: { [key: string]: any } = await api.post( const result3: { [key: string]: any } = await api.post('system/setting/SUBSCRIBE_MODE', selectedSubscribeMode.value)
'system/setting/SUBSCRIBE_MODE',
selectedSubscribeMode.value,
)
const result4: { [key: string]: any } = await api.post( const result4: { [key: string]: any } = await api.post(
'system/setting/SUBSCRIBE_RSS_INTERVAL', 'system/setting/SUBSCRIBE_RSS_INTERVAL',
selectedRssInterval.value, selectedRssInterval.value,
) )
if (result1.success && result2.success && result3.success && result4.success) if (result1.success && result2.success && result3.success && result4.success) $toast.success('订阅站点保存成功')
$toast.success('订阅站点保存成功') else $toast.error('订阅站点保存失败!')
else } catch (error) {
$toast.error('订阅站点保存失败!')
}
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -129,18 +121,14 @@ async function querySites() {
// 查询订阅搜索开关 // 查询订阅搜索开关
const result: { [key: string]: any } = await api.get('system/setting/SUBSCRIBE_SEARCH') const result: { [key: string]: any } = await api.get('system/setting/SUBSCRIBE_SEARCH')
if (result.success) if (result.success) enableIntervalSearch.value = result.data?.value
enableIntervalSearch.value = result.data?.value
// 查询订阅模式 // 查询订阅模式
const result2: { [key: string]: any } = await api.get('system/setting/SUBSCRIBE_MODE') const result2: { [key: string]: any } = await api.get('system/setting/SUBSCRIBE_MODE')
if (result2.success) if (result2.success) selectedSubscribeMode.value = result2.data?.value
selectedSubscribeMode.value = result2.data?.value
// 查询站点RSS周期 // 查询站点RSS周期
const result3: { [key: string]: any } = await api.get('system/setting/SUBSCRIBE_RSS_INTERVAL') const result3: { [key: string]: any } = await api.get('system/setting/SUBSCRIBE_RSS_INTERVAL')
if (result3.success) if (result3.success) selectedRssInterval.value = result3.data?.value
selectedRssInterval.value = result3.data?.value } catch (error) {
}
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -162,8 +150,7 @@ async function queryCustomFilters(ruleType: string) {
} }
}) })
} }
} } catch (error) {
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -182,35 +169,27 @@ async function saveCustomFilters(ruleType: string) {
.join('>') .join('>')
} }
// 保存 // 保存
const result: { [key: string]: any } = await api.post( const result: { [key: string]: any } = await api.post(`system/setting/${ruleType}`, value)
`system/setting/${ruleType}`,
value,
)
const msg = ruleType === 'SubscribeFilterRules' ? '订阅优先级' : '洗版优先级' const msg = ruleType === 'SubscribeFilterRules' ? '订阅优先级' : '洗版优先级'
if (result.success) if (result.success) $toast.success(`${msg}保存成功`)
$toast.success(`${msg}保存成功`) else $toast.error(`${msg}保存失败!`)
else } catch (error) {
$toast.error(`${msg}保存失败!`)
}
catch (error) {
console.log(error) console.log(error)
} }
} }
// 更新规则卡片的值 // 更新规则卡片的值
function updateFilterCardValue(pri: string, rules: string[]) { function updateSubscribeFilterCardValue(pri: string, rules: string[]) {
const card = subscribeFilterCards.value.find(card => card.pri === pri) const card = subscribeFilterCards.value.find(card => card.pri === pri)
if (card) if (card) card.rules = rules
card.rules = rules
} }
// 更新洗版规则卡片的值 // 更新洗版规则卡片的值
function updateFilterCardValue2(pri: string, rules: string[]) { function updateBestVersionFilterCardValue(pri: string, rules: string[]) {
const card = bestVersionFilterCards.value.find(card => card.pri === pri) const card = bestVersionFilterCards.value.find(card => card.pri === pri)
if (card) if (card) card.rules = rules
card.rules = rules
} }
// 移除卡片 // 移除卡片
@@ -223,10 +202,8 @@ function filterCardClose(ruleType: string, pri: string) {
return card return card
}) })
// 更新 subscribeFilterCards.value // 更新 subscribeFilterCards.value
if (ruleType === 'SubscribeFilterRules') if (ruleType === 'SubscribeFilterRules') subscribeFilterCards.value = updatedCards
subscribeFilterCards.value = updatedCards else bestVersionFilterCards.value = updatedCards
else
bestVersionFilterCards.value = updatedCards
} }
// 增加卡片 // 增加卡片
@@ -242,58 +219,22 @@ function addFilterCard(ruleType: string) {
cards.value.push(newCard) cards.value.push(newCard)
} }
// 上调优先级 // 根据列表的拖动顺序更新优先级
function onLevelUp(filterCards: FilterCard[], pri: string) { function dragOrderEnd(ruleType: string) {
// 找到当前卡片 ;(ruleType === 'SubscribeFilterRules' ? subscribeFilterCards.value : bestVersionFilterCards.value).map(
const card = filterCards.find(card => card.pri === pri) (card, index) => {
if (!card) card.pri = (index + 1).toString()
return return card
},
// 找到当前卡片的上一张卡片 )
const prevCard = filterCards.find(card => card.pri === (parseInt(pri) - 1).toString())
if (!prevCard)
return
// 交换两张卡片的优先级
const temp = card.pri
card.pri = prevCard.pri
prevCard.pri = temp
// 卡片重新按优先级排序
filterCards.sort((a, b) => parseInt(a.pri) - parseInt(b.pri))
}
// 下调优先级
function onLevelDown(filterCards: FilterCard[], pri: string) {
// 找到当前卡片
const card = filterCards.find(card => card.pri === pri)
if (!card)
return
// 找到当前卡片的下一张卡片
const nextCard = filterCards.find(card => card.pri === (parseInt(pri) + 1).toString())
if (!nextCard)
return
// 交换两张卡片的优先级
const temp = card.pri
card.pri = nextCard.pri
nextCard.pri = temp
// 卡片重新按优先级排序
filterCards.sort((a, b) => parseInt(a.pri) - parseInt(b.pri))
} }
// 查询包含与排除规则 // 查询包含与排除规则
async function queryDefaultFilter() { async function queryDefaultFilter() {
try { try {
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get('system/setting/DefaultFilterRules')
'system/setting/DefaultFilterRules', if (result.data?.value) defaultFilterRules.value = result.data?.value
) } catch (error) {
if (result.data?.value)
defaultFilterRules.value = result.data?.value
}
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -301,16 +242,10 @@ async function queryDefaultFilter() {
// 保存包含与排除规则 // 保存包含与排除规则
async function saveDefaultFilter() { async function saveDefaultFilter() {
try { try {
const result: { [key: string]: any } = await api.post( const result: { [key: string]: any } = await api.post('system/setting/DefaultFilterRules', defaultFilterRules.value)
'system/setting/DefaultFilterRules', if (result.success) $toast.success('默认包含/排除规则保存成功')
defaultFilterRules.value, else $toast.error('默认包含/排除规则保存失败!')
) } catch (error) {
if (result.success)
$toast.success('默认包含/排除规则保存成功')
else
$toast.error('默认包含/排除规则保存失败!')
}
catch (error) {
console.log(error) console.log(error)
} }
} }
@@ -318,13 +253,10 @@ async function saveDefaultFilter() {
// 分享规则 // 分享规则
function shareRules(ruleType: string) { function shareRules(ruleType: string) {
let filterCards: Ref<FilterCard[]> let filterCards: Ref<FilterCard[]>
if (ruleType === 'SubscribeFilterRules') if (ruleType === 'SubscribeFilterRules') filterCards = subscribeFilterCards
filterCards = subscribeFilterCards else filterCards = bestVersionFilterCards
else
filterCards = bestVersionFilterCards
// 有值才处理 // 有值才处理
if (filterCards.value.length === 0) if (filterCards.value.length === 0) return
return
// 将卡片规则接装为字符串 // 将卡片规则接装为字符串
const value = filterCards.value const value = filterCards.value
@@ -336,8 +268,7 @@ function shareRules(ruleType: string) {
try { try {
copyToClipboard(value) copyToClipboard(value)
$toast.success('优先级规则已复制到剪贴板') $toast.success('优先级规则已复制到剪贴板')
} } catch (error) {
catch (error) {
$toast.error('优先级规则复制失败!') $toast.error('优先级规则复制失败!')
} }
} }
@@ -351,20 +282,14 @@ async function importRules(ruleType: string) {
// 监听导入代码变化 // 监听导入代码变化
watchEffect(() => { watchEffect(() => {
if (!importCodeString.value) if (!importCodeString.value) return
return if (!currentRuleType.value) return
if (!currentRuleType.value)
return
// 导入代码需要以空格开头和结束,没有则拼接 // 导入代码需要以空格开头和结束,没有则拼接
if (!importCodeString.value.startsWith(' ')) if (!importCodeString.value.startsWith(' ')) importCodeString.value = ` ${importCodeString.value}`
importCodeString.value = ` ${importCodeString.value}` if (!importCodeString.value.endsWith(' ')) importCodeString.value = `${importCodeString.value} `
if (!importCodeString.value.endsWith(' '))
importCodeString.value = `${importCodeString.value} `
let filterCards: Ref<FilterCard[]> let filterCards: Ref<FilterCard[]>
if (currentRuleType.value === 'SubscribeFilterRules') if (currentRuleType.value === 'SubscribeFilterRules') filterCards = subscribeFilterCards
filterCards = subscribeFilterCards else filterCards = bestVersionFilterCards
else
filterCards = bestVersionFilterCards
// 将导入的代码转换为规则卡片 // 将导入的代码转换为规则卡片
const groups = importCodeString.value.split('>') const groups = importCodeString.value.split('>')
filterCards.value = groups.map((group: string, index: number) => { filterCards.value = groups.map((group: string, index: number) => {
@@ -386,10 +311,12 @@ onMounted(() => {
<template> <template>
<VRow> <VRow>
<VCol cols="12"> <VCol cols="12">
<VCard title="订阅站点"> <VCard>
<VCardSubtitle> 只有选中的站点才会在订阅中使用</VCardSubtitle>
<VCardItem> <VCardItem>
<VCardTitle>订阅站点</VCardTitle>
<VCardSubtitle>只有选中的站点才会在订阅中使用</VCardSubtitle>
</VCardItem>
<VCardText>
<VChipGroup v-model="selectedRssSites" column multiple> <VChipGroup v-model="selectedRssSites" column multiple>
<VChip <VChip
v-for="site in allSites" v-for="site in allSites"
@@ -402,7 +329,7 @@ onMounted(() => {
{{ site.name }} {{ site.name }}
</VChip> </VChip>
</VChipGroup> </VChipGroup>
</VCardItem> </VCardText>
<VCardText> <VCardText>
<VForm> <VForm>
<VRow> <VRow>
@@ -411,7 +338,8 @@ onMounted(() => {
v-model="selectedSubscribeMode" v-model="selectedSubscribeMode"
:items="subscribeModeItems" :items="subscribeModeItems"
label="订阅模式" label="订阅模式"
hint="自动:系统自动爬取站点首页资源;站点RSS使用站点RSS订阅资源站点RSS会自动获取也可手动在站点管理中补全" hint="自动:自动爬取站点首页站点RSS通过站点RSS链接订阅"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
@@ -419,7 +347,8 @@ onMounted(() => {
v-model="selectedRssInterval" v-model="selectedRssInterval"
:items="rssIntervalItems" :items="rssIntervalItems"
label="站点RSS周期" label="站点RSS周期"
hint="设置站点RSS运行周期在订阅模式为站点RSS时生效" hint="设置站点RSS运行周期在订阅模式为`站点RSS`时生效"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
@@ -428,154 +357,134 @@ onMounted(() => {
<VSwitch <VSwitch
v-model="enableIntervalSearch" v-model="enableIntervalSearch"
label="开启订阅定时搜索" label="开启订阅定时搜索"
hint="开启后系统每隔24小时将按名称搜索全站补全订阅可能漏掉的资源" hint="每隔24小时全站搜索补全订阅可能漏掉的资源"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardItem> <VCardText>
<VBtn type="submit" @click="saveSelectedRssSites"> <VBtn type="submit" @click="saveSelectedRssSites"> 保存 </VBtn>
保存 </VCardText>
</VBtn>
</VCardItem>
</VCard> </VCard>
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<VCard title="订阅优先级"> <VCard>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="shareRules('SubscribeFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
@click="importRules('SubscribeFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在正常订阅时默认使用的优先级未在优先级中的资源将不会自动下载</VCardSubtitle>
<VCardItem> <VCardItem>
<div class="grid gap-3 grid-filterrule-card"> <template #append>
<FilterRuleCard <IconBtn>
v-for="(card, index) in subscribeFilterCards" <VIcon icon="mdi-dots-vertical" />
:key="index" <VMenu activator="parent" close-on-content-click>
:pri="card.pri" <VList>
:maxpri="subscribeFilterCards.length.toString()" <VListItem variant="plain" @click="shareRules('SubscribeFilterRules')">
:rules="card.rules" <template #prepend>
@changed="updateFilterCardValue" <VIcon icon="mdi-share" />
@close="filterCardClose('SubscribeFilterRules', card.pri)" </template>
@leveldown="onLevelDown(subscribeFilterCards, card.pri)" <VListItemTitle>分享</VListItemTitle>
@levelup="onLevelUp(subscribeFilterCards, card.pri)" </VListItem>
/> <VListItem variant="plain" @click="importRules('SubscribeFilterRules')">
</div> <template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardTitle>订阅优先级</VCardTitle>
<VCardSubtitle> 设置在正常订阅时默认使用的优先级未在优先级中的资源将不会自动下载</VCardSubtitle>
</VCardItem> </VCardItem>
<VCardItem> <VCardText>
<VBtn <draggable
type="submit" v-model="subscribeFilterCards"
class="me-2" handle=".cursor-move"
@click="saveCustomFilters('SubscribeFilterRules')" item-key="pri"
> tag="div"
保存 @end="dragOrderEnd('SubscribeFilterRules')"
</VBtn> :component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
<VBtn
color="success"
variant="tonal"
@click="addFilterCard('SubscribeFilterRules')"
> >
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="subscribeFilterCards.length.toString()"
:rules="element.rules"
@changed="updateSubscribeFilterCardValue"
@close="filterCardClose('SubscribeFilterRules', element.pri)"
/>
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveCustomFilters('SubscribeFilterRules')"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addFilterCard('SubscribeFilterRules')">
<VIcon icon="mdi-plus" /> <VIcon icon="mdi-plus" />
</VBtn> </VBtn>
</VCardItem> </VCardText>
</VCard> </VCard>
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<VCard title="洗版优先级"> <VCard>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="shareRules('BestVersionFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
@click="importRules('BestVersionFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在订阅洗版时使用的优先级匹配优先级1时洗版完成</VCardSubtitle>
<VCardItem> <VCardItem>
<div class="grid gap-3 grid-filterrule-card"> <VCardTitle>洗版优先级</VCardTitle>
<FilterRuleCard <template #append>
v-for="(card, index) in bestVersionFilterCards" <IconBtn>
:key="index" <VIcon icon="mdi-dots-vertical" />
:pri="card.pri" <VMenu activator="parent" close-on-content-click>
:maxpri="bestVersionFilterCards.length.toString()" <VList>
:rules="card.rules" <VListItem variant="plain" @click="shareRules('BestVersionFilterRules')">
@changed="updateFilterCardValue2" <template #prepend>
@close="filterCardClose('BestVersionFilterRules', card.pri)" <VIcon icon="mdi-share" />
@leveldown="onLevelDown(bestVersionFilterCards, card.pri)" </template>
@levelup="onLevelUp(bestVersionFilterCards, card.pri)" <VListItemTitle>分享</VListItemTitle>
/> </VListItem>
</div> <VListItem variant="plain" @click="importRules('BestVersionFilterRules')">
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在订阅洗版时使用的优先级匹配优先级1时洗版完成</VCardSubtitle>
</VCardItem> </VCardItem>
<VCardItem> <VCardText>
<VBtn <draggable
type="submit" v-model="bestVersionFilterCards"
class="me-2" handle=".cursor-move"
@click="saveCustomFilters('BestVersionFilterRules')" item-key="pri"
> tag="div"
保存 @end="dragOrderEnd('BestVersionFilterRules')"
</VBtn> :component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
<VBtn
color="success"
variant="tonal"
@click="addFilterCard('BestVersionFilterRules')"
> >
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="bestVersionFilterCards.length.toString()"
:rules="element.rules"
@changed="updateBestVersionFilterCardValue"
@close="filterCardClose('BestVersionFilterRules', element.pri)"
/>
</template>
</draggable>
</VCardText>
<VCardText>
<VBtn type="submit" class="me-2" @click="saveCustomFilters('BestVersionFilterRules')"> 保存 </VBtn>
<VBtn color="success" variant="tonal" @click="addFilterCard('BestVersionFilterRules')">
<VIcon icon="mdi-plus" /> <VIcon icon="mdi-plus" />
</VBtn> </VBtn>
</VCardItem> </VCardText>
</VCard> </VCard>
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<VCard title="默认过滤规则"> <VCard>
<VCardSubtitle> 设置在订阅时默认使用的过滤规则</VCardSubtitle> <VCardItem>
<VCardTitle>默认过滤规则</VCardTitle>
<VCardSubtitle> 设置在订阅时默认使用的过滤规则</VCardSubtitle>
</VCardItem>
<VCardText> <VCardText>
<VForm> <VForm>
<VRow> <VRow>
@@ -584,7 +493,8 @@ onMounted(() => {
v-model="defaultFilterRules.include" v-model="defaultFilterRules.include"
type="text" type="text"
label="包含(关键字、正则式)" label="包含(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或" hint="包含规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
@@ -592,7 +502,8 @@ onMounted(() => {
v-model="defaultFilterRules.exclude" v-model="defaultFilterRules.exclude"
type="text" type="text"
label="排除(关键字、正则式)" label="排除(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或" hint="排除规则,支持正式表达式,多个关键字用 | 分隔表示或"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
@@ -601,7 +512,8 @@ onMounted(() => {
type="text" type="text"
label="电影文件大小GB" label="电影文件大小GB"
placeholder="0-30" placeholder="0-30"
hint="格式0-30表示030GB之间的资源" hint="文件大小范围,格式0-30表示0-30GB之间的资源"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
@@ -610,7 +522,8 @@ onMounted(() => {
type="text" type="text"
label="剧集单集文件大小GB" label="剧集单集文件大小GB"
placeholder="0-10" placeholder="0-10"
hint="格式0-10表示010GB之间的资源" hint="单集文件大小范围,格式0-10表示0-10GB之间的资源"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
@@ -620,47 +533,29 @@ onMounted(() => {
label="最小做种数" label="最小做种数"
placeholder="0" placeholder="0"
hint="小于该值的资源将被过滤掉0表示不过滤" hint="小于该值的资源将被过滤掉0表示不过滤"
persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
<VTextField <VTextField
v-model="defaultFilterRules.min_seeders_time" v-model="defaultFilterRules.min_seeders_time"
type="text" type="text"
label="最少做种数生效发布时间(分钟)" label="最少做种数生效发布时间(分钟)"
placeholder="0" placeholder="0"
hint="发布时间距现在大于该值的资源将生效最小做种数规则0表示不生效" hint="发布时间距当前时间大于该值的资源将生效最小做种数规则0表示不生效"
persistent-hint
/> />
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardItem> <VCardText>
<VBtn <VBtn type="submit" @click="saveDefaultFilter"> 保存 </VBtn>
type="submit" </VCardText>
@click="saveDefaultFilter"
>
保存
</VBtn>
</VCardItem>
</VCard> </VCard>
</VCol> </VCol>
</VRow> </VRow>
<VDialog <VDialog v-model="importCodeDialog" width="60rem" scrollable>
v-model="importCodeDialog" <ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
width="60rem"
scrollable
>
<ImportCodeDialog
v-model="importCodeString"
title="导入优先级规则"
@close="importCodeDialog = false"
/>
</VDialog> </VDialog>
</template> </template>
<style lang='scss'>
.grid-filterrule-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

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

View File

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

View File

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

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