Compare commits

..

314 Commits

Author SHA1 Message Date
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
jxxghp
c949ea2667 Update version to 1.8.6 in package.json 2024-05-05 19:53:52 +08:00
jxxghp
2bcb28d0c0 Update card cover size in PluginAppCard and PluginCard components 2024-05-05 19:51:36 +08:00
jxxghp
bd257554cd Update grid-template-columns in TorrentCardListView and SubscribeListView components 2024-05-05 19:37:57 +08:00
jxxghp
68a27e0b61 remove defer for plugins & sites 2024-05-05 19:35:26 +08:00
jxxghp
8b589bdb9c feat:媒体详情显示集存在标识 2024-05-05 13:45:30 +08:00
jxxghp
1a25710aac fix user/current 调用过多 2024-05-05 12:01:01 +08:00
jxxghp
271d59ca51 Fix issues with MediaCard and SubscribeListView components 2024-05-05 11:54:20 +08:00
jxxghp
37e5e57d5b fix #2011 2024-05-05 11:22:10 +08:00
jxxghp
f817b20545 fix 历史记录进度条 2024-04-30 18:16:09 +08:00
jxxghp
5f8619805e fix mediainfocard 2024-04-30 11:56:15 +08:00
jxxghp
c9d4629bfa fix ui 2024-04-30 11:27:51 +08:00
jxxghp
c9c27c83d4 rollback 2024-04-30 09:27:09 +08:00
jxxghp
7a6a985c47 fix 2024-04-30 09:19:07 +08:00
jxxghp
225df7b1e6 插件支持最近更新排序 2024-04-30 08:35:29 +08:00
jxxghp
97ede69609 fix cols 2024-04-30 07:17:01 +08:00
jxxghp
c5ded86d8a fix:站点信息维护增加属性 2024-04-29 20:26:56 +08:00
jxxghp
b4f049ecda fix searchbar size 2024-04-29 15:12:15 +08:00
jxxghp
56692eb6cb feat 搜索框记忆 2024-04-29 14:51:00 +08:00
jxxghp
7c22c60190 plugins defer render 2024-04-29 13:45:21 +08:00
jxxghp
2f2c4d4a44 fix 2024-04-29 13:37:20 +08:00
jxxghp
c1c71916db fix dialog 2024-04-29 13:31:27 +08:00
jxxghp
4b15a7454c fix ui 2024-04-29 12:47:52 +08:00
jxxghp
22e723587d 调整插件页面UI 2024-04-29 12:30:21 +08:00
jxxghp
969adaf5bb Merge pull request #122 from hotlcc/develop-20240429-页面优化
升级vuetify-use-dialog至v0.6.11,解决弹出Confirm后点击overlay关闭后无法再次弹出Confirm的问题
2024-04-29 11:30:55 +08:00
Allen
c2214e8300 升级vuetify-use-dialog至v0.6.11,解决弹出Confirm后点击overlay关闭后无法再次弹出Confirm的问题 2024-04-29 11:28:37 +08:00
jxxghp
10af659227 fix hint 2024-04-29 10:51:34 +08:00
jxxghp
5cd3757f4f rollback 2024-04-29 09:55:25 +08:00
jxxghp
81f674ea01 test 2024-04-29 07:16:16 +08:00
jxxghp
1846ee0ffe fix ui 2024-04-28 21:45:16 +08:00
jxxghp
14a825093a 更新 package.json 2024-04-28 21:07:57 +08:00
jxxghp
d70f477bc1 fix theme 2024-04-28 18:36:10 +08:00
jxxghp
c9c897ffb5 Merge pull request #121 from hotlcc/develop-20240428-页面优化
主题配置保存到服务端,使各端主题一致
2024-04-28 18:10:28 +08:00
Allen
462dea3e05 解决媒体卡片右上角评分和入库标记冲突,hover的时候展示评分 2024-04-28 17:40:39 +08:00
Allen
4e7a0084dd 兼容处理不支持黑白配色的老旧设备 2024-04-28 16:40:47 +08:00
Allen
0268df0e24 主题配置保存到服务端,使各端主题一致 2024-04-28 16:18:12 +08:00
jxxghp
f926ca66c0 fix ui 2024-04-28 14:05:51 +08:00
jxxghp
16b5898928 fix ui 2024-04-28 13:48:16 +08:00
jxxghp
c1bb66cc9d fix 插件过滤 & 标签 2024-04-28 13:40:49 +08:00
jxxghp
f7502d0d18 Merge pull request #118 from thsrite/main 2024-04-28 10:26:46 +08:00
thsrite
b4975f649c fix 默认过滤规则支持最少做种人数生效发布时间,防止过滤掉到最新发布的种子 2024-04-28 10:21:46 +08:00
jxxghp
89353c1f7e feat:媒体搜索聚合开关设置 2024-04-28 08:56:00 +08:00
jxxghp
fce10b6dca media card icon 2024-04-27 18:16:05 +08:00
jxxghp
2cf95c6706 feat:发现内容聚合 2024-04-27 15:19:50 +08:00
jxxghp
58ab1599db fix person cards 2024-04-27 08:44:39 +08:00
jxxghp
9745c2ea1a fix hint 2024-04-27 08:18:58 +08:00
jxxghp
9db46e2949 fix ui 2024-04-27 08:16:36 +08:00
jxxghp
7949505104 fix ui 2024-04-26 23:58:06 +08:00
jxxghp
db0d5133e8 feat:人物搜索 2024-04-26 20:35:40 +08:00
jxxghp
54415377ee Merge pull request #117 from thsrite/main
fix 搜索默认规则支持最小做种数
2024-04-25 16:21:52 +08:00
thsrite
d7f55477da fix 搜索默认规则支持最小做种数 2024-04-25 16:19:50 +08:00
jxxghp
faca586fa7 Merge pull request #116 from hotlcc/develop-20240425-页面优化
修改“本地存在未安装的旧版本插件且云端有更新时”不会在插件市场展示的问题
2024-04-25 16:09:16 +08:00
jxxghp
5f3ba7b9c7 fix bug 2024-04-25 16:06:47 +08:00
Allen
abace4a58d 修改“本地存在未安装的旧版本插件且云端有更新时”不会在插件市场展示的问题 2024-04-25 15:43:30 +08:00
jxxghp
5895cea587 feat:进度条公共组件 2024-04-25 15:07:35 +08:00
jxxghp
cdbcef5232 release 2024-04-25 13:52:12 +08:00
jxxghp
d5d6bfdc56 feat:插件详情支持动态API调用 2024-04-25 13:30:25 +08:00
jxxghp
75ae7f0c15 Merge pull request #115 from hotlcc/develop-20240425-页面优化 2024-04-25 12:07:22 +08:00
Allen
6931451f18 媒体详情页面搜索资源按钮当下拉选项只有一个时不触发下拉框直接搜索 2024-04-25 11:25:52 +08:00
jxxghp
f5625e1354 fix bug 2024-04-25 10:26:20 +08:00
jxxghp
d1be4a30b6 feat:前端版本号显示 2024-04-25 10:19:09 +08:00
jxxghp
5c13362db2 fix fullscreen 2024-04-25 09:48:12 +08:00
jxxghp
6c71dce80c 历史记录可排序 2024-04-25 08:51:02 +08:00
jxxghp
790c397951 fix ui 2024-04-25 08:44:36 +08:00
jxxghp
e28e74b874 Merge pull request #113 from hotlcc/develop-20240424-1-插件搜索框宽度优化 2024-04-24 21:42:10 +08:00
Allen
b99ea22d89 插件搜索弹出框小屏下全屏 2024-04-24 21:28:15 +08:00
jxxghp
8938195c5d fix 2024-04-24 18:21:08 +08:00
jxxghp
887b4a7862 fix ui 2024-04-24 18:02:30 +08:00
jxxghp
7c9c39fa0e 更新 package.json 2024-04-24 17:41:07 +08:00
jxxghp
3b800753ec Merge pull request #112 from hotlcc/develop-20240424-VDialog优化 2024-04-24 17:40:28 +08:00
jxxghp
647119052c Merge pull request #111 from dh336699/hotfix-history-scofield 2024-04-24 17:38:07 +08:00
Allen
e9ce6bbd4e 消息中心弹窗小屏时全屏 2024-04-24 17:34:37 +08:00
Allen
1fee27f78e 系统健康检查弹窗小屏时全屏 2024-04-24 17:34:16 +08:00
Allen
e7a334861d 规则测试弹窗小屏时全屏 2024-04-24 17:33:07 +08:00
Allen
267ae3436d 实时日志弹窗小屏时全屏 2024-04-24 17:32:48 +08:00
hao.dai
60ff9f1891 fix: 1.登录双重认证增加防抖 2.历史记录搜索框增加防抖 3.开启项目vscode配置文件 2024-04-24 17:29:54 +08:00
Allen
f83efd23df 网络测试弹窗小屏下全屏 2024-04-24 17:29:24 +08:00
Allen
db60f02745 名称测试弹窗小屏下全屏 2024-04-24 17:28:30 +08:00
Allen
3e109bd27c dashborad配置弹窗小屏下全屏 2024-04-24 17:25:50 +08:00
Allen
c4ccf6e3fa 订阅编辑弹窗小屏时全屏 2024-04-24 17:16:27 +08:00
Allen
fb1a246e4a 订阅历史弹窗小屏时全屏 2024-04-24 17:15:20 +08:00
Allen
a418b03c06 文件整理弹窗小屏时全屏 2024-04-24 17:13:32 +08:00
Allen
e9fee000ca 插件数据页面小屏下全屏 2024-04-24 17:09:26 +08:00
Allen
71c13e0653 插件配置弹窗小屏下全屏 2024-04-24 17:08:46 +08:00
Allen
32d7f933f8 站点编辑弹窗小屏下全屏 2024-04-24 17:07:15 +08:00
Allen
f28dd810ce 站点资源弹窗小屏下全屏 2024-04-24 17:05:46 +08:00
Allen
aaedd88ca7 站点更新弹窗小屏下全屏展示 2024-04-24 17:04:04 +08:00
Allen
00dee40917 站点更新弹窗添加关闭按钮 2024-04-24 16:49:50 +08:00
hao.dai
019248b605 Merge remote-tracking branch 'upstream/main'
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2024-04-24 15:43:56 +08:00
hao.dai
826f37bcc4 fix: 账号设置warning问题 2024-04-24 15:43:11 +08:00
jxxghp
fa02a23e4c 更新 package.json 2024-04-24 10:19:02 +08:00
jxxghp
7143fb6f67 Merge pull request #110 from hotlcc/develop-20240423-低版safari时间兼容 2024-04-24 10:18:39 +08:00
Allen
e1524c26cd 统一处理低版本safari浏览器Date兼容性问题 2024-04-24 10:06:32 +08:00
jxxghp
72088dff2e release v1.8.3 2024-04-23 17:48:35 +08:00
jxxghp
8e6d3cf30e fix dashboard config 2024-04-23 10:25:43 +08:00
jxxghp
144992ccec Merge pull request #104 from hotlcc/develop-20240417-用户配置
dashboard配置支持保存入库
2024-04-23 10:00:36 +08:00
jxxghp
673e883ae6 Merge branch 'main' into develop-20240417-用户配置 2024-04-23 10:00:29 +08:00
Allen
f197ed7972 优化dashboard配置功能 2024-04-23 09:52:11 +08:00
jxxghp
ce642aceed release v2 2024-04-22 10:04:23 +08:00
jxxghp
d5411489c0 fix ui 2024-04-22 10:02:57 +08:00
jxxghp
26c66627f8 Merge pull request #108 from thsrite/main 2024-04-20 19:52:12 +08:00
thsrite
c654986042 fix 2024-04-20 19:22:55 +08:00
thsrite
c5b5c15f99 fix 2024-04-20 19:18:16 +08:00
thsrite
7727b0f1c3 fix 2024-04-20 19:11:44 +08:00
thsrite
3d551ac45b fix 订阅时编辑规则移到默认订阅规则页面 2024-04-20 18:48:02 +08:00
jxxghp
555a00b731 fix postercard 2024-04-19 23:08:41 +08:00
jxxghp
9f9091b23e 更新 package.json 2024-04-19 22:45:42 +08:00
jxxghp
14c343142f Merge pull request #107 from falling/main 2024-04-19 22:45:00 +08:00
falling
890920775a fix 安卓手机端hover事件被VCard的click事件覆盖问题 2024-04-19 22:00:15 +08:00
jxxghp
7b38d2d74f fix #105 2024-04-19 19:51:14 +08:00
jxxghp
e85c2870e2 更新 SubscribeHistoryDialog.vue 2024-04-19 17:08:41 +08:00
jxxghp
cfbc5802e4 fix VInfiniteScroll 2024-04-19 13:56:57 +08:00
jxxghp
40cdb820fb fix ui 2024-04-19 13:16:13 +08:00
jxxghp
f63beb776e fix 订阅历史记录 2024-04-19 08:24:57 +08:00
jxxghp
20f031b2e2 rename components 2024-04-18 22:59:00 +08:00
jxxghp
b0f28b7e7c fix 2024-04-18 22:33:03 +08:00
jxxghp
62bb6de80d feat:订阅历史 2024-04-18 21:00:35 +08:00
Allen
3db4d883af fixbug 2024-04-18 15:19:24 +08:00
Allen
8cb514d70e dashboard配置支持保存入库 2024-04-18 12:40:14 +08:00
jxxghp
2d7880351b release 2024-04-18 11:14:03 +08:00
jxxghp
e1ee3ef2db fix #1918 2024-04-18 11:13:36 +08:00
jxxghp
aff30c48a0 fix site stat 2024-04-18 08:12:46 +08:00
jxxghp
55eea50a6e test release 2024-04-17 23:02:37 +08:00
jxxghp
9ff212c94d feat: 插件页面支持slot 2024-04-17 22:55:45 +08:00
jxxghp
6350c7e9e6 feat:插件支持渲染弹窗关闭按钮 2024-04-17 21:20:31 +08:00
jxxghp
d097c1c17c fix ui 2024-04-17 19:31:50 +08:00
jxxghp
b9ee6b4039 fix ui 2024-04-17 15:30:40 +08:00
jxxghp
f1238a03b3 fix 2024-04-17 14:51:05 +08:00
jxxghp
e90cf3ee77 test release 2024-04-17 14:41:22 +08:00
jxxghp
468607c8e8 feat:站点状态显示 2024-04-17 14:38:40 +08:00
jxxghp
5bd9283177 Merge pull request #102 from dh336699/feature-issue-94 2024-04-17 12:44:17 +08:00
hao.dai
117b12348c fix: 低版本Safari浏览器不能正确显示订阅的更新日期 2024-04-17 12:38:34 +08:00
jxxghp
0d325b6eb8 fix ui 2024-04-17 08:16:11 +08:00
jxxghp
86d5903f32 更新 TransferHistoryView.vue 2024-04-16 18:31:32 +08:00
jxxghp
3b518d6f33 release 2024-04-16 11:34:37 +08:00
jxxghp
78f57e7d4b Merge pull request #101 from dh336699/feature-optimization-ranking 2024-04-16 11:33:17 +08:00
hao.dai
f710f1bfc0 fix: 修复ranking页面大批量warning问题 2024-04-16 11:24:07 +08:00
jxxghp
c5d4fc62e6 fix ui 2024-04-16 10:05:39 +08:00
jxxghp
60606d5eb9 fix poster card 2024-04-16 08:22:58 +08:00
jxxghp
8751236380 fix bug 2024-04-16 08:21:05 +08:00
jxxghp
2291ce3680 fix 仍有Bug 2024-04-15 21:23:56 +08:00
jxxghp
16ed589857 Merge pull request #100 from Aodi/main 2024-04-15 18:30:58 +08:00
aodi
b59254ca42 fix 编码斜杠禁用的反代无法加载图片 修改url编码 2024-04-15 18:09:44 +08:00
aodi
6e3f9b285d fix 编码斜杠禁用的反代无法加载图片 修改url编码 2024-04-15 18:08:58 +08:00
aodi
8bcff774fa fix 编码斜杠禁用的反代无法加载图片 修改url编码 2024-04-15 18:04:21 +08:00
aodi
9b04b12dec fix 编码斜杠禁用的反代无法加载图片
fix 编码斜杠禁用的反代无法加载图片 修改url编码

fix 编码斜杠禁用的反代无法加载图片 修改url编码
2024-04-15 18:02:28 +08:00
aodi
b22d81a9e9 fix 编码斜杠禁用的反代无法加载图片 修改url编码 2024-04-15 17:14:21 +08:00
aodi
6c80a3a8cd fix 编码斜杠禁用的反代无法加载图片 2024-04-15 16:27:30 +08:00
jxxghp
059d836653 Merge pull request #98 from hotlcc/develop-20240415-页面优化 2024-04-15 10:06:56 +08:00
Allen
2c3ecfeb6f 低版本浏览器at函数兼容性调整为应用级生效 2024-04-15 01:52:17 +00:00
Allen
b07e5eecc3 解决通知渠道不选时显示空白项的问题 2024-04-15 01:49:50 +00:00
jxxghp
1847bc90cf 更新 package.json 2024-04-15 06:46:27 +08:00
jxxghp
899aaae47c Merge pull request #97 from BlueflameLi/main 2024-04-15 06:44:58 +08:00
BlueflameLi
bcc05086a4 fix 历史记录切换每页条数时重复发送一次请求 2024-04-15 02:12:27 +08:00
BlueflameLi
d2cc547875 fix [错误报告]:历史记录默认一页50条但实际默认一页10条 #96 2024-04-15 02:05:31 +08:00
jxxghp
c6127f440e feat 更新时查看更新说明 2024-04-13 18:36:01 +08:00
jxxghp
c2849ad49f fix toast z-index 2024-04-13 18:16:28 +08:00
jxxghp
9c6ba294f9 fix ui 2024-04-13 09:20:59 +08:00
jxxghp
b2a8707e91 fix ui 2024-04-13 09:18:25 +08:00
jxxghp
193e3085a9 feat:下载状态标记 2024-04-13 09:01:18 +08:00
jxxghp
2401f38e9f fix ui 2024-04-12 21:31:24 +08:00
jxxghp
4ea65727a1 更新 package.json 2024-04-12 19:41:25 +08:00
jxxghp
3d6cfe260c fix ui 2024-04-12 18:26:00 +08:00
jxxghp
4c33a09c3c Merge pull request #95 from dh336699/feature-issue-1851 2024-04-12 15:21:04 +08:00
jxxghp
8d25743680 fix 2024-04-12 12:56:45 +08:00
hao.dai
4bc5b763a2 Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend into feature-issue-1851 2024-04-11 09:50:06 +08:00
jxxghp
00ea179c90 fix ui 2024-04-10 18:57:36 +08:00
hao.dai
a17d40d2d0 feat: 1.历史记录新增根据本地数据进行文件夹筛选 2.全量数据切换虚拟表格组件提升性能 2024-04-10 18:52:45 +08:00
jxxghp
62ddd703f1 feat:支持查看插件更新记录 2024-04-10 16:45:17 +08:00
jxxghp
77ab0ccae2 feat:搜索支持指定季 2024-04-10 14:46:36 +08:00
jxxghp
f377ac3fcc fix search 2024-04-09 13:20:54 +08:00
jxxghp
a81becd77b fix 2024-04-07 11:53:47 +08:00
jxxghp
a004f1c758 fix 2024-04-07 11:29:49 +08:00
jxxghp
b19b015986 fix PerfectScrollbar 2024-04-07 11:15:18 +08:00
jxxghp
3f0c1213ad upgrade packages 2024-04-07 10:54:12 +08:00
jxxghp
f3a781d857 fix:减少无效请求 2024-04-07 08:28:37 +08:00
jxxghp
a07a32f648 fix ui 2024-04-06 16:07:18 +08:00
jxxghp
1e1117b187 fix:优化文件管理性能 2024-04-06 09:25:46 +08:00
jxxghp
0e161b1735 fix filemanager ui 2024-04-06 09:04:02 +08:00
jxxghp
98cbb8dc29 fix ui 2024-04-05 23:50:48 +08:00
jxxghp
9c17d2d335 release 2024-04-05 23:33:54 +08:00
jxxghp
d4ea7f48c0 fix ui 2024-04-05 23:27:19 +08:00
jxxghp
3ecfe3ba94 Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend 2024-04-05 22:34:36 +08:00
jxxghp
a959594348 feat:插件搜索 2024-04-05 22:34:34 +08:00
jxxghp
faa1027c04 Merge pull request #93 from hotlcc/develop-低版本浏览器兼容性 2024-04-05 12:17:07 +08:00
Allen
6f68732ef9 低版本浏览器数组不支持at函数问题修复 2024-04-05 04:11:50 +00:00
jxxghp
3a4e936938 fix datatable 2024-04-03 11:55:37 +08:00
jxxghp
8b4b79fa10 fix name 2024-04-02 16:36:27 +08:00
jxxghp
ce0dda0455 fix ui 2024-04-02 13:17:40 +08:00
jxxghp
fd1ee398c4 update vuetify => 3.5.7 2024-04-02 11:37:25 +08:00
jxxghp
1488017bf2 fix #1797 2024-04-02 10:26:15 +08:00
jxxghp
367f4236ad fix #92 2024-04-02 08:26:58 +08:00
jxxghp
ec202f22e8 Merge pull request #92 from thsrite/main 2024-04-01 19:10:57 +08:00
thsrite
08081da29e fix 组件复用 2024-04-01 14:27:41 +08:00
thsrite
5511424bd6 feat 设置订阅默认规则 2024-04-01 13:30:51 +08:00
jxxghp
9f2c848413 fix hint 2024-03-31 19:23:11 +08:00
jxxghp
d5efe2b499 feat:增加设置项提示 2024-03-31 09:36:31 +08:00
jxxghp
9b989fc40f feat:插件配置保存动画 2024-03-31 08:24:02 +08:00
138 changed files with 11117 additions and 11035 deletions

View File

@@ -24,7 +24,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: '18' node-version: '20'
cache: 'yarn' cache: 'yarn'
- name: Download Icons - name: Download Icons
@@ -39,6 +39,7 @@ jobs:
run: | run: |
yarn yarn
yarn build yarn build
echo "$frontend_version" > dist/version.txt
zip -r dist.zip dist zip -r dist.zip dist
- name: Generate Release - name: Generate Release

View File

@@ -21,14 +21,9 @@
} }
], ],
"rules": { "rules": {
"max-line-length": [
120,
{
"ignore": "comments"
}
],
"liberty/use-logical-spec": true, "liberty/use-logical-spec": true,
"selector-class-pattern": null, "selector-class-pattern": null,
"color-function-notation": null "color-function-notation": null
} },
} "fix": true
}

12
.vscode/settings.json vendored
View File

@@ -6,9 +6,6 @@
"[javascript]": { "[javascript]": {
"editor.formatOnSave": false "editor.formatOnSave": false
}, },
"[typescript]": {
"editor.formatOnSave": false
},
"[markdown]": { "[markdown]": {
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint" "editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
}, },
@@ -25,7 +22,7 @@
}, },
// Vue // Vue
"[vue]": { "[vue]": {
"editor.formatOnSave": false "editor.formatOnSave": true
}, },
// Extension: Volar // Extension: Volar
"volar.preview.port": 3000, "volar.preview.port": 3000,
@@ -34,6 +31,10 @@
"source.fixAll.eslint": "explicit", "source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit" "source.fixAll.stylelint": "explicit"
}, },
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"eslint.alwaysShowStatus": true, "eslint.alwaysShowStatus": true,
"eslint.format.enable": true, "eslint.format.enable": true,
// Extension: Stylelint // Extension: Stylelint
@@ -53,6 +54,7 @@
"stylelint", "stylelint",
"touchless", "touchless",
"triggerer", "triggerer",
"unref",
"vuetify" "vuetify"
], ],
// Extension: Comment Anchors // Extension: Comment Anchors
@@ -104,4 +106,4 @@
] ]
}, },
"vue3snippets.enable-compile-vue-file-on-did-save-code": false "vue3snippets.enable-compile-vue-file-on-did-save-code": false
} }

View File

@@ -1,6 +1,6 @@
# MoviePilot-Frontend # MoviePilot-Frontend
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目。 [MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目NodeJS版本>= `v20.12.1`
## 推荐的IDE设置 ## 推荐的IDE设置

322
auto-imports.d.ts vendored
View File

@@ -1,6 +1,7 @@
/* eslint-disable */ /* eslint-disable */
/* prettier-ignore */ /* prettier-ignore */
// @ts-nocheck // @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import // Generated by unplugin-auto-import
export {} export {}
declare global { declare global {
@@ -41,6 +42,7 @@ declare global {
const h: typeof import('vue')['h'] const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch'] const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject'] const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isDefined: typeof import('@vueuse/core')['isDefined'] const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy'] const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive'] const isReactive: typeof import('vue')['isReactive']
@@ -77,6 +79,7 @@ declare global {
const onUpdated: typeof import('vue')['onUpdated'] const onUpdated: typeof import('vue')['onUpdated']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch'] const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide'] const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const reactify: typeof import('@vueuse/core')['reactify'] const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject'] const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive'] const reactive: typeof import('vue')['reactive']
@@ -105,6 +108,7 @@ declare global {
const toReactive: typeof import('@vueuse/core')['toReactive'] const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef'] const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs'] const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef'] const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount'] const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount'] const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
@@ -143,6 +147,7 @@ declare global {
const useCeil: typeof import('@vueuse/math')['useCeil'] const useCeil: typeof import('@vueuse/math')['useCeil']
const useClamp: typeof import('@vueuse/math')['useClamp'] const useClamp: typeof import('@vueuse/math')['useClamp']
const useClipboard: typeof import('@vueuse/core')['useClipboard'] const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned'] const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode'] const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog'] const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
@@ -308,11 +313,13 @@ declare global {
// for type re-export // for type re-export
declare global { declare global {
// @ts-ignore // @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, InjectionKey, PropType, Ref, VNode } from 'vue' export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue')
} }
// for vue template auto import // for vue template auto import
import { UnwrapRef } from 'vue' import { UnwrapRef } from 'vue'
declare module 'vue' { declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties { interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']> readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']> readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
@@ -351,6 +358,7 @@ declare module 'vue' {
readonly h: UnwrapRef<typeof import('vue')['h']> readonly h: UnwrapRef<typeof import('vue')['h']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']> readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']> readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']> readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']> readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']> readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
@@ -387,6 +395,7 @@ declare module 'vue' {
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']> readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']> readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly provide: UnwrapRef<typeof import('vue')['provide']> readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']> readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']> readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']> readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
@@ -415,6 +424,7 @@ declare module 'vue' {
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']> readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']> readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']> readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']> readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']> readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']> readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
@@ -453,6 +463,316 @@ declare module 'vue' {
readonly useCeil: UnwrapRef<typeof import('@vueuse/math')['useCeil']> readonly useCeil: UnwrapRef<typeof import('@vueuse/math')['useCeil']>
readonly useClamp: UnwrapRef<typeof import('@vueuse/math')['useClamp']> readonly useClamp: UnwrapRef<typeof import('@vueuse/math')['useClamp']>
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']> readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>
readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>
readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>
readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']>
readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']>
readonly useFloor: UnwrapRef<typeof import('@vueuse/math')['useFloor']>
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']>
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMath: UnwrapRef<typeof import('@vueuse/math')['useMath']>
readonly useMax: UnwrapRef<typeof import('@vueuse/math')['useMax']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
readonly usePrecision: UnwrapRef<typeof import('@vueuse/math')['usePrecision']>
readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>
readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStore: UnwrapRef<typeof import('vuex')['useStore']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
readonly useTextareaAutosize: UnwrapRef<typeof import('@vueuse/core')['useTextareaAutosize']>
readonly useThrottle: UnwrapRef<typeof import('@vueuse/core')['useThrottle']>
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']>
readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']>
readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']>
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
readonly useTrunc: UnwrapRef<typeof import('@vueuse/math')['useTrunc']>
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']>
readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']>
readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']>
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
readonly watchDebounced: UnwrapRef<typeof import('@vueuse/core')['watchDebounced']>
readonly watchDeep: UnwrapRef<typeof import('@vueuse/core')['watchDeep']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchIgnorable: UnwrapRef<typeof import('@vueuse/core')['watchIgnorable']>
readonly watchImmediate: UnwrapRef<typeof import('@vueuse/core')['watchImmediate']>
readonly watchOnce: UnwrapRef<typeof import('@vueuse/core')['watchOnce']>
readonly watchPausable: UnwrapRef<typeof import('@vueuse/core')['watchPausable']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
readonly watchThrottled: UnwrapRef<typeof import('@vueuse/core')['watchThrottled']>
readonly watchTriggerable: UnwrapRef<typeof import('@vueuse/core')['watchTriggerable']>
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
}
}
declare module '@vue/runtime-core' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createLogger: UnwrapRef<typeof import('vuex')['createLogger']>
readonly createNamespacedHelpers: UnwrapRef<typeof import('vuex')['createNamespacedHelpers']>
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createStore: UnwrapRef<typeof import('vuex')['createStore']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly logicAnd: UnwrapRef<typeof import('@vueuse/math')['logicAnd']>
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('vuex')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('vuex')['mapGetters']>
readonly mapMutations: UnwrapRef<typeof import('vuex')['mapMutations']>
readonly mapState: UnwrapRef<typeof import('vuex')['mapState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly reactiveComputed: UnwrapRef<typeof import('@vueuse/core')['reactiveComputed']>
readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']>
readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']>
readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
readonly tryOnMounted: UnwrapRef<typeof import('@vueuse/core')['tryOnMounted']>
readonly tryOnScopeDispose: UnwrapRef<typeof import('@vueuse/core')['tryOnScopeDispose']>
readonly tryOnUnmounted: UnwrapRef<typeof import('@vueuse/core')['tryOnUnmounted']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly useAbs: UnwrapRef<typeof import('@vueuse/math')['useAbs']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
readonly useArrayFind: UnwrapRef<typeof import('@vueuse/core')['useArrayFind']>
readonly useArrayFindIndex: UnwrapRef<typeof import('@vueuse/core')['useArrayFindIndex']>
readonly useArrayFindLast: UnwrapRef<typeof import('@vueuse/core')['useArrayFindLast']>
readonly useArrayIncludes: UnwrapRef<typeof import('@vueuse/core')['useArrayIncludes']>
readonly useArrayJoin: UnwrapRef<typeof import('@vueuse/core')['useArrayJoin']>
readonly useArrayMap: UnwrapRef<typeof import('@vueuse/core')['useArrayMap']>
readonly useArrayReduce: UnwrapRef<typeof import('@vueuse/core')['useArrayReduce']>
readonly useArraySome: UnwrapRef<typeof import('@vueuse/core')['useArraySome']>
readonly useArrayUnique: UnwrapRef<typeof import('@vueuse/core')['useArrayUnique']>
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useAverage: UnwrapRef<typeof import('@vueuse/math')['useAverage']>
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']>
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>
readonly useCeil: UnwrapRef<typeof import('@vueuse/math')['useCeil']>
readonly useClamp: UnwrapRef<typeof import('@vueuse/math')['useClamp']>
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']> readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']> readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']> readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>

6
components.d.ts vendored
View File

@@ -3,18 +3,18 @@
// @ts-nocheck // @ts-nocheck
// Generated by unplugin-vue-components // Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {} export {}
declare module '@vue/runtime-core' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default'] DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default'] ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default'] ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
LoadingBanner: typeof import('./src/@core/components/LoadingBanner.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default'] MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
StatIcon: typeof import('./src/@core/components/StatIcon.vue')['default']
ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default'] ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default']
} }
} }

View File

@@ -30,6 +30,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 +160,4 @@
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,6 @@
{ {
"name": "moviepilot", "name": "moviepilot",
"version": "1.7.8", "version": "1.9.3",
"private": true, "private": true,
"bin": "dist/service.js", "bin": "dist/service.js",
"scripts": { "scripts": {
@@ -19,95 +19,86 @@
] ]
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.2.0",
"@casl/vue": "^2.2.0",
"@floating-ui/dom": "1.2.8",
"@vueuse/core": "^10.1.2",
"@vueuse/math": "^10.1.2",
"ace-builds": "^1.32.6",
"apexcharts-clevision": "^3.28.5",
"axios": "1.4.0",
"axios-mock-adapter": "^1.21.4",
"chart.js": "^4.1.2",
"colorthief": "^2.4.0",
"express": "^4.18.2",
"express-http-proxy": "^2.0.0",
"jwt-decode": "^3.1.2",
"nprogress": "^0.2.0",
"postcss-purgecss": "^5.0.0",
"prismjs": "^1.29.0",
"pull-refresh-vue3": "^0.3.1",
"qrcode.vue": "^3.4.1",
"roboto-fontface": "^0.10.0",
"sass": "^1.59.3",
"tailwindcss": "^3.3.2",
"unplugin-vue-define-options": "^1.3.5",
"vite-plugin-pwa": "^0.16.4",
"vue": "^3.3.2",
"vue-chartjs": "^5.2.0",
"vue-flatpickr-component": "11.0.3",
"vue-i18n": "^9.2.2",
"vue-prism-component": "^2.0.0",
"vue-router": "^4.2.0",
"vue-toast-notification": "^3",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.4.1",
"vue3-perfect-scrollbar": "^1.6.0",
"vuetify": "3.3.5",
"vuetify-use-dialog": "^0.6.0",
"vuex": "^4.1.0",
"vuex-persistedstate": "^4.1.0",
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@antfu/eslint-config-vue": "^0.38.6",
"@fullcalendar/core": "^6.1.8", "@fullcalendar/core": "^6.1.8",
"@fullcalendar/daygrid": "^6.1.8", "@fullcalendar/daygrid": "^6.1.8",
"@fullcalendar/interaction": "^6.1.7", "@fullcalendar/interaction": "^6.1.7",
"@fullcalendar/list": "^6.1.7", "@fullcalendar/list": "^6.1.7",
"@fullcalendar/timegrid": "^6.1.7", "@fullcalendar/timegrid": "^6.1.7",
"@fullcalendar/vue3": "^6.1.8", "@fullcalendar/vue3": "^6.1.8",
"@iconify/utils": "^2.1.22",
"@vueuse/core": "^10.1.2",
"@vueuse/math": "^10.1.2",
"ace-builds": "^1.32.6",
"apexcharts-clevision": "^3.28.5",
"axios": "1.6.8",
"colorthief": "^2.4.0",
"dayjs": "^1.11.10",
"express": "^4.18.2",
"express-http-proxy": "^2.0.0",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"qrcode.vue": "^3.4.1",
"sass": "^1.59.3",
"tailwindcss": "^3.3.2",
"unplugin-vue-define-options": "^1.3.5",
"vite-plugin-pwa": "^0.19.8",
"vue": "^3.3.2",
"vue-router": "^4.2.0",
"vue-toast-notification": "^3",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.4.1",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "3.6.8",
"vuetify-use-dialog": "^0.6.11",
"vuex": "^4.1.0",
"vuex-persistedstate": "^4.1.0",
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@antfu/eslint-config-vue": "^0.43.1",
"@iconify-json/mdi": "^1.1.52", "@iconify-json/mdi": "^1.1.52",
"@iconify/tools": "^2.2.0", "@iconify/tools": "^4.0.4",
"@iconify/vue": "4.1.1", "@iconify/vue": "4.1.1",
"@intlify/unplugin-vue-i18n": "^0.10.0", "@intlify/unplugin-vue-i18n": "^4.0.0",
"@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/aspect-ratio": "^0.4.2",
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/node": "^20.1.4", "@types/node": "^20.1.4",
"@types/webfontloader": "^1.6.34", "@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^5.59.5", "@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^5.59.5", "@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-vue": "^4.2.3", "@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",
"eslint": "^8.40.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",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.0.1", "eslint-plugin-promise": "^6.0.1",
"eslint-plugin-regex": "^1.10.0", "eslint-plugin-regex": "^1.10.0",
"eslint-plugin-sonarjs": "^0.19.0", "eslint-plugin-sonarjs": "^0.25.1",
"eslint-plugin-unicorn": "^47.0.0", "eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-vue": "^9.12.0", "eslint-plugin-vue": "^9.12.0",
"postcss": "^8.4.24", "postcss": "8",
"postcss-html": "^1.5.0", "postcss-html": "^1.5.0",
"stylelint": "14.15.0", "stylelint": "16.3.1",
"stylelint-config-idiomatic-order": "9.0.0", "stylelint-config-idiomatic-order": "10.0.0",
"stylelint-config-standard-scss": "6.1.0", "stylelint-config-standard-scss": "13.1.0",
"stylelint-use-logical-spec": "4.1.0", "stylelint-use-logical-spec": "5.0.1",
"type-fest": "^3.10.0", "type-fest": "^4.15.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"unplugin-auto-import": "^0.15.1", "unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.24.1", "unplugin-vue-components": "^0.26.0",
"vite": "^4.3.5", "vite": "^5.2.8",
"vite-plugin-pages": "^0.29.0", "vite-plugin-pages": "^0.32.1",
"vite-plugin-vue-layouts": "^0.8.0", "vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "1.0.2", "vite-plugin-vuetify": "2.0.3",
"vue-shepherd": "^3.0.0", "vue-shepherd": "^3.0.0",
"vue-tsc": "^1.6.5" "vue-tsc": "^2.0.10"
}, },
"packageManager": "yarn@1.22.18", "packageManager": "yarn@1.22.18",
"resolutions": { "resolutions": {
"postcss": "8" "postcss": "8"
} }
} }

View File

@@ -2,6 +2,7 @@
"name": "MoviePilot", "name": "MoviePilot",
"short_name": "MoviePilot", "short_name": "MoviePilot",
"start_url": "./", "start_url": "./",
"display": "standalone",
"icons": [ "icons": [
{ {
"src": "./android-chrome-192x192.png", "src": "./android-chrome-192x192.png",
@@ -30,7 +31,6 @@
], ],
"theme_color": "#28243D", "theme_color": "#28243D",
"background_color": "#28243D", "background_color": "#28243D",
"display": "standalone",
"shortcuts": [ "shortcuts": [
{ {
"name": "推荐", "name": "推荐",
@@ -77,4 +77,4 @@
] ]
} }
] ]
} }

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;
} }

View File

@@ -1,16 +1,21 @@
<script lang="ts" setup> <script lang="ts" setup>
// 定义输入参数
const props = defineProps({
// 是否显示
innerClass: String,
})
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['click']) const emit = defineEmits(['click', 'update:modelValue'])
// 按钮点击 // 按钮点击
function onClick() { function onClick() {
emit('update:modelValue', false)
emit('click') emit('click')
} }
</script> </script>
<template> <template>
<IconBtn <IconBtn
class="absolute right-3 top-3" :class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'"
@click.stop="onClick" @click.stop="onClick"
> >
<VIcon icon="mdi-close" /> <VIcon icon="mdi-close" />

View File

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

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
interface Props {
color?: string
message?: string
}
const props = defineProps<Props>()
</script>
<template>
<div class="absolute top-2 right-2 flex items-center justify-between p-2 shadow">
<VBadge :color="props.color" bordered>
<template #badge>
<VIcon icon="mdi-pulse"></VIcon>
</template>
</VBadge>
</div>
</template>

View File

@@ -1,7 +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 { 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[]
@@ -11,63 +18,33 @@ 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 },
) )
function updateTheme() { const $toast = useToast()
const autoTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
globalTheme.name.value = theme
savedTheme.value = theme
// 修改载入时背景色
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
themeTransition()
}
// 监听系统主题变化 // 自定义CSS弹窗
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme) const cssDialog = ref(false)
watch( // 自定义 CSS
() => currentThemeName.value, const customCSS = ref('')
() => updateTheme(),
)
function changeTheme() { // 编辑器主题
const nextTheme = getNextThemeName() const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
currentThemeName.value = nextTheme
localStorage.setItem('theme', nextTheme)
}
// Apply saved theme on page load
// onMounted(() => {
// globalTheme.name.value = savedTheme.value
// })
function hasScrollbar(el?: Element | null) {
if (!el || el.nodeType !== Node.ELEMENT_NODE)
return false
const style = window.getComputedStyle(el)
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
}
// 主题切换动画
function themeTransition() { function themeTransition() {
const x = performance.now() const x = performance.now()
for (let i = 0; i++ < 1e7; (i << 9) & ((9 % 9) * 9 + 9)); for (let i = 0; i++ < 1e7; (i << 9) & ((9 % 9) * 9 + 9));
const cost = performance.now() - x const cost = performance.now() - x
if (cost > 10) if (cost > 10) return
return
const el: HTMLElement = document.querySelector('[data-v-app]')! const el: HTMLElement = document.querySelector('[data-v-app]')!
const children = el.querySelectorAll('*') as NodeListOf<HTMLElement> const children = el.querySelectorAll('*') as NodeListOf<HTMLElement>
children.forEach((el) => { children.forEach(el => {
if (hasScrollbar(el)) { if (hasScrollbar(el)) {
el.dataset.scrollX = String(el.scrollLeft) el.dataset.scrollX = String(el.scrollLeft)
el.dataset.scrollY = String(el.scrollTop) el.dataset.scrollY = String(el.scrollTop)
@@ -99,7 +76,7 @@ function themeTransition() {
}) })
document.body.append(copy) document.body.append(copy)
; (copy.querySelectorAll('[data-scroll-x], [data-scroll-y]') as NodeListOf<HTMLElement>).forEach((el) => { ;(copy.querySelectorAll('[data-scroll-x], [data-scroll-y]') as NodeListOf<HTMLElement>).forEach(el => {
el.scrollLeft = +el.dataset.scrollX! el.scrollLeft = +el.dataset.scrollX!
el.scrollTop = +el.dataset.scrollY! el.scrollTop = +el.dataset.scrollY!
}) })
@@ -117,12 +94,145 @@ function themeTransition() {
el.addEventListener('transitionend', onTransitionend) el.addEventListener('transitionend', onTransitionend)
el.addEventListener('transitioncancel', onTransitionend) el.addEventListener('transitioncancel', onTransitionend)
} }
// 更新主题
function updateTheme() {
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
globalTheme.name.value = theme
savedTheme.value = theme
themeTransition()
// 保存主题到本地
localStorage.setItem('theme', theme)
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
}
// 切换主题
function changeTheme(theme: string) {
let nextTheme = theme
if (!theme) nextTheme = getNextThemeName()
currentThemeName.value = nextTheme
// 保存主题到服务端
try {
api.post('/user/config/theme', nextTheme, {
headers: {
'Content-Type': 'text/plain',
},
})
} catch (e) {
console.error('保存主题到服务端失败')
}
}
// 是否有滚动条
function hasScrollbar(el?: Element | null) {
if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
const style = window.getComputedStyle(el)
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
}
// 监听系统主题变化
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
} catch (e) {
console.error('当前设备不支持监听系统主题变化')
}
// 查询当前主题的图标
const getThemeIcon = computed(() => {
const theme = props.themes.find(t => t.name === currentThemeName.value)
return theme?.icon ?? 'mdi-circle'
})
// 监听设置主题变化
watch(
() => currentThemeName.value,
() => 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

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

View File

@@ -1,5 +1,12 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import ZH_CN from 'dayjs/locale/zh-cn'
import { isToday } from './index' import { isToday } from './index'
dayjs.extend(relativeTime)
dayjs.locale(ZH_CN)
export function avatarText(value: string) { export function avatarText(value: string) {
if (!value) if (!value)
return '' return ''
@@ -19,7 +26,7 @@ export function kFormatter(num: number) {
* Format and return date in Humanize format * Format and return date in Humanize format
* Intl docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format * Intl docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format
* Intl Constructor: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat * Intl Constructor: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
* @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(value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) {
@@ -32,8 +39,8 @@ export function formatDate(value: string, formatting: Intl.DateTimeFormatOptions
/** /**
* Return short human friendly month representation of date * Return short human friendly month representation of date
* Can also convert date to only time if date is of today (Better UX) * Can also convert date to only time if date is of today (Better UX)
* @param {String} value date to format * @param {string} value date to format
* @param {Boolean} toTimeForCurrentDay Shall convert to time if day is today/current * @param {boolean} toTimeForCurrentDay Shall convert to time if day is today/current
*/ */
export function formatDateToMonthShort(value: string, toTimeForCurrentDay = true) { export function formatDateToMonthShort(value: string, toTimeForCurrentDay = true) {
const date = new Date(value) const date = new Date(value)
@@ -107,7 +114,7 @@ export function formatBytes(bytes: number, decimals = 2) {
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}` return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`
} }
// 格式化剧集列表 // 格式化剧集列表
@@ -150,20 +157,21 @@ export function formatEp(nums: number[]): string {
// 将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 date = new Date(dateString) // const timeDifference = dayjs().millisecond() - dayjs(dateString).millisecond()
const currentDate = new Date() // const secondsDifference = Math.floor(timeDifference / 1000)
const timeDifference = currentDate.getTime() - date.getTime() // const minutesDifference = Math.floor(secondsDifference / 60)
const secondsDifference = Math.floor(timeDifference / 1000) // const hoursDifference = Math.floor(minutesDifference / 60)
const minutesDifference = Math.floor(secondsDifference / 60) // const daysDifference = Math.floor(hoursDifference / 24)
const hoursDifference = Math.floor(minutesDifference / 60)
const daysDifference = Math.floor(hoursDifference / 24)
if (daysDifference > 0) // if (daysDifference > 0)
return `${daysDifference}天前` // return `${daysDifference}天前`
else if (hoursDifference > 0) // else if (hoursDifference > 0)
return `${hoursDifference}小时前` // return `${hoursDifference}小时前`
else if (minutesDifference > 0) // else if (minutesDifference > 0)
return `${minutesDifference}分钟前` // return `${minutesDifference}分钟前`
else // else
return '刚刚' // return '刚刚'
if (!dateString)
return ''
return dayjs(dateString).fromNow()
} }

View File

@@ -33,12 +33,16 @@ export function isToday(date: Date) {
) )
} }
// 计算时间差返回xx天/xx小时/xx分钟/xx秒 /**
* 计算时间差返回xx天/xx小时/xx分钟/xx秒
*
* @deprecated 建议使用:@core/utils/formatters.ts formatDateDifference
*/
export function calculateTimeDifference(inputTime: string): string { export function calculateTimeDifference(inputTime: string): string {
if (!inputTime) if (!inputTime)
return '' return ''
const inputDate = new Date(inputTime) const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
const currentDate = new Date() const currentDate = new Date()
const timeDifference = currentDate.getTime() - inputDate.getTime() const timeDifference = currentDate.getTime() - inputDate.getTime()
@@ -70,7 +74,7 @@ export function calculateTimeDiff(inputTime: string): string {
return '' return ''
// 使用当前时区 // 使用当前时区
const inputDate = new Date(inputTime) const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
const currentDate = new Date() const currentDate = new Date()
const timeDifference = currentDate.getTime() - inputDate.getTime() const timeDifference = currentDate.getTime() - inputDate.getTime()
@@ -114,3 +118,12 @@ export function isNullOrEmptyObject(obj: any): boolean {
// 然后判断是否为空对象 // 然后判断是否为空对象
return !!(typeof obj === 'object' && Object.keys(obj).length === 0) return !!(typeof obj === 'object' && Object.keys(obj).length === 0)
} }
// 判断系统配置色是否是黑暗的
export function checkPrefersColorSchemeIsDark(): boolean {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches
} catch (e) {
return false
}
}

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Component } from 'vue' import type { Component } from 'vue'
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import logo from '@images/logo.svg?raw' import logo from '@images/logo.svg?raw'
@@ -85,6 +84,7 @@ function handleNavScroll(evt: Event) {
.visible { .visible {
visibility: visible !important; visibility: visible !important;
} }
// 👉 Vertical Nav // 👉 Vertical Nav
.layout-vertical-nav { .layout-vertical-nav {
position: fixed; position: fixed;
@@ -96,8 +96,8 @@ function handleNavScroll(evt: Event) {
inset-block-start: 0; inset-block-start: 0;
inset-inline-start: 0; inset-inline-start: 0;
transition: transform 0.25s ease-in-out, inline-size 0.25s ease-in-out, box-shadow 0.25s ease-in-out; transition: transform 0.25s ease-in-out, inline-size 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
will-change: transform, inline-size;
visibility: hidden; visibility: hidden;
will-change: transform, inline-size;
&:not(.overlay-nav) { &:not(.overlay-nav) {
visibility: visible; visibility: visible;

View File

@@ -52,7 +52,7 @@ export default defineComponent({
'main', 'main',
{ class: 'layout-page-content' }, { class: 'layout-page-content' },
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true },
h('section', { class: 'page-content-container' }, slots.default?.()), () => h('section', { class: 'page-content-container' }, slots.default?.()),
), ),
) )

View File

@@ -19,7 +19,7 @@ $layout-horizontal-nav-layout-navbar-z-index: 11 !default;
$layout-boxed-content-width: 90rem !default; $layout-boxed-content-width: 90rem !default;
// 👉Footer // 👉Footer
$layout-vertical-nav-footer-height: 3.5rem !default; $layout-vertical-nav-footer-height: 0rem !default;
// 👉 Layout overlay // 👉 Layout overlay
$layout-overlay-z-index: 11 !default; $layout-overlay-z-index: 11 !default;

View File

@@ -1,3 +1,2 @@
@use "_global"; @use "_global";
@use "vue3-perfect-scrollbar/dist/vue3-perfect-scrollbar.min.css";
@use "_classes"; @use "_classes";

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,45 +1,49 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import store from './store' const { global: globalTheme } = useTheme()
// 提示框 // 生效主题
const $toast = useToast() async function setTheme() {
let themeValue = localStorage.getItem('theme') || 'light'
// 设置主题 const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
function setTheme() { globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
const { global: globalTheme } = useTheme()
let theme = localStorage.getItem('theme') || 'light'
if (theme === 'auto')
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
globalTheme.name.value = theme
} }
// 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) => { if (window.Apex) {
const message = event.data // 数据标签
if (message) window.Apex.dataLabels = {
$toast.info(message) formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
}) // 如果有小数点,保留两位小数,否则保留整数
const data = w.config.series[seriesIndex]
onBeforeUnmount(() => { return data.toFixed(data % 1 === 0 ? 0 : 1)
eventSource.close() },
}) }
// 图例
window.Apex.legend = {
labels: {
useSeriesColors: true,
},
}
// 标题
window.Apex.title = {
style: {
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
},
} }
} }
// 页面加载时,加载当前用户数据 // 页面加载时,加载当前用户数据
onBeforeMount(async () => { onBeforeMount(async () => {
setTheme() setTheme()
startSSEMessager()
}) })
</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

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

View File

@@ -1,9 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Axios } from 'axios' import type { Axios } from 'axios'
import axios from 'axios' import axios from 'axios'
import List from './filebrowser/List.vue' import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue'
import Toolbar from './filebrowser/Toolbar.vue'
import type { EndPoints } from '@/api/types' import type { EndPoints } from '@/api/types'
// 输入参数 // 输入参数
@@ -100,7 +99,7 @@ onMounted(() => {
<template> <template>
<VCard class="mx-auto" :loading="loading > 0 || !path"> <VCard class="mx-auto" :loading="loading > 0 || !path">
<div v-if="path"> <div v-if="path">
<Toolbar <FileToolbar
:path="path" :path="path"
:storages="storagesArray" :storages="storagesArray"
:storage="activeStorage" :storage="activeStorage"
@@ -111,24 +110,20 @@ onMounted(() => {
@foldercreated="refreshPending = true" @foldercreated="refreshPending = true"
@sortchanged="sortChanged" @sortchanged="sortChanged"
/> />
<VRow no-gutters> <FileList
<VCol> :path="path"
<List :storage="activeStorage"
:path="path" :icons="fileIcons"
:storage="activeStorage" :endpoints="endpoints"
:icons="fileIcons" :axios="axiosInstance"
:endpoints="endpoints" :refreshpending="refreshPending"
:axios="axiosInstance" :sort="sort"
:refreshpending="refreshPending" @pathchanged="pathChanged"
:sort="sort" @loading="loadingChanged"
@pathchanged="pathChanged" @refreshed="refreshPending = false"
@loading="loadingChanged" @filedeleted="refreshPending = true"
@refreshed="refreshPending = false" @renamed="refreshPending = true"
@filedeleted="refreshPending = true" />
@renamed="refreshPending = true"
/>
</VCol>
</VRow>
</div> </div>
</VCard> </VCard>
</template> </template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import miscpose from '@images/pages/pose-fs-9.png' import image from '@images/no-data.svg'
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -11,25 +11,21 @@ interface Props {
</script> </script>
<template> <template>
<div class="flex flex-col"> <VEmptyState :image="image" size="250">
<ErrorHeader <template #title>
:error-code="props.errorCode" <div class="mt-8 text-2xl">
:error-title="props.errorTitle" {{ props.errorTitle }}
:error-description="props.errorDescription" </div>
/> </template>
<!-- 👉 Image --> <template #text>
<div class="text-center"> <div class="text-subtitle mt-3">
<VImg {{ props.errorDescription }}
:src="miscpose" </div>
class="mx-auto pt-10" </template>
max-width="250"
cover <template #actions>
/>
<slot name="button" /> <slot name="button" />
</div> </template>
</div> </VEmptyState>
</template> </template>
<style lang="scss">
</style>

View File

@@ -18,23 +18,18 @@ 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')
} }
// 计算图片地址 // 计算图片地址
const getImgUrl = computed(() => { const getImgUrl = computed(() => {
const image = props.media?.image || '' const image = props.media?.image || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}/0` return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
}) })
</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

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

View File

@@ -1,50 +0,0 @@
<script setup lang="ts">
import { kFormatter } from '@core/utils/formatters'
interface Props {
title: string
color?: string
icon: string
stats: number
change: number
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change) === 1)
</script>
<template>
<VCard>
<VCardText class="d-flex align-center">
<VAvatar
size="44"
rounded
:color="props.color"
variant="tonal"
class="me-4"
>
<VIcon
:icon="props.icon"
size="30"
/>
</VAvatar>
<div>
<span class="text-caption">{{ props.title }}</span>
<div class="d-flex align-center flex-wrap">
<span class="text-h6 font-weight-semibold">{{ kFormatter(props.stats) }}</span>
<div
v-if="props.change"
:class="`${isPositive ? 'text-success' : 'text-error'} mt-1`"
>
<VIcon :icon="isPositive ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
<span class="text-caption font-weight-semibold">{{ Math.abs(props.change) }}%</span>
</div>
</div>
</div>
</VCardText>
</VCard>
</template>

View File

@@ -1,56 +0,0 @@
<script setup lang="ts">
interface Props {
title: string
color?: string
icon: string
stats: string
change: number
subtitle: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change) === 1)
</script>
<template>
<VCard>
<VCardText class="d-flex align-center">
<VAvatar
v-if="props.icon"
size="38"
:color="props.color"
>
<VIcon
:icon="props.icon"
size="24"
/>
</VAvatar>
<VSpacer />
<MoreBtn class="me-n3 mt-n1" />
</VCardText>
<VCardText>
<h6 class="text-sm font-weight-semibold mb-2">
{{ props.title }}
</h6>
<div
v-if="props.change"
class="d-flex align-center mb-2"
>
<span class="font-weight-semibold text-h5 me-2">{{ props.stats }}</span>
<span
:class="isPositive ? 'text-success' : 'text-error'"
class="text-caption"
>
{{ isPositive ? `+${props.change}` : props.change }}%
</span>
</div>
<span class="text-caption">{{ props.subtitle }}</span>
</VCardText>
</VCard>
</template>

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
interface Props {
title: string
subtitle: string
stats: string
change: number
image: string
color?: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change) === 1)
</script>
<template>
<VCard class="overflow-visible">
<div class="d-flex position-relative">
<VCardText>
<h6 class="text-base font-weight-semibold mb-4">
{{ props.title }}
</h6>
<div class="d-flex align-center flex-wrap mb-4">
<h5 class="text-h5 font-weight-semibold me-2">
{{ props.stats }}
</h5>
<span
class="text-caption"
:class="isPositive ? 'text-success' : 'text-error'"
>
{{ isPositive ? `+${props.change}` : props.change }}%
</span>
</div>
<VChip
v-if="props.subtitle"
size="small"
:color="props.color"
>
{{ props.subtitle }}
</VChip>
</VCardText>
<VSpacer />
<div class="illustrator-img">
<VImg
v-if="props.image"
:src="props.image"
:width="110"
/>
</div>
</div>
</VCard>
</template>
<style lang="scss">
.illustrator-img {
position: absolute;
inset-block-end: 0;
inset-inline-end: 5%;
}
</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

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

View File

@@ -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

@@ -56,7 +56,7 @@ function getImgUrl(url: string) {
if (!url) if (!url)
return getDefaultImage() return getDefaultImage()
else else
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(url)}/0` return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
} }
// 根据多张图片生成媒体库封面 // 根据多张图片生成媒体库封面
@@ -68,7 +68,7 @@ async function drawImages(imageList: string[]) {
// 为所有图片添加system/img前缀 // 为所有图片添加system/img前缀
for (let i = 0; i < IMAGES.length; i++) for (let i = 0; i < IMAGES.length; i++)
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(IMAGES[i])}/0` IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
// canvas // canvas
const canvas = canvasRef.value const canvas = canvasRef.value

View File

@@ -1,13 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { PropType, Ref } from 'vue' import type { PropType, Ref } from 'vue'
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import SubscribeEditForm from '../form/SubscribeEditForm.vue' import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import { formatSeason } from '@/@core/utils/formatters' import { formatSeason } 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 { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types' import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
import router from '@/router' import router from '@/router'
import noImage from '@images/no-image.jpeg' import noImage from '@images/no-image.jpeg'
import tmdbImage from '@images/logos/tmdb.png'
import doubanImage from '@images/logos/douban-black.png'
import bangumiImage from '@images/logos/bangumi.png'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -16,6 +19,8 @@ const props = defineProps({
height: String, height: String,
}) })
const store = useStore()
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
@@ -52,31 +57,33 @@ const seasonInfos = ref<TmdbSeason[]>([])
// 选中的订阅季 // 选中的订阅季
const seasonsSelected = ref<TmdbSeason[]>([]) const seasonsSelected = ref<TmdbSeason[]>([])
// 来源角标字典
const sourceIconDict: { [key: string]: any } = {
themoviedb: tmdbImage,
douban: doubanImage,
bangumi: bangumiImage,
}
// 获得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}`
} }
// 订阅弹窗选择的多季 // 订阅弹窗选择的多季
function subscribeSeasons() { function subscribeSeasons() {
subscribeSeasonDialog.value = false subscribeSeasonDialog.value = false
seasonsSelected.value.forEach((season) => { seasonsSelected.value.forEach(season => {
addSubscribe(season.season_number) addSubscribe(season.season_number)
}) })
} }
// 角标颜色 // 角标颜色
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'
} }
// 添加订阅处理 // 添加订阅处理
@@ -90,29 +97,24 @@ async function handleAddSubscribe() {
$toast.error(`${props.media?.title} 查询剧集信息失败!`) $toast.error(`${props.media?.title} 查询剧集信息失败!`)
return return
} }
// 检查各季的缺失状态 // 检查各季的缺失状态
await checkSeasonsNotExists() await checkSeasonsNotExists()
if (!tmdbFlag.value) if (!tmdbFlag.value) return
return
if (seasonInfos.value.length === 1) { if (seasonInfos.value.length === 1) {
// 添加订阅 // 添加订阅
addSubscribe(1) addSubscribe(1)
} } else {
else {
// 弹出季选择列表,支持多选 // 弹出季选择列表,支持多选
seasonsSelected.value = [] seasonsSelected.value = []
subscribeSeasonDialog.value = true subscribeSeasonDialog.value = true
} }
} } else if (props.media?.type === '电视剧') {
else if (props.media?.type === '电视剧') {
// 豆瓣电视剧,只会有一季 // 豆瓣电视剧,只会有一季
const season = props.media?.season ?? 1 const season = props.media?.season ?? 1
// 添加订阅 // 添加订阅
addSubscribe(season) addSubscribe(season)
} } else {
else {
// 电影 // 电影
addSubscribe() addSubscribe()
} }
@@ -147,46 +149,32 @@ async function addSubscribe(season = 0) {
} }
// 提示 // 提示
showSubscribeAddToast( showSubscribeAddToast(result.success, props.media?.title ?? '', season, result.message, best_version)
result.success,
props.media?.title ?? '',
season,
result.message,
best_version,
)
// 弹出订阅编辑弹窗 // 弹出订阅编辑弹窗
if (result.success && seasonsSelected.value.length <= 1) { if (result.success && seasonsSelected.value.length <= 1) {
const show_edit_dialog = await querySubscribeRules() const show_edit_dialog = await queryDefaultSubscribeConfig()
if (show_edit_dialog) { if (show_edit_dialog) {
subscribeId.value = result.data.id subscribeId.value = result.data.id
subscribeEditDialog.value = true subscribeEditDialog.value = true
} }
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} finally {
doneNProgress()
} }
doneNProgress()
} }
// 弹出添加订阅提示 // 弹出添加订阅提示
function showSubscribeAddToast(result: boolean, function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
title: string, if (season) title = `${title} ${formatSeason(season.toString())}`
season: number,
message: string,
best_version: number) {
if (season)
title = `${title} ${formatSeason(season.toString())}`
let subname = '订阅' let subname = '订阅'
if (best_version > 0) if (best_version > 0) subname = '洗版订阅'
subname = '洗版订阅'
if (result && seasonsSelected.value.length > 1) if (result) $toast.success(`${title} 添加${subname}成功!`)
$toast.success(`${title} 添加${subname}成功`) else if (!result) $toast.error(`${title} 添加${subname}失败:${message}`)
else if (!result)
$toast.error(`${title} 添加${subname}失败:${message}`)
} }
// 调用API取消订阅 // 调用API取消订阅
@@ -196,37 +184,31 @@ async function removeSubscribe() {
try { try {
const mediaid = getMediaId() const mediaid = getMediaId()
const result: { [key: string]: any } = await api.delete( const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {
`subscribe/media/${mediaid}`, params: {
{ season: props.media?.season,
params: {
season: props.media?.season,
},
}, },
) })
if (result.success) { if (result.success) {
isSubscribed.value = false isSubscribed.value = false
$toast.success(`${props.media?.title} 已取消订阅!`) $toast.success(`${props.media?.title} 已取消订阅!`)
} } else {
else {
$toast.error(`${props.media?.title} 取消订阅失败:${result.message}`) $toast.error(`${props.media?.title} 取消订阅失败:${result.message}`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} finally {
doneNProgress()
} }
doneNProgress()
} }
// 查询当前媒体是否已订阅 // 查询当前媒体是否已订阅
async function handleCheckSubscribe() { async function handleCheckSubscribe() {
try { try {
const result = await checkSubscribe(props.media?.season) const result = await checkSubscribe(props.media?.season)
if (result) if (result) isSubscribed.value = true
isSubscribed.value = true } catch (error) {
}
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -244,10 +226,8 @@ async function handleCheckExists() {
}, },
}) })
if (result.success) if (result.success) isExists.value = true
isExists.value = true } catch (error) {
}
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -265,62 +245,60 @@ async function checkSubscribe(season = 0) {
}) })
return result.id || null return result.id || null
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
return null return null
} }
// 检查所有季的缺失状态 // 检查所有季的缺失状态(数据库)
async function checkSeasonsNotExists() { async function checkSeasonsNotExists() {
// 开始处理 // 开始处理
startNProgress() startNProgress()
try { try {
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media) const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
if (result) { if (result) {
result.forEach((item) => { result.forEach(item => {
// 0-已入库 1-部分缺失 2-全部缺失 // 0-已入库 1-部分缺失 2-全部缺失
let state = 0 let state = 0
if (item.episodes.length === 0) if (item.episodes.length === 0) state = 2
state = 2 else if (item.episodes.length < item.total_episode) state = 1
else if (item.episodes.length < item.total_episode)
state = 1
seasonsNotExisted.value[item.season] = state seasonsNotExisted.value[item.season] = state
}) })
} }
} } 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的所有季信息
async function getMediaSeasons() { async function getMediaSeasons() {
try { try {
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`) seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
// 查询订阅弹窗规则 // 查询订阅弹窗规则
async function querySubscribeRules() { async function queryDefaultSubscribeConfig() {
// 非管理员不显示
if (!store.state.auth.superUser) return false
try { try {
const result: { [key: string]: any } = await api.get( let subscribe_config_url = ''
'system/setting/DefaultFilterRules', if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
) else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
if (result.data?.value)
return result.data.value.show_edit_dialog const result: { [key: string]: any } = await api.get(subscribe_config_url)
}
catch (error) { if (result.data?.value) return result.data.value.show_edit_dialog
} catch (error) {
console.log(error) console.log(error)
} }
return false return false
@@ -328,49 +306,41 @@ async function querySubscribeRules() {
// 爱心订阅按钮响应 // 爱心订阅按钮响应
function handleSubscribe() { function handleSubscribe() {
if (isSubscribed.value) if (isSubscribed.value) removeSubscribe()
removeSubscribe() else handleAddSubscribe()
else
handleAddSubscribe()
} }
// 计算存在状态的颜色 // 计算存在状态的颜色
function getExistColor(season: number) { function getExistColor(season: number) {
const state = seasonsNotExisted.value[season] const state = seasonsNotExisted.value[season]
if (!state) if (!state) return 'success'
return 'success'
if (state === 1) if (state === 1) return 'warning'
return 'warning' else if (state === 2) return 'error'
else if (state === 2) else return 'success'
return 'error'
else
return 'success'
} }
// 计算存在状态的文本 // 计算存在状态的文本
function getExistText(season: number) { function getExistText(season: number) {
const state = seasonsNotExisted.value[season] const state = seasonsNotExisted.value[season]
if (!state) if (!state) return '已入库'
return '已入库'
if (state === 1) if (state === 1) return '部分缺失'
return '部分缺失' else if (state === 2) return '缺失'
else if (state === 2) else return '已入库'
return '缺失'
else
return '已入库'
} }
// 打开详情页 // 打开详情页
function goMediaDetail() { function goMediaDetail(isHovering = false) {
router.push({ if (isHovering) {
path: '/media', router.push({
query: { path: '/media',
mediaid: getMediaId(), query: {
type: props.media?.type, mediaid: getMediaId(),
}, type: props.media?.type,
}) },
})
}
} }
// 开始搜索 // 开始搜索
@@ -381,6 +351,7 @@ function handleSearch() {
keyword: getMediaId(), keyword: getMediaId(),
type: props.media?.type, type: props.media?.type,
area: 'title', area: 'title',
season: props.media?.season,
}, },
}) })
} }
@@ -393,41 +364,37 @@ onBeforeMount(() => {
// 计算图片地址 // 计算图片地址
const getImgUrl: Ref<string> = computed(() => { const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) if (imageLoadError.value) return noImage
return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
// 如果地址中包含douban则使用中转代理 // 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com')) if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}` return `${import.meta.env.VITE_API_BASE_URL}douban/img?imgurl=${encodeURIComponent(url)}`
return url return url
}) })
// 拼装季图片地址 // 拼装季图片地址
function getSeasonPoster(posterPath: string) { function getSeasonPoster(posterPath: string) {
if (!posterPath) if (!posterPath) return ''
return ''
return `https://image.tmdb.org/t/p/w500${posterPath}` return `https://image.tmdb.org/t/p/w500${posterPath}`
} }
// 将yyyy-mm-dd转换为yyyy年mm月dd日 // 将yyyy-mm-dd转换为yyyy年mm月dd日
function formatAirDate(airDate: string) { function formatAirDate(airDate: string) {
if (!airDate) if (!airDate) return ''
return '' const date = new Date(airDate.replaceAll(/-/g, '/'))
const date = new Date(airDate)
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}` return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
} }
// 从yyyy-mm-dd中提取年份 // 从yyyy-mm-dd中提取年份
function getYear(airDate: string) { function getYear(airDate: string) {
if (!airDate) if (!airDate) return ''
return '' const date = new Date(airDate.replaceAll(/-/g, '/'))
const date = new Date(airDate)
return date.getFullYear() return date.getFullYear()
} }
</script> </script>
<template> <template>
<VHover v-bind="props"> <VHover>
<template #default="hover"> <template #default="hover">
<VCard <VCard
v-bind="hover.props" v-bind="hover.props"
@@ -438,6 +405,7 @@ function getYear(airDate: string) {
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering, 'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded, 'ring-1': isImageLoaded,
}" }"
@click.stop="goMediaDetail(hover.isHovering)"
> >
<VImg <VImg
aspect-ratio="2/3" aspect-ratio="2/3"
@@ -453,79 +421,69 @@ function getYear(airDate: string) {
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" /> <VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div> </div>
</template> </template>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 本地存在标识 -->
<ExistIcon v-if="isExists" />
<!-- 评分角标 -->
<VChip
v-if="isImageLoaded && props.media?.vote_average && !isExists"
variant="elevated"
size="small"
:class="getChipColor('rating')"
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.vote_average }}
</VChip>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
@click.stop="goMediaDetail"
>
<span class="font-bold">{{ props.media?.year }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
{{ props.media?.overview }}
</p>
<div class="flex align-center justify-between">
<IconBtn
icon="mdi-magnify"
color="white"
@click.stop="handleSearch"
/>
<IconBtn
icon="mdi-heart"
:color="isSubscribed ? 'error' : 'white'"
@click.stop="handleSubscribe"
/>
</div>
</VCardText>
</VImg> </VImg>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 本地存在标识 -->
<ExistIcon v-if="isExists && !hover.isHovering" />
<!-- 评分角标 -->
<VChip
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
variant="elevated"
size="small"
:class="getChipColor('rating')"
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.vote_average }}
</VChip>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
<span class="font-bold">{{ props.media?.year }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
{{ props.media?.overview }}
</p>
<div class="flex align-center justify-between">
<IconBtn icon="mdi-magnify" color="white" @click.stop="handleSearch" />
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
</div>
</VCardText>
<VAvatar
size="24"
density="compact"
class="absolute bottom-1 right-1"
tile
v-if="!hover.isHovering && isImageLoaded && props.media?.source"
>
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>
</VCard> </VCard>
</template> </template>
</VHover> </VHover>
<!-- 订阅季弹窗 --> <!-- 订阅季弹窗 -->
<VBottomSheet <VBottomSheet v-if="subscribeSeasonDialog" v-model="subscribeSeasonDialog" inset scrollable>
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"> <VCardItem>
订阅 - {{ props.media?.title }} <VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
</VCardTitle> </VCardItem>
<VDivider />
<VCardText> <VCardText>
<VList <VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
v-model:selected="seasonsSelected" <VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
lines="three"
select-strategy="classic"
>
<VListItem
v-for="(item, i) in seasonInfos" :key="i"
:value="item"
>
<template #prepend> <template #prepend>
<VImg <VImg
height="90" height="90"
@@ -542,16 +500,9 @@ function getYear(airDate: string) {
</template> </template>
</VImg> </VImg>
</template> </template>
<VListItemTitle> <VListItemTitle> {{ item.season_number }} </VListItemTitle>
{{ item.season_number }}
</VListItemTitle>
<VListItemSubtitle class="mt-1 me-2"> <VListItemSubtitle class="mt-1 me-2">
<VChip <VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
v-if="item.vote_average"
color="primary"
size="small"
class="mb-1"
>
<VIcon icon="mdi-star" /> {{ item.vote_average }} <VIcon icon="mdi-star" /> {{ item.vote_average }}
</VChip> </VChip>
{{ getYear(item.air_date || '') }} {{ item.episode_count }} {{ getYear(item.air_date || '') }} {{ item.episode_count }}
@@ -560,12 +511,7 @@ function getYear(airDate: string) {
{{ media?.title }} {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播 {{ media?.title }} {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播
</VListItemSubtitle> </VListItemSubtitle>
<VListItemSubtitle> <VListItemSubtitle>
<VChip <VChip v-if="seasonsNotExisted" class="mt-2" size="small" :color="getExistColor(item.season_number || 0)">
v-if="seasonsNotExisted"
class="mt-2"
size="small"
:color="getExistColor(item.season_number || 0)"
>
{{ getExistText(item.season_number || 0) }} {{ getExistText(item.season_number || 0) }}
</VChip> </VChip>
</VListItemSubtitle> </VListItemSubtitle>
@@ -578,23 +524,25 @@ function getYear(airDate: string) {
</VList> </VList>
</VCardText> </VCardText>
<div class="my-2 text-center"> <div class="my-2 text-center">
<VBtn <VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
:disabled="seasonsSelected.length === 0"
width="30%"
@click="subscribeSeasons"
>
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }} {{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
</VBtn> </VBtn>
</div> </div>
</VCard> </VCard>
</VBottomSheet> </VBottomSheet>
<!-- 订阅编辑弹窗 --> <!-- 订阅编辑弹窗 -->
<SubscribeEditForm <SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog" v-model="subscribeEditDialog"
:subid="subscribeId" :subid="subscribeId"
@close="subscribeEditDialog = false" @close="subscribeEditDialog = false"
@save="subscribeEditDialog = false" @save="subscribeEditDialog = false"
@remove="() => { subscribeEditDialog = false; handleCheckSubscribe(); }" @remove="
() => {
subscribeEditDialog = false
handleCheckSubscribe()
}
"
/> />
</template> </template>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { PropType } from 'vue' import type { PropType } from 'vue'
import type { Context } from '@/api/types' import type { Context } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -9,15 +10,13 @@ const props = defineProps({
// TMDB图片转换为w500大小 // TMDB图片转换为w500大小
function getW500Image(url = '') { function getW500Image(url = '') {
if (!url) if (!url) return ''
return ''
return url.replace('original', 'w500') return url.replace('original', 'w500')
} }
// 打开TMDB详情页面 // 打开TMDB详情页面
function openTmdbPage(type: string, tmdbId: number) { function openTmdbPage(type: string, tmdbId: number) {
if (!type || !tmdbId) if (!type || !tmdbId) return
return
const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}` const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}`
window.open(url, '_blank') window.open(url, '_blank')
@@ -31,10 +30,7 @@ function openTmdbPage(type: string, tmdbId: number) {
v-if="context?.meta_info?.name" v-if="context?.meta_info?.name"
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row" class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
> >
<div <div v-if="context?.media_info?.poster_path" class="ma-auto">
v-if="context?.media_info?.poster_path"
class="ma-auto"
>
<VImg <VImg
width="10rem" width="10rem"
aspect-ratio="2/3" aspect-ratio="2/3"
@@ -75,16 +71,10 @@ function openTmdbPage(type: string, tmdbId: number) {
variant="elevated" variant="elevated"
class="me-1 mb-1 text-white bg-blue-500" class="me-1 mb-1 text-white bg-blue-500"
> >
{{ {{ context?.media_info?.type || context?.meta_info?.type }}
context?.media_info?.type || context?.meta_info?.type
}}
</VChip> </VChip>
<!-- 二级分类 --> <!-- 二级分类 -->
<VChip <VChip v-if="context?.media_info?.category" variant="elevated" class="me-1 mb-1 text-white bg-blue-500">
v-if="context?.media_info?.category"
variant="elevated"
class="me-1 mb-1 text-white bg-blue-500"
>
{{ context?.media_info?.category }} {{ context?.media_info?.category }}
</VChip> </VChip>
<!-- TMDBID --> <!-- TMDBID -->
@@ -98,18 +88,10 @@ function openTmdbPage(type: string, tmdbId: number) {
{{ context?.media_info?.tmdb_id }} {{ context?.media_info?.tmdb_id }}
</VChip> </VChip>
<!-- meta_info --> <!-- meta_info -->
<VChip <VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
v-if="context?.meta_info?.edition"
variant="elevated"
class="me-1 mb-1 text-white bg-red-500"
>
{{ context?.meta_info?.edition }} {{ context?.meta_info?.edition }}
</VChip> </VChip>
<VChip <VChip v-if="context?.meta_info?.resource_pix" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
v-if="context?.meta_info?.resource_pix"
variant="elevated"
class="me-1 mb-1 text-white bg-red-500"
>
{{ context?.meta_info?.resource_pix }} {{ context?.meta_info?.resource_pix }}
</VChip> </VChip>
<VChip <VChip
@@ -126,36 +108,19 @@ function openTmdbPage(type: string, tmdbId: number) {
> >
{{ context?.meta_info?.audio_encode }} {{ context?.meta_info?.audio_encode }}
</VChip> </VChip>
<VChip <VChip v-if="context?.meta_info?.resource_team" variant="elevated" class="me-1 mb-1 text-white bg-cyan-500">
v-if="context?.meta_info?.resource_team"
variant="elevated"
class="me-1 mb-1 text-white bg-cyan-500"
>
{{ context?.meta_info?.resource_team }} {{ context?.meta_info?.resource_team }}
</VChip> </VChip>
</VCardItem> </VCardItem>
</div> </div>
</div> </div>
<VAlert <VAlert v-if="!context?.meta_info?.name" icon="mdi-alert-circle-outline"> 识别失败无法识别到有效信息 </VAlert>
v-if="!context?.meta_info?.name"
icon="mdi-alert-circle-outline"
>
识别失败无法识别到有效信息
</VAlert>
</VCol> </VCol>
<VExpansionPanels <VExpansionPanels v-show="!isNullOrEmptyObject(context?.meta_info.apply_words)">
v-show="context?.meta_info?.title !== context?.meta_info.org_string"
>
<VExpansionPanel> <VExpansionPanel>
<VExpansionPanelTitle> <VExpansionPanelTitle> 识别词应用详情 </VExpansionPanelTitle>
识别词应用详情
</VExpansionPanelTitle>
<VExpansionPanelText> <VExpansionPanelText>
<VChip <VChip variant="elevated" class="me-1 mb-1 break-all" color="primary">
variant="elevated"
class="me-1 mb-1 break-all"
color="primary"
>
{{ context?.meta_info.org_string }} {{ context?.meta_info.org_string }}
</VChip> </VChip>
<VChip <VChip

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import personIcon from '@images/misc/person-icon.png' import personIcon from '@images/misc/person-icon.png'
import type { TmdbPerson } from '@/api/types' import type { Person } from '@/api/types'
import router from '@/router' import router from '@/router'
const personProps = defineProps({ const personProps = defineProps({
person: Object as PropType<TmdbPerson>, person: Object as PropType<Person>,
width: String, width: String,
height: String, height: String,
}) })
@@ -17,26 +17,54 @@ const isImageLoaded = ref(false)
// //
function getPersonImage() { function getPersonImage() {
if (!personInfo.value?.profile_path) if (personProps.person?.source === 'themoviedb') {
if (!personInfo.value?.profile_path) return personIcon
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
} else if (personProps.person?.source === 'douban') {
if (!personInfo.value?.avatar) return personIcon
if (typeof personInfo.value?.avatar === 'object') {
return personInfo.value?.avatar?.normal
} else {
return personInfo.value?.avatar
}
} else if (personProps.person?.source === 'bangumi') {
if (!personInfo.value?.images) return personIcon
return personInfo.value?.images?.medium
} else {
return personIcon return personIcon
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}` }
}
//
function getPersonName() {
return personInfo.value?.name
}
//
function getPersonCharacter() {
if (personProps.person?.source === 'bangumi') {
if (!personInfo.value?.career) return ''
return personInfo.value?.career.join('、')
} else {
return personInfo.value?.character
}
} }
// //
function goPersonDetail() { function goPersonDetail() {
if (!personInfo.value?.id) if (!personInfo.value?.id) return
return
router.push({ router.push({
path: '/person', path: '/person',
query: { query: {
personid: personInfo.value?.id, personid: personInfo.value?.id,
source: personInfo.value?.source,
}, },
}) })
} }
</script> </script>
<template> <template>
<VHover v-bind="personProps"> <VHover>
<template #default="hover"> <template #default="hover">
<VCard <VCard
v-bind="hover.props" v-bind="hover.props"
@@ -49,9 +77,9 @@ function goPersonDetail() {
@click.stop="goPersonDetail" @click.stop="goPersonDetail"
> >
<div <div
class="person-card relative transform-gpu cursor-pointer rounded shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700" class="person-card relative transform-gpu cursor-pointer rounded shadow transition duration-150 ease-in-out scale-100 ring-gray-700"
> >
<div style="padding-bottom: 150%;"> <div style="padding-block-end: 150%">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2"> <div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center"> <div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
<VAvatar <VAvatar
@@ -60,19 +88,17 @@ function goPersonDetail() {
'ring-1 ring-gray-700': isImageLoaded, 'ring-1 ring-gray-700': isImageLoaded,
}" }"
> >
<VImg <VImg :src="getPersonImage()" cover @load="isImageLoaded = true" />
v-img
:src="getPersonImage()"
cover
@load="isImageLoaded = true"
/>
</VAvatar> </VAvatar>
</div> </div>
<div class="w-full truncate text-center font-bold"> <div class="w-full truncate text-center font-bold">
{{ personInfo?.name }} {{ getPersonName() }}
</div> </div>
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;"> <div
{{ personInfo?.character }} class="overflow-hidden whitespace-normal text-center text-sm"
style="display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2"
>
{{ getPersonCharacter() }}
</div> </div>
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" /> <div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
</div> </div>

View File

@@ -1,9 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import VersionHistory from '../misc/VersionHistory.vue'
import api from '@/api' import api from '@/api'
import type { Plugin } from '@/api/types' import type { Plugin } from '@/api/types'
import noImage from '@images/logos/plugin.png' import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image' import { getDominantColor } from '@/@core/utils/image'
import { isNullOrEmptyObject } from '@/@core/utils'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -37,6 +40,15 @@ const isImageLoaded = ref(false)
// 图片是否加载失败 // 图片是否加载失败
const imageLoadError = ref(false) const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 计算插件标签
const pluginLabels = computed(() => {
if (!props.plugin?.plugin_label) return []
return props.plugin.plugin_label.split(',')
})
// 图片加载完成 // 图片加载完成
async function imageLoaded() { async function imageLoaded() {
isImageLoaded.value = true isImageLoaded.value = true
@@ -52,15 +64,12 @@ async function installPlugin() {
progressDialog.value = true progressDialog.value = true
progressText.value = `正在安装 ${props.plugin?.plugin_name} v${props?.plugin?.plugin_version} ...` progressText.value = `正在安装 ${props.plugin?.plugin_name} v${props?.plugin?.plugin_version} ...`
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
`plugin/install/${props.plugin?.id}`, params: {
{ repo_url: props.plugin?.repo_url,
params: { force: props.plugin?.has_update,
repo_url: props.plugin?.repo_url,
force: props.plugin?.has_update,
},
}, },
) })
// 隐藏等待提示框 // 隐藏等待提示框
progressDialog.value = false progressDialog.value = false
@@ -70,23 +79,20 @@ async function installPlugin() {
// 通知父组件刷新 // 通知父组件刷新
emit('install') emit('install')
} } else {
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}`) $toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
// 计算图标路径 // 计算图标路径
const iconPath: Ref<string> = computed(() => { const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) if (imageLoadError.value) return noImage
return noImage
// 如果是网络图片则使用代理后返回 // 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http')) if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}/1` return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
return `./plugin_icon/${props.plugin?.plugin_icon}` return `./plugin_icon/${props.plugin?.plugin_icon}`
}) })
@@ -97,60 +103,62 @@ function visitPluginPage() {
let repoUrl = props.plugin?.repo_url let repoUrl = props.plugin?.repo_url
if (repoUrl) { if (repoUrl) {
if (repoUrl.includes('raw.githubusercontent.com')) { if (repoUrl.includes('raw.githubusercontent.com')) {
if (!repoUrl.endsWith('/')) if (!repoUrl.endsWith('/')) repoUrl += '/'
repoUrl += '/'
if (repoUrl.split('/').length < 6) if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
repoUrl = `${repoUrl}main/`
try { try {
const [user, repo] = repoUrl.split('/').slice(-4, -2) const [user, repo] = repoUrl.split('/').slice(-4, -2)
repoUrl = `https://github.com/${user}/${repo}` repoUrl = `https://github.com/${user}/${repo}`
} } catch (error) {
catch (error) {
return return
} }
} }
} } else {
else {
repoUrl = props.plugin?.author_url repoUrl = props.plugin?.author_url
} }
window.open(repoUrl, '_blank') window.open(repoUrl, '_blank')
} }
// 显示更新日志
function showUpdateHistory() {
releaseDialog.value = true
}
// 弹出菜单 // 弹出菜单
const dropdownItems = ref([ const dropdownItems = ref([
{ {
title: '查看详情', title: '项目主页',
value: 1, value: 1,
show: true,
props: { props: {
prependIcon: 'mdi-information-outline', prependIcon: 'mdi-github',
click: visitPluginPage, click: visitPluginPage,
}, },
}, },
{
title: '更新说明',
value: 2,
show: !isNullOrEmptyObject(props.plugin?.history || {}),
props: {
prependIcon: 'mdi-update',
click: showUpdateHistory,
},
},
]) ])
</script> </script>
<template> <template>
<VCard <VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
:width="props.width" <div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
:height="props.height"
@click="installPlugin"
>
<div
class="relative pa-4 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>
<VIcon icon="mdi-dots-vertical" class="text-white" /> <VIcon icon="mdi-dots-vertical" class="text-white" />
<VMenu <VMenu activator="parent" close-on-content-click>
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem
v-for="(item, i) in dropdownItems" v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i" :key="i"
variant="plain" variant="plain"
@click="item.props.click" @click="item.props.click"
@@ -164,9 +172,7 @@ const dropdownItems = ref([
</VMenu> </VMenu>
</IconBtn> </IconBtn>
</div> </div>
<VAvatar <VAvatar size="6rem">
size="8rem"
>
<VImg <VImg
ref="imageRef" ref="imageRef"
:src="iconPath" :src="iconPath"
@@ -183,16 +189,17 @@ const dropdownItems = ref([
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span> <span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle> </VCardTitle>
<VCardText class="pb-2"> <VCardText class="pb-2">
{{ props.plugin?.plugin_desc }} <div>{{ props.plugin?.plugin_desc }}</div>
<div>
<VChip v-for="label in pluginLabels" variant="tonal" size="small" class="me-1 my-1" color="info" label>
{{ label }}
</VChip>
</div>
</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 <a :href="props.plugin?.author_url" target="_blank" @click.stop>
:href="props.plugin?.author_url"
target="_blank"
@click.stop
>
{{ props.plugin?.plugin_author }} {{ props.plugin?.plugin_author }}
</a> </a>
</span> </span>
@@ -203,22 +210,13 @@ const dropdownItems = ref([
</VCardText> </VCardText>
</VCard> </VCard>
<!-- 安装插件进度框 --> <!-- 安装插件进度框 -->
<VDialog <ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
v-model="progressDialog" <!-- 更新日志 -->
:scrim="false" <VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
width="25rem" <VCard :title="`${props.plugin?.plugin_name} 更新说明`">
> <DialogCloseBtn @click="releaseDialog = false" />
<VCard <VDivider />
color="primary" <VersionHistory :history="props.plugin?.history" />
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</VCardText>
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
@@ -230,7 +228,7 @@ const dropdownItems = ref([
-webkit-backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
background: rgba(29, 39, 59, 48%); background: rgba(29, 39, 59, 48%);
content: ""; content: '';
inset: 0; inset: 0;
} }
</style> </style>

View File

@@ -6,21 +6,28 @@ import api from '@/api'
import type { Plugin } from '@/api/types' import type { Plugin } from '@/api/types'
import FormRender from '@/components/render/FormRender.vue' import FormRender from '@/components/render/FormRender.vue'
import PageRender from '@/components/render/PageRender.vue' import PageRender from '@/components/render/PageRender.vue'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import { isNullOrEmptyObject } from '@core/utils' import { isNullOrEmptyObject } from '@core/utils'
import noImage from '@images/logos/plugin.png' import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image' import { getDominantColor } from '@/@core/utils/image'
import store from '@/store' import store from '@/store'
import { useDisplay } from 'vuetify'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
plugin: Object as PropType<Plugin>, plugin: Object as PropType<Plugin>,
count: Number, count: Number, // 下载次数
action: Boolean, // 动作标识
width: String, width: String,
height: String, height: String,
}) })
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['remove', 'save']) const emit = defineEmits(['remove', 'save', 'actionDone'])
// 背景颜色 // 背景颜色
const backgroundColor = ref('#28A9E1') const backgroundColor = ref('#28A9E1')
@@ -56,7 +63,7 @@ const pluginInfoDialog = ref(false)
const progressText = ref('正在更新插件...') const progressText = ref('正在更新插件...')
// 插件数据页面配置项 // 插件数据页面配置项
let pluginPageItems = reactive([]) let pluginPageItems = ref([])
// 图片是否加载完成 // 图片是否加载完成
const isImageLoaded = ref(false) const isImageLoaded = ref(false)
@@ -64,6 +71,20 @@ const isImageLoaded = ref(false)
// 图片是否加载失败 // 图片是否加载失败
const imageLoadError = ref(false) const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 监听动作标识如为true则打开详情
watch(
() => props.action,
(newAction, oldAction) => {
if (newAction && !oldAction) {
openPluginDetail()
emit('actionDone')
}
},
)
// 图片加载完成 // 图片加载完成
async function imageLoaded() { async function imageLoaded() {
isImageLoaded.value = true isImageLoaded.value = true
@@ -72,26 +93,27 @@ async function imageLoaded() {
backgroundColor.value = await getDominantColor(imageElement) backgroundColor.value = await getDominantColor(imageElement)
} }
// 显示更新日志
function showUpdateHistory() {
// 检查当前版本是否有更新日志
if (isNullOrEmptyObject(props.plugin?.history)) {
updatePlugin()
} else {
releaseDialog.value = true
}
}
// 调用API卸载插件 // 调用API卸载插件
async function uninstallPlugin() { 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) if (!isConfirmed) return
return
try { try {
// 显示等待提示框 // 显示等待提示框
progressDialog.value = true progressDialog.value = true
progressText.value = `正在卸载 ${props.plugin?.plugin_name} ...` progressText.value = `正在卸载 ${props.plugin?.plugin_name} ...`
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`) const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
@@ -102,12 +124,10 @@ async function uninstallPlugin() {
// 通知父组件刷新 // 通知父组件刷新
emit('remove') emit('remove')
} } else {
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 卸载失败:${result.message}}`) $toast.error(`插件 ${props.plugin?.plugin_name} 卸载失败:${result.message}}`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -118,11 +138,9 @@ async function loadPluginForm() {
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`) const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (result) { if (result) {
pluginFormItems = result.conf pluginFormItems = result.conf
if (result.model) if (result.model) pluginConfigForm.value = result.model
pluginConfigForm.value = result.model
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -131,10 +149,8 @@ async function loadPluginForm() {
async function loadPluginPage() { async function loadPluginPage() {
try { try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`) const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
if (result) if (result) pluginPageItems.value = result
pluginPageItems = result } catch (error) {
}
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -143,29 +159,30 @@ async function loadPluginPage() {
async function loadPluginConf() { async function loadPluginConf() {
try { try {
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`) const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
if (!isNullOrEmptyObject(result)) if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
pluginConfigForm.value = result } catch (error) {
}
catch (error) {
console.error(error) console.error(error)
} }
} }
// 调用API保存配置数据 // 调用API保存配置数据
async function savePluginConf() { async function savePluginConf() {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
try { try {
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value) const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
if (result.success) { if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`) progressDialog.value = false
pluginConfigDialog.value = false pluginConfigDialog.value = false
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
// 通知父组件刷新 // 通知父组件刷新
emit('save') emit('save')
} } else {
else { progressDialog.value = false
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`) $toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -191,11 +208,10 @@ async function showPluginConfig() {
// 计算图标路径 // 计算图标路径
const iconPath: Ref<string> = computed(() => { const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) if (imageLoadError.value) return noImage
return noImage
// 如果是网络图片则使用代理后返回 // 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http')) if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}/1` return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
return `./plugin_icon/${props.plugin?.plugin_icon}` return `./plugin_icon/${props.plugin?.plugin_icon}`
}) })
@@ -205,18 +221,9 @@ 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) if (!isConfirmed) return
return
try { try {
const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`) const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)
@@ -224,12 +231,10 @@ async function resetPlugin() {
$toast.success(`插件 ${props.plugin?.plugin_name} 数据已重置`) $toast.success(`插件 ${props.plugin?.plugin_name} 数据已重置`)
// 通知父组件刷新 // 通知父组件刷新
emit('save') emit('save')
} } else {
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 重置失败:${result.message}}`) $toast.error(`插件 ${props.plugin?.plugin_name} 重置失败:${result.message}}`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -237,19 +242,17 @@ async function resetPlugin() {
// 更新插件 // 更新插件
async function updatePlugin() { async function updatePlugin() {
try { try {
releaseDialog.value = false
// 显示等待提示框 // 显示等待提示框
progressDialog.value = true progressDialog.value = true
progressText.value = `正在更新 ${props.plugin?.plugin_name} ...` progressText.value = `正在更新 ${props.plugin?.plugin_name} ...`
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
`plugin/install/${props.plugin?.id}`, params: {
{ repo_url: props.plugin?.repo_url,
params: { force: true,
repo_url: props.plugin?.repo_url,
force: true,
},
}, },
) })
// 隐藏等待提示框 // 隐藏等待提示框
progressDialog.value = false progressDialog.value = false
@@ -259,12 +262,10 @@ async function updatePlugin() {
// 通知父组件刷新 // 通知父组件刷新
emit('save') emit('save')
} } else {
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 更新失败:${result.message}`) $toast.error(`插件 ${props.plugin?.plugin_name} 更新失败:${result.message}`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -277,10 +278,18 @@ function visitAuthorPage() {
// 查看日志URL // 查看日志URL
function openLoggerWindow() { function openLoggerWindow() {
const token = store.state.auth.token const token = store.state.auth.token
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log` const url = `${
import.meta.env.VITE_API_BASE_URL
}system/logging?token=${token}&length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank') window.open(url, '_blank')
} }
// 打开插件详情
function openPluginDetail() {
if (props.plugin?.has_page) showPluginInfo()
else showPluginConfig()
}
// 弹出菜单 // 弹出菜单
const dropdownItems = ref([ const dropdownItems = ref([
{ {
@@ -308,7 +317,7 @@ const dropdownItems = ref([
props: { props: {
prependIcon: 'mdi-arrow-up-circle-outline', prependIcon: 'mdi-arrow-up-circle-outline',
color: 'success', color: 'success',
click: updatePlugin, click: showUpdateHistory,
}, },
}, },
{ {
@@ -354,46 +363,34 @@ const dropdownItems = ref([
]) ])
// 监听插件状态变化 // 监听插件状态变化
watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => { watch(
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3) () => props.plugin?.has_update,
if (updateItemIndex !== -1) (newHasUpdate, _) => {
dropdownItems.value[updateItemIndex].show = newHasUpdate const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
}) if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
},
)
// 监听插件窗口状态变化
watch(
() => props.plugin?.page_open,
(newOpenState, _) => {
if (newOpenState) openPluginDetail()
},
)
</script> </script>
<template> <template>
<!-- 插件卡片 --> <!-- 插件卡片 -->
<VCard <VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
v-if="isVisible" <div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
:width="props.width" <div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 left-1">
:height="props.height" <VIcon icon="mdi-new-box" class="text-white" />
@click="() => {
if (props.plugin?.has_page)
showPluginInfo()
else
showPluginConfig()
}"
>
<div
class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${backgroundColor}` }"
>
<div
v-if="props.plugin?.has_update"
class="me-n3 absolute top-0 left-1"
>
<VIcon
icon="mdi-new-box"
class="text-white"
/>
</div> </div>
<div class="me-n3 absolute top-0 right-3"> <div class="me-n3 absolute top-0 right-3">
<IconBtn> <IconBtn>
<VIcon icon="mdi-dots-vertical" class="text-white" /> <VIcon icon="mdi-dots-vertical" class="text-white" />
<VMenu <VMenu activator="parent" close-on-content-click>
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem
v-for="(item, i) in dropdownItems" v-for="(item, i) in dropdownItems"
@@ -412,9 +409,7 @@ watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
</VMenu> </VMenu>
</IconBtn> </IconBtn>
</div> </div>
<VAvatar <VAvatar size="6rem">
size="8rem"
>
<VImg <VImg
ref="imageRef" ref="imageRef"
:src="iconPath" :src="iconPath"
@@ -426,104 +421,70 @@ watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
/> />
</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 }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span> {{ props.plugin?.plugin_name }}
<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 <VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
v-model="pluginConfigDialog" <VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
scrollable <DialogCloseBtn v-model="pluginConfigDialog" />
max-width="60rem" <VDivider />
>
<VCard
:title="`${props.plugin?.plugin_name} - 配置`"
class="rounded-t"
>
<DialogCloseBtn @click="pluginConfigDialog = false" />
<VCardText> <VCardText>
<FormRender <FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
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 v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
查看数据 查看数据
</VBtn> </VBtn>
<VSpacer /> <VSpacer />
<VBtn <VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
variant="tonal"
@click="savePluginConf"
>
保存
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
<!-- 插件数据页面 --> <!-- 插件数据页面 -->
<VDialog <VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
v-model="pluginInfoDialog" <VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
scrollable <DialogCloseBtn v-model="pluginInfoDialog" />
max-width="80rem" <VCardText class="min-h-40">
> <PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
<VCard
:title="`${props.plugin?.plugin_name}`"
class="rounded-t"
>
<DialogCloseBtn @click="pluginInfoDialog = false" />
<VCardText>
<PageRender
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>
<!-- 更新插件进度框 -->
<VDialog <!-- 进度框 -->
v-model="progressDialog" <ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
:scrim="false"
width="25rem" <!-- 更新日志 -->
> <VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard <VCard :title="`${props.plugin?.plugin_name} 更新说明`">
color="primary" <DialogCloseBtn @click="releaseDialog = false" />
> <VDivider />
<VCardText class="text-center"> <VersionHistory :history="props.plugin?.history" />
{{ progressText }} <VDivider />
<VProgressLinear <VCardText>
indeterminate <VBtn @click="updatePlugin" block>
color="white" <template #prepend>
class="mb-0 mt-1" <VIcon icon="mdi-arrow-up-circle-outline" />
/> </template>
更新到最新版本
</VBtn>
</VCardText> </VCardText>
</VCard> </VCard>
</VDialog> </VDialog>
@@ -536,7 +497,7 @@ watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
-webkit-backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
background: rgba(29, 39, 59, 48%); background: rgba(29, 39, 59, 48%);
content: ""; content: '';
inset: 0; inset: 0;
} }
</style> </style>

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/${encodeURIComponent(image)}/0` return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
}) })
// 跳转播放 // 跳转播放
function goPlay() { function goPlay(isHovering = false) {
if (props.media?.link) if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
window.open(props.media?.link, '_blank')
} }
</script> </script>
@@ -53,7 +48,7 @@ function goPlay() {
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering, 'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded, 'ring-1': isImageLoaded,
}" }"
@click.stop="goPlay" @click.stop="goPlay(hover.isHovering)"
> >
<VImg <VImg
aspect-ratio="2/3" aspect-ratio="2/3"
@@ -69,27 +64,27 @@ function goPlay() {
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" /> <VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div> </div>
</template> </template>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
<span class="font-bold">{{ props.media?.subtitle }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
</VCardText>
</VImg> </VImg>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
<span class="font-bold">{{ props.media?.subtitle }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
</VCardText>
</VCard> </VCard>
</template> </template>
</VHover> </VHover>

View File

@@ -1,12 +1,17 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { PropType } from 'vue' import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import SiteAddEditForm from '../form/SiteAddEditForm.vue' import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteTorrentTable from '../table/SiteTorrentTable.vue' import SiteTorrentTable from '../table/SiteTorrentTable.vue'
import { requiredValidator } from '@/@validators' import { requiredValidator } from '@/@validators'
import api from '@/api' import api from '@/api'
import type { Site } from '@/api/types' import type { Site, SiteStatistic } from '@/api/types'
import ExistIcon from '@core/components/ExistIcon.vue' import { isNullOrEmptyObject } from '@/@core/utils'
import { useDisplay } from 'vuetify'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数 // 输入参数
const cardProps = defineProps({ const cardProps = defineProps({
@@ -58,12 +63,14 @@ const userPwForm = ref({
code: '', code: '',
}) })
// 站点使用统计
const siteStats = ref<SiteStatistic>({})
// 查询站点图标 // 查询站点图标
async function getSiteIcon() { async function getSiteIcon() {
try { try {
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -75,15 +82,23 @@ async function testSite() {
testButtonDisable.value = true testButtonDisable.value = true
const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`) const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`)
if (result.success) if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
$toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`) else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
else
$toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
testButtonText.value = '测试' testButtonText.value = '测试'
testButtonDisable.value = false testButtonDisable.value = false
getSiteStats()
} catch (error) {
console.error(error)
} }
catch (error) { }
// 查询站点使用统计
async function getSiteStats() {
try {
siteStats.value = await api.get(`site/statistic/${cardProps.site?.domain}`)
} catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -101,8 +116,7 @@ async function handleResourceBrowse() {
// 调用API更新站点Cookie UA // 调用API更新站点Cookie UA
async function updateSiteCookie() { async function updateSiteCookie() {
try { try {
if (!userPwForm.value.username || !userPwForm.value.password) if (!userPwForm.value.username || !userPwForm.value.password) return
return
// 更新按钮状态 // 更新按钮状态
siteCookieDialog.value = false siteCookieDialog.value = false
@@ -111,26 +125,20 @@ async function updateSiteCookie() {
progressDialog.value = true progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...` progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
`site/cookie/${cardProps.site?.id}`, params: {
{ username: userPwForm.value.username,
params: { password: userPwForm.value.password,
username: userPwForm.value.username, code: userPwForm.value.code,
password: userPwForm.value.password,
code: userPwForm.value.code,
},
}, },
) })
if (result.success) if (result.success) $toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
$toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`) else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
else
$toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
progressDialog.value = false progressDialog.value = false
updateButtonDisable.value = false updateButtonDisable.value = false
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -140,234 +148,175 @@ function openSitePage() {
window.open(cardProps.site?.url, '_blank') window.open(cardProps.site?.url, '_blank')
} }
// 根据站点状态显示不同的状态图标
const statColor = computed(() => {
if (isNullOrEmptyObject(siteStats.value)) {
return 'secondary'
}
if (siteStats.value?.lst_state == 1) {
return 'error'
} else if (siteStats.value?.lst_state == 0) {
if (!siteStats.value?.seconds) return 'secondary'
if (siteStats.value?.seconds >= 5) return 'warning'
return 'success'
}
})
// 监听resourceDialog如果为false则重新查询站点使用统计
watch(resourceDialog, value => {
if (!value) getSiteStats()
})
// 保存站点
function saveSite() {
siteEditDialog.value = false
emit('update')
}
// 装载时查询站点图标 // 装载时查询站点图标
onMounted(() => { onMounted(() => {
getSiteIcon() getSiteIcon()
getSiteStats()
}) })
</script> </script>
<template> <template>
<VCard <div>
:height="cardProps.height" <VCard
:width="cardProps.width" :height="cardProps.height"
:flat="!cardProps.site?.is_active" :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 <template #image>
class="absolute right-2 bottom-2 rounded" <VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
variant="flat" <VImg :src="siteIcon" />
rounded="0" </VAvatar>
> </template>
<VImg :src="siteIcon" /> <VCardItem>
</VAvatar> <VCardTitle class="font-bold">
</template> <span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
<VCardItem> </VCardTitle>
<VCardTitle class="font-bold"> <VCardSubtitle>
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span> <span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
</VCardTitle> </VCardSubtitle>
<VCardSubtitle> </VCardItem>
{{ cardProps.site?.url }} <VCardText class="py-2">
</VCardSubtitle> <VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
</VCardItem> <template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
<ExistIcon v-if="cardProps.site?.is_active" /> </template>
</VTooltip>
<VCardText class="py-2"> <VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
<VTooltip <template #activator="{ props }">
v-if="cardProps.site?.render === 1" <VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
text="浏览器仿真" </template>
> </VTooltip>
<template #activator="{ props }"> <VTooltip v-if="cardProps.site?.limit_interval" text="流控">
<VIcon <template #activator="{ props }">
color="primary" <VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
class="me-2" </template>
v-bind="props" </VTooltip>
icon="mdi-apple-safari" <VTooltip v-if="cardProps.site?.filter" text="过滤">
/> <template #activator="{ props }">
</template> <VIcon color="primary" class="me-2" v-bind="props" icon="mdi-filter-cog-outline" />
</VTooltip> </template>
</VTooltip>
<VTooltip
v-if="cardProps.site?.proxy === 1"
text="代理"
>
<template #activator="{ props }">
<VIcon
color="primary"
class="me-2"
v-bind="props"
icon="mdi-network-outline"
/>
</template>
</VTooltip>
<VTooltip
v-if="cardProps.site?.limit_interval"
text="流控"
>
<template #activator="{ props }">
<VIcon
color="primary"
class="me-2"
v-bind="props"
icon="mdi-speedometer"
/>
</template>
</VTooltip>
<VTooltip
v-if="cardProps.site?.filter"
text="过滤"
>
<template #activator="{ props }">
<VIcon
color="primary"
class="me-2"
v-bind="props"
icon="mdi-filter-cog-outline"
/>
</template>
</VTooltip>
</VCardText>
<VDivider
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">
<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 <template #prepend>
variant="tonal" <VIcon icon="mdi-refresh" />
@click="updateSiteCookie" </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> </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弹窗 -->
<SiteAddEditForm <VDialog v-model="siteCookieDialog" max-width="50rem">
v-model="siteEditDialog" <!-- Dialog Content -->
:siteid="cardProps.site?.id" <VCard title="更新站点Cookie & UA">
@save="siteEditDialog = false; emit('update')" <DialogCloseBtn @click="siteCookieDialog = false" />
@remove="emit('remove')" <VCardText>
@close="siteEditDialog = false" <VForm @submit.prevent="() => {}">
/> <VRow>
<!-- 站点资源弹窗 --> <VCol cols="12" md="4">
<VDialog <VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
v-model="resourceDialog" </VCol>
max-width="80rem" <VCol cols="12" md="4">
scrollable <VTextField
z-index="1010" v-model="userPwForm.password"
> label="密码"
<!-- Dialog Content --> :type="isPasswordVisible ? 'text' : 'password'"
<VCard :title="`浏览站点 - ${cardProps.site?.name}`"> :append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
<DialogCloseBtn @click="resourceDialog = false" /> :rules="[requiredValidator]"
<VCardText class="pt-2"> @click:append-inner="isPasswordVisible = !isPasswordVisible"
<SiteTorrentTable :site="cardProps.site?.id" /> @keydown.enter="updateSiteCookie"
</VCardText> />
</VCard> </VCol>
</VDialog> <VCol cols="12" md="4">
<VDialog <VTextField v-model="userPwForm.code" label="两步验证" />
v-model="progressDialog" </VCol>
:scrim="false" </VRow>
width="25rem" </VForm>
> </VCardText>
<VCard
color="primary" <VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="updateSiteCookie" prepend-icon="mdi-refresh" class="px-5"> 开始更新 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 站点编辑弹窗 -->
<SiteAddEditDialog
v-if="siteEditDialog"
v-model="siteEditDialog"
:siteid="cardProps.site?.id"
@save="saveSite"
@remove="emit('remove')"
@close="siteEditDialog = false"
/>
<!-- 站点资源弹窗 -->
<VDialog
v-if="resourceDialog"
v-model="resourceDialog"
max-width="80rem"
scrollable
z-index="1010"
:fullscreen="!display.mdAndUp.value"
> >
<VCardText class="text-center"> <VCard :title="`浏览站点 - ${cardProps.site?.name}`">
{{ progressText }} <DialogCloseBtn @click="resourceDialog = false" />
<VProgressLinear <VDivider />
indeterminate <VCardText class="pt-2">
color="white" <SiteTorrentTable :site="cardProps.site?.id" />
class="mb-0 mt-1" </VCardText>
/> </VCard>
</VCardText> </VDialog>
</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,7 +1,7 @@
<script lang='ts' setup> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import SubscribeEditForm from '../form/SubscribeEditForm.vue' import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import { calculateTimeDifference } from '@/@core/utils' import { formatDateDifference } from '@/@core/utils/formatters'
import { formatSeason } from '@/@core/utils/formatters' import { formatSeason } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import type { Subscribe } from '@/api/types' import type { Subscribe } from '@/api/types'
@@ -25,13 +25,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?.last_update
? `${calculateTimeDifference(props.media?.last_update || '')}`
: ''
}`,
)
// 图片加载完成响应 // 图片加载完成响应
function imageLoadHandler() { function imageLoadHandler() {
@@ -40,23 +34,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,
) )
} }
@@ -73,16 +61,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)
} }
} }
@@ -90,15 +75,11 @@ 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) {
}
catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -135,11 +116,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,
}, },
}) })
@@ -159,136 +136,108 @@ const dropdownItems = ref([
</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>
/> <VListItem
<span v-for="(item, i) in dropdownItems"
v-if="props.media?.season" :key="i"
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>
/> </VList>
<span </VMenu>
v-if="props.media?.username" </IconBtn>
class="text-subtitle-2 me-4" </div>
:class="getTextClass()" </template>
> </VCardItem>
{{ props.media?.username }} <VCardText>
</span> <p class="clamp-text mb-0" :class="getTextClass()">
</div> {{ props.media?.description }}
</VCardText> </p>
<VCardText </VCardText>
v-if="lastUpdateText" <VCardText class="d-flex justify-space-between align-center flex-wrap">
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300" <div class="d-flex align-center">
> <IconBtn
<VIcon v-if="props.media?.total_episode"
icon="mdi-download" v-bind="props"
class="me-1" icon="mdi-progress-clock"
/> :color="getTextColor()"
{{ lastUpdateText }} class="me-1"
</VCardText> />
<VProgressLinear <span v-if="props.media?.season" class="text-subtitle-2 me-4" :class="getTextClass()"
v-if="getPercentage() > 0" >{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
:model-value="getPercentage()" {{ props.media?.total_episode }}</span
bg-color="success" >
color="success" <IconBtn v-if="props.media?.username" icon="mdi-account" :color="getTextColor()" class="me-1" />
/> <span v-if="props.media?.username" class="text-subtitle-2 me-4" :class="getTextClass()">
</VCard> {{ 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>
<!-- 订阅编辑弹窗 --> <!-- 订阅编辑弹窗 -->
<SubscribeEditForm <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

@@ -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 } from '@/api/types' import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -36,20 +36,20 @@ const meta = ref(props.torrent?.meta_info)
// 站点图标 // 站点图标
const siteIcon = ref('') const siteIcon = ref('')
// 存储是否已经下载过的记录
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,24 +59,15 @@ 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)
} }
// 添加下载 // 添加下载
async function addDownload(_media: any, _torrent: any) { async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress() startNProgress()
try { try {
const result: { [key: string]: any } = await api.post('download/', { const result: { [key: string]: any } = await api.post('download/', {
@@ -87,13 +78,12 @@ async function addDownload(_media: any, _torrent: any) {
if (result.success) { if (result.success) {
// 添加下载成功 // 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`) $toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
} downloaded.value.push(_torrent?.enclosure || '')
else { } else {
// 添加下载失败 // 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`) $toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
doneNProgress() doneNProgress()
@@ -111,14 +101,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'
} }
// 装载时查询站点图标 // 装载时查询站点图标
@@ -131,17 +117,11 @@ onMounted(() => {
<VCard <VCard
:width="props.width" :width="props.width"
:height="props.height" :height="props.height"
: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>
@@ -154,18 +134,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>
@@ -191,25 +163,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
@@ -222,51 +180,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"
@@ -275,10 +206,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>
@@ -288,30 +216,16 @@ onMounted(() => {
<VExpandTransition> <VExpandTransition>
<div v-show="showMoreTorrents"> <div v-show="showMoreTorrents">
<VDivider /> <VDivider />
<VChipGroup class="p-3"> <VChipGroup class="p-3" column>
<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 } from '@/api/types' import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -33,20 +33,20 @@ const meta = ref(props.torrent?.meta_info)
// 站点图标 // 站点图标
const siteIcon = ref('') const siteIcon = ref('')
// 存储是否已经下载过的记录
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
@@ -56,24 +56,15 @@ 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)
} }
// 添加下载 // 添加下载
async function addDownload(_media: any, _torrent: any) { async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress() startNProgress()
try { try {
const result: { [key: string]: any } = await api.post('download/', { const result: { [key: string]: any } = await api.post('download/', {
@@ -84,13 +75,12 @@ async function addDownload(_media: any, _torrent: any) {
if (result.success) { if (result.success) {
// 添加下载成功 // 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`) $toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
} downloaded.value.push(_torrent?.enclosure || '')
else { } else {
// 添加下载失败 // 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`) $toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
doneNProgress() doneNProgress()
@@ -108,14 +98,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'
} }
// 装载时查询站点图标 // 装载时查询站点图标
@@ -125,16 +111,9 @@ onMounted(() => {
</script> </script>
<template> <template>
<VListItem @click="handleAddDownload"> <VListItem @click="handleAddDownload" :variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'">
<template <template v-if="!showMoreTorrents" #prepend>
v-if="!showMoreTorrents" <VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
#prepend
>
<VAvatar
class="rounded"
variant="flat"
@click.stop="openTorrentDetail"
>
<VImg :src="siteIcon" /> <VImg :src="siteIcon" />
</VAvatar> </VAvatar>
</template> </template>
@@ -146,25 +125,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
@@ -177,51 +142,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"
@@ -232,18 +170,10 @@ onMounted(() => {
<template #append> <template #append>
<div class="me-n3"> <div class="me-n3">
<IconBtn> <IconBtn>
<VIcon <VIcon icon="mdi-dots-vertical" />
icon="mdi-dots-vertical" <VMenu activator="parent" close-on-content-click>
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem variant="plain" @click="openTorrentDetail()">
variant="plain"
@click="openTorrentDetail()"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-information" /> <VIcon icon="mdi-information" />
</template> </template>

View File

@@ -0,0 +1,33 @@
<script lang="ts" setup>
// 输入参数
const props = defineProps({
title: String,
})
// 定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 代码
const codeString = ref('')
// 导入
function handleImport() {
emit('update:modelValue', codeString.value)
emit('close')
}
</script>
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard :title="props.title" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VTextarea v-model="codeString" />
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3"> 导入 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
const props = defineProps({
value: Number,
text: String,
})
</script>
<template>
<!-- 手动整理进度框 -->
<VDialog :scrim="false" width="25rem">
<VCard color="primary">
<VCardText class="text-center">
{{ props.text }}
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,9 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import TmdbSelectorCard from '../cards/TmdbSelectorCard.vue' import MediaIdSelector from '../misc/MediaIdSelector.vue'
import store from '@/store' import store from '@/store'
import api from '@/api' import api from '@/api'
import { numberValidator } from '@/@validators' import { numberValidator } from '@/@validators'
import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { MediaDirectory } from '@/api/types'
//
const display = useDisplay()
// //
const props = defineProps({ const props = defineProps({
@@ -15,19 +21,22 @@ const props = defineProps({
// //
const emit = defineEmits(['done', 'close']) const emit = defineEmits(['done', 'close'])
// 150 // 1100
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,
})), })),
) )
//
const mediaSource = ref('themoviedb')
// //
const $toast = useToast() const $toast = useToast()
// TMDB // TMDB
const tmdbSelectorDialog = ref(false) const mediaSelectorDialog = ref(false)
// SSE // SSE
const progressEventSource = ref<EventSource>() const progressEventSource = ref<EventSource>()
@@ -45,8 +54,9 @@ 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,
season: null, season: null,
type_name: '', type_name: '',
transfer_type: '', transfer_type: '',
@@ -55,12 +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
@@ -72,7 +102,7 @@ function startLoadingProgress() {
progressEventSource.value = new EventSource( progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer?token=${token}`, `${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer?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
@@ -89,8 +119,7 @@ function stopLoadingProgress() {
// //
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
async function transfer() { async function transfer() {
if (!props.logids && !props.path) if (!props.logids && !props.path) return
return
// //
progressDialog.value = true progressDialog.value = true
@@ -100,32 +129,33 @@ async function transfer() {
if (props.path) { if (props.path) {
// //
try { try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { const result: { [key: string]: any } = await api.post(
params: transferForm, 'transfer/manual',
}) {},
{
params: transferForm,
},
)
// //
if (result.success) if (result.success) $toast.success(`${props.path} 整理完成!`)
$toast.success(`${props.path} 整理完成`) else $toast.error(`${props.path} 整理失败:${result.message}`)
} catch (e) {
else
$toast.error(`${props.path} 整理失败:${result.message}`)
}
catch (e) {
console.log(e) console.log(e)
} }
} } else if (props.logids) {
else if (props.logids) {
// //
for (const logid of props.logids) { for (const logid of props.logids) {
transferForm.logid = logid transferForm.logid = logid
try { try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { const result: { [key: string]: any } = await api.post(
params: transferForm, 'transfer/manual',
}) {},
if (!result.success) {
$toast.error(`历史记录 ${logid} 重新整理失败:${result.message}`) params: transferForm,
} },
catch (e) { )
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}`)
} catch (e) {
console.log(e) console.log(e)
} }
} }
@@ -138,35 +168,56 @@ async function transfer() {
// //
emit('done') emit('done')
} }
// API
async function loadSystemSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result) mediaSource.value = result.data?.RECOGNIZE_SOURCE || 'themoviedb'
} catch (e) {
console.error(e)
}
}
//
async function loadLibraryDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/LibraryDirectories')
if (result.success && result.data?.value) {
libraryDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
onMounted(() => {
loadSystemSettings()
loadLibraryDirectories()
})
</script> </script>
<template> <template>
<VDialog <VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
scrollable
max-width="60rem"
>
<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 <VCol cols="12" md="8">
cols="12" <VCombobox
md="8"
>
<VTextField
v-model="transferForm.target" v-model="transferForm.target"
:items="targetDirectories"
label="目的路径" label="目的路径"
placeholder="留空自动" placeholder="留空自动"
hint="留空将自动匹配目标路径"
/> />
</VCol> </VCol>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VSelect <VSelect
v-model="transferForm.transfer_type" v-model="transferForm.transfer_type"
label="整理方式" label="整理方式"
@@ -183,34 +234,42 @@ async function transfer() {
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VSelect <VSelect
v-model="transferForm.type_name" v-model="transferForm.type_name"
label="类型" label="类型"
:items="[{ title: '自动', value: '' }, { title: '电影', value: '电影' }, { title: '电视剧', value: '电视剧' }]" :items="[
{ title: '自动', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
]"
/> />
</VCol> </VCol>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VTextField <VTextField
v-if="mediaSource === 'themoviedb'"
v-model="transferForm.tmdbid" v-model="transferForm.tmdbid"
:disabled="transferForm.type_name === ''" :disabled="transferForm.type_name === ''"
label="TMDBID" label="TheMovieDb编号"
placeholder="留空自动识别" placeholder="留空自动识别"
:rules="[numberValidator]" :rules="[numberValidator]"
append-inner-icon="mdi-magnify" append-inner-icon="mdi-magnify"
@click:append-inner="tmdbSelectorDialog = true" hint="点击图标按名称搜索,留空将自动重新识别"
@click:append-inner="mediaSelectorDialog = true"
/>
<VTextField
v-else
v-model="transferForm.doubanid"
:disabled="transferForm.type_name === ''"
label="豆瓣编号"
placeholder="留空自动识别"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
hint="点击图标按名称搜索,留空将自动重新识别"
@click:append-inner="mediaSelectorDialog = true"
/> />
</VCol> </VCol>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VSelect <VSelect
v-show="transferForm.type_name === '电视剧'" v-show="transferForm.type_name === '电视剧'"
v-model.number="transferForm.season" v-model.number="transferForm.season"
@@ -225,6 +284,7 @@ async function transfer() {
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}"
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -232,6 +292,7 @@ async function transfer() {
v-model="transferForm.episode_detail" v-model="transferForm.episode_detail"
label="指定集数" label="指定集数"
placeholder="起始集,终止集如1或1,2" placeholder="起始集,终止集如1或1,2"
hint="直接指定集数或者范围,格式:起始集,终止集如1或1,2"
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -239,6 +300,7 @@ async function transfer() {
v-model="transferForm.episode_part" v-model="transferForm.episode_part"
label="指定Part" label="指定Part"
placeholder="如part1" placeholder="如part1"
hint="指定集数的Part如part1"
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -246,6 +308,7 @@ async function transfer() {
v-model.number="transferForm.episode_offset" v-model.number="transferForm.episode_offset"
label="集数偏移" label="集数偏移"
placeholder="如-10" placeholder="如-10"
hint="对集数进行偏移运算,如-10表示文件名中的集数减10为整理后集数"
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -254,53 +317,37 @@ async function transfer() {
label="最小文件大小MB" label="最小文件大小MB"
:rules="[numberValidator]" :rules="[numberValidator]"
placeholder="0" placeholder="0"
hint="最小文件大小,小于此大小的文件将被忽略不进行整理"
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="transferForm.scrape" label="刮削元数据" hint="整理完成后自动刮削元数据" />
</VCol>
</VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions> <VCardActions class="pt-3">
<VBtn depressed @click="emit('close')">
取消
</VBtn>
<VSpacer /> <VSpacer />
<VBtn <VBtn variant="elevated" @click="transfer" prepend-icon="mdi-arrow-right-bold" class="px-5"> 开始整理 </VBtn>
variant="tonal"
@click="transfer"
>
开始整理
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
<!-- 手动整理进度框 --> <!-- 手动整理进度框 -->
<VDialog <ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
v-if="progressValue"
color="white"
class="mb-0 mt-1"
:model-value="progressValue"
/>
</VCardText>
</VCard>
</VDialog>
<!-- TMDB ID搜索框 --> <!-- TMDB ID搜索框 -->
<VDialog <VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
v-model="tmdbSelectorDialog" <MediaIdSelector
width="40rem" v-if="mediaSource === 'themoviedb'"
scrollable
>
<TmdbSelectorCard
v-model="transferForm.tmdbid" v-model="transferForm.tmdbid"
@close="tmdbSelectorDialog = false" @close="mediaSelectorDialog = false"
:type="mediaSource"
/>
<MediaIdSelector
v-else
v-model="transferForm.doubanid"
@close="mediaSelectorDialog = false"
:type="mediaSource"
/> />
</VDialog> </VDialog>
</VDialog> </VDialog>

View File

@@ -4,6 +4,14 @@ import type { Site } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress' 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 { useConfirm } from 'vuetify-use-dialog'
//
const display = useDisplay()
//
const createConfirm = useConfirm()
// //
const props = defineProps({ const props = defineProps({
@@ -40,7 +48,7 @@ const statusItems = [
// 150 // 150
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,
})), })),
@@ -48,8 +56,7 @@ const priorityItems = ref(
// //
watchEffect(async () => { watchEffect(async () => {
if (props.siteid) if (props.siteid) fetchSiteInfo()
fetchSiteInfo()
}) })
// //
@@ -58,27 +65,24 @@ async function fetchSiteInfo() {
siteForm.value = await api.get(`site/${props.siteid}`) siteForm.value = await api.get(`site/${props.siteid}`)
siteForm.value.proxy = siteForm.value.proxy === 1 siteForm.value.proxy = siteForm.value.proxy === 1
siteForm.value.render = siteForm.value.render === 1 siteForm.value.render = siteForm.value.render === 1
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
// API // API
async function addSite() { async function addSite() {
if (!siteForm.value?.url) if (!siteForm.value?.url) return
return
startNProgress() startNProgress()
try { try {
const result: { [key: string]: string } = await api.post('site/', siteForm.value) const result: { [key: string]: string } = await api.post('site/', siteForm.value)
if (result.success) { if (result.success) {
$toast.success('新增站点成功') $toast.success('新增站点成功')
emit('save') emit('save')
} else {
$toast.error(`新增站点失败:${result.message}`)
} }
} catch (error) {
else { $toast.error(`新增站点失败:${result.message}`) }
}
catch (error) {
console.error(error) console.error(error)
} }
doneNProgress() doneNProgress()
@@ -86,14 +90,18 @@ 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) if (result.success) emit('remove')
emit('remove')
else $toast.error(`${siteForm.value?.name} 删除失败:${result.message}`) else $toast.error(`${siteForm.value?.name} 删除失败:${result.message}`)
} } catch (error) {
catch (error) {
$toast.error(`${siteForm.value?.name} 删除失败!`) $toast.error(`${siteForm.value?.name} 删除失败!`)
console.error(error) console.error(error)
} }
@@ -107,10 +115,10 @@ async function updateSiteInfo() {
if (result.success) { if (result.success) {
$toast.success(`${siteForm.value?.name} 更新成功!`) $toast.success(`${siteForm.value?.name} 更新成功!`)
emit('save') emit('save')
} else {
$toast.error(`${siteForm.value?.name} 更新失败:${result.message}`)
} }
else { $toast.error(`${siteForm.value?.name} 更新失败:${result.message}`) } } catch (error) {
}
catch (error) {
$toast.error(`${siteForm.value?.name} 更新失败!`) $toast.error(`${siteForm.value?.name} 更新失败!`)
console.error(error) console.error(error)
} }
@@ -119,155 +127,135 @@ async function updateSiteInfo() {
</script> </script>
<template> <template>
<VDialog <VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
scrollable
:close-on-back="false"
persistent
eager
max-width="60rem"
>
<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 <VCol cols="12" md="6">
cols="12"
md="6"
>
<VTextField <VTextField
v-model="siteForm.url" v-model="siteForm.url"
label="站点地址" label="站点地址"
:rules="[requiredValidator]" :rules="[requiredValidator]"
hint="格式http://www.example.com/"
/> />
</VCol> </VCol>
<VCol <VCol cols="6" md="3">
cols="12"
md="3"
>
<VSelect <VSelect
v-model="siteForm.pri" v-model="siteForm.pri"
label="优先级" label="优先级"
:items="priorityItems" :items="priorityItems"
:rules="[requiredValidator]" :rules="[requiredValidator]"
hint="站点资源下载优先级,优先级数字越小越优先下载"
/> />
</VCol> </VCol>
<VCol <VCol cols="6" md="3">
cols="12" <VSelect v-model="siteForm.is_active" :items="statusItems" label="状态" />
md="3"
>
<VSelect
v-model="siteForm.is_active"
:items="statusItems"
label="状态"
/>
</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时将会使用此地址获取站点种子资源该地址一般会自动获取也可手动补充"
/> />
</VCol> </VCol>
<VCol cols="12" md="3">
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间,为空将使用默认值" />
</VCol>
<VCol cols="12"> <VCol cols="12">
<VTextarea <VTextarea
v-model="siteForm.cookie" v-model="siteForm.cookie"
label="站点Cookie" label="站点Cookie"
hint="浏览器打开站点首页打开开发人员工具刷新页面后在网络选项中找到首页地址在请求头中获取Cookie信息"
/> />
</VCol> </VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="在开发人员工具网络请求头中获取Authorization仅个别站点需要"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="siteForm.apikey" label="令牌API Key" hint="站点的访问API Key仅个别站点需要" />
</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配套使用"
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VTextField <VTextField
v-model="siteForm.limit_interval" v-model="siteForm.limit_interval"
label="单位周期(秒)" label="单位周期(秒)"
:rules="[numberValidator]" :rules="[numberValidator]"
hint="设定站点限流的单位周期单位为秒0为不限流"
/> />
</VCol> </VCol>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VTextField <VTextField
v-model="siteForm.limit_count" v-model="siteForm.limit_count"
label="访问次数" label="周期内访问次数"
:rules="[numberValidator]" :rules="[numberValidator]"
hint="设定单位周期内站点允许的访问次数0为不限制"
/> />
</VCol> </VCol>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VTextField <VTextField
v-model="siteForm.limit_seconds" v-model="siteForm.limit_seconds"
label="访问间隔(秒)" label="访问间隔(秒)"
:rules="[numberValidator]" :rules="[numberValidator]"
hint="设定单位周期内每次站点访问需间隔时间单位为秒0为不限制"
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol <VCol cols="12" md="6">
cols="12" <VSwitch v-model="siteForm.proxy" label="代理" hint="站点是否需要代理访问,需要设置好代理服务器信息" />
md="6"
>
<VSwitch
v-model="siteForm.proxy"
label="代理"
/>
</VCol> </VCol>
<VCol <VCol cols="12" md="6">
cols="12"
md="6"
>
<VSwitch <VSwitch
v-model="siteForm.render" v-model="siteForm.render"
label="仿真" label="仿真"
hint="站点是否需要使用浏览器模拟访问,开启可以一定程度上提升连通性,但会大大增加站点请求时间"
/> />
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions> <VCardActions class="pt-3">
<VBtn <VBtn v-if="props.oper !== 'add'" color="error" @click="deleteSiteInfo" variant="outlined" class="me-3">
v-if="props.oper === 'add'"
@click="emit('close')"
>
取消
</VBtn>
<VBtn
v-else
color="error"
@click="deleteSiteInfo"
>
删除 删除
</VBtn> </VBtn>
<VSpacer /> <VSpacer />
<VBtn <VBtn
v-if="props.oper === 'add'" v-if="props.oper === 'add'"
color="primary" color="primary"
variant="tonal" variant="elevated"
@click="addSite" @click="addSite"
prepend-icon="mdi-plus"
class="px-5"
> >
新增 新增
</VBtn> </VBtn>
<VBtn <VBtn
v-else v-else
color="primary" color="primary"
variant="tonal" variant="elevated"
@click="updateSiteInfo" @click="updateSiteInfo"
prepend-icon="mdi-content-save"
class="px-5"
> >
保存 保存
</VBtn> </VBtn>

View File

@@ -2,11 +2,21 @@
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 { useConfirm } from 'vuetify-use-dialog'
//
const display = useDisplay()
//
const createConfirm = useConfirm()
// //
const props = defineProps({ const props = defineProps({
subid: Number, subid: Number,
default: Boolean,
type: String,
}) })
// //
@@ -15,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 }[]>([])
@@ -41,6 +54,8 @@ const subscribeForm = ref<Subscribe>({
username: '', username: '',
current_priority: 0, current_priority: 0,
save_path: '', save_path: '',
date: '',
show_edit_dialog: false,
}) })
// //
@@ -55,14 +70,47 @@ async function updateSubscribeInfo() {
$toast.success(`${subscribeForm.value.name} 更新成功!`) $toast.success(`${subscribeForm.value.name} 更新成功!`)
// //
emit('save') emit('save')
} else {
$toast.error(`${subscribeForm.value.name} 更新失败:${result.message}`)
} }
else { $toast.error(`${subscribeForm.value.name} 更新失败:${result.message}`) } } catch (e) {
}
catch (e) {
console.log(e) console.log(e)
} }
} }
//
async function saveDefaultSubscribeConfig() {
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
if (result.success) $toast.success(`${props.type}订阅默认规则保存成功`)
else $toast.error(`${props.type}订阅默认规则保存失败!`)
//
emit('save')
} catch (error) {
console.log(error)
}
}
//
async function queryDefaultSubscribeConfig() {
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.get(subscribe_config_url)
if (result.data.value) subscribeForm.value = result.data?.value ?? ''
} catch (error) {
console.log(error)
}
}
// //
async function loadSites() { async function loadSites() {
try { try {
@@ -70,8 +118,7 @@ async function loadSites() {
// //
siteList.value = data.filter(item => item.is_active) siteList.value = data.filter(item => item.is_active)
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -79,10 +126,9 @@ async function loadSites() {
// //
async function getSiteList() { async function getSiteList() {
// //
if (!siteList.value.length) if (!siteList.value.length) await loadSites()
await loadSites()
const maps = siteList.value.map((item) => { const maps = siteList.value.map(item => {
return { return {
title: item.name, title: item.name,
value: item.id, value: item.id,
@@ -95,35 +141,54 @@ async function getSiteList() {
// //
async function getSubscribeInfo() { async function getSubscribeInfo() {
try { try {
const result: Subscribe = await api.get( const result: Subscribe = await api.get(`subscribe/${props.subid}`)
`subscribe/${props.subid}`,
)
subscribeForm.value = result subscribeForm.value = result
subscribeForm.value.best_version = subscribeForm.value.best_version === 1 subscribeForm.value.best_version = subscribeForm.value.best_version === 1
subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1 subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
// //
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( const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
`subscribe/${props.subid}`,
)
if (result.success) { if (result.success) {
// //
emit('remove') emit('remove')
} }
} } catch (e) {
catch (e) {
console.log(e) console.log(e)
} }
} }
//
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([
{ {
@@ -208,164 +273,136 @@ const effectOptions = ref([
}, },
]) ])
watchEffect(() => { onMounted(() => {
if (props.subid) { loadDownloadDirectories()
getSiteList() getSiteList()
getSubscribeInfo() if (props.subid) getSubscribeInfo()
} if (props.default) queryDefaultSubscribeConfig()
}) })
</script> </script>
<template> <template>
<VDialog <VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
scrollable
max-width="60rem"
>
<VCard <VCard
:title="`编辑订阅 - ${subscribeForm.name} ${subscribeForm.season ? `第 ${subscribeForm.season} 季` : ''}`" :title="`${
props.default
? `${props.type}默认订阅规则`
: `编辑订阅 - ${subscribeForm.name} ${subscribeForm.season ? `${subscribeForm.season}` : ''}`
}`"
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>
<VCol <VCol cols="12" md="8">
cols="12"
md="8"
>
<VTextField <VTextField
v-if="!props.default"
v-model="subscribeForm.keyword" v-model="subscribeForm.keyword"
label="搜索关键词" label="搜索关键词"
hint="设定搜索关键词后将使用此关键词搜索站点资源否则自动使用themoviedb中的名称搜索"
/> />
</VCol> </VCol>
<VCol <VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
v-if="subscribeForm.type === '电视剧'"
cols="12"
md="2"
>
<VTextField <VTextField
v-model="subscribeForm.total_episode" v-model="subscribeForm.total_episode"
label="总集数" label="总集数"
:rules="[numberValidator]" :rules="[numberValidator]"
hint="手动设定总集数"
/> />
</VCol> </VCol>
<VCol <VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
v-if="subscribeForm.type === '电视剧'"
cols="12"
md="2"
>
<VTextField <VTextField
v-model="subscribeForm.start_episode" v-model="subscribeForm.start_episode"
label="开始集数" label="开始集数"
:rules="[numberValidator]" :rules="[numberValidator]"
hint="只下载此集数及之后的集"
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol <VCol cols="12" md="4">
cols="12" <VSelect v-model="subscribeForm.quality" label="质量" :items="qualityOptions" />
md="4"
>
<VSelect
v-model="subscribeForm.quality"
label="质量"
:items="qualityOptions"
/>
</VCol> </VCol>
<VCol <VCol cols="12" md="4">
cols="12" <VSelect v-model="subscribeForm.resolution" label="分辨率" :items="resolutionOptions" />
md="4"
>
<VSelect
v-model="subscribeForm.resolution"
label="分辨率"
:items="resolutionOptions"
/>
</VCol> </VCol>
<VCol <VCol cols="12" md="4">
cols="12" <VSelect v-model="subscribeForm.effect" label="特效" :items="effectOptions" />
md="4"
>
<VSelect
v-model="subscribeForm.effect"
label="特效"
:items="effectOptions"
/>
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VTextField <VTextField
v-model="subscribeForm.include" v-model="subscribeForm.include"
label="包含(关键字、正则式)" label="包含(关键字、正则式)"
hint="支持正则表达式,多个关键字用 | 分隔表示或"
/> />
</VCol> </VCol>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VTextField <VTextField
v-model="subscribeForm.exclude" v-model="subscribeForm.exclude"
label="排除(关键字、正则式)" label="排除(关键字、正则式)"
hint="支持正则表达式,多个关键字用 | 分隔表示或"
/> />
</VCol> </VCol>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VSelect <VSelect
v-model="subscribeForm.sites" v-model="subscribeForm.sites"
:items="selectSitesOptions" :items="selectSitesOptions"
chips chips
label="订阅站点" label="订阅站点"
multiple multiple
hint="只订阅选中的订阅站点,不选则订阅所有可订阅站点"
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol <VCol cols="12">
cols="12" <VCombobox
>
<VTextField
v-model="subscribeForm.save_path" v-model="subscribeForm.save_path"
:items="targetDirectories"
label="保存路径" label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VSwitch <VSwitch
v-model="subscribeForm.best_version" v-model="subscribeForm.best_version"
label="洗版" label="洗版"
hint="开启后不管媒体库是否存在,均会根据洗版优先级进行过滤下载,直到下载到了最高优先级的资源为止"
/> />
</VCol> </VCol>
<VCol <VCol cols="12" md="4">
cols="12"
md="4"
>
<VSwitch <VSwitch
v-model="subscribeForm.search_imdbid" v-model="subscribeForm.search_imdbid"
label="使用 ImdbID 搜索" label="使用 ImdbID 搜索"
hint="开启后将使用 ImdbID 搜索资源,搜索结果更精确,但不是所有站点都支持"
/>
</VCol>
<VCol v-if="props.default" cols="12" md="4">
<VSwitch
v-model="subscribeForm.show_edit_dialog"
label="订阅时编辑更多规则"
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 color="error" @click="removeSubscribe">
取消订阅 取消订阅
</VBtn> </VBtn>
<VSpacer /> <VSpacer />
<VBtn <VBtn
variant="tonal" variant="elevated"
@click="updateSubscribeInfo" @click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
prepend-icon="mdi-content-save"
class="px-5"
> >
保存 保存
</VBtn> </VBtn>

View File

@@ -0,0 +1,211 @@
<script lang="ts" setup>
import api from '@/api'
import { Subscribe } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters'
import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
type: String,
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'save'])
// 订阅历史列表
const historyList = ref<Subscribe[]>([])
// 当前加载数据
const currData = ref<Subscribe[]>([])
// 当前页
const currentPage = ref(1)
// 每页数量
const pageSize = ref(30)
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 进度框
const progressDialog = ref(false)
// 进度文字
const progressText = ref('正在重新订阅...')
// 调用API查询列表
async function loadHistory({ done }: { done: any }) {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
// 调用API查询列表
try {
// 设置加载中
loading.value = true
currData.value = await api.get(`subscribe/history/${props.type}`, {
params: {
page: currentPage.value,
count: pageSize.value,
},
})
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
} else {
// 合并数据
historyList.value = [...historyList.value, ...currData.value]
// 页码+1
currentPage.value++
// 返回加载成功
done('ok')
}
// 取消加载中
loading.value = false
} catch (e) {
console.error(e)
// 返回加载失败
done('error')
}
}
// 重新订阅
async function reSubscribe(item: Subscribe) {
if (item.type === '电影') progressText.value = `正在重新订阅 ${item.name} ...`
else progressText.value = `正在重新订阅 ${item.name}${item.season} 季 ...`
progressDialog.value = true
try {
const result: { [key: string]: any } = await api.post('subscribe', item)
if (result.success) {
emit('save')
}
} catch (e) {
console.error(e)
}
progressDialog.value = false
}
// 删除记录
async function deleteHistory(item: Subscribe) {
try {
const result: { [key: string]: any } = await api.delete(`subscribe/history/${item.id}`)
if (result.success) {
historyList.value = historyList.value.filter(i => i.id !== item.id)
}
} catch (e) {
console.error(e)
}
}
// 弹出菜单
const dropdownItems = ref([
{
title: '重新订阅',
value: 1,
color: '',
props: {
prependIcon: 'mdi-redo',
click: reSubscribe,
},
},
{
title: '删除',
value: 2,
color: 'error',
props: {
prependIcon: 'mdi-delete',
click: deleteHistory,
},
},
])
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%">
<VCardItem>
<VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle>
</VCardItem>
<VDivider />
<DialogCloseBtn
@click="
() => {
emit('close')
}
"
/>
<VList lines="two">
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-hidden" @load="loadHistory">
<template #loading>
<LoadingBanner />
</template>
<template #empty />
<template v-for="(item, i) in historyList" :key="i">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded shadow ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }} <span class="text-sm"> {{ item.season }} </span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
</template>
</VInfiniteScroll>
</VList>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</VDialog>
</template>

View File

@@ -0,0 +1,510 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import type { PropType } from 'vue'
import { useConfirm } from 'vuetify-use-dialog'
import axios from 'axios'
import { useToast } from 'vue-toast-notification'
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
import { formatBytes } from '@core/utils/formatters'
import type { Context, EndPoints, FileItem } from '@/api/types'
import store from '@/store'
import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 输入参数
const inProps = defineProps({
icons: Object,
storage: String,
path: String,
endpoints: Object as PropType<EndPoints>,
axios: Object as PropType<Axios>,
refreshpending: Boolean,
sort: String,
})
// 对外事件
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
// 提示框
const $toast = useToast()
// 是否正在加载
const loading = ref(true)
// 识别进度条
const progressDialog = ref(false)
// 识别进度文本
const progressText = ref('请稍候 ...')
// 识别进度
const progressValue = ref(0)
// 确认框
const createConfirm = useConfirm()
// 存储空间类型
const storage = ref(inProps.storage ?? '')
// axios实例
const axiosInstance = ref<Axios>(inProps.axios ?? axios)
// 内容列表
const items = ref<FileItem[]>([])
// 过滤条件
const filter = ref('')
// 重命名弹窗
const renamePopper = ref(false)
// 整理弹窗
const transferPopper = ref(false)
// 新名称
const newName = ref('')
// 当前名称
const currentItem = ref<FileItem>()
// 识别结果
const nameTestResult = ref<Context>()
// 识别结果对话框
const nameTestDialog = ref(false)
// 目录过滤
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)))
// 文件过滤
const files = computed(() => items.value.filter(item => item.type === 'file' && item.basename.includes(filter.value)))
// 是否目录
const isDir = computed(() => inProps.path?.endsWith('/'))
// 是否文件
const isFile = computed(() => !isDir.value)
// 是否为图片文件
const isImage = computed(() => {
const ext = inProps.path?.split('.').pop()?.toLowerCase()
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
})
// 调API加载内容
async function load() {
loading.value = true
emit('loading', true)
// 参数
const url = inProps.endpoints?.list.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(inProps.path || ''))
.replace(/{sort}/g, inProps.sort || 'name')
const config = {
url,
method: inProps.endpoints?.list.method || 'get',
}
// 加载数据
items.value = (await axiosInstance.value.request(config)) ?? []
emit('loading', false)
loading.value = false
}
// 删除项目
async function deleteItem(item: FileItem) {
const confirmed = await createConfirm({
title: '确认',
content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.basename}`,
})
if (confirmed) {
emit('loading', true)
const url = inProps.endpoints?.delete.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(item.path))
const config = {
url,
method: inProps.endpoints?.delete.method || 'post',
}
await axiosInstance.value.request(config)
emit('filedeleted')
emit('loading', false)
// 重新加载
load()
}
}
// 切换路径
function changePath(_path: string) {
emit('pathchanged', _path)
}
// 新窗口中下载文件
function download(path: string) {
if (!path) return
const token = store.state.auth.token
const url_path = inProps.endpoints?.download.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(path))
const url = `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
// 下载文件
window.open(url, '_blank')
}
// 显示图片
function getImgLink(path: string) {
if (!path) return ''
const token = store.state.auth.token
const url_path = inProps.endpoints?.image.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(path))
return `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
}
// 显示重命名弹窗
function showRenmae(item: FileItem) {
currentItem.value = item
newName.value = item.name
renamePopper.value = true
}
// 重命名
async function rename() {
emit('loading', true)
const url = inProps.endpoints?.rename.url
.replace(/{storage}/g, inProps.storage)
.replace(/{path}/g, encodeURIComponent(currentItem.value?.path || ''))
.replace(/{newname}/g, encodeURIComponent(newName.value))
const config = {
url,
method: inProps.endpoints?.mkdir.method || 'post',
}
// 调API
await inProps.axios?.request(config)
renamePopper.value = false
newName.value = ''
emit('loading', false)
// 通知重新加载
emit('renamed')
}
// 显示整理对话框
function showTransfer(item: FileItem) {
currentItem.value = item
transferPopper.value = true
}
// 将文件修改时间timestape转换为本地时间
function formatTime(timestape: number) {
return new Date(timestape * 1000).toLocaleString()
}
// 监听path变化
watch(
() => inProps.path,
async () => {
items.value = []
nameTestResult.value = undefined
nameTestDialog.value = false
await load()
},
)
// 监听refreshPending变化
watch(
() => inProps.refreshpending,
async () => {
if (inProps.refreshpending) {
await load()
emit('refreshed')
}
},
)
// 调用API识别
async function recognize(path: string) {
try {
// 显示进度条
progressDialog.value = true
progressText.value = `正在识别 ${path} ...`
progressValue.value = 0
nameTestResult.value = await api.get('media/recognize_file', {
params: {
path,
},
})
// 关闭进度条
progressDialog.value = false
if (!nameTestResult.value) $toast.error(`${path} 识别失败!`)
nameTestDialog.value = !!nameTestResult.value?.meta_info?.name
} catch (error) {
console.error(error)
}
}
// 调用API刮削
async function scrape(path: string) {
try {
// 显示进度条
progressDialog.value = true
progressText.value = `正在刮削 ${path} ...`
const result: { [key: string]: any } = await api.get('media/scrape', {
params: {
path,
},
})
// 关闭进度条
progressDialog.value = false
if (!result.success) $toast.error(result.message)
else $toast.success(`${path}削刮完成!`)
} catch (error) {
console.error(error)
}
}
// 弹出菜单
const dropdownItems = ref([
{
title: '识别',
value: 1,
props: {
prependIcon: 'mdi-text-recognition',
click: (_item: FileItem) => {
recognize(_item.path || '')
},
},
},
{
title: '刮削',
value: 2,
props: {
prependIcon: 'mdi-auto-fix',
click: (_item: FileItem) => {
scrape(_item.path || '')
},
},
},
{
title: '重命名',
value: 3,
props: {
prependIcon: 'mdi-rename',
click: showRenmae,
},
},
{
title: '整理',
value: 4,
props: {
prependIcon: 'mdi-folder-arrow-right',
click: showTransfer,
},
},
{
title: '删除',
value: 5,
props: {
prependIcon: 'mdi-delete-outline',
color: 'error',
click: deleteItem,
},
},
])
onMounted(() => {
load()
})
</script>
<template>
<VCard class="d-flex flex-column">
<VToolbar v-if="!loading" density="compact" flat color="gray">
<VTextField
v-if="!isFile"
v-model="filter"
hide-details
flat
density="compact"
variant="solo-filled"
placeholder="搜索 ..."
prepend-inner-icon="mdi-filter-outline"
class="me-2"
rounded="0"
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
<VIcon color="primary"> mdi-text-recognition </VIcon>
</IconBtn>
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
<VIcon color="primary"> mdi-download </VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="load">
<VIcon color="primary"> mdi-refresh </VIcon>
</IconBtn>
</VToolbar>
<VCardText v-if="loading" class="text-center flex flex-col items-center">
<VProgressCircular size="48" indeterminate color="primary" />
</VCardText>
<VCardText v-if="!path" class="grow d-flex justify-center align-center grey--text"> 选择目录或文件 </VCardText>
<VCardText v-else-if="isFile && !isImage" class="text-center break-all">
<strong>{{ items[0]?.name }}</strong
><br />
大小{{ formatBytes(items[0]?.size || 0) }}<br />
修改时间{{ formatTime(items[0]?.modify_time || 0) }}
</VCardText>
<VCardText v-else-if="isFile && isImage" class="grow d-flex justify-center align-center">
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
</VCardText>
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList subheader>
<VVirtualScroll class="virtual-scroll-div" :items="[...dirs, ...files]">
<template #default="{ item }">
<VHover>
<template #default="hover">
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="changePath(item.path)">
<template #prepend>
<VIcon
v-if="inProps.icons && item.extension"
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
/>
<VIcon v-else icon="mdi-folder-outline" />
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle v-if="item.size">
{{ formatBytes(item.size) }}
</VListItemSubtitle>
<template #append>
<IconBtn class="d-sm-none">
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span v-if="hover.isHovering" class="flex">
<VTooltip text="识别">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="刮削">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="重命名">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="整理">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="删除">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</template>
</VTooltip>
</span>
</template>
</VListItem>
</template>
</VHover>
</template>
</VVirtualScroll>
</VList>
</VCardText>
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
没有目录或文件
</VCardText>
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5"> 空目录 </VCardText>
</VCard>
<!-- 重命名弹窗 -->
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="50rem">
<VCard title="重命名">
<VCardText>
<VTextField v-model="newName" label="名称" />
</VCardText>
<VCardActions>
<VBtn depressed @click="renamePopper = false"> 取消 </VBtn>
<VSpacer />
<VBtn :disabled="!newName" depressed variant="tonal" @click="rename"> 重命名 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 文件整理弹窗 -->
<ReorganizeDialog
v-if="transferPopper"
v-model="transferPopper"
:path="currentItem?.path"
@done="
() => {
transferPopper = false
load()
}
"
@close="transferPopper = false"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- 识别结果对话框 -->
<VDialog v-if="nameTestDialog" v-model="nameTestDialog" width="50rem">
<VCard>
<DialogCloseBtn @click="nameTestDialog = false" />
<VCardItem>
<MediaInfoCard :context="nameTestResult" />
</VCardItem>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
.v-card {
block-size: 100%;
}
.v-toolbar {
background: rgb(var(--v-table-header-background));
}
.virtual-scroll-div {
block-size: calc(100vh - 14rem);
}
@media (width <= 768px) {
.virtual-scroll-div {
block-size: calc(100vh - 17rem);
}
}
</style>

View File

@@ -1,671 +0,0 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import type { PropType } from 'vue'
import { useConfirm } from 'vuetify-use-dialog'
import axios from 'axios'
import { useToast } from 'vue-toast-notification'
import ReorganizeForm from '../form/ReorganizeForm.vue'
import { formatBytes } from '@core/utils/formatters'
import type { Context, EndPoints, FileItem } from '@/api/types'
import store from '@/store'
import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import { useDefer } from '@/@core/utils/dom'
// 输入参数
const inProps = defineProps({
icons: Object,
storage: String,
path: String,
endpoints: Object as PropType<EndPoints>,
axios: Object as PropType<Axios>,
refreshpending: Boolean,
sort: String,
})
// 对外事件
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
// 提示框
const $toast = useToast()
// 是否正在加载
const loading = ref(true)
// 识别进度条
const progressDialog = ref(false)
// 识别进度文本
const progressText = ref('请稍候 ...')
// 识别进度
const progressValue = ref(0)
// 确认框
const createConfirm = useConfirm()
// 存储空间类型
const storage = ref(inProps.storage ?? '')
// axios实例
const axiosInstance = ref<Axios>(inProps.axios ?? axios)
// 内容列表
const items = ref<FileItem[]>([])
// 过滤条件
const filter = ref('')
// 重命名弹窗
const renamePopper = ref(false)
// 整理弹窗
const transferPopper = ref(false)
// 新名称
const newName = ref('')
// 当前名称
const currentItem = ref<FileItem>()
// 识别结果
const nameTestResult = ref<Context>()
// 识别结果对话框
const nameTestDialog = ref(false)
// 延迟加载
let defer = (_: number) => true
// 目录过滤
const dirs = computed(() =>
items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)),
)
// 文件过滤
const files = computed(() =>
items.value.filter(item => item.type === 'file' && item.basename.includes(filter.value)),
)
// 是否目录
const isDir = computed(() => inProps.path?.endsWith('/'))
// 是否文件
const isFile = computed(() => !isDir.value)
// 是否为图片文件
const isImage = computed(() => {
const ext = inProps.path?.split('.').pop()?.toLowerCase()
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
})
// 调API加载内容
async function load() {
loading.value = true
emit('loading', true)
// 参数
const url = inProps.endpoints?.list.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(inProps.path || ''))
.replace(/{sort}/g, inProps.sort || 'name')
const config = {
url,
method: inProps.endpoints?.list.method || 'get',
}
// 加载数据
items.value = await axiosInstance.value.request(config) ?? []
defer = useDefer(items.value.length)
emit('loading', false)
loading.value = false
}
// 删除项目
async function deleteItem(item: FileItem) {
const confirmed = await createConfirm({
title: '确认',
content: `是否确认删除${
item.type === 'dir' ? '目录' : '文件'
} ${item.basename}`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
cancellationButtonProps: {
variant: 'tonal',
},
})
if (confirmed) {
emit('loading', true)
const url = inProps.endpoints?.delete.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(item.path))
const config = {
url,
method: inProps.endpoints?.delete.method || 'post',
}
await axiosInstance.value.request(config)
emit('filedeleted')
emit('loading', false)
// 重新加载
load()
}
}
// 切换路径
function changePath(_path: string) {
emit('pathchanged', _path)
}
// 新窗口中下载文件
function download(path: string) {
if (!path)
return
const token = store.state.auth.token
const url_path = inProps.endpoints?.download.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(path))
const url = `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
// 下载文件
window.open(url, '_blank')
}
// 显示图片
function getImgLink(path: string) {
if (!path)
return ''
const token = store.state.auth.token
const url_path = inProps.endpoints?.image.url
.replace(/{storage}/g, storage.value)
.replace(/{path}/g, encodeURIComponent(path))
return `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
}
// 显示重命名弹窗
function showRenmae(item: FileItem) {
currentItem.value = item
newName.value = item.name
renamePopper.value = true
}
// 重命名
async function rename() {
emit('loading', true)
const url = inProps.endpoints?.rename.url
.replace(/{storage}/g, inProps.storage)
.replace(/{path}/g, encodeURIComponent(currentItem.value?.path || ''))
.replace(/{newname}/g, encodeURIComponent(newName.value))
const config = {
url,
method: inProps.endpoints?.mkdir.method || 'post',
}
// 调API
await inProps.axios?.request(config)
renamePopper.value = false
newName.value = ''
emit('loading', false)
// 通知重新加载
emit('renamed')
}
// 显示整理对话框
function showTransfer(item: FileItem) {
currentItem.value = item
transferPopper.value = true
}
// 将文件修改时间timestape转换为本地时间
function formatTime(timestape: number) {
return new Date(timestape * 1000).toLocaleString()
}
// 监听path变化
watch(
() => inProps.path,
async () => {
items.value = []
nameTestResult.value = undefined
nameTestDialog.value = false
await load()
},
)
// 监听refreshPending变化
watch(
() => inProps.refreshpending,
async () => {
if (inProps.refreshpending) {
await load()
emit('refreshed')
}
},
)
// 调用API识别
async function recognize(path: string) {
try {
// 显示进度条
progressDialog.value = true
progressText.value = `正在识别 ${path} ...`
progressValue.value = 0
nameTestResult.value = await api.get('media/recognize_file', {
params: {
path,
},
})
// 关闭进度条
progressDialog.value = false
if (!nameTestResult.value)
$toast.error(`${path} 识别失败!`)
nameTestDialog.value = !!nameTestResult.value?.meta_info?.name
}
catch (error) {
console.error(error)
}
}
// 调用API刮削
async function scrape(path: string) {
try {
// 显示进度条
progressDialog.value = true
progressText.value = `正在刮削 ${path} ...`
const result: { [key: string]: any } = await api.get('media/scrape', {
params: {
path,
},
})
// 关闭进度条
progressDialog.value = false
if (!result.success)
$toast.error(result.message)
else
$toast.success(`${path}削刮完成!`)
}
catch (error) {
console.error(error)
}
}
// 弹出菜单
const dropdownItems = ref([
{
title: '识别',
value: 1,
props: {
prependIcon: 'mdi-text-recognition',
click: (_item: FileItem) => {
recognize(_item.path || '')
},
},
}, {
title: '刮削',
value: 2,
props: {
prependIcon: 'mdi-auto-fix',
click: (_item: FileItem) => {
scrape(_item.path || '')
},
},
}, {
title: '重命名',
value: 3,
props: {
prependIcon: 'mdi-rename',
click: showRenmae,
},
},
{
title: '整理',
value: 4,
props: {
prependIcon: 'mdi-folder-arrow-right',
click: showTransfer,
},
},
{
title: '删除',
value: 5,
props: {
prependIcon: 'mdi-delete-outline',
color: 'error',
click: deleteItem,
},
},
])
onMounted(() => {
load()
})
</script>
<template>
<VCard class="d-flex flex-column">
<VCardText
v-if="loading"
class="text-center flex flex-col items-center"
>
<VProgressCircular
size="48"
indeterminate
color="primary"
/>
</VCardText>
<VCardText
v-if="!path"
class="grow d-flex justify-center align-center grey--text"
>
选择目录或文件
</VCardText>
<VCardText
v-else-if="isFile && !isImage"
class="text-center break-all"
>
<strong>{{ items[0]?.name }}</strong><br>
大小{{ formatBytes(items[0]?.size || 0) }}<br>
修改时间{{ formatTime(items[0]?.modify_time || 0) }}
</VCardText>
<VCardText
v-else-if="isFile && isImage"
class="grow d-flex justify-center align-center"
>
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
</VCardText>
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList v-if="dirs.length" subheader>
<VListSubheader>目录</VListSubheader>
<VHover
v-for="(item, index) in dirs"
:key="index"
>
<template #default="hover">
<VListItem
v-if="defer(index)"
v-bind="hover.props"
class="px-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon icon="mdi-folder-outline" />
</template>
<VListItemTitle v-text="item.name" />
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span v-show="hover.isHovering" class="flex">
<VTooltip text="识别">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="刮削">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="重命名">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="整理">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="删除">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</template>
</VTooltip>
</span>
</template>
</VListItem>
</template>
</VHover>
</VList>
<VDivider v-if="dirs.length && files.length" />
<VList v-if="files.length" subheader>
<VListSubheader>文件</VListSubheader>
<VHover
v-for="(item, index) in files"
:key="index"
>
<template #default="hover">
<VListItem
v-if="defer(index)"
v-bind="hover.props"
class="pl-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon v-if="inProps.icons" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle> {{ formatBytes(item.size) }}</VListItemSubtitle>
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span v-show="hover.isHovering" class="flex">
<VTooltip text="识别">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="刮削">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="重命名">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="整理">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="删除">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</template>
</VTooltip>
</span>
</template>
</VListItem>
</template>
</VHover>
</VList>
</VCardText>
<VCardText
v-else-if="filter"
class="grow d-flex justify-center align-center grey--text py-5"
>
没有目录或文件
</VCardText>
<VCardText
v-else-if="!loading"
class="grow d-flex justify-center align-center grey--text py-5"
>
空目录
</VCardText>
<VDivider v-if="path" />
<VToolbar v-if="!loading" density="compact" flat color="gray">
<VTextField
v-if="!isFile"
v-model="filter"
hide-details
flat
density="compact"
variant="solo-filled"
placeholder="搜索 ..."
prepend-inner-icon="mdi-filter-outline"
class="me-2"
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
<VIcon color="primary">
mdi-text-recognition
</VIcon>
</IconBtn>
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
<VIcon color="primary">
mdi-download
</VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="load">
<VIcon color="primary">
mdi-refresh
</VIcon>
</IconBtn>
</VToolbar>
</VCard>
<!-- 重命名弹窗 -->
<VDialog
v-model="renamePopper"
max-width="50rem"
>
<VCard title="重命名">
<VCardText>
<VTextField v-model="newName" label="名称" />
</VCardText>
<VCardActions>
<VBtn depressed @click="renamePopper = false">
取消
</VBtn>
<VSpacer />
<VBtn
:disabled="!newName"
depressed
variant="tonal"
@click="rename"
>
重命名
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 文件整理弹窗 -->
<ReorganizeForm
v-model="transferPopper"
:path="currentItem?.path"
@done="transferPopper = false; load()"
@close="transferPopper = false"
/>
<!-- 手动整理进度框 -->
<VDialog
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
v-if="progressValue"
color="white"
class="mb-0 mt-1"
:model-value="progressValue"
/>
</VCardText>
</VCard>
</VDialog>
<!-- 识别结果对话框 -->
<VDialog
v-model="nameTestDialog"
width="50rem"
>
<VCard>
<DialogCloseBtn @click="nameTestDialog = false" />
<VCardItem>
<MediaInfoCard :context="nameTestResult" />
</VCardItem>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
.v-card {
height: 100%;
}
.v-toolbar{
background: rgb(var(--v-table-header-background));
}
</style>

View File

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

View File

@@ -0,0 +1,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

@@ -2,10 +2,16 @@
import api from '@/api' import api from '@/api'
import type { MediaInfo } from '@/api/types' import type { MediaInfo } from '@/api/types'
//
const props = defineProps({
type: String, // themoviedb | douban
})
interface TmdbItem { interface TmdbItem {
title: string title: string
overview: string overview: string
tmdbid: number tmdbid: number
doubanid: string
poster: string poster: string
} }
@@ -21,25 +27,23 @@ const keyword = ref('')
const loading = ref(false) const loading = ref(false)
// ref // ref
const tmdbKeyword = ref<HTMLElement | null>(null) const inputKeyword = ref<HTMLElement | null>(null)
// //
function selectMedia(item: TmdbItem) { function selectMedia(item: TmdbItem) {
emit('update:modelValue', item.tmdbid) emit('update:modelValue', item.tmdbid || item.doubanid)
emit('close') emit('close')
} }
// TMDBw500 // TMDBw500
function getW500Image(url = '') { function getW500Image(url = '') {
if (!url) if (!url) return ''
return ''
return url.replace('original', 'w500') return url.replace('original', 'w500')
} }
// //
async function searchMedias() { async function searchMedias() {
if (!keyword) if (!keyword) return
return
// API // API
try { try {
@@ -57,16 +61,17 @@ async function searchMedias() {
// //
for (const item of result) { for (const item of result) {
if (props.type && props.type !== item.source) continue
items.value.push({ items.value.push({
tmdbid: item.tmdb_id || 0, tmdbid: item.tmdb_id || 0,
doubanid: item.douban_id || '',
poster: getW500Image(item.poster_path), poster: getW500Image(item.poster_path),
title: `${item.title}${item.year}`, title: `${item.title}${item.year}`,
overview: `<span class="text-primary">${item.type}</span> ${item.overview}`, overview: `<span class="text-primary">${item.type}</span> ${item.overview}`,
}) })
} }
loading.value = false loading.value = false
} } catch (e) {
catch (e) {
console.error(e) console.error(e)
} }
} }
@@ -75,25 +80,22 @@ async function searchMedias() {
onMounted(() => { onMounted(() => {
// 500ms // 500ms
setTimeout(() => { setTimeout(() => {
tmdbKeyword.value?.focus() inputKeyword.value?.focus()
}, 500) }, 500)
}) })
</script> </script>
<template> <template>
<VCard <VCard class="mx-auto" width="100%">
class="mx-auto"
width="100%"
>
<VToolbar flat class="p-0"> <VToolbar flat class="p-0">
<VTextField <VTextField
ref="tmdbKeyword" ref="inputKeyword"
v-model="keyword" v-model="keyword"
label="输入名称搜索" label="输入名称搜索"
single-line single-line
placeholder="电影或电视剧名称" placeholder="电影或电视剧名称"
variant="solo" variant="solo"
append-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
flat flat
class="mx-1" class="mx-1"
:loading="loading" :loading="loading"
@@ -101,15 +103,17 @@ onMounted(() => {
@keydown.enter="searchMedias" @keydown.enter="searchMedias"
/> />
</VToolbar> </VToolbar>
<DialogCloseBtn
<VList @click="
v-if="items.length > 0" () => {
lines="three" emit('close')
> }
"
/>
<VDivider />
<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 <VListItem @click="selectMedia(item)">
@click="selectMedia(item)"
>
<template #prepend> <template #prepend>
<VImg <VImg
height="75" height="75"
@@ -131,7 +135,6 @@ onMounted(() => {
</VListItemTitle> </VListItemTitle>
<VListItemSubtitle class="mt-2" v-html="item.overview" /> <VListItemSubtitle class="mt-2" v-html="item.overview" />
</VListItem> </VListItem>
<VDivider v-if="i < items.length - 1" class="mt-1" inset />
</template> </template>
</VList> </VList>
</VCard> </VCard>

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
// 输入参数
const props = defineProps({
history: Object as PropType<{ [key: string]: string }>,
})
</script>
<template>
<VCardText>
<VList>
<VListItem v-for="(value, key) in props.history" :key="key">
<VListItemTitle class="font-bold text-lg">
{{ key }}
</VListItemTitle>
<div class="text-gray-500">
{{ value }}
</div>
</VListItem>
</VList>
</VCardText>
</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

@@ -1,47 +1,90 @@
<script lang="ts" setup> <script lang="ts" setup>
import { type PropType, ref } from 'vue' import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { type PropType } from 'vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { RenderProps } from '@/api/types'
// 组件接口 // 定议外部事件
interface RenderProps { const emit = defineEmits(['action'])
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>,
}) })
// 配置元素 // 进度框
const formItem = ref<RenderProps>(elementProps.config ?? { const progressDialog = ref(false)
component: 'div',
text: '', // 进度框文本
html: '', const progressText = ref('正在处理...')
props: {},
content: [], // 元素API事件响应
async function commonAction(api_path: string, method: string, params = {}) {
if (!api_path || !method) return
progressDialog.value = true
try {
if (method.toUpperCase() === 'GET') {
await api.get(api_path, {
params: params,
})
} else {
await api.post(api_path, params)
}
emit('action')
} catch (error) {
console.error(error)
}
progressDialog.value = false
}
// 组装事件
let componentEvents = reactive<{ [key: string]: any }>({})
watchEffect(() => {
if (!isNullOrEmptyObject(elementProps.config?.events)) {
for (const key in elementProps.config?.events) {
const attr = elementProps.config?.events[key]
const func = async () => {
await commonAction(attr['api'], attr['method'], attr['params'])
}
componentEvents[key] = func
}
}
}) })
</script> </script>
<template> <template>
<Component <Component
:is="formItem.component" :is="elementProps.config?.component"
v-if="!formItem.html" v-if="!elementProps.config?.html"
v-bind="formItem.props" v-bind="elementProps.config?.props"
v-on="componentEvents"
> >
{{ formItem.text }} {{ elementProps.config?.text }}
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
<slot :name="name" v-bind="_props">
<PageRender
v-for="(slotItem, slotIndex) in content || []"
:key="slotIndex"
:config="slotItem"
@action="emit('action')"
/>
</slot>
</template>
<PageRender <PageRender
v-for="(innerItem, innerIndex) in (formItem.content || [])" v-for="(innerItem, innerIndex) in elementProps.config?.content || []"
:key="innerIndex" :key="innerIndex"
:config="innerItem" :config="innerItem"
@action="emit('action')"
/> />
</Component> </Component>
<Component <Component
:is="formItem.component" :is="elementProps.config?.component"
v-if="formItem.html" v-if="elementProps.config?.html"
v-bind="formItem.props" v-bind="elementProps.config?.props"
v-html="formItem.html" v-html="elementProps.config?.html"
v-on="componentEvents"
/> />
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</template> </template>

View File

@@ -1,12 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue' import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
// 输入参数
const props = defineProps({
linkurl: String,
title: String,
})
// 元素 // 元素
const slideview_content = ref() const slideview_content = ref()
// 分页切换状态 // 分页切换状态
@@ -95,7 +89,7 @@ onActivated(() => {
<template> <template>
<div class="flex justify-between mt-3"> <div class="flex justify-between mt-3">
<slot name="title"> <slot name="title">
<SlideViewTitle v-bind="props" /> <SlideViewTitle />
</slot> </slot>
<div v-if="disabled !== 3" class="me-1 d-none d-md-flex"> <div v-if="disabled !== 3" class="me-1 d-none d-md-flex">
<VBtn <VBtn

View File

@@ -1,9 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
// 输入参数 // 输入参数
const props = defineProps({ const props = inject('rankingPropsKey')
linkurl: String,
title: String,
})
</script> </script>
<template> <template>

View File

@@ -60,22 +60,17 @@ async function getResourceList() {
try { try {
resourceDataList.value = await api.get(`site/resource/${props.site}`) resourceDataList.value = await api.get(`site/resource/${props.site}`)
resourceLoading.value = false resourceLoading.value = false
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
// 促销Chip类 // 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) { function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) if (downloadVolume === 0) return 'text-white bg-lime-500'
return 'text-white bg-lime-500' else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (downloadVolume < 1) else if (uploadVolume !== 1) return 'text-white bg-sky-500'
return 'text-white bg-green-500' else return 'text-white bg-gray-500'
else if (uploadVolume !== 1)
return 'text-white bg-sky-500'
else
return 'text-white bg-gray-500'
} }
// 添加下载 // 添加下载
@@ -83,18 +78,9 @@ async function addDownload(_torrent: any) {
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: '确认', title: '确认',
content: `是否确认下载【${_torrent.site_name}${_torrent?.title} ?`, content: `是否确认下载【${_torrent.site_name}${_torrent?.title} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
}) })
if (!isConfirmed) if (!isConfirmed) return
return
startNProgress() startNProgress()
try { try {
@@ -103,13 +89,11 @@ async function addDownload(_torrent: any) {
if (result.success) { if (result.success) {
// 添加下载成功 // 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`) $toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
} } else {
else {
// 添加下载失败 // 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`) $toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`)
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
doneNProgress() doneNProgress()
@@ -136,34 +120,24 @@ onMounted(() => {
hover hover
items-per-page-text="每页条数" items-per-page-text="每页条数"
page-text="{0}-{1} {2} " page-text="{0}-{1} {2} "
loading-text="加载中..."
> >
<template #item.title="{ item }"> <template #item.title="{ item }">
<a href="javascript:void(0)" @click.stop="addDownload(item.raw)"> <a href="javascript:void(0)" @click.stop="addDownload(item)">
<div class="text-high-emphasis pt-1"> <div class="text-high-emphasis pt-1">
{{ item.raw.title }} {{ item.title }}
</div> </div>
<div class="text-sm my-1"> <div class="text-sm my-1">
{{ item.raw.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.raw?.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.raw?.freedate_diff" {{ item.freedate_diff }}
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ item.raw?.freedate_diff }}
</VChip> </VChip>
<VChip <VChip
v-for="(label, index) in item.raw?.labels" v-for="(label, index) in item.labels"
:key="index" :key="index"
variant="elevated" variant="elevated"
size="small" size="small"
@@ -173,59 +147,49 @@ onMounted(() => {
{{ label }} {{ label }}
</VChip> </VChip>
<VChip <VChip
v-if="item.raw?.downloadvolumefactor !== 1 || item.raw?.uploadvolumefactor !== 1" v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
:class=" :class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
getVolumeFactorClass(item.raw?.downloadvolumefactor, item.raw?.uploadvolumefactor)
"
variant="elevated" variant="elevated"
size="small" size="small"
class="me-1 mb-1" class="me-1 mb-1"
> >
{{ item.raw?.volume_factor }} {{ item.volume_factor }}
</VChip> </VChip>
</a> </a>
</template> </template>
<template #item.pubdate="{ item }"> <template #item.pubdate="{ item }">
<div>{{ item.raw.date_elapsed }}</div> <div>{{ item.date_elapsed }}</div>
<div class="text-sm"> <div class="text-sm">
{{ item.raw.pubdate }} {{ item.pubdate }}
</div> </div>
</template> </template>
<template #item.size="{ item }"> <template #item.size="{ item }">
<div class="text-nowrap whitespace-nowrap"> <div class="text-nowrap whitespace-nowrap">
{{ formatFileSize(item.raw.size) }} {{ formatFileSize(item.size) }}
</div> </div>
</template> </template>
<template #item.seeders="{ item }"> <template #item.seeders="{ item }">
<div>{{ item.raw.seeders }}</div> <div>{{ item.seeders }}</div>
</template> </template>
<template #item.peers="{ item }"> <template #item.peers="{ item }">
<div>{{ item.raw.peers }}</div> <div>{{ item.peers }}</div>
</template> </template>
<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.raw.page_url)"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-information" /> <VIcon icon="mdi-information" />
</template> </template>
<VListItemTitle>查看详情</VListItemTitle> <VListItemTitle>查看详情</VListItemTitle>
</VListItem> </VListItem>
<VListItem <VListItem
v-if="item.raw.enclosure?.startsWith('http')" v-if="item.enclosure?.startsWith('http')"
variant="plain" variant="plain"
@click="downloadTorrentFile(item.raw.enclosure)" @click="downloadTorrentFile(item.enclosure)"
> >
<template #prepend> <template #prepend>
<VIcon icon="mdi-download" /> <VIcon icon="mdi-download" />
@@ -237,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,23 @@
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))
}
</script> </script>
<template> <template>
@@ -21,126 +27,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 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 +75,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

@@ -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,106 +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<string>('')
// 搜索弹窗
const searchDialog = ref(false) const searchDialog = ref(false)
// ref // 注册快捷键
const searchWordInput = ref<HTMLElement | null>(null) Mousetrap.bind(['command+k', 'ctrl+k'], openSearchDialog)
// Search
function search() {
if (!searchWord.value)
return
searchDialog.value = false
router.push({
path: '/browse/media/search',
query: {
title: searchWord.value,
},
})
}
// 打开搜索弹窗 // 打开搜索弹窗
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">
<VTextField
ref="searchWordInput"
v-model="searchWord"
label="电影、电视剧名称"
@keydown.enter="search"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="search"
>
搜索
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
<!-- 👉 Search Icon --> <!-- 👉 Search Icon -->
<IconBtn <div class="d-flex align-center cursor-pointer ms-lg-n2" style="user-select: none">
class="d-lg-none" <IconBtn @click="openSearchDialog">
@click="openSearchDialog" <VIcon icon="ri-search-line" />
> </IconBtn>
<VIcon icon="mdi-magnify" /> <span class="d-none d-md-flex align-center text-disabled ms-2" @click="openSearchDialog">
</IconBtn> <span class="me-3">搜索</span>
<!-- 👉 Search Textfield --> <span class="meta-key">K</span>
<span class="w-1/5"> </span>
<VTextField </div>
key="search_navbar" <!-- 搜索弹窗 -->
v-model="searchWord" <SearchBarView v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
class="d-none d-lg-block text-disabled search-box"
density="compact"
variant="solo"
label="搜索电影、电视剧"
append-inner-icon="mdi-magnify"
single-line
hide-details
flat
rounded
@click:append-inner="search"
@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

@@ -7,6 +7,10 @@ import ModuleTestView from '@/views/system/ModuleTestView.vue'
import MessageView from '@/views/system/MessageView.vue' 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'
// 显示器宽度
const display = useDisplay()
// App捷径 // App捷径
const appsMenu = ref(false) const appsMenu = ref(false)
@@ -63,8 +67,7 @@ async function sendMessage() {
user_message.value = '' user_message.value = ''
sendButtonDisabled.value = false sendButtonDisabled.value = false
scrollMessageToEnd() scrollMessageToEnd()
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -88,10 +91,7 @@ onMounted(() => {
> >
<!-- Menu Activator --> <!-- Menu Activator -->
<template #activator="{ props }"> <template #activator="{ props }">
<IconBtn <IconBtn class="ms-2" v-bind="props">
class="me-2"
v-bind="props"
>
<VIcon icon="mdi-checkbox-multiple-blank-outline" /> <VIcon icon="mdi-checkbox-multiple-blank-outline" />
</IconBtn> </IconBtn>
</template> </template>
@@ -107,132 +107,61 @@ onMounted(() => {
</VCardItem> </VCardItem>
<div class="ps ps--active-y"> <div class="ps ps--active-y">
<VRow class="ma-0 mt-n1"> <VRow class="ma-0 mt-n1">
<VCol <VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e">
cols="6" <VListItem class="pa-4" @click="nameTestDialog = true">
class="text-center cursor-pointer pa-0 shortcut-icon border-e" <VAvatar size="48" variant="tonal">
>
<VListItem
class="pa-4"
@click="nameTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-text-recognition" /> <VIcon icon="mdi-text-recognition" />
</VAvatar> </VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0"> <h6 class="text-base font-weight-medium mt-2 mb-0">识别</h6>
识别
</h6>
<span class="text-sm">名称识别测试</span> <span class="text-sm">名称识别测试</span>
</VListItem> </VListItem>
</VCol> </VCol>
<VCol <VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e" @click="() => {}">
cols="6" <VListItem class="pa-4" @click="ruleTestDialog = true">
class="text-center cursor-pointer pa-0 shortcut-icon border-e" <VAvatar size="48" variant="tonal">
@click="() => {}"
>
<VListItem
class="pa-4"
@click="ruleTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-filter-cog-outline" /> <VIcon icon="mdi-filter-cog-outline" />
</VAvatar> </VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0"> <h6 class="text-base font-weight-medium mt-2 mb-0">优先级</h6>
优先级
</h6>
<span class="text-sm">优先级规则测试</span> <span class="text-sm">优先级规则测试</span>
</VListItem> </VListItem>
</VCol> </VCol>
</VRow> </VRow>
<VRow class="ma-0 mt-n1 border-t"> <VRow class="ma-0 mt-n1 border-t">
<VCol <VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e" @click="() => {}">
cols="6" <VListItem class="pa-4" @click="loggingDialog = true">
class="text-center cursor-pointer pa-0 shortcut-icon border-e" <VAvatar size="48" variant="tonal">
@click="() => {}"
>
<VListItem
class="pa-4"
@click="loggingDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-file-document-outline" /> <VIcon icon="mdi-file-document-outline" />
</VAvatar> </VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0"> <h6 class="text-base font-weight-medium mt-2 mb-0">日志</h6>
日志
</h6>
<span class="text-sm">实时日志</span> <span class="text-sm">实时日志</span>
</VListItem> </VListItem>
</VCol> </VCol>
<VCol <VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon" @click="() => {}">
cols="6" <VListItem class="pa-4" @click="netTestDialog = true">
class="text-center cursor-pointer pa-0 shortcut-icon" <VAvatar size="48" variant="tonal">
@click="() => {}"
>
<VListItem
class="pa-4"
@click="netTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-network-outline" /> <VIcon icon="mdi-network-outline" />
</VAvatar> </VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0"> <h6 class="text-base font-weight-medium mt-2 mb-0">网络</h6>
网络
</h6>
<span class="text-sm">网速连通性测试</span> <span class="text-sm">网速连通性测试</span>
</VListItem> </VListItem>
</VCol> </VCol>
</VRow> </VRow>
<VRow class="ma-0 mt-n1 border-t"> <VRow class="ma-0 mt-n1 border-t">
<VCol <VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e" @click="() => {}">
cols="6" <VListItem class="pa-4" @click="systemTestDialog = true">
class="text-center cursor-pointer pa-0 shortcut-icon border-e" <VAvatar size="48" variant="tonal">
@click="() => {}"
>
<VListItem
class="pa-4"
@click="systemTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-cog-outline" /> <VIcon icon="mdi-cog-outline" />
</VAvatar> </VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0"> <h6 class="text-base font-weight-medium mt-2 mb-0">系统</h6>
系统
</h6>
<span class="text-sm">健康检查</span> <span class="text-sm">健康检查</span>
</VListItem> </VListItem>
</VCol> </VCol>
<VCol <VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e" @click="() => {}">
cols="6" <VListItem class="pa-4" @click="messageDialog = true">
class="text-center cursor-pointer pa-0 shortcut-icon border-e" <VAvatar size="48" variant="tonal">
@click="() => {}"
>
<VListItem
class="pa-4"
@click="messageDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-message-outline" /> <VIcon icon="mdi-message-outline" />
</VAvatar> </VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0"> <h6 class="text-base font-weight-medium mt-2 mb-0">消息</h6>
消息
</h6>
<span class="text-sm">消息中心</span> <span class="text-sm">消息中心</span>
</VListItem> </VListItem>
</VCol> </VCol>
@@ -241,34 +170,30 @@ onMounted(() => {
</VCard> </VCard>
</VMenu> </VMenu>
<!-- 名称测试弹窗 --> <!-- 名称测试弹窗 -->
<VDialog <VDialog v-if="nameTestDialog" v-model="nameTestDialog" max-width="50rem" scrollable>
v-model="nameTestDialog"
max-width="50rem"
>
<VCard title="名称识别测试"> <VCard title="名称识别测试">
<DialogCloseBtn @click="nameTestDialog = false" /> <DialogCloseBtn @click="nameTestDialog = false" />
<VCardItem> <VCardText>
<NameTestView /> <NameTestView />
</VCardItem> </VCardText>
</VCard> </VCard>
</VDialog> </VDialog>
<!-- 网络测试弹窗 --> <!-- 网络测试弹窗 -->
<VDialog <VDialog v-if="netTestDialog" v-model="netTestDialog" max-width="35rem" max-height="85vh" scrollable>
v-model="netTestDialog"
max-width="35rem"
>
<VCard title="网络测试"> <VCard title="网络测试">
<DialogCloseBtn @click="netTestDialog = false" /> <DialogCloseBtn @click="netTestDialog = false" />
<VCardItem> <VCardText>
<NetTestView /> <NetTestView />
</VCardItem> </VCardText>
</VCard> </VCard>
</VDialog> </VDialog>
<!-- 实时日志弹窗 --> <!-- 实时日志弹窗 -->
<VDialog <VDialog
v-if="loggingDialog"
v-model="loggingDialog" v-model="loggingDialog"
class="w-full lg:w-4/5"
scrollable scrollable
max-width="70rem"
:fullscreen="!display.mdAndUp.value"
> >
<VCard> <VCard>
<DialogCloseBtn @click="loggingDialog = false" /> <DialogCloseBtn @click="loggingDialog = false" />
@@ -276,7 +201,9 @@ onMounted(() => {
<VCardTitle class="inline-flex"> <VCardTitle class="inline-flex">
实时日志 实时日志
<a class="mx-2 inline-flex items-center justify-center" :href="allLoggingUrl()" target="_blank"> <a class="mx-2 inline-flex items-center justify-center" :href="allLoggingUrl()" target="_blank">
<div class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"> <div
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
>
<VIcon icon="mdi-open-in-new" /> <VIcon icon="mdi-open-in-new" />
<span class="ms-1">在新窗口中打开</span> <span class="ms-1">在新窗口中打开</span>
</div> </div>
@@ -289,11 +216,7 @@ onMounted(() => {
</VCard> </VCard>
</VDialog> </VDialog>
<!-- 规则测试弹窗 --> <!-- 规则测试弹窗 -->
<VDialog <VDialog v-if="ruleTestDialog" v-model="ruleTestDialog" max-width="50rem" scrollable>
v-model="ruleTestDialog"
max-width="50rem"
scrollable
>
<VCard title="优先级测试"> <VCard title="优先级测试">
<DialogCloseBtn @click="ruleTestDialog = false" /> <DialogCloseBtn @click="ruleTestDialog = false" />
<VCardText> <VCardText>
@@ -302,11 +225,7 @@ onMounted(() => {
</VCard> </VCard>
</VDialog> </VDialog>
<!-- 系统健康检查弹窗 --> <!-- 系统健康检查弹窗 -->
<VDialog <VDialog v-if="systemTestDialog" v-model="systemTestDialog" max-width="35rem" max-height="85vh" scrollable>
v-model="systemTestDialog"
max-width="50rem"
scrollable
>
<VCard title="系统健康检查"> <VCard title="系统健康检查">
<DialogCloseBtn @click="systemTestDialog = false" /> <DialogCloseBtn @click="systemTestDialog = false" />
<VCardText> <VCardText>
@@ -316,9 +235,11 @@ onMounted(() => {
</VDialog> </VDialog>
<!-- 消息中心弹窗 --> <!-- 消息中心弹窗 -->
<VDialog <VDialog
v-if="messageDialog"
v-model="messageDialog" v-model="messageDialog"
max-width="60rem" max-width="60rem"
scrollable scrollable
:fullscreen="!display.mdAndUp.value"
> >
<VCard title="消息中心"> <VCard title="消息中心">
<DialogCloseBtn @click="messageDialog = false" /> <DialogCloseBtn @click="messageDialog = false" />
@@ -339,13 +260,7 @@ onMounted(() => {
@keydown.enter="sendMessage" @keydown.enter="sendMessage"
> >
<template #append> <template #append>
<VBtn <VBtn color="primary" :disabled="sendButtonDisabled" @click="sendMessage"> 发送 </VBtn>
color="primary"
:disabled="sendButtonDisabled"
@click="sendMessage"
>
发送
</VBtn>
</template> </template>
</VTextField> </VTextField>
</VCardItem> </VCardItem>

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

@@ -5,6 +5,7 @@ import { useToast } from 'vue-toast-notification'
import router from '@/router' 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'
// Vuex Store // Vuex Store
const store = useStore() const store = useStore()
@@ -22,7 +23,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')
} }
@@ -33,14 +35,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) {
@@ -56,8 +50,7 @@ async function restart() {
$toast.error(result.message) $toast.error(result.message)
return return
} }
} } catch (error) {
catch (error) {
console.error(error) console.error(error)
} }
// 注销 // 注销
@@ -72,125 +65,70 @@ const avatar = store.state.auth.avatar
</script> </script>
<template> <template>
<VAvatar <VAvatar class="cursor-pointer ms-3" color="primary" variant="tonal">
class="cursor-pointer"
color="primary"
variant="tonal"
>
<VImg :src="avatar ?? avatar1" /> <VImg :src="avatar ?? avatar1" />
<!-- SECTION Menu --> <!-- SECTION Menu -->
<VMenu <VMenu activator="parent" width="230" location="bottom end" offset="14px">
activator="parent"
width="230"
location="bottom end"
offset="14px"
>
<VList> <VList>
<!-- 👉 User Avatar & Name --> <!-- 👉 User Avatar & Name -->
<VListItem> <VListItem>
<template #prepend> <template #prepend>
<VListItemAction start> <VListItemAction start>
<VAvatar <VAvatar color="primary" variant="tonal">
color="primary"
variant="tonal"
>
<VImg :src="avatar ?? avatar1" /> <VImg :src="avatar ?? avatar1" />
</VAvatar> </VAvatar>
</VListItemAction> </VListItemAction>
</template> </template>
<VListItemTitle class="font-weight-semibold"> <VListItemTitle class="font-weight-semibold">
{{ superUser ? "管理员" : "普通用户" }} {{ superUser ? '管理员' : '普通用户' }}
</VListItemTitle> </VListItemTitle>
<VListItemSubtitle>{{ userName }}</VListItemSubtitle> <VListItemSubtitle>{{ userName }}</VListItemSubtitle>
</VListItem> </VListItem>
<VDivider class="my-2" /> <VDivider class="my-2" />
<!-- 👉 Profile --> <!-- 👉 Profile -->
<VListItem <VListItem v-if="superUser" link @click="router.push('/setting?tab=account')">
v-if="superUser"
link
to="setting"
>
<template #prepend> <template #prepend>
<VIcon <VIcon class="me-2" icon="mdi-account-outline" size="22" />
class="me-2"
icon="mdi-account-outline"
size="22"
/>
</template> </template>
<VListItemTitle>设定</VListItemTitle> <VListItemTitle>设定</VListItemTitle>
</VListItem> </VListItem>
<!-- 👉 FAQ -->
<VListItem href="https://github.com/jxxghp/MoviePilot/blob/main/README.md" target="_blank">
<template #prepend>
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
</template>
<VListItemTitle>帮助</VListItemTitle>
</VListItem>
<!-- Divider --> <!-- Divider -->
<VDivider class="my-2" /> <VDivider class="my-2" />
<!-- 👉 restart --> <!-- 👉 restart -->
<VListItem <VListItem v-if="superUser" @click="restart">
v-if="superUser"
@click="restart"
>
<template #prepend> <template #prepend>
<VIcon <VIcon class="me-2" icon="mdi-restart" size="22" />
class="me-2"
icon="mdi-restart"
size="22"
/>
</template> </template>
<VListItemTitle>重启</VListItemTitle> <VListItemTitle>重启</VListItemTitle>
</VListItem> </VListItem>
<!-- 👉 FAQ --> <!-- Divider -->
<VListItem <VDivider class="my-2" />
href="https://github.com/jxxghp/MoviePilot/blob/main/README.md"
target="_blank"
>
<template #prepend>
<VIcon
class="me-2"
icon="mdi-help-circle-outline"
size="22"
/>
</template>
<VListItemTitle>帮助</VListItemTitle>
</VListItem>
<!-- 👉 Logout --> <!-- 👉 Logout -->
<VListItem @click="logout"> <VListItem @click="logout">
<template #prepend> <VBtn color="error" block>
<VIcon <template #append> <VIcon size="small" icon="mdi-logout" /> </template>
class="me-2" 退出登录
icon="mdi-logout" </VBtn>
size="22"
/>
</template>
<VListItemTitle>注销</VListItemTitle>
</VListItem> </VListItem>
</VList> </VList>
</VMenu> </VMenu>
<!-- !SECTION --> <!-- !SECTION -->
</VAvatar> </VAvatar>
<!-- 重启进度框 --> <!-- 重启进度框 -->
<VDialog <ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在重启 ..." />
v-model="progressDialog"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
正在重启 ...
<VProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</VCardText>
</VCard>
</VDialog>
</template> </template>

View File

@@ -1,17 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import DefaultLayoutWithVerticalNav from './components/DefaultLayoutWithVerticalNav.vue' import DefaultLayoutWithVerticalNav from './components/DefaultLayoutWithVerticalNav.vue'
import api from '@/api'
const router = useRouter()
const route = useRoute() const route = useRoute()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
api.get('user/current')
.catch(() => {
router.replace('/login')
})
}
})
</script> </script>
<template> <template>
@@ -27,5 +17,5 @@ document.addEventListener('visibilitychange', () => {
<style lang="scss"> <style lang="scss">
// As we are using `layouts` plugin we need its styles to be imported // As we are using `layouts` plugin we need its styles to be imported
@use "@layouts/styles/default-layout"; @use '@layouts/styles/default-layout';
</style> </style>

View File

@@ -15,15 +15,43 @@ 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 { VTreeview } from 'vuetify/labs/VTreeview'
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 { fixArrayAt } from '@/@core/utils/compatibility'
// 修复低版本Safari等浏览器数组不支持at函数的问题
fixArrayAt()
// 加载字体
loadFonts() 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('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
@@ -33,6 +61,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(VueApexCharts)
.mount('#app') .mount('#app')
.$nextTick(() => removeEl('#loading-bg')) .$nextTick(() => removeEl('#loading-bg'))

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import api from '@/api'
import MediaCardListView from '@/views/discover/MediaCardListView.vue' import MediaCardListView from '@/views/discover/MediaCardListView.vue'
import PersonCardListView from '@/views/discover/PersonCardListView.vue'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -11,14 +13,16 @@ const props = defineProps({
const route = useRoute() const route = useRoute()
// 标题 // 标题
const title = route.query?.title?.toString() let title = route.query?.title?.toString()
// 类型
const type = route.query?.type?.toString()
if (type === 'person') title = '演员:' + title
// 计算API路径 // 计算API路径
function getApiPath(paths: string[] | string) { function getApiPath(paths: string[] | string) {
if (Array.isArray(paths)) if (Array.isArray(paths)) return paths.join('/')
return paths.join('/') else return paths
else
return paths
} }
</script> </script>
@@ -26,14 +30,15 @@ function getApiPath(paths: string[] | string) {
<div> <div>
<div v-if="title" class="mt-3 md:flex md:items-center md:justify-between"> <div v-if="title" class="mt-3 md:flex md:items-center md:justify-between">
<div class="min-w-0 flex-1 mx-0"> <div class="min-w-0 flex-1 mx-0">
<h2 class="mb-4 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0" data-testid="page-header"> <h2
class="mb-4 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0"
data-testid="page-header"
>
<span class="text-moviepilot">{{ title }}</span> <span class="text-moviepilot">{{ title }}</span>
</h2> </h2>
</div> </div>
</div> </div>
<MediaCardListView <PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths || '')" :params="route.query" />
:apipath="getApiPath(props.paths || '')" <MediaCardListView v-else :apipath="getApiPath(props.paths || '')" :params="route.query" />
:params="route.query"
/>
</div> </div>
</template> </template>

View File

@@ -1,34 +1,30 @@
<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 api from '@/api'
import AnalyticsSpeed from '@/views/dashboard/AnalyticsSpeed.vue' import { isNullOrEmptyObject } from '@/@core/utils'
import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue' import { DashboardItem } from '@/api/types'
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue' import store from '@/store'
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue' import DashboardElement from '@/components/misc/DashboardElement.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'
// 仪表盘配置 // 从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,
@@ -39,140 +35,326 @@ 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()
}
} }
const config = ref(JSON.parse(localStorage.getItem('MP_DASHBOARD') || '{}'))
if (Object.keys(config.value).length === 0) { // 按order的顺序对dashboardConfigs进行排序
config.value = default_config function sortDashboardConfigs() {
localStorage.setItem('MP_DASHBOARD', JSON.stringify(config.value)) dashboardConfigs.value.sort((a, b) => {
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() {
localStorage.setItem('MP_DASHBOARD', JSON.stringify(config.value)) // 启用配置
const data = JSON.stringify(enableConfig.value)
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())
// 保存到服务端
try {
await api.post('/user/config/Dashboard', data, {
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>
<VRow class="match-height"> <!-- 仪表板 -->
<VCol <draggable
v-if="config.storage" v-model="dashboardConfigs"
cols="12" @end="dragOrderEnd"
md="4" handle=".cursor-move"
> item-key="id"
<AnalyticsStorage /> tag="VRow"
</VCol> :component-data="elevatedConf"
<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>
<!-- 底部操作按钮 -->
<span class="fixed right-5 bottom-5">
<VBtn icon="mdi-view-dashboard-edit" class="me-2" color="primary" size="x-large" @click="dialog = true" />
</span>
<!-- 弹窗根据配置生成选项 -->
<VDialog
v-model="dialog"
max-width="600"
scrollable
> >
<VCard title="设置仪表板"> <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" size="x-large" fixed app appear @click="dialog = true" />
<!-- 弹窗根据配置生成选项 -->
<VDialog v-model="dialog" max-width="35rem" scrollable>
<VCard>
<VCardItem>
<VCardTitle>设置仪表板</VCardTitle>
</VCardItem>
<VDivider />
<VCardText> <VCardText>
<VRow> <VRow>
<VCol <VCol
v-for="(item, key) in dashboard_names" v-for="item in dashboardConfigs"
:key="key" :key="buildPluginDashboardId(item.id, item.key)"
cols="12" cols="6"
md="4" md="4"
sm="4"
> >
<VCheckbox <VCheckbox
v-model="config[key]" v-model="enableConfig[buildPluginDashboardId(item.id, item.key)]"
:label="dashboard_names[key]" :label="item.attrs?.title ?? item.name"
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="isElevated" label="自适应组件高度" />
</VCol>
</VRow>
</VCardText> </VCardText>
<VCardActions> <VDivider />
<VBtn <VCardText class="pt-5 text-end">
color="primary"
@click="dialog = false"
>
取消
</VBtn>
<VSpacer /> <VSpacer />
<VBtn <VBtn variant="outlined" color="secondary" class="me-4" @click="dialog = false"> 关闭 </VBtn>
color="primary" <VBtn @click="saveDashboardConfig">
@click="setDashboardConfig" <template #prepend>
> <VIcon icon="mdi-content-save" />
</template>
保存 保存
</VBtn> </VBtn>
</VCardActions> </VCardText>
</VCard> </VCard>
</vdialog> </VDialog>
</template> </template>

View File

@@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { debounce } from 'lodash'
import { VForm } from 'vuetify/components/VForm' import { VForm } from 'vuetify/components/VForm'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { requiredValidator } from '@/@validators' import { requiredValidator } from '@/@validators'
import api from '@/api' import api from '@/api'
import router from '@/router' import router from '@/router'
import logo from '@images/logo.png' import logo from '@images/logo.png'
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
const { global: globalTheme } = useTheme()
// Vuex Store // Vuex Store
const store = useStore() const store = useStore()
@@ -31,7 +36,7 @@ const backgroundImageUrl = ref('')
// 背景图片加载状态 // 背景图片加载状态
const isImageLoaded = ref(false) const isImageLoaded = ref(false)
// 是否开启二次验证 // 是否开启双重验证
const isOTP = ref(false) const isOTP = ref(false)
// 用户名称输入框 // 用户名称输入框
@@ -48,9 +53,8 @@ async function fetchBackgroundImage() {
console.log(error) console.log(error)
}) })
} }
// 查询是否开启双重验证
// 查询是否开启二次验证 const fetchOTP = debounce(async () => {
async function fetchOTP() {
const userid = usernameInput.value?.value const userid = usernameInput.value?.value
if (!userid) { if (!userid) {
isOTP.value = false isOTP.value = false
@@ -64,6 +68,32 @@ async function fetchOTP() {
.catch((error: any) => { .catch((error: any) => {
console.log(error) console.log(error)
}) })
}, 500)
// 获取用户主题配置
async function fetchThemeConfig() {
const response = await api.get('/user/config/theme')
if (response && response.data && response.data.value) {
return response.data.value
}
return null
}
// 生效主题
async function setTheme() {
let themeValue = (await fetchThemeConfig()) || localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 存储主题到本地
localStorage.setItem('theme', themeValue)
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
}
async function afterLogin() {
// 生效主题配置
await setTheme()
// 跳转到首页或回原始页面
router.push(store.state.auth.originalPath ?? '/')
} }
// 登录获取token事件 // 登录获取token事件
@@ -103,21 +133,16 @@ function login() {
store.dispatch('auth/updateUserName', username) store.dispatch('auth/updateUserName', username)
store.dispatch('auth/updateAvatar', avatar) store.dispatch('auth/updateAvatar', avatar)
// 跳转到首页或回原始页面 // 登录后处理
router.push(store.state.auth.originalPath ?? '/') afterLogin()
}) })
.catch((error: any) => { .catch((error: any) => {
// 登录失败,显示错误提示 // 登录失败,显示错误提示
if (!error.response) if (!error.response) errorMessage.value = '登录失败,请检查网络连接'
errorMessage.value = '登录失败,请检查网络连接' else if (error.response.status === 401) errorMessage.value = '登录失败,请检查用户名、密码或双重验证是否正确'
else if (error.response.status === 401) else if (error.response.status === 403) errorMessage.value = '登录失败,您没有权限访问'
errorMessage.value = '登录失败,请检查用户名、密码或二次验证是否正确' else if (error.response.status === 500) errorMessage.value = '登录失败,服务器错误'
else if (error.response.status === 403) else errorMessage.value = `登录失败 ${error.response.status},请检查用户名、密码或双重验证码是否正确`
errorMessage.value = '登录失败,您没有权限访问'
else if (error.response.status === 500)
errorMessage.value = '登录失败,服务器错误'
else
errorMessage.value = `登录失败 ${error.response.status},请检查用户名、密码或二次验证码是否正确`
}) })
} }
@@ -130,8 +155,7 @@ onMounted(() => {
// 如果token存在且保持登录状态为true则跳转到首页 // 如果token存在且保持登录状态为true则跳转到首页
if (token && remember) { if (token && remember) {
router.push('/') router.push('/')
} } else {
else {
// 获取背景图片 // 获取背景图片
fetchBackgroundImage() fetchBackgroundImage()
} }
@@ -160,16 +184,11 @@ onMounted(() => {
</div> </div>
</template> </template>
<VCardTitle class="font-weight-semibold text-2xl text-uppercase"> <VCardTitle class="font-weight-semibold text-2xl text-uppercase"> MoviePilot </VCardTitle>
MoviePilot
</VCardTitle>
</VCardItem> </VCardItem>
<VCardText> <VCardText>
<VForm <VForm ref="refForm" @submit.prevent="() => {}">
ref="refForm"
@submit.prevent="() => {}"
>
<VRow> <VRow>
<!-- username --> <!-- username -->
<VCol cols="12"> <VCol cols="12">
@@ -188,42 +207,22 @@ onMounted(() => {
v-model="form.password" v-model="form.password"
label="密码" label="密码"
: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'
"
:rules="[requiredValidator]" :rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible" @click:append-inner="isPasswordVisible = !isPasswordVisible"
/> />
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<VTextField <VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
v-if="isOTP"
v-model="form.otp_password"
label="二次验证码"
type="input"
/>
<!-- remember me checkbox --> <!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap"> <div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox <VCheckbox v-model="form.remember" label="保持登录" required />
v-model="form.remember"
label="保持登录"
required
/>
</div> </div>
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<!-- login button --> <!-- login button -->
<VBtn <VBtn block type="submit" @click="login"> 登录 </VBtn>
block <div v-if="errorMessage" class="text-error mt-2 text-shadow">
type="submit"
@click="login"
>
登录
</VBtn>
<div
v-if="errorMessage"
class="text-error mt-2 text-shadow"
>
{{ errorMessage }} {{ errorMessage }}
</div> </div>
</VCol> </VCol>
@@ -236,7 +235,7 @@ onMounted(() => {
</template> </template>
<style lang="scss"> <style lang="scss">
@use "@core/scss/pages/page-auth.scss"; @use '@core/scss/pages/page-auth.scss';
.v-card-item__prepend { .v-card-item__prepend {
padding-inline-end: 0 !important; padding-inline-end: 0 !important;

View File

@@ -7,15 +7,15 @@ const route = useRoute()
// Person Id // Person Id
const personid = route.query?.personid?.toString() const personid = route.query?.personid?.toString()
// 来源
const source = route.query?.source?.toString()
// 类型 // 类型
const type = route.query?.type?.toString() const type = route.query?.type?.toString()
</script> </script>
<template> <template>
<div> <div>
<PersonDetailView <PersonDetailView :personid="personid" :type="type" :source="source" />
:personid="personid"
:type="type"
/>
</div> </div>
</template> </template>

View File

@@ -1,85 +1,82 @@
<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}[]>([
{
apipath: 'tmdb/trending',
linkurl: "/browse/tmdb/trending?title=流行趋势",
title: "流行趋势",
},
{
apipath: "douban/showing",
linkurl: "/browse/douban/showing?title=正在热映",
title: "正在热映"
},
{
apipath: "bangumi/calendar",
linkurl: "/browse/bangumi/calendar?title=Bangumi每日放送",
title: "Bangumi每日放送"
},
{
apipath: "tmdb/movies",
linkurl: "/browse/tmdb/movies?title=热门电影",
title: "热门电影"
},
{
apipath: "tmdb/tvs?with_original_language=zh|en|ja|ko",
linkurl: "/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=热门电视剧",
title: "热门电视剧"
},
{
apipath: "douban/movie_hot",
linkurl: "/browse/douban/movie_hot?title=热门电影",
title: "热门电影"
},
{
apipath: "douban/tv_hot",
linkurl: "/browse/douban/tv_hot?title=热门电视剧",
title: "热门电视剧"
},
{
apipath: "douban/tv_animation",
linkurl: "/browse/douban/tv_animation?title=热门动漫",
title: "热门动漫"
},
{
apipath: "douban/movies",
linkurl: "/browse/douban/movies?title=最新电影",
title: "最新电影"
},
{
apipath: "douban/tvs",
linkurl: "/browse/douban/tvs?title=最新电视剧",
title: "最新电视剧"
},
{
apipath: "douban/movie_top250",
linkurl: "/browse/douban/movie_top250?title=电影TOP250",
title: "电影TOP250"
},
{
apipath: "douban/tv_weekly_chinese",
linkurl: "/browse/douban/tv_weekly_chinese?title=国产剧集榜",
title: "国产剧集榜"
},
{
apipath: "douban/tv_weekly_global",
linkurl: "/browse/douban/tv_weekly_global?title=全球剧集榜",
title: "全球剧集榜"
}
])
</script> </script>
<template> <template>
<div> <div>
<MediaCardSlideView <MediaCardSlideView
apipath="tmdb/trending" v-for="item in viewList"
linkurl="/browse/tmdb/trending?title=流行趋势" :key="item.apipath"
title="流行趋势" v-bind="item"
/>
<MediaCardSlideView
apipath="douban/showing"
linkurl="/browse/douban/showing?title=正在热映"
title="正在热映"
/>
<MediaCardSlideView
apipath="bangumi/calendar"
linkurl="/browse/bangumi/calendar?title=Bangumi每日放送"
title="Bangumi每日放送"
/>
<MediaCardSlideView
apipath="tmdb/movies"
linkurl="/browse/tmdb/movies?title=热门电影"
title="热门电影"
/>
<MediaCardSlideView
apipath="tmdb/tvs?with_original_language=zh|en|ja|ko"
linkurl="/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=热门电视剧"
title="热门电视剧"
/>
<MediaCardSlideView
apipath="douban/movie_hot"
linkurl="/browse/douban/movie_hot?title=热门电影"
title="热门电影"
/>
<MediaCardSlideView
apipath="douban/tv_hot"
linkurl="/browse/douban/tv_hot?title=热门电视剧"
title="热门电视剧"
/>
<MediaCardSlideView
apipath="douban/tv_animation"
linkurl="/browse/douban/tv_animation?title=热门动漫"
title="热门动漫"
/>
<MediaCardSlideView
apipath="douban/movies"
linkurl="/browse/douban/movies?title=最新电影"
title="最新电影"
/>
<MediaCardSlideView
apipath="douban/tvs"
linkurl="/browse/douban/tvs?title=最新电视剧"
title="最新电视剧"
/>
<MediaCardSlideView
apipath="douban/movie_top250"
linkurl="/browse/douban/movie_top250?title=电影TOP250"
title="电影TOP250"
/>
<MediaCardSlideView
apipath="douban/tv_weekly_chinese"
linkurl="/browse/douban/tv_weekly_chinese?title=国产剧集榜"
title="国产剧集榜"
/>
<MediaCardSlideView
apipath="douban/tv_weekly_global"
linkurl="/browse/douban/tv_weekly_global?title=全球剧集榜"
title="全球剧集榜"
/> />
</div> </div>
</template> </template>

View File

@@ -18,6 +18,9 @@ const type = route.query?.type?.toString() ?? ''
// 搜索字段 // 搜索字段
const area = route.query?.area?.toString() ?? '' const area = route.query?.area?.toString() ?? ''
// 搜索季
const season = route.query?.season?.toString() ?? ''
// 视图类型从localStorage中读取 // 视图类型从localStorage中读取
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card') const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
@@ -36,6 +39,12 @@ const progressValue = ref(0)
// 加载进度SSE // 加载进度SSE
const progressEventSource = ref<EventSource>() const progressEventSource = ref<EventSource>()
// 错误标题
const errorTitle = ref('没有数据')
// 错误描述
const errorDescription = ref('未搜索到任何资源')
// 使用SSE监听加载进度 // 使用SSE监听加载进度
function startLoadingProgress() { function startLoadingProgress() {
progressText.value = '正在搜索,请稍候...' progressText.value = '正在搜索,请稍候...'
@@ -45,7 +54,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
@@ -71,28 +80,33 @@ 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()
// 优先按TMDBID精确查询 // 优先按TMDBID精确查询
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) { if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
dataList.value = await api.get(`search/media/${keyword}`, { const result: { [key: string]: any } = await api.get(`search/media/${keyword}`, {
params: { params: {
mtype: type, mtype: type,
area, area,
season,
}, },
}) })
} if (result.success) {
else { dataList.value = result.data
} else {
errorDescription.value = result.message
}
} else {
// 按标题模糊查询 // 按标题模糊查询
dataList.value = await api.get(`search/title/${keyword}`) dataList.value = await api.get(`search/title/${keyword}`)
} }
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)
} }
@@ -105,42 +119,26 @@ onMounted(() => {
</script> </script>
<template> <template>
<div v-if="!isRefreshed" class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"> <LoadingBanner v-if="!isRefreshed" class="mt-12" :text="progressText" :progress="progressValue" />
<VProgressCircular v-if="!keyword" size="48" indeterminate color="primary" />
<VProgressCircular v-if="keyword" class="mb-3" color="primary" :model-value="progressValue" size="64" />
<span>{{ progressText }}</span>
</div>
<NoDataFound <NoDataFound
v-if="dataList.length === 0 && isRefreshed" v-if="dataList.length === 0 && isRefreshed"
error-code="404" :error-title="errorTitle"
error-title="没有资源" :error-description="errorDescription"
error-description="没有搜索到符合条件的资源"
/> />
<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>
<!-- 视图切换 --> <!-- 视图切换 -->
<span v-if="dataList.length > 0" class="fixed right-5 bottom-5"> <VFab
<VBtn v-if="viewType === 'list'"
v-if="viewType === 'list'" icon="mdi-view-grid"
size="x-large" location="bottom"
icon="mdi-view-grid" size="x-large"
color="primary" fixed
@click="setViewType('card')" app
/> appear
<VBtn @click="setViewType('card')"
v-else />
size="x-large" <VFab v-else icon="mdi-view-list" location="bottom" size="x-large" fixed app appear @click="setViewType('list')" />
icon="mdi-view-list"
color="primary"
@click="setViewType('list')"
/>
</span>
</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,79 +10,34 @@ 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 <VTabs v-model="activeTab" show-arrows class="v-tabs-pill">
v-model="activeTab" <VTab
show-arrows v-for="item in SettingTabs"
> :key="item.icon"
<VTab v-for="item in tabs" :key="item.icon" :value="item.tab"> :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 <VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
v-model="activeTab"
class="mt-5 disable-tab-transition"
:touch="false"
>
<!-- 用户 --> <!-- 用户 -->
<VWindowItem value="account"> <VWindowItem value="account">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
@@ -89,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>
@@ -130,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,39 @@
<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()
// 当前标签
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="电影" />
</transition>
</VWindowItem>
<VWindowItem value="popular">
<transition name="fade-slide" appear>
<SubscribePopularView type="电影" />
</transition>
</VWindowItem>
</VWindow>
</div> </div>
</template> </template>

View File

@@ -1,9 +1,38 @@
<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)
// 跳转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="电视剧" />
</transition>
</VWindowItem>
<VWindowItem value="popular">
<transition name="fade-slide" appear>
<SubscribePopularView type="电视剧" />
</transition>
</VWindowItem>
</VWindow>
</div> </div>
</template> </template>

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,
@@ -159,13 +158,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()
} }

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

@@ -0,0 +1,221 @@
// 导般菜单
export const SystemNavMenus = [
{
title: '仪表板',
icon: 'mdi-home-outline',
to: '/dashboard',
header: '开始',
admin: false,
},
{
title: '推荐',
icon: 'mdi-table-star',
to: '/ranking',
header: '发现',
admin: false,
},
{
title: '资源搜索',
icon: 'mdi-magnify',
to: '/resource',
header: '发现',
admin: false,
},
{
title: '电影',
icon: 'mdi-movie-roll',
to: '/subscribe-movie?tab=mysub',
header: '订阅',
admin: false,
},
{
title: '电视剧',
icon: 'mdi-television-classic',
to: '/subscribe-tv?tab=mysub',
header: '订阅',
admin: false,
},
{
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',
},
]

View File

@@ -5,39 +5,37 @@
#nprogress .bar { #nprogress .bar {
background: rgb(var(--v-theme-primary)) !important; background: rgb(var(--v-theme-primary)) !important;
top: env(safe-area-inset-top) !important; inset-block-start: env(safe-area-inset-top) !important;
} }
#nprogress .peg { #nprogress .peg {
box-shadow: 0 0 10px rgb(var(--v-theme-primary)), 0 0 5px rgb(var(--v-theme-primary)) !important; box-shadow: 0 0 10px rgb(var(--v-theme-primary)), 0 0 5px rgb(var(--v-theme-primary)) !important;
-webkit-transform: rotate(0deg) translate(0px, -1px); transform: rotate(0deg) translate(0, -1px);
-ms-transform: rotate(0deg) translate(0px, -1px);
transform: rotate(0deg) translate(0px, -1px);
} }
.v-toast--bottom { .v-toast--bottom {
margin-bottom: env(safe-area-inset-bottom);
z-index: 2500; z-index: 2500;
margin-block-end: env(safe-area-inset-bottom);
} }
.v-toast--top { .v-toast--top {
margin-top: env(safe-area-inset-top);
z-index: 2500; z-index: 2500;
margin-block-start: env(safe-area-inset-top);
} }
.v-dialog > .v-overlay__content { .v-dialog > .v-overlay__content {
margin-top: calc(env(safe-area-inset-top) + 1rem); margin-block-start: calc(env(safe-area-inset-top) + 1rem);
max-height: calc(100% - env(safe-area-inset-top) - 1rem); max-block-size: calc(100% - env(safe-area-inset-top) - 1rem);
} }
.v-dialog > .v-overlay__content{ .v-dialog > .v-overlay__content{
width: calc(100% - 1rem); inline-size: calc(100% - 1rem);
} }
.v-dialog--fullscreen > .v-overlay__content{ .v-dialog--fullscreen > .v-overlay__content{
margin-top: env(safe-area-inset-top); inline-size: 100%;
max-height: calc(100% - env(safe-area-inset-top)); margin-block-start: env(safe-area-inset-top);
width: 100%; max-block-size: calc(100% - env(safe-area-inset-top));
} }
/* router view transition fade-slide */ /* router view transition fade-slide */
@@ -62,21 +60,20 @@
} }
.text-moviepilot { .text-moviepilot {
background-clip: text;
background-image: linear-gradient(to bottom right,var(--tw-gradient-stops)); background-image: linear-gradient(to bottom right,var(--tw-gradient-stops));
color: transparent;
--tw-gradient-from: #818cf8; --tw-gradient-from: #818cf8;
--tw-gradient-to: rgba(129,140,248,0); --tw-gradient-to: rgba(129,140,248,0%);
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to); --tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
--tw-gradient-to: #c084fc; --tw-gradient-to: #c084fc;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
} }
.slider-header { .slider-header {
position: relative; position: relative;
margin-top: 1.5rem;
margin-bottom: 1rem;
display: flex; display: flex;
margin-block: 1.5rem 1rem;
} }
.slider-title { .slider-title {
@@ -87,25 +84,27 @@
line-height: 1.75rem; line-height: 1.75rem;
} }
@media (min-width: 640px){ @media (width >= 640px){
.slider-title { .slider-title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.5rem; font-size: 1.5rem;
line-height: 2.25rem; line-height: 2.25rem;
text-overflow: ellipsis;
white-space: nowrap;
} }
} }
// 美化滚动条 // 美化滚动条
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; block-size: 8px;
height: 8px; inline-size: 8px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
border-radius: 3px; border-radius: 3px;
background: rgb(var(--v-theme-perfect-scrollbar-thumb)); background: rgb(var(--v-theme-perfect-scrollbar-thumb));
-webkit-box-shadow: inset 0 0 10px rgba(0,0,0,0.2); box-shadow: inset 0 0 10px rgba(0,0,0,20%);
@media(hover){ @media(hover){
&:hover{ &:hover{
background: #a1a1a1; background: #a1a1a1;
@@ -120,10 +119,72 @@
.backdrop-blur { .backdrop-blur {
--tw-backdrop-blur: blur(8px)!important; --tw-backdrop-blur: blur(8px)!important;
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important; backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;
} }
.v-toolbar{ .v-toolbar{
background: rgb(var(--v-table-header-background)); background: rgb(var(--v-table-header-background));
} }
.v-toast {
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>

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