Compare commits

...

865 Commits
main ... v2.3.9

Author SHA1 Message Date
jxxghp
8b53cd0a09 feat: 为多个组件的 VMenu 添加 scrim 属性 2025-04-13 09:42:58 +08:00
jxxghp
3d7a0d9b0d feat: 为多个组件添加边框样式 2025-04-13 09:19:20 +08:00
jxxghp
114844ad48 refactor: 移除 UserNotification 组件中多余的样式定义,简化代码 2025-04-12 09:59:41 +08:00
jxxghp
f83f709080 feat: 调整 SubscribeCard 组件的图像宽度,优化搜索框对齐和样式,移除多余的样式定义 2025-04-12 09:45:43 +08:00
jxxghp
999a6e7c6e fix 站点列表权限问题 2025-04-12 09:30:17 +08:00
jxxghp
2fd4c0b2ea feat: 优化搜索框样式,调整图标和文本,提升用户体验 2025-04-11 15:45:02 +08:00
jxxghp
16d62642f6 fix:大量tooltip对象 2025-04-11 15:20:36 +08:00
jxxghp
96a0ce8c5f feat: 为排序选择器添加 variant 属性,简化样式并移除多余 CSS 2025-04-11 08:36:24 +08:00
jxxghp
7703d8157c feat: 移除多个组件中的多余 variant 属性,简化代码 2025-04-11 08:29:00 +08:00
jxxghp
87aa4e902c feat: 优化 SlideView 组件样式,调整圆角和阴影效果 2025-04-11 07:09:50 +08:00
jxxghp
c86f32fab5 更新 styles.scss 2025-04-10 23:13:25 +08:00
jxxghp
f85ac34753 feat: 增强工作流侧边栏,支持移动端显示和组件点击事件处理 2025-04-10 21:12:48 +08:00
jxxghp
f58d4fcb7e 更新 recommend.vue 2025-04-09 23:01:13 +08:00
jxxghp
675c32cee3 feat: 使用 VCard 和 VProgressLinear 组件优化资源页面的加载进度显示 2025-04-09 22:39:41 +08:00
jxxghp
de011b35db 更新 TorrentCardListView.vue 2025-04-09 22:04:25 +08:00
jxxghp
288a7ebc20 更新 TorrentRowListView.vue 2025-04-09 22:03:47 +08:00
jxxghp
d7c3167ecd feat: 优化 TorrentRowListView 组件的数据过滤和排序逻辑 2025-04-09 21:49:15 +08:00
jxxghp
3205ae3ebe feat: 移除多个组件的背景颜色以优化样式 2025-04-09 21:31:57 +08:00
jxxghp
2ba609fb78 更新 package.json 2025-04-09 19:48:08 +08:00
jxxghp
7e70b1b7ab fix: 调整组件的内边距和边距以优化布局 2025-04-09 15:59:00 +08:00
jxxghp
561bdf4137 feat: 优化 ScrollToTopBtn 组件样式和布局,调整按钮位置和尺寸 2025-04-09 15:22:55 +08:00
jxxghp
22a2bb65c8 feat: 优化 TransferHistoryView 组件的布局和样式 2025-04-09 15:05:31 +08:00
jxxghp
c4f6db9f9f feat: 优化 ForkSubscribeDialog 组件按钮 2025-04-09 13:27:26 +08:00
jxxghp
e5d2140ea3 fix: 移除推荐内容的内边距以优化布局 2025-04-09 11:55:51 +08:00
jxxghp
83e57deec3 fix: 调整布局组件的宽度和内边距以改善响应式设计 2025-04-09 11:52:49 +08:00
jxxghp
fc357a03e5 feat: 更新 HeaderTab 组件以支持动态项和排序功能 2025-04-09 10:39:17 +08:00
jxxghp
f031077fbd feat: 更新插件市场设置对话框和订阅页面 2025-04-09 08:29:33 +08:00
jxxghp
02de63210d feat: 添加 HeaderTab 组件 2025-04-09 08:07:53 +08:00
jxxghp
98610e3e0d feat: 隐藏小屏幕设备上的回到顶部按钮 2025-04-08 21:57:35 +08:00
jxxghp
bb6cfd9d0e feat: 回到顶部按钮组件化 2025-04-08 21:52:04 +08:00
jxxghp
57c6d7e8f3 Merge pull request #322 from madrays/v2
继续优化探索页UI
2025-04-08 21:40:11 +08:00
jxxghp
6d8b850b15 Merge branch 'v2' into v2 2025-04-08 21:39:48 +08:00
madrays
db6c3ea36c 继续优化探索页UI 2025-04-08 20:43:43 +08:00
jxxghp
0ddf7ab070 feat: 优化主题切换器和用户通知组件的样式 2025-04-08 19:34:27 +08:00
jxxghp
93686bd354 feat: 添加推荐源类型字段,优化推荐页面分类逻辑和样式 2025-04-08 19:14:16 +08:00
jxxghp
89e4a68a03 feat: add ScrollToTopBtn component and integrate it into multiple pages
- Added ScrollToTopBtn component for smooth scrolling to the top of the page.
- Registered ScrollToTopBtn in main.ts.
- Integrated ScrollToTopBtn into browse.vue, discover.vue, recommend.vue, resource.vue pages.
- Updated components.d.ts to include ScrollToTopBtn type definition.
- Refactored MediaCard.vue and SlideView.vue for improved hover effects and styling.
- Cleaned up unused styles and optimized existing styles for better performance and readability.
2025-04-08 17:43:20 +08:00
jxxghp
204719caf8 Merge pull request #321 from madrays/v2
优化探索页UI
2025-04-08 16:31:34 +08:00
madrays
267f53942b 优化探索页UI 2025-04-08 15:47:41 +08:00
jxxghp
7ae9bbc4f0 优化 TorrentItem 列表性能 2025-04-08 15:35:11 +08:00
jxxghp
717b460246 更新设定菜单图标为 mdi-cog-outline 2025-04-08 15:04:22 +08:00
jxxghp
d52a814d77 优化 SubscribeCard 组件的图片点击事件 2025-04-08 14:52:07 +08:00
jxxghp
6e1503334e 优化主题切换器和用户通知组件样式,添加文件大小显示,调整布局和样式变量 2025-04-08 13:32:36 +08:00
jxxghp
34a33f87b2 更新 Vuetify 默认设置,修改菜单图标,调整样式变量,优化用户资料组件样式 2025-04-08 12:24:52 +08:00
jxxghp
03d885d391 Merge pull request #320 from madrays/v2 2025-04-08 06:44:08 +08:00
madrays
c50b25997d Merge branch 'jxxghp:v2' into v2 2025-04-08 01:05:39 +08:00
madrays
3adcc894b7 统一顶栏及侧边栏各项ui风格 2025-04-08 01:03:29 +08:00
jxxghp
8a73ad63ee 更新 TheMovieDbView.vue 2025-04-05 14:33:16 +08:00
jxxghp
69b5b2b900 更新 SearchSiteDialog.vue 2025-04-05 14:21:37 +08:00
jxxghp
97075fc167 更新 ProgressDialog.vue 2025-04-05 08:26:28 +08:00
jxxghp
bde70a2e26 fix:季集判断 2025-04-04 18:11:28 +08:00
jxxghp
2e05c8079b 优化剧集组管理,添加剧集组选项和查询功能,调整订阅逻辑 2025-04-04 12:17:29 +08:00
jxxghp
357191afcf 修复 SubscribeSeasonDialog 关闭事件处理,优化季信息显示逻辑 2025-04-04 10:51:55 +08:00
jxxghp
8352ba335b 添加 SubscribeSeasonDialog 组件,实现媒体季订阅功能,包含季信息获取和缺失状态检查 2025-04-04 10:47:32 +08:00
jxxghp
7ae3e402cf 优化 MediaCard 和 SearchSiteDialog 组件,移除全选功能并调整已选择站点的逻辑 2025-04-04 09:39:04 +08:00
jxxghp
c5e62cc8e4 优化多个组件,调整样式和功能以提升用户体验,添加季数选择功能 2025-04-04 09:27:06 +08:00
jxxghp
ddb0befa4d 更新 package.json 2025-04-03 20:18:37 +08:00
jxxghp
82c19f3512 优化 SiteCard 组件,修复点击事件处理以提升用户交互体验 2025-04-03 19:20:32 +08:00
jxxghp
e95552b47a 优化 SiteCard 组件,添加删除确认功能并调整样式 2025-04-03 19:18:59 +08:00
jxxghp
50ffcc6e92 优化订阅和整理对话框,添加剧集组选项和编号,调整布局以提升用户体验 2025-04-03 18:35:21 +08:00
jxxghp
43beb1df95 优化 SiteCard 组件,使用 IconBtn 替代 button 以提升用户体验 2025-04-03 12:59:30 +08:00
jxxghp
3cef928b75 优化文件浏览器组件性能,提升加载速度 2025-04-03 12:57:47 +08:00
jxxghp
6f5d62f1f9 优化文件浏览器组件样式 2025-04-03 08:29:47 +08:00
jxxghp
e3a385c989 优化文件浏览器组件,修复目录树切换逻辑 2025-04-03 08:23:07 +08:00
jxxghp
12356abf00 添加文件浏览器目录树切换功能,优化用户界面交互 2025-04-03 07:20:15 +08:00
jxxghp
50e76496a2 更新 TorrentCardListView.vue 2025-04-02 16:00:34 +08:00
jxxghp
a98bf08b2d 优化工作流操作对MacOS的删除键设置,提升跨平台用户体验 2025-04-02 14:23:49 +08:00
jxxghp
697fd57bc7 优化工作流操作调整布局和删除键设置 2025-04-02 14:21:08 +08:00
jxxghp
7a691fe4e7 优化多个页面的标签样式,提升组件一致性和用户体验 2025-04-02 13:20:22 +08:00
jxxghp
3822ab20d5 优化多个视图的卡片样式 2025-04-02 12:13:54 +08:00
jxxghp
88f261584f 优化卡片样式,移除多余的圆角设置,提升组件一致性 2025-04-02 10:44:47 +08:00
jxxghp
62db4508da 优化通知和捷径栏的卡片样式 2025-04-02 10:16:16 +08:00
jxxghp
122acc7ad3 优化用户卡片样式 2025-04-02 09:28:47 +08:00
jxxghp
c15927cca0 优化用户列表和卡片样式,提升响应式布局和用户体验 2025-04-02 09:06:55 +08:00
jxxghp
b5b2de30a2 Merge pull request #319 from madrays/v2 2025-04-02 07:02:40 +08:00
madrays
aebce53450 重构用户卡片页面 2025-04-02 01:34:30 +08:00
jxxghp
3c261a2c29 调整卡片组件的样式,优化悬停效果,提升用户体验 2025-04-01 18:52:07 +08:00
jxxghp
6a6a3bd463 优化 VCard 组件的样式,移除多余的圆角设置,提升界面一致性 2025-04-01 13:20:25 +08:00
jxxghp
ae62847ded 优化组件结构,调整 VCard 使用,提升界面一致性和可读性 2025-04-01 13:17:07 +08:00
jxxghp
c873787a89 调整 VCard 组件的阴影层级,优化资源列表容器的结构 2025-04-01 07:15:30 +08:00
jxxghp
410ff78ef5 优化 SiteCard 组件,调整样式和结构,提升可读性和用户体验 2025-04-01 00:06:31 +08:00
jxxghp
ed53fbae93 Merge pull request #318 from madrays/v2 2025-03-31 23:43:05 +08:00
madrays
a3d8aa6a33 重构站点页面 2025-03-31 23:24:14 +08:00
jxxghp
24d03431c4 Merge pull request #317 from cddjr/fix_bangumi 2025-03-31 21:13:23 +08:00
景大侠
1d40d4a329 fix Bangumi每日放送 2025-03-31 20:25:05 +08:00
jxxghp
564896d99d 更新 TransferHistoryView.vue 2025-03-31 19:54:42 +08:00
jxxghp
2b2e25202d 优化 SiteCard 组件的样式,调整内边距和布局,改善用户界面 2025-03-31 19:45:49 +08:00
jxxghp
9055b95d00 调整多个组件的样式,修正高度计算并移除 scoped 样式 2025-03-31 19:27:47 +08:00
jxxghp
5a8eb5b10e 优化多个组件的样式,添加 scoped 样式以避免样式冲突 2025-03-31 18:44:56 +08:00
jxxghp
3e36cb6e31 优化 TorrentRowListView 组件的样式,调整过滤项显示和内边距 2025-03-31 16:04:13 +08:00
jxxghp
6b4b44aec6 优化 TorrentCard 和 TorrentItem 组件的样式,调整媒体标题和站点名称的布局 2025-03-31 15:31:52 +08:00
jxxghp
91a10c9d28 优化 TorrentCard 和 TorrentItem 组件的样式,调整行数和媒体查询的响应式设计 2025-03-31 15:03:21 +08:00
jxxghp
d7fbbd2d28 调整 FileBrowser 组件的样式,修正外层 DIV 和文件列表的高度计算 2025-03-31 14:26:45 +08:00
jxxghp
7b171e2c6f 优化移动端头部和筛选菜单的样式,调整间距和对话框尺寸 2025-03-31 14:24:15 +08:00
jxxghp
90ecaa1891 更新 TorrentCard.vue 2025-03-31 13:49:59 +08:00
jxxghp
842f7401a0 更新 package.json 2025-03-31 13:40:30 +08:00
jxxghp
77a6c591ff 优化 AddDownloadDialog 组件的样式,调整下载器和保存目录选择器的显示效果 2025-03-31 13:35:36 +08:00
jxxghp
9bd3aebd73 重构 TorrentItem 组件,移除未使用的函数,优化样式和过滤器菜单 2025-03-31 13:24:14 +08:00
jxxghp
b70d03e86b 优化 TorrentCard 和 TorrentItem 组件的样式,调整过滤器相关 UI 组件的显示效果 2025-03-31 13:03:56 +08:00
jxxghp
7d1ff9876f 更新 TorrentRowListView.vue 2025-03-31 12:07:10 +08:00
jxxghp
2cd8303191 更新 TorrentCardListView.vue 2025-03-31 12:06:37 +08:00
jxxghp
21dbaf6db5 优化文件浏览器样式,增加文件列表大小限制,调整文件导航器内边距 2025-03-31 12:02:15 +08:00
jxxghp
f9f45d9e32 fix FileBrowser UI 2025-03-31 11:33:47 +08:00
jxxghp
ef5db9ee4b fix ui 2025-03-30 19:54:55 +08:00
jxxghp
a909cdc21c rollback menu layout 2025-03-30 18:02:25 +08:00
jxxghp
b8e546a584 Merge pull request #316 from madrays/v2 2025-03-30 17:35:25 +08:00
madrays
c4f54dcddc 修复搜索结果卡片视图筛选可视性,优化季集显示位置,重构nodatafound情形ui,重构文件管理器ui并增加文件树功能,优化侧边栏高度规避竖向滑动条 2025-03-30 17:25:34 +08:00
jxxghp
59b5e4a330 更新 package.json 2025-03-30 01:16:20 +08:00
jxxghp
f8f7275438 Merge pull request #315 from madrays/v2 2025-03-30 00:55:25 +08:00
madrays
6eec2e97f9 Merge branch 'v2' of https://github.com/madrays/MoviePilot-Frontend into v2 2025-03-30 00:47:22 +08:00
madrays
9020494f65 改进搜索体验:优化搜索对话框、进度条及无数据提示 2025-03-30 00:46:12 +08:00
madrays
43fbc7abd7 改进搜索体验:优化搜索对话框、进度条及无数据提示 2025-03-30 00:37:48 +08:00
jxxghp
d65a4b747d Merge pull request #314 from madrays/v2 2025-03-29 22:35:55 +08:00
madrays
849fad8a8a 优化移动端界面:修复列表视图筛选栏固定问题,优化头部布局减少空间占用 2025-03-29 22:31:46 +08:00
jxxghp
f0b2d14502 fix: 更新 TorrentCard 组件以支持多个站点图标的加载和显示 2025-03-29 20:50:55 +08:00
jxxghp
fa169fb785 fix: 优化初始加载状态的条件判断 2025-03-29 20:07:48 +08:00
jxxghp
49acf7fba3 fix: 重置加载进度值并调整搜索进度卡的内边距 2025-03-29 20:05:38 +08:00
jxxghp
80d55dae8d 更新 resource.vue 2025-03-29 19:41:55 +08:00
jxxghp
76aa5407a2 更新 TorrentRowListView.vue 2025-03-29 19:03:30 +08:00
jxxghp
d70789934f 更新 TorrentCardListView.vue 2025-03-29 19:03:03 +08:00
jxxghp
398e8b6afc feat: 优化过滤器按钮显示逻辑,支持动态显示和已选择过滤项 2025-03-29 18:28:56 +08:00
jxxghp
593fede47c feat: 更新主题颜色和背景色 2025-03-29 17:04:49 +08:00
jxxghp
40c7e9c126 chore: 更新版本号至 2.3.6 2025-03-29 15:42:20 +08:00
jxxghp
54e2f70ee0 feat: 优化 TorrentCard 组件标题显示,支持多行文本截断 2025-03-29 08:27:03 +08:00
jxxghp
81f85b9e46 feat: 优化搜索结果界面UI,感谢 @madrays 2025-03-29 08:11:13 +08:00
jxxghp
60a5476e59 Merge pull request #313 from cddjr/trimemedia
初步支持飞牛影视
2025-03-28 19:28:02 +08:00
景大侠
4271b63530 初步支持飞牛影视 2025-03-28 15:51:55 +08:00
jxxghp
8aca17f0c6 fix: 更新 AliyunAuthDialog 以使用二维码 URL 并调整状态处理逻辑 2025-03-28 13:39:36 +08:00
jxxghp
4f238dc1a3 fix: 移除 AliyunAuthDialog 和 U115AuthDialog 中的 refreshToken 相关代码 2025-03-25 12:57:42 +08:00
jxxghp
d4777fde70 fix: 移除新建文件夹对话框的 v-if 条件 2025-03-24 13:32:30 +08:00
jxxghp
b6c823c386 chore: 更新版本号至2.3.5 2025-03-23 16:39:56 +08:00
jxxghp
b7488214fc feat: 添加全选/全不选功能及按钮文本更新 2025-03-23 12:28:30 +08:00
jxxghp
06b6c3f3cb fix: 调整对话框最大宽度至45rem 2025-03-22 14:11:27 +08:00
jxxghp
abfaf926c4 fix #305 2025-03-22 10:03:11 +08:00
jxxghp
6eabeb09c9 fix #293 2025-03-22 09:53:21 +08:00
jxxghp
a15afabfa7 fix #310 2025-03-22 09:27:27 +08:00
jxxghp
30276d5022 fixhttps://github.com/jxxghp/MoviePilot/issues/4002 2025-03-22 08:08:50 +08:00
jxxghp
683ddc3fce fix: 更新二维码获取逻辑,修复定时器设置位置并优化提示信息 2025-03-21 20:32:05 +08:00
jxxghp
f00f79279b Merge pull request #309 from Aqr-K/fix/settings 2025-03-21 09:55:11 +08:00
Aqr-K
7989965b1a fix: VTextarea no longer displays all rows 2025-03-16 22:19:52 +08:00
jxxghp
5b84ce307b fix: 移除对话框的 persistent 属性 2025-03-15 11:52:16 +08:00
jxxghp
d13264b10e Merge pull request #307 from Aqr-K/fix/types 2025-03-10 19:13:34 +08:00
Aqr-K
29a1c4ae35 fix: 增加 @types/mousetrap 修复 mousetrap 类型缺失警告 2025-03-10 18:59:16 +08:00
jxxghp
9ac15e530a feat: 更新工作流操作对话框,移除节点和连接线的删除逻辑,改为使用删除键处理 2025-03-10 11:03:02 +08:00
jxxghp
d4b446280a feat: 优化媒体服务器播放列表的添加逻辑,避免重复项 2025-03-10 10:43:53 +08:00
jxxghp
4593898549 feat: 优化媒体服务器库和播放列表的添加逻辑,避免重复项 2025-03-10 10:42:12 +08:00
jxxghp
c030d1a309 feat: 在保存通知发送时间后添加系统重载功能 2025-03-10 09:02:16 +08:00
jxxghp
fd71e471b2 feat: 优化发现页面标签页的激活逻辑并初始化选中标签 2025-03-10 08:55:10 +08:00
jxxghp
bc245e0a7a feat: 在发现页面激活时添加排序订阅顺序功能 2025-03-10 08:22:28 +08:00
jxxghp
8236461c37 feat: 根据路由 meta 动态调整 footer 高度 2025-03-10 08:15:24 +08:00
jxxghp
e1e8344764 feat: 注册 Pinia 状态管理并提供全局设置 2025-03-10 08:08:52 +08:00
jxxghp
14398e083e 更新 PathField.vue 2025-03-10 07:48:40 +08:00
jxxghp
f36fe075ce chore: 更新版本号至 2.3.4 2025-03-09 21:44:15 +08:00
jxxghp
25cf9d7fce feat: 根据路由 meta 决定是否显示 footer 2025-03-09 21:43:52 +08:00
jxxghp
9355788221 feat: 添加组件激活时的数据加载功能 2025-03-09 19:43:34 +08:00
jxxghp
64042b51e9 feat: 通和发送时间设置 2025-03-09 18:52:05 +08:00
jxxghp
7145af48ad fix: 优化发现标签页的拖拽图标显示 2025-03-08 18:37:19 +08:00
jxxghp
ddb5468656 feat: 添加可拖拽的发现标签页并实现顺序保存功能 2025-03-08 16:12:21 +08:00
jxxghp
793cdd8f4c feat: 添加进度框以显示系统配置重载状态 2025-03-08 08:07:04 +08:00
jxxghp
faafbb59c6 Merge pull request #304 from Aqr-K/fix/workflow 2025-03-05 06:43:46 +08:00
Aqr-K
cd0ea07c2f fix: 修复创建同类型的节点时,数据未隔离的问题 2025-03-05 04:28:50 +08:00
Aqr-K
f6e3807a3d fix: 完善连接 workflow 节点时的合法性校验 2025-03-05 04:24:54 +08:00
jxxghp
fc36496aee chore: 更新版本号至 2.3.3 2025-03-02 12:32:15 +08:00
jxxghp
1c8881d7a4 feat: 添加重置任务功能 2025-03-02 12:27:58 +08:00
jxxghp
f6e8aacd0f feat: 优化任务执行功能,添加继续执行和重新开始选项;移除媒体过滤器中的类别选择 2025-03-02 11:17:28 +08:00
jxxghp
79ddc39492 feat: 添加标签输入框,优化下载器和 RSS 获取操作的界面布局 2025-03-02 09:45:44 +08:00
jxxghp
e63c5fb8e5 feat: 更新发送事件和发送消息的副标题,明确任务执行内容 2025-03-01 18:26:32 +08:00
jxxghp
695f4827fd chore: 更新版本号至 2.3.2 2025-03-01 15:38:39 +08:00
jxxghp
5a8b183c0f feat: 添加来源类型下拉框,优化媒体获取操作界面 2025-03-01 14:07:07 +08:00
jxxghp
2845a889ed feat: 修复导入工作流代码时的 JSON 解析问题 2025-02-28 22:08:49 +08:00
jxxghp
6333103050 feat: 为工作流组件添加外层 div 包裹,优化布局结构 2025-02-28 22:05:48 +08:00
jxxghp
cb6be91538 feat: 添加扫描目录功能,支持将目录文件扫描到队列 2025-02-28 21:11:21 +08:00
jxxghp
8cdd4b4af5 feat: 修改任务执行成功提示信息,增加延迟刷新 2025-02-28 19:03:00 +08:00
jxxghp
f4ec2029d9 feat: 移除工作流任务卡片的禁用状态 2025-02-28 18:18:24 +08:00
jxxghp
b84b0f229f feat: 添加搜索方式下拉框,优化工作流操作对话框布局 2025-02-28 18:13:13 +08:00
jxxghp
ef6a01a32f feat: 调整导航栏底部高度,禁用卡片点击涟漪效果 2025-02-28 13:02:38 +08:00
jxxghp
b451b8066a feat: 添加仅下载缺失资源的开关选项 2025-02-28 12:15:38 +08:00
jxxghp
57efd516c5 feat: 优化工作流任务卡片和列表视图的布局 2025-02-28 11:19:07 +08:00
jxxghp
d5979e6bf3 feat: 修复进度计算逻辑,添加加载状态和禁用功能 2025-02-27 20:39:22 +08:00
jxxghp
d75970cb2a feat: 更新工作流任务卡片 2025-02-27 20:15:24 +08:00
jxxghp
ad4bb07cd7 feat: 更新工作流组件,优化界面布局,添加消息和媒体过滤功能 2025-02-27 18:56:05 +08:00
jxxghp
9c558c3625 feat: 添加工作流组件的边缘处理和循环执行功能,优化订阅和RSS获取操作 2025-02-27 17:09:01 +08:00
jxxghp
b467bb6c56 feat: 重构工作流组件,动态加载节点类型,移除旧的侧边栏和背景组件 2025-02-27 13:55:06 +08:00
jxxghp
5cd021ea85 feat: 优化插件弹窗加载速度 2025-02-27 12:44:39 +08:00
jxxghp
3d64382c9b feat: 更新拖放功能,重构状态管理,优化工作流组件,添加节点和边的确认删除功能 2025-02-26 21:11:24 +08:00
jxxghp
6d5d4354d9 feat: 重构工作流对话框,合并添加和编辑功能,新增流程操作对话框 2025-02-26 19:07:00 +08:00
jxxghp
1b43446b5c feat: 添加自动刷新功能,每30秒更新工作流数据 2025-02-26 18:24:38 +08:00
jxxghp
7a9984f392 feat: 添加已完成动作数计算和优化工作流列表视图 2025-02-26 18:09:11 +08:00
jxxghp
3c6fbfb106 feat: 添加工作流任务卡片组件,支持编辑、删除和执行功能 2025-02-26 13:58:52 +08:00
jxxghp
bab46964ff feat: 优化用户界面和交互提示 2025-02-25 21:06:43 +08:00
jxxghp
661919f27a feat: 优化用户界面和交互提示 2025-02-25 20:52:43 +08:00
jxxghp
f3a03349b4 feat: 添加工作流新增对话框和编辑功能,优化工作流列表视图 2025-02-25 17:28:09 +08:00
jxxghp
29791bf986 fix #303 2025-02-25 08:35:28 +08:00
jxxghp
a06f0f29c6 Merge pull request #303 from Aqr-K/build/reduce-size 2025-02-25 06:57:48 +08:00
Aqr-K
b426d94180 fix: relevant settings of pinia and lodash-es 2025-02-24 19:50:47 +08:00
Aqr-K
5618d87e58 refactor: replace lodash with lodash-es 2025-02-24 19:49:10 +08:00
Aqr-K
721d4f7685 refactor: replace Vuex with Pinia 2025-02-24 19:26:56 +08:00
jxxghp
7a025bcd38 feat(Workflow): add modules 2025-02-23 13:16:01 +08:00
jxxghp
24a8125621 feat(package): 添加 @vue-flow/core 依赖及相关更新 2025-02-23 12:04:20 +08:00
jxxghp
468584a906 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2025-02-22 12:11:42 +08:00
jxxghp
c056ec9377 feat(Workflow): 添加工作流功能,包含工作流列表和相关接口定义 2025-02-22 12:11:38 +08:00
jxxghp
87239994ae feat(SiteCard): 优化按钮位置 2025-02-21 13:10:48 +08:00
jxxghp
da09860a53 fix(AccountSettingRule): 更新删除按钮图标为mdi-delete-empty-outline 2025-02-21 13:02:12 +08:00
jxxghp
195ee5b2a6 feat(AccountSetting): 重构规则验证逻辑 2025-02-21 12:54:32 +08:00
jxxghp
32621ee299 feat(Search): 添加站点搜索功能,支持选择和过滤 2025-02-21 10:12:16 +08:00
jxxghp
40645180a0 fix(styles): 修复滚动阻止时的样式问题 2025-02-21 09:29:10 +08:00
jxxghp
59d4b1e544 feat(SearchBar): 添加站点搜索功能,支持多选和过滤 2025-02-20 13:03:37 +08:00
jxxghp
8962a2c4ac fix 2025-02-20 11:23:46 +08:00
jxxghp
6955f35ad1 chore(package): 更新版本号至 2.3.0 2025-02-18 16:49:37 +08:00
jxxghp
1f722e7d7f fix(styles): 移除数据表页脚的上边距 2025-02-18 16:44:30 +08:00
jxxghp
5e587dfd88 feat(SiteResource): 添加站点资源分类功能并优化资源查询逻辑 2025-02-18 16:27:31 +08:00
jxxghp
2c687e5648 feat(UserProfile): 添加功能视图菜单 2025-02-18 13:39:54 +08:00
jxxghp
fdb0f63283 fix(AppCenter): 修改菜单列表过滤逻辑以优化用户权限管理 2025-02-18 12:57:43 +08:00
jxxghp
002e675b47 fix(SiteCard): 修改站点卡片的点击事件和菜单项文本以优化用户体验 2025-02-18 08:34:54 +08:00
jxxghp
114f2a2dd0 fix(Footer): 优化更多菜单的激活状态计算逻辑 2025-02-18 08:12:16 +08:00
jxxghp
c314d49e11 feat(Footer): 使用 VMenu 组件替代 VBottomSheet 以优化更多菜单的展示 2025-02-18 07:56:11 +08:00
jxxghp
f5d0556808 fix(Footer): 修改对话框内容类以调整样式;添加隐藏滚动条的样式混合 2025-02-17 20:54:54 +08:00
jxxghp
27bc2a488f feat(SiteResourceDialog): 改进对话框布局 2025-02-17 20:41:52 +08:00
jxxghp
3a5999c341 fix(SubscribeShareCard): 添加 h-full 类以优化卡片布局 2025-02-16 16:15:25 +08:00
jxxghp
a80a099ee7 fix(Footer): 移除底部导航的 active 属性 2025-02-15 13:04:33 +08:00
jxxghp
68f458738a feat(Footer): 优化底部导航样式 2025-02-15 12:58:13 +08:00
jxxghp
0f08f69738 fix(MediaCard): 修改按钮大小并优化底部填充样式 2025-02-15 08:50:05 +08:00
jxxghp
a664066465 Merge pull request #301 from InfinityPacer/v2 2025-02-15 08:11:01 +08:00
jxxghp
e6c11665a5 更新 package.json 版本号至 2.2.9 2025-02-14 19:33:52 +08:00
InfinityPacer
c119384c22 fix(settings): remove invalid githubMirrorsItems 2025-02-14 14:19:48 +08:00
jxxghp
787cccb89f feat:在多个组件中添加onActivated钩子以优化数据加载逻辑 2025-02-11 17:15:55 +08:00
jxxghp
3df5d75c46 更新 package.json 2025-02-10 22:26:12 +08:00
jxxghp
de6ad2479e feat:为DiscoverSource接口添加依赖关系字典,优化过滤参数的watch逻辑 2025-02-10 22:05:33 +08:00
jxxghp
632dfbaf10 feat:优化TheMovieDbView组件的watch逻辑 2025-02-10 16:46:43 +08:00
jxxghp
68c14c24b8 feat:为TMDB排序和风格字典添加类型定义,优化过滤参数的逻辑,确保参数有效性 2025-02-09 22:21:43 +08:00
jxxghp
d343d6d54d feat:优化TheMovieDbView组件的watch逻辑,分离类型和过滤参数的监听,确保列表刷新更高效 2025-02-09 22:00:38 +08:00
jxxghp
391a160f97 更新 package.json 2025-02-09 11:59:53 +08:00
jxxghp
2d95110f75 feat:优化推荐页面,修复MediaCardSlideView的key绑定,使用电影标题作为唯一标识 2025-02-09 11:57:46 +08:00
jxxghp
e2ced8d36d feat:更新TheMovieDbView组件,添加电影和电视剧风格字典,优化类型过滤逻辑 2025-02-09 11:55:40 +08:00
jxxghp
a2b4511602 feat:优化FormRender组件的属性解析逻辑,支持动态表达式绑定 2025-02-09 11:41:35 +08:00
jxxghp
bdccc71b64 feat:优化FormRender组件的事件处理逻辑,支持动态函数绑定 2025-02-09 11:28:28 +08:00
jxxghp
d7038a7d18 feat:优化FormRender组件,增强v-model和v-show支持,改进属性绑定逻辑;在TheMovieDbView中添加儿童类别 2025-02-09 11:21:46 +08:00
jxxghp
3998e1f685 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2025-02-08 21:48:03 +08:00
jxxghp
5def9d5f81 feat:重构推荐页面,添加推荐数据源接口并更新路由和视图 2025-02-08 21:47:57 +08:00
jxxghp
c62937371e 更新 package.json 2025-02-08 20:19:12 +08:00
jxxghp
52843dcf97 feat:在排名页面中添加TMDB和豆瓣的热门电影及电视剧链接 2025-02-08 20:14:45 +08:00
jxxghp
ef5680d5ad feat:在ExtraSourceView中添加默认过滤参数支持,确保过滤条件的完整性 2025-02-08 12:53:26 +08:00
jxxghp
bd3f24c84b feat:添加媒体季信息接口,更新相关组件以支持季信息 2025-02-08 12:46:36 +08:00
jxxghp
399f85c52e chore:更新版本号至2.2.7-1 2025-02-07 18:21:13 +08:00
jxxghp
14430e5c89 feat:为选中的v-chip添加自定义颜色样式 2025-02-07 18:09:40 +08:00
jxxghp
b703757d28 feat:添加评分格式化功能,优化媒体卡片中的评分显示 2025-02-07 17:00:35 +08:00
jxxghp
b642eabbb3 feat:在媒体相关组件中添加媒体ID、标题和年份的支持 2025-02-06 20:33:14 +08:00
jxxghp
673596d8f9 feat:在媒体信息中添加媒体ID前缀和媒体ID 2025-02-06 19:21:02 +08:00
jxxghp
b14e927e6c feat:支持探索扩展 2025-02-06 18:04:49 +08:00
jxxghp
b03ae41ac7 feat:在index.html中添加初始加载背景样式 2025-02-06 16:26:10 +08:00
jxxghp
92a0a9fe2f feat:重构主题存储逻辑,优化加载背景和颜色设置 2025-02-06 16:00:32 +08:00
jxxghp
2511acfea1 feat:优化加载背景样式 2025-02-06 13:48:03 +08:00
jxxghp
361a4e0414 feat:优化分组逻辑,使用元信息增强分组键 2025-02-06 11:42:32 +08:00
jxxghp
7e310236fe feat:在转移历史视图中添加分组功能 2025-02-06 10:38:28 +08:00
jxxghp
8705606c70 更新 package.json 2025-02-06 08:39:54 +08:00
jxxghp
1f812a5258 feat:重构过滤选项逻辑,优化过滤表单和排序功能 2025-02-06 08:30:27 +08:00
jxxghp
e9264fa472 feat:小屏搜索结果列表模式增加过滤按钮 2025-02-05 17:40:57 +08:00
jxxghp
9164a1aefc fix #295 2025-02-04 09:59:05 +08:00
jxxghp
30351a02ee 升级版本号 2025-01-31 08:00:53 +08:00
jxxghp
7f918408a6 优化加载界面的样式,调整HTML和CSS以改善用户体验 2025-01-31 08:00:30 +08:00
jxxghp
82f69bcad0 修复在小屏幕下的返回按钮显示逻辑 2025-01-30 21:18:02 +08:00
jxxghp
83b25eabbb 优化对话框组件的样式和属性设置 2025-01-29 19:21:57 +08:00
jxxghp
47da6db51a 为filterParams添加默认排序选项 2025-01-29 19:07:43 +08:00
jxxghp
eee092a7fd fix #292 2025-01-29 18:57:22 +08:00
jxxghp
4c0f65fcbc fix https://github.com/jxxghp/MoviePilot/issues/3823 2025-01-29 18:51:06 +08:00
jxxghp
acbd979569 bangumi添加年份过滤选项 2025-01-28 09:23:45 +08:00
jxxghp
52b68c18bf 优化标签显示效果 2025-01-28 09:11:52 +08:00
jxxghp
c6a74a75da build 2025-01-28 08:23:28 +08:00
jxxghp
e39eb62f52 调整资源页面导入路径,修正TorrentCardListView和TorrentRowListView组件的引用 2025-01-28 08:22:44 +08:00
jxxghp
4ecec4865d 优化过滤选项,简化组件结构,添加评分滑块功能 2025-01-28 08:20:41 +08:00
jxxghp
589007a22a 优化主题设置逻辑,简化代码结构 2025-01-28 07:32:45 +08:00
jxxghp
4d4c9516c6 重构发现页面,添加豆瓣和TheMovieDb过滤选项,优化媒体卡组件 2025-01-27 21:08:52 +08:00
jxxghp
8491f26617 更新菜单项 2025-01-27 18:37:58 +08:00
jxxghp
fcb3768a76 更新菜单项图标,添加豆瓣和TheMovieDb的SVG和PNG图标 2025-01-27 18:16:57 +08:00
jxxghp
966bb769df 更新浏览和发现页面,重构相关组件,调整路由和菜单项 2025-01-27 18:05:02 +08:00
jxxghp
dc8f7caab0 更新 menu.ts 2025-01-27 12:25:42 +08:00
jxxghp
683346d652 添加发现页面及相关路由和菜单项 2025-01-27 11:25:43 +08:00
jxxghp
f5fe39b2d2 更新 App.vue 2025-01-26 22:28:30 +08:00
jxxghp
51beb53f51 更新 index.html 2025-01-26 22:27:41 +08:00
jxxghp
9d3f03c83a 更新 index.html 2025-01-26 22:14:10 +08:00
jxxghp
3eda1e4ef7 更新 index.html 2025-01-26 22:12:42 +08:00
jxxghp
7181f83d66 更新 Footer.vue 2025-01-26 22:10:10 +08:00
jxxghp
fffad6e1b8 更新 UserListView.vue 2025-01-26 22:08:32 +08:00
jxxghp
7f3906e5cb 更新 loader.css 2025-01-26 08:59:31 +08:00
jxxghp
f836d175f0 fix(App.vue): 优化页面加载时的背景移除逻辑,增加延迟以确保渲染完成 2025-01-26 08:48:22 +08:00
jxxghp
f49cafc0cc feat: 添加确保渲染完成的函数并优化加载背景移除逻辑 2025-01-26 08:42:09 +08:00
jxxghp
a3ecad3436 feat: 添加刷新状态控制,优化多个视图的显示逻辑 2025-01-25 19:34:39 +08:00
jxxghp
a019dbd44e refactor(SubscribeListView): 移除不必要的 VPullToRefresh 组件,简化订阅列表渲染逻辑 2025-01-25 19:15:02 +08:00
jxxghp
b316f960a1 Merge pull request #291 from InfinityPacer/v2 2025-01-25 07:43:42 +08:00
InfinityPacer
d049b26825 fix(LibraryCard): handle image loading errors with gradient 2025-01-25 03:07:14 +08:00
jxxghp
852579c6ee 更新 ForkSubscribeDialog.vue,调整 VCardSubtitle 的行数限制以改善文本显示 2025-01-23 17:24:17 +08:00
jxxghp
5adcfa1877 更新 ForkSubscribeDialog.vue 2025-01-22 19:10:27 +08:00
jxxghp
f74458629e 为 DashboardRender 组件添加 key 属性以优化渲染性能 2025-01-22 18:58:06 +08:00
jxxghp
798f9249f8 更新 package.json 版本号至 2.2.4 2025-01-22 18:48:28 +08:00
jxxghp
6b4383643f 为 ForkSubscribeDialog 组件添加用户关注功能,并在 DashboardRender 组件中实现组件重渲染 2025-01-22 13:19:09 +08:00
jxxghp
256e8d0452 为 ForkSubscribeDialog 组件的 VDialog 添加 scrollable 属性 2025-01-21 08:28:59 +08:00
jxxghp
4112214c1f 添加豆瓣用户字段并更新账号绑定标题 2025-01-20 18:25:28 +08:00
jxxghp
c183158ffe 更新 package.json 2025-01-20 13:25:49 +08:00
jxxghp
d523790c0f fix ApexCharts 2025-01-19 14:31:51 +08:00
jxxghp
615ce34a72 Merge pull request #288 from wikrin/v2 2025-01-18 07:08:48 +08:00
Attente
1d59b3566c fix: jxxghp/MoviePilot#3747 2025-01-18 02:54:27 +08:00
jxxghp
8071b90a2b 修复滚动阻塞时的样式问题 2025-01-17 19:50:56 +08:00
jxxghp
8966584ca0 优化海报卡片样式和背景渐变 2025-01-17 19:31:15 +08:00
jxxghp
822711a530 refactor:重构路径输入组件 2025-01-17 13:28:31 +08:00
jxxghp
1fe8aeb9e1 优化日志解析性能 2025-01-16 19:51:58 +08:00
jxxghp
f021ba8a98 优化媒体卡片和海报卡片的点击事件处理,改进路由滚动行为,注册中止控制器以管理异步请求 2025-01-16 19:25:51 +08:00
jxxghp
e4af05cd56 添加媒体信息中的合集类型和ID,优化媒体卡片的跳转逻辑,增加搜索系列合集的功能 2025-01-16 17:52:16 +08:00
jxxghp
43d1cdb91c fix 订阅历史对话框 2025-01-16 15:23:37 +08:00
jxxghp
ed3f66681f 更新依赖版本,优化组件和服务工作者的导入,调整 SCSS 混合宏,修复 Vite 配置中的文件缓存大小限制 2025-01-16 15:14:58 +08:00
jxxghp
c718d57e77 优化重启确认对话框的布局 2025-01-16 08:16:51 +08:00
jxxghp
ce2e88a532 优化日志视图,动态设置日志内容的文本颜色 2025-01-15 22:20:58 +08:00
jxxghp
e60015a477 优化日志解析逻辑,添加倒序插入和日志数量限制 2025-01-15 22:03:28 +08:00
jxxghp
761e3ac76d 更新依赖版本,优化日志处理逻辑,添加日志解析功能 2025-01-15 21:43:39 +08:00
jxxghp
2cf5535376 更新 vuetify 和 vite-plugin-vuetify 版本,优化 PathInput 组件的状态管理 2025-01-15 19:24:36 +08:00
jxxghp
1a3d76d7b9 添加重启确认对话框并优化用户配置对话框的布局 2025-01-15 18:39:26 +08:00
jxxghp
942a536289 修改 PluginCard.vue,替换 VCardText 为 VCardItem,以优化组件结构 2025-01-15 17:07:16 +08:00
jxxghp
fb1f6abf2e 更新 main.ts 2025-01-15 16:42:12 +08:00
jxxghp
61ecb421e6 修改加载背景元素的移除逻辑,确保正确清除加载指示器 2025-01-15 15:37:59 +08:00
jxxghp
0098f9db2f 调整 main.ts 文件的导入顺序 2025-01-15 15:28:20 +08:00
jxxghp
2a348a7f18 优化 main.ts 文件的导入顺序,调整样式文件和核心插件的导入位置 2025-01-15 14:58:48 +08:00
jxxghp
838dff4758 优化 Vite 配置,添加运行时缓存策略,确保静态资源和图像的高效加载 2025-01-15 14:25:17 +08:00
jxxghp
7fb78a86ba 优化 main.ts 文件的导入顺序,提升代码可读性;调整 styles.scss 中的样式定位 2025-01-15 14:10:51 +08:00
jxxghp
07c815e943 Merge pull request #287 from wikrin/v2 2025-01-15 13:40:30 +08:00
jxxghp
9a4392eceb 添加分享人唯一ID,支持删除订阅分享功能,并优化相关组件的事件处理 2025-01-15 13:31:50 +08:00
Attente
dc25e457eb fiix(setting): 保存目录后重载模块 2025-01-14 17:31:19 +08:00
jxxghp
d65ed9725c 移除 CronInput 组件中未使用的 api 导入和 FileItem 类型 2025-01-13 13:07:22 +08:00
jxxghp
41ce095505 更新 SubscribeShareCard 和 ForkSubscribeDialog 组件,调整图标颜色,添加搜索词显示,优化识别词的显示行数,并在 ForkSubscribeDialog 中添加复用次数提示 2025-01-13 08:56:51 +08:00
jxxghp
0e2290ce8a 更新 SubscribeShareDialog 组件,添加 share_title 字段的格式化,设置标题为只读,并优化说明提示文本;在确认分享按钮中添加加载状态 2025-01-13 08:16:27 +08:00
jxxghp
1b8db5b7f1 优化 FormRender 组件的渲染逻辑,增强对插槽和内容的支持,简化模板结构 2025-01-12 18:11:39 +08:00
jxxghp
0cb42c1117 更新 FormRender 组件,使用 RenderProps 类型替代原有的 config 类型,并增强渲染逻辑以支持 html 和 text 属性 2025-01-12 18:05:37 +08:00
jxxghp
a289fe3da5 更新 package.json,版本号从 2.2.0 升级至 2.2.1 2025-01-12 17:03:04 +08:00
jxxghp
f53192cfa2 更新 MessageCard 组件,添加对 props.message.action 的检查以优化条件渲染逻辑 2025-01-12 17:02:29 +08:00
jxxghp
235e014542 重构 PluginCard 组件,替换 DynamicRender 为 FormRender;在 ForkSubscribeDialog 组件中添加处理状态以优化用户体验;删除不再使用的 DynamicRender 组件 2025-01-12 16:40:33 +08:00
jxxghp
211b05c643 更新 DynamicRender 组件,添加对 config.text 的支持以增强渲染功能 2025-01-12 16:24:05 +08:00
jxxghp
3e1bd687f1 Merge pull request #286 from InfinityPacer/v2 2025-01-11 21:31:51 +08:00
jxxghp
072fb01a04 更新 CronInput 组件,添加 persistent 属性以优化 VMenu 行为 2025-01-11 20:48:45 +08:00
jxxghp
81fbf4f5ba 更新 CronInput 组件,修改当前 CRON 值的绑定方式,以支持 v-model 绑定 2025-01-11 20:24:06 +08:00
jxxghp
88c86f49bf 重构 DirectoryCard 组件,替换 VPathField 为 PathInput;删除不再使用的 PathField 组件;更新 CronInput 组件以支持 v-model 绑定;添加 CronField 组件以简化 CRON 表达式输入 2025-01-11 20:20:05 +08:00
jxxghp
3023214072 重构 PluginCard 组件,替换 FormRender 为 DynamicRender,优化动态渲染逻辑;删除不再使用的 FormRender 组件 2025-01-11 16:24:16 +08:00
jxxghp
6ea6f89ab2 FIXME 2025-01-11 15:00:23 +08:00
jxxghp
43c6672ab1 fixme 2025-01-11 14:15:52 +08:00
InfinityPacer
5cb56127d5 feat(login): add autocomplete attributes for browser auto-fill 2025-01-11 13:56:01 +08:00
jxxghp
afa333243f 添加 VCronInput 公共组件,用于快速录入CRON表达式 2025-01-11 13:28:46 +08:00
jxxghp
047e99e27c 更新 SubscribeShareDialog.vue:添加分享处理状态,禁用分享按钮以防止重复提交 2025-01-09 16:19:28 +08:00
jxxghp
eef6f37ace 更新 MessageCard.vue:调整卡片宽度,优化文本处理逻辑以支持换行 2025-01-09 12:44:07 +08:00
jxxghp
e8ede6e606 更新设置相关组件:优化错误提示信息,增强用户反馈 2025-01-09 12:29:45 +08:00
jxxghp
bfb4ea4123 更新 package.json 2025-01-09 08:24:57 +08:00
jxxghp
51b0403f64 更新 MessageCard.vue:优化图片显示和文本处理逻辑 2025-01-09 08:22:12 +08:00
jxxghp
a5cd396de6 更新 DirectoryCard.vue 2025-01-07 20:50:12 +08:00
jxxghp
754bc3d3c9 fix(login): improve error messages and update error display component 2025-01-07 09:56:14 +08:00
jxxghp
07a2bcfb97 更新 package.json 2025-01-06 18:01:02 +08:00
jxxghp
20222201ae Merge pull request #284 from InfinityPacer/v2 2025-01-06 17:59:21 +08:00
jxxghp
a2a5ddd66c 升级版本号至 2.1.9 2025-01-06 11:55:29 +08:00
InfinityPacer
7bfc7602a7 fix(log): add LOG_FILE_FORMAT 2025-01-06 02:38:58 +08:00
InfinityPacer
b52b2cedad fix(log): update hint 2025-01-06 02:37:40 +08:00
jxxghp
e93df6ba2c 为通知设置添加“操作用户和管理员”选项 2025-01-05 13:17:46 +08:00
jxxghp
f9f29ccc3c 调整样式以考虑安全区域的上下内边距,优化布局和溢出处理 2025-01-04 14:12:07 +08:00
jxxghp
3bd63ab7c8 为遮罩层添加最小高度并调整溢出样式 2025-01-04 12:23:03 +08:00
jxxghp
301ea445bb 优化样式,合并对话框的边距设置,并为遮罩层添加最小高度 2025-01-04 12:13:03 +08:00
jxxghp
475bee28c6 优化用户头像上传界面,调整布局和样式 2025-01-04 11:52:45 +08:00
jxxghp
cd69920b41 更新 UserAddEditDialog.vue 2025-01-04 11:08:11 +08:00
jxxghp
83aab4e47d 更新 styles.scss 2025-01-04 10:58:45 +08:00
jxxghp
e12093c966 更新 UserAddEditDialog.vue 2025-01-04 10:52:42 +08:00
jxxghp
f21d546d18 更新 UserAddEditDialog.vue 2025-01-04 10:39:34 +08:00
jxxghp
26c8a6ba43 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2025-01-04 10:23:20 +08:00
jxxghp
827bb8ba69 feat(AccountSettingSystem): 添加备用TMDB API域名选项 2025-01-04 10:23:16 +08:00
jxxghp
d1d2ef37d2 更新 package.json 2025-01-04 10:16:20 +08:00
jxxghp
659594898b refactor(SiteCard): Rename test button text and simplify action button structure 2025-01-04 10:07:01 +08:00
jxxghp
7569401fe0 style(SiteCard): Update card layout and remove inline styles 2025-01-04 09:43:24 +08:00
jxxghp
dc9c86273d Merge pull request #281 from wintsa123/v2 2025-01-03 22:19:26 +08:00
jxxghp
0e816e678a Merge pull request #283 from Aqr-K/feature/log 2025-01-03 13:38:52 +08:00
wintsa
ff1c2a890c remove console 2025-01-03 09:56:06 +08:00
Aqr-K
b802ad8a75 feat(SystemSettings): Add the log setting UI 2025-01-03 06:23:44 +08:00
wintsa
c11fb54b0b remove console 2025-01-03 00:03:45 +08:00
wintsa
856dec3991 忘记把注释去掉了 2025-01-02 15:42:25 +08:00
wintsa
1d8c71da3f 添加取消请求 2025-01-02 15:39:36 +08:00
jxxghp
4152d0f715 更新 package.json 2024-12-31 07:06:18 +08:00
jxxghp
0ead8cc052 fix(PluginCard): 调整插件卡片的 CSS 类以确保高度填充 2024-12-30 19:18:26 +08:00
jxxghp
2b5ecf3f8a fix(PluginCard): 移除悬停显示插件版本和描述的过渡效果以简化组件结构 2024-12-30 19:10:49 +08:00
jxxghp
de7aeeaeb3 fix(UserAuthDialog): 移除 VDialog 的可滚动属性以优化用户体验 2024-12-30 18:21:29 +08:00
jxxghp
994c52f6aa feat(PluginCard): 在插件卡片中添加悬停显示插件版本信息 2024-12-29 20:21:13 +08:00
jxxghp
c6eb744257 feat(PluginAppCard, PluginCard): 添加 VSlideYTransition 以增强插件描述的显示效果 2024-12-29 20:18:30 +08:00
jxxghp
4f462c5cfd feat(PluginAppCard, PluginCard): 添加插件详情弹窗并优化样式 2024-12-29 19:59:14 +08:00
jxxghp
60850970a8 fix(ForkSubscribeDialog): 更新样式,优化文本对齐和可读性 2024-12-29 14:34:17 +08:00
jxxghp
3b2d5e45bb feat: 添加 MediaInfoDialog 组件并更新相关引用 2024-12-29 14:22:27 +08:00
jxxghp
a604d3223a Merge pull request #279 from Aqr-K/fix/search 2024-12-29 07:54:40 +08:00
Aqr-K
00bd1c45a1 fix: bug
- 增加防抖,解决输入打断
2024-12-28 22:34:32 +08:00
jxxghp
4bc6dc7af7 Merge pull request #278 from Aqr-K/fix/search
fix(search): Input method optimization
2024-12-28 14:38:59 +08:00
jxxghp
3a8effd01f fix(FilterRuleGroupCard, TransferQueueDialog): 更新组件结构,替换 VCardText 为 VCardItem,优化显示效果 2024-12-28 12:16:31 +08:00
Aqr-K
da67088e9c 移除无用值 2024-12-28 11:54:50 +08:00
jxxghp
bacd4d23a3 fix(TransferQueueDialog): 更新加载进度逻辑 2024-12-27 18:42:57 +08:00
jxxghp
020f667749 fix(TransferQueueDialog): 更新加载进度处理逻辑 2024-12-27 17:11:00 +08:00
Aqr-K
84652e8c82 更新 SearchBarView.vue 2024-12-27 16:01:30 +08:00
Aqr-K
565ebd936e 更新 TransferHistoryView.vue 2024-12-27 14:30:37 +08:00
Aqr-K
3af127c66f fix(search): Input method optimization
- 合成文字输入法的支持,允许输入到中途进行暂停等操作,而不打断输入触发高频事件
2024-12-27 14:29:21 +08:00
jxxghp
849bb04249 fix(styles): 调整 grid-plugin-card 的列宽,从 18rem 更新为 20rem 2024-12-27 08:01:12 +08:00
jxxghp
09d647877f fix(ReorganizeDialog, TransferHistoryView, AccountSettingSystem, SearchBarView): 更新提示信息,将“历史记录”替换为“整理记录” 2024-12-27 07:56:20 +08:00
jxxghp
c868afbcbf feat(ReorganizeDialog, TransferQueueDialog): 优化错误提示,添加媒体状态颜色和任务计数功能 2024-12-27 07:53:51 +08:00
jxxghp
3f033bfdec 更新 menu.ts 2024-12-26 22:35:24 +08:00
jxxghp
eca2f43e0e 更新 TransferQueueDialog.vue 2024-12-26 22:30:47 +08:00
jxxghp
eeb17040f7 更新 menu.ts 2024-12-26 22:30:00 +08:00
jxxghp
11dee1ed62 更新 package.json 2024-12-26 21:26:28 +08:00
jxxghp
11a6232f83 Merge pull request #277 from Aqr-K/style/site 2024-12-26 17:32:44 +08:00
jxxghp
9eded24e0e Merge pull request #276 from InfinityPacer/v2 2024-12-26 17:31:55 +08:00
Aqr-K
7548882148 style(site): 样式调整 2024-12-26 16:25:32 +08:00
InfinityPacer
4ad89955d4 feat(config): add TOKENIZED_SEARCH 2024-12-26 13:56:27 +08:00
jxxghp
a53553d658 feat(TransferQueueDialog): 添加状态标签以显示任务状态,优化进度条显示逻辑 2024-12-26 13:42:59 +08:00
jxxghp
2602cb0998 feat(TransferQueueDialog): 优化任务队列显示,添加媒体信息和任务管理功能 2024-12-26 13:30:22 +08:00
jxxghp
e402de29d5 Merge pull request #275 from wikrin/v2 2024-12-26 09:28:46 +08:00
Attente
a4cc1cc615 feat(dialog): 仅在指定条件下启用指定集数 2024-12-26 08:24:11 +08:00
jxxghp
2d900baad1 feat(ReorganizeDialog): 添加SSE支持以监听文件整理进度 2024-12-25 21:55:20 +08:00
jxxghp
17d6f6db05 feat(ReorganizeDialog): 重构整理功能,支持后台处理和日志整理 2024-12-25 20:39:53 +08:00
jxxghp
7f3ba543b7 feat(TransferQueue): 添加整理队列对话框及相关功能 2024-12-25 18:12:50 +08:00
jxxghp
b33cb8a12c refactor(Menu): update menu titles for clarity and consistency 2024-12-25 13:25:03 +08:00
jxxghp
cfa8b78c2e Merge pull request #274 from Aqr-K/style/site 2024-12-24 22:05:40 +08:00
Aqr-K
4024daf189 style(site): 统一对齐高度 2024-12-24 21:49:03 +08:00
jxxghp
77d7c3bb61 refactor(ReorganizeDialog): update progress text and remove success messages 2024-12-24 14:17:56 +08:00
jxxghp
868ad57e12 refactor(ReorganizeDialog): remove SSE progress handling and improve toast messages 2024-12-24 13:49:47 +08:00
jxxghp
eaf9724295 Merge pull request #272 from InfinityPacer/v2 2024-12-23 12:11:21 +08:00
InfinityPacer
30e98de38a Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2024-12-23 02:47:52 +08:00
InfinityPacer
79c606370c feat(MediaCard): implement lazy loading for API calls 2024-12-23 02:47:37 +08:00
jxxghp
b70597b5f5 Merge pull request #271 from InfinityPacer/v2 2024-12-21 07:59:41 +08:00
InfinityPacer
a469282730 fix(subscribe): reactive update for subscribeState and lastUpdateText 2024-12-20 14:14:23 +08:00
jxxghp
c3708360fa Merge pull request #270 from libashanxi/v2 2024-12-20 12:05:40 +08:00
libashanxi
80f0560e0f Update AccountSettingAbout.vue
修改文档链接
2024-12-20 09:23:10 +08:00
jxxghp
84951cdc44 feat(subscribe): update display list based on user role and improve sorting logic 2024-12-20 08:04:18 +08:00
jxxghp
a72cb797ab fix plugin order 2024-12-18 08:10:04 +08:00
jxxghp
6898e6b816 Merge pull request #269 from Aqr-K/patch-1 2024-12-18 06:59:39 +08:00
Aqr-K
adb0b966ff fix: bug
- 少了个e,导致 `size_range` 导入没生效
2024-12-18 00:16:40 +08:00
jxxghp
81284b8d21 Merge pull request #267 from InfinityPacer/v2 2024-12-12 17:29:52 +08:00
InfinityPacer
1a2b112e64 feat(subscribe): add support for reset movie reset subscribe 2024-12-11 20:15:47 +08:00
InfinityPacer
442c484dc9 feat(subscribe): add state reset to 'R' on subscription reset 2024-12-11 20:02:43 +08:00
InfinityPacer
2368c2f25f Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2024-12-11 19:58:51 +08:00
InfinityPacer
2320c58254 fix(subscribe): update reset confirmation message 2024-12-11 19:58:33 +08:00
jxxghp
c9a4f36414 Merge pull request #266 from wikrin/v2 2024-12-11 06:55:38 +08:00
Attente
1df9a981b2 发布时间规则支持区间 2024-12-10 23:39:51 +08:00
jxxghp
cbc917b834 Merge pull request #265 from Aqr-K/style-rule 2024-12-10 07:14:07 +08:00
Aqr-K
240a568d16 style(rule): Swap the display of id and name in customRuleCard 2024-12-09 22:09:57 +08:00
jxxghp
eb1a847faa Merge pull request #264 from Aqr-K/fix-copy 2024-12-09 17:33:36 +08:00
Aqr-K
e09e57879b Update yarn.lock 2024-12-09 14:36:10 +08:00
Aqr-K
ddd2982971 fix(copy): add copy-to-clipboard 2024-12-09 14:34:53 +08:00
Aqr-K
621da7e4ef fix(copy): Mobile compatibility issues. 2024-12-09 14:33:12 +08:00
Aqr-K
420827c389 Update AccountSettingSystem.vue 2024-12-09 14:31:45 +08:00
Aqr-K
ce9399b894 Update AccountSettingRule.vue 2024-12-09 14:31:19 +08:00
Aqr-K
1bdd08c59a fix(copy): Mobile compatibility issues. 2024-12-09 14:28:41 +08:00
Aqr-K
52d62dda81 fix(copy): Mobile compatibility issues.
- 增加 `type` 细分来源
- 适配新的复制方法
2024-12-09 14:26:43 +08:00
Aqr-K
d69e3cedae fix(copy): Mobile compatibility issues 2024-12-09 14:22:44 +08:00
jxxghp
648bfcdd0d 更新 package.json 2024-12-09 11:32:01 +08:00
jxxghp
e4b8ff0a64 feat:订阅和插件支持手动排序 2024-12-09 10:43:00 +08:00
jxxghp
4576ef854d Merge pull request #263 from Aqr-K/fix-rule 2024-12-08 15:46:00 +08:00
Aqr-K
7323668db5 Update AccountSettingRule.vue 2024-12-08 13:25:13 +08:00
Aqr-K
b11d709070 Update AccountSettingRule.vue 2024-12-08 13:24:53 +08:00
Aqr-K
e25ac006c2 feat(rule): add deleteAllRules Btn. 2024-12-08 13:20:21 +08:00
Aqr-K
9e85e7edce fix(rule): 移除创建新卡片时,对可选参数的赋值
- 解决导出时,空白值内容过多的问题。
2024-12-07 20:46:00 +08:00
Aqr-K
804bcd440c fix(rule): 修复自定义规导入时,部分参数被抛弃的bug 2024-12-07 18:57:49 +08:00
jxxghp
6e4c896cb7 更新 package.json 2024-12-07 07:40:43 +08:00
jxxghp
0cae89f8e3 Merge pull request #262 from Aqr-K/fix-rule 2024-12-07 07:40:12 +08:00
Aqr-K
59a7607c07 Update AccountSettingRule.vue 2024-12-07 05:28:09 +08:00
Aqr-K
6dca0c157f fix(rule): bug
- 替换错误的规则校验方法。
2024-12-06 23:44:04 +08:00
jxxghp
d2aa5a64aa chore(package): bump version to 2.1.2 2024-12-06 15:23:56 +08:00
jxxghp
2620a55c5a refactor(DirectoryCard): update storage options and labels for clarity 2024-12-06 12:09:24 +08:00
jxxghp
14e33215f8 Merge pull request #261 from InfinityPacer/v2 2024-12-05 23:03:14 +08:00
InfinityPacer
6862c2a744 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2024-12-05 19:37:44 +08:00
InfinityPacer
fb215e8d87 feat(Subscribe): support update subscription status 2024-12-05 19:36:41 +08:00
InfinityPacer
f52ad2151b style(SubscribeCard): support different styles for subscription states 2024-12-05 19:35:23 +08:00
jxxghp
1a47b7d09d Merge pull request #260 from InfinityPacer/v2 2024-12-03 20:53:27 +08:00
InfinityPacer
f292071a34 fix(saveSiteSetting): update success toast message for consistency 2024-12-03 19:10:01 +08:00
InfinityPacer
dd616d29e8 fix(saveSiteSetting): add error toast for failed settings save 2024-12-03 19:07:58 +08:00
jxxghp
0509f18d66 Merge pull request #257 from Aqr-K/v2-rulessettings 2024-11-29 18:32:28 +08:00
Aqr-K
f59fb119e4 Update ImportCodeDialog.vue 2024-11-29 17:33:00 +08:00
Aqr-K
46127cac1f Update FilterRuleGroupCard.vue 2024-11-29 17:31:35 +08:00
Aqr-K
c1abf76211 Update AccountSettingRule.vue 2024-11-29 17:30:11 +08:00
jxxghp
fe5b45d48d Merge pull request #256 from InfinityPacer/v2 2024-11-29 15:14:49 +08:00
InfinityPacer
10ac1ebf7b Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2024-11-29 13:06:50 +08:00
InfinityPacer
e5d8144510 fix(api): update subscribe endpoint URL to include trailing slash 2024-11-29 13:06:32 +08:00
jxxghp
f9a65fba7a 更新 package.json 2024-11-29 07:09:54 +08:00
jxxghp
9b4138349b Merge pull request #255 from wikrin/manual_transfer 2024-11-28 07:24:19 +08:00
Attente
db9c9db5a9 改进手动整理类型/类别可选逻辑 2024-11-28 05:23:20 +08:00
jxxghp
24e992339f Merge pull request #254 from InfinityPacer/v2 2024-11-27 16:26:12 +08:00
InfinityPacer
f26d1babf7 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2024-11-27 15:32:30 +08:00
InfinityPacer
de3347cea1 feat(encoding): add detection performance mode 2024-11-27 15:32:09 +08:00
jxxghp
e900fac4bd Merge pull request #253 from Aqr-K/v2-main 2024-11-27 07:02:51 +08:00
jxxghp
396218a467 Merge pull request #251 from wikrin/add 2024-11-27 07:02:20 +08:00
Attente
d3a66ffa8c 去除自动选项
- 仅当`目的路径`为空时,才会设为`自动`
- 已选择`整理方式`后, 再选择配置的`媒体库目录`整理方式不再改变
- `自定义路径`时:
- - `整理方式`为自动时,会修改为`复制`,请注意
- - `整理方式`不为自动时, 不会改变
2024-11-27 05:53:31 +08:00
Aqr-K
1e7ffb4c2e Update main.ts 2024-11-26 22:41:22 +08:00
Aqr-K
3df5d4c690 Update main.ts 2024-11-25 16:42:12 +08:00
Aqr-K
02a8331996 fix: 移除多余import 2024-11-25 16:32:01 +08:00
Aqr-K
a29ad6a091 fix: 调整注册顺序,解决重复注册的警告 2024-11-25 16:30:57 +08:00
Attente
3ef1e65412 手动整理中整理方式增加自动 2024-11-25 13:24:43 +08:00
jxxghp
2deaec1fc6 Merge pull request #250 from InfinityPacer/v2 2024-11-24 17:59:22 +08:00
InfinityPacer
c9b0b23d36 fix(downloader): remove redundant checks and prompts 2024-11-24 17:57:25 +08:00
jxxghp
f06cca4ead Merge pull request #249 from wikrin/v2 2024-11-24 16:58:58 +08:00
Attente
a1990ce3e4 fix(dialog): correct storage option selection in Reorganize Dialog 2024-11-24 16:47:44 +08:00
jxxghp
cbbf023030 更新 package.json 2024-11-24 07:35:25 +08:00
jxxghp
307aa724eb Merge pull request #248 from wikrin/rfc-247 2024-11-23 23:07:13 +08:00
Attente
cd6f37d80f Merge branch 'v2' into rfc-247 2024-11-23 23:05:47 +08:00
jxxghp
b903134770 feat(ReorganizeDialog): 添加文件操作整理方式选项 2024-11-23 23:02:50 +08:00
Attente
11effdd297 Revert "refactor: 优化目标目录下拉框和路径变化监听逻辑"
This reverts commit 01f63a4b6b.
2024-11-23 22:42:48 +08:00
jxxghp
8873d8372d feat(ReorganizeDialog): 添加文件操作整理方式选项 2024-11-23 21:32:27 +08:00
jxxghp
964aa29d12 Merge pull request #245 from wikrin/dev 2024-11-23 19:21:03 +08:00
Attente
b45a3c6539 qb下载器添加用户名输入限制 2024-11-23 17:21:11 +08:00
jxxghp
b72b7ad0fb Merge pull request #246 from DDS-Derek/issue_rfc 2024-11-23 12:38:43 +08:00
DDSRem
0e3106d8c1 chore(issue): add rfc template 2024-11-23 12:34:59 +08:00
jxxghp
71a6626fa9 feat(ReorganizeDialog): 添加按类型和类别分类的选项 2024-11-23 11:21:29 +08:00
jxxghp
68006bac88 chore(yarn.lock): 更新terser到版本5.36.0 2024-11-22 13:02:05 +08:00
jxxghp
34cbcc38a6 feat(NetTestView): 添加对api.github.com和raw.githubusercontent.com的支持 2024-11-22 12:40:05 +08:00
jxxghp
f4daee85c7 Merge pull request #243 from Aqr-K/v2-terser 2024-11-21 18:18:20 +08:00
Aqr-K
dd347039b5 Update vite.config.ts 2024-11-21 16:50:48 +08:00
Aqr-K
0c9367d58a Update package.json 2024-11-21 16:50:18 +08:00
jxxghp
af10c4f1c3 feat(TransferHistoryView): 增加重做目标存储的响应式支持 2024-11-21 16:02:28 +08:00
jxxghp
52fbeda941 Merge pull request #242 from wikrin/v2 2024-11-21 14:00:17 +08:00
jxxghp
ace23af363 chore(package): 更新版本号至2.0.9 2024-11-21 12:14:15 +08:00
jxxghp
a097d89d68 feat(DownloadSettings): 更新下载器设置API,优化下载器加载逻辑 2024-11-21 10:26:12 +08:00
jxxghp
77cb817523 feat(AliyunAuthDialog): 添加配置支持,允许自定义refreshToken和保存设置 2024-11-20 20:25:32 +08:00
jxxghp
c956e271a2 refactor(ReorganizeDialog): 重构目标路径输入组件,移除不必要的目录加载逻辑 2024-11-20 19:24:10 +08:00
jxxghp
6413f30d18 chore(package): 更新版本号至2.0.8 2024-11-20 13:16:16 +08:00
jxxghp
789e748df0 fix(U115AuthDialog): 添加配置支持和自定义Cookie输入 2024-11-20 13:15:31 +08:00
Attente
c89edae375 downloader属性不再为可选, 使组件具有初始值 2024-11-20 11:40:24 +08:00
Attente
f4dca4922b Update SiteAddEditDialog.vue 2024-11-20 10:43:47 +08:00
Attente
73b9ef5ee7 fix(dialog): 站点下载器字段修改默认值 2024-11-20 10:38:04 +08:00
jxxghp
462742961a fix(SubscribeEditDialog): 下载器字段更新默认值 2024-11-20 08:26:07 +08:00
jxxghp
5a647fabfa fix(UserProfile): 添加超级用户条件以控制站点认证对话框的显示 2024-11-20 08:16:27 +08:00
jxxghp
2580ceac20 更新 subscribe.vue 2024-11-19 22:23:43 +08:00
jxxghp
6905391785 fix(dialog): 移除目标列的列数限制以提高灵活性 2024-11-19 21:29:00 +08:00
jxxghp
7406226e68 fix(auth): 初始化认证表单时提供默认值以避免空值 2024-11-19 21:24:53 +08:00
jxxghp
af9ee00ad3 Merge pull request #240 from InfinityPacer/v2
feat(site): update site timeout hint to indicate 0 as unlimited
2024-11-19 18:24:30 +08:00
jxxghp
01f63a4b6b refactor: 优化目标目录下拉框和路径变化监听逻辑 2024-11-19 18:10:03 +08:00
jxxghp
45e48755d3 Merge pull request #241 from wikrin/ReorganizeDialog
不再强制绑定目的路径的配置
2024-11-19 18:01:19 +08:00
Attente
f6c740738f 不再强制绑定目的路径的配置
- resolve jxxghp/MoviePilot#2959
2024-11-19 17:00:52 +08:00
InfinityPacer
29780cd4b7 feat(site): update site timeout hint to indicate 0 as unlimited 2024-11-19 11:11:48 +08:00
jxxghp
a050b7c7d5 feat: 更新版本至2.0.7 2024-11-19 08:45:39 +08:00
jxxghp
1f25387f81 Merge pull request #239 from Aqr-K/v2-subscribe 2024-11-18 21:25:28 +08:00
Aqr-K
36fb7b53ba fix: 使用 SubscribeTvTabs 作为标签页 2024-11-18 21:23:17 +08:00
Aqr-K
354295ffda Merge branch 'jxxghp:v2' into v2-subscribe 2024-11-18 21:21:34 +08:00
jxxghp
4f28018f4f feat(MediaCard): 添加媒体卡详情展示,优化悬停效果 2024-11-17 16:54:40 +08:00
jxxghp
5d37666bea fix https://github.com/jxxghp/MoviePilot/issues/3143 2024-11-17 14:46:54 +08:00
jxxghp
705e81db7f chore(package): 更新版本号至 2.0.6 2024-11-17 14:25:43 +08:00
Aqr-K
57d5859727 feat: 增加 订阅分享 标签页 2024-11-17 05:16:50 +08:00
jxxghp
06387ab33e feat(auth): 优化二维码登录状态处理逻辑并更新二维码组件 2024-11-17 02:07:46 +08:00
jxxghp
5f0c3b3639 chore(package): 更新版本号至 2.0.5 2024-11-16 09:47:27 +08:00
jxxghp
414fb8afd1 feat(subscribe): 添加下载器选项到订阅设置 2024-11-16 08:59:49 +08:00
jxxghp
58fbaaa8f4 Merge pull request #237 from wikrin/downloader 2024-11-16 07:54:24 +08:00
Attente
040790a672 fix 资源搜索下载时设置的下载器不生效的问题 2024-11-16 01:41:53 +08:00
Attente
bf36e39f3b feat(site): 添加站点自定义下载器功能 2024-11-16 00:28:56 +08:00
jxxghp
a780946915 Merge pull request #236 from Ricca111111/mpf 2024-11-15 06:42:56 +08:00
jxxghp
1d537c2799 Merge pull request #235 from Aqr-K/v2-settings-rule 2024-11-15 06:41:10 +08:00
Ricca
6a3e383f30 modify FilterRuleGroupCard.vue 2024-11-15 01:33:10 +08:00
Aqr-K
cb72c6b586 错误传参 2024-11-14 23:46:26 +08:00
Aqr-K
384e1a63b3 fix(settings): bug
- 移除空值转换
2024-11-14 23:43:25 +08:00
Aqr-K
e6357d0a54 fix(settings): bug 2024-11-14 23:09:19 +08:00
jxxghp
a0ebb42e1e fix: 调整 AddDownloadDialog 组件标题顺序以更好地显示种子来源 2024-11-14 20:25:10 +08:00
jxxghp
324fec8f94 fix: 更新 RcloneConfigDialog 组件标题为 RClone配置 2024-11-14 19:58:03 +08:00
jxxghp
226efc3d85 feat: 更新 AddDownloadDialog 组件以显示种子信息和文件大小,并优化布局 2024-11-14 18:59:26 +08:00
jxxghp
e785997d99 feat: 更新存储选项以包含图标并简化存储逻辑 2024-11-14 17:21:48 +08:00
jxxghp
7998b51e6b chore: 更新版本号至 2.0.4 2024-11-14 17:14:59 +08:00
jxxghp
e54384fcd7 fix: 更新 StorageCard 组件以正确显示未配置状态 2024-11-14 17:10:36 +08:00
jxxghp
39946cad1b fix: 优化 FileList 组件中的文件和目录图标显示逻辑 2024-11-14 14:38:27 +08:00
jxxghp
6041ae9344 feat: 在 FileBrowser 组件中添加 AList 存储选项 2024-11-14 14:21:27 +08:00
jxxghp
dc9fda8d86 feat: 添加 AList 存储选项及配置对话框 2024-11-14 12:56:12 +08:00
jxxghp
7dd3877955 fix: 更新 DirectoryCard.vue 中的自动整理方式下拉字典 2024-11-14 08:06:49 +08:00
jxxghp
5386fc54ff Merge pull request #233 from amtoaer/v2 2024-11-14 06:50:00 +08:00
amtoaer
c3839f092f fix: 修复站点数据显示错误 2024-11-14 01:50:58 +08:00
jxxghp
4c8207ef9a 更新 DefaultLayout.vue 2024-11-12 23:12:27 +08:00
jxxghp
539a7de1ad Update DefaultLayout.vue to fix conditional rendering of the back button 2024-11-12 20:42:02 +08:00
jxxghp
935b2c4edb 更新 package.json 2024-11-12 18:27:30 +08:00
jxxghp
e1a03166b0 Merge pull request #232 from InfinityPacer/v2 2024-11-12 18:14:35 +08:00
InfinityPacer
c7be304085 feat(db): add support for SQLite WAL mode 2024-11-12 17:23:57 +08:00
jxxghp
2f8c815053 Update SearchBarView.vue to remove unused code for displaying useful menus and plugins 2024-11-12 15:03:09 +08:00
jxxghp
249e1c6ebd Update AccountSettingSite.vue to add option for reading site messages during data refresh 2024-11-12 13:58:22 +08:00
jxxghp
22c97d1c01 更新 ReorganizeDialog.vue 2024-11-12 12:15:28 +08:00
jxxghp
ff3d45ec91 Update SiteUserDataDialog.vue to add refresh functionality 2024-11-12 09:52:24 +08:00
jxxghp
4caf671e1c Update hint for resource size range in CustomRuleCard.vue 2024-11-09 18:00:58 +08:00
jxxghp
741876dcaa 更新 package.json 2024-11-08 12:49:43 +08:00
jxxghp
5c6f32a7db Update TorrentItem.vue to display site name in subtitle 2024-11-07 20:10:46 +08:00
jxxghp
80b24cbfbc Update StorageCard.vue to improve download handling 2024-11-07 20:09:46 +08:00
jxxghp
8afed9768d Update StorageCard.vue to display a more informative toast message 2024-11-07 19:18:43 +08:00
jxxghp
1f4dacff02 Merge pull request #230 from thsrite/v2 2024-11-06 10:53:46 +08:00
thsrite
a046c0ec45 fix 导入自定义规则 && 优先级规则组时保留原有 2024-11-06 09:27:17 +08:00
jxxghp
82d0fd2b11 Bump version to 2.0.1 2024-11-05 21:21:53 +08:00
jxxghp
e2fb55d910 Merge pull request #227 from InfinityPacer/v2 2024-11-05 15:50:43 +08:00
InfinityPacer
7754c41d34 fix API_TOKEN length 2024-11-05 15:35:10 +08:00
InfinityPacer
eea30c3a0d fix FANART_ENABLE 2024-11-05 15:27:18 +08:00
jxxghp
bfe41a0642 refactor(setting): 补充设置项 2024-11-05 14:54:18 +08:00
jxxghp
4ba0151c42 refactor(setting): 重构设置界面布局 2024-11-05 13:13:33 +08:00
jxxghp
98bdfb160e Merge pull request #226 from Aqr-K/v2-settings
feat(user): New avatar file add webp format support
2024-11-04 12:44:40 +08:00
jxxghp
6327649501 refactor(setting): 移除防抖时间 2024-11-04 12:43:52 +08:00
Aqr-K
6937f5e1b1 feat(user): New avatar file add webp format support
- 新头像增加 `webp` 格式支持
2024-11-04 12:39:38 +08:00
jxxghp
e3ce4196fe fix 内置过滤规则 2024-11-04 12:15:19 +08:00
jxxghp
bb67a051c2 feat(plugin): 添加插件市场设置窗口
该提交添加了一个新的组件PluginMarketSettingDialog.vue,用于插件市场的设置窗口。该窗口可以通过点击插件市场设置图标打开,并提供了保存设置的功能。

该提交还在PluginCardListView.vue中引入了PluginMarketSettingDialog组件,并在点击插件市场设置图标时打开该窗口。

该提交的目的是为了提供一个方便的界面,让用户可以设置插件市场的仓库地址。
2024-11-04 11:27:43 +08:00
jxxghp
812dd1f184 feat(dialog): Update SubscribeEditDialog.vue
- Add conditional rendering for certain form fields based on the 'default' prop value
- Improve user experience by showing relevant form fields only when necessary
2024-11-04 10:54:34 +08:00
jxxghp
37d6612434 Merge pull request #225 from Aqr-K/v2-settings
feat(settings): 配置中心基本功能内置化,修复部分bug
2024-11-04 10:20:16 +08:00
Aqr-K
9cbafdfab8 feat(user): Add file type check and size determination
- 增加 文件类型检查
- 增加 文件大小限制,800KB
2024-11-03 18:28:07 +08:00
Aqr-K
1c4d806e58 feat(settings): add AccountSettingTransfer.vue
- 增加 整理标签页,增加相关设置功能。
2024-11-02 10:54:27 +08:00
Aqr-K
aba2ee29dd feat(settings): AccountSettingSearch
- 增加 整合多名称资源搜索结果、下载站点字幕、交互式搜索自动下载用户
2024-11-02 10:53:02 +08:00
Aqr-K
51deb29145 style(aettings): AdvancedSystemSettingsDialog
- 调整开关宽度
2024-11-02 04:51:44 +08:00
jxxghp
1f7a677db3 Refactor login page styles for better alignment and responsiveness 2024-11-01 12:30:50 +08:00
Aqr-K
0fb0652919 统一settings的区域规范 2024-10-31 22:58:57 +08:00
Aqr-K
39c7e723ba feat(settings): AccountSettingRule
- 增加对于 内置规则 的判断,避免与内置规则使用同一名称与ID。
2024-10-31 22:42:07 +08:00
Aqr-K
a9ddf159cc 同步 2024-10-31 22:08:34 +08:00
Aqr-K
22b93e1ae3 remove(setting)
- 移除测试版的通过规则命中,来设置保存按钮禁用方法。
2024-10-31 20:08:50 +08:00
Aqr-K
93b83048cf feat(settings): AccountSettingSearch
- 增加防抖。
2024-10-31 20:06:50 +08:00
Aqr-K
1c18f3a4f2 style(settings)
- 修改目录的保存与新增按钮的间距,保证与其他标签页的宽度相同。
- 统一全部 serrings 的保存按钮的规范。
2024-10-31 18:55:08 +08:00
Aqr-K
b5a01a7a42 feat(settings): AccountSettingDirectory
- 增加防抖。
2024-10-31 18:22:59 +08:00
Aqr-K
caf211c34e style(settings)
- 同步卡片的logo显示距离
2024-10-31 18:20:20 +08:00
Aqr-K
8b79c70be7 fix(settings): AccountSettingSystem
- 删除开发残留的敏感 consle.log
2024-10-31 18:09:39 +08:00
Aqr-K
6a4a218152 fix(settings): AccountSettingSite bug
- 拆分 CC 与 站点刷新,解决CC保存时,站点刷新也会被提交保存的问题。
2024-10-31 18:05:30 +08:00
Aqr-K
6bc420d57f feat(settings): AccountSettingService
- 增加防抖。
- `card` 从父组件获取到的值改为深复制,解决 `card` 内修改数据,会直接导致在父组件中同步更新的问题。
2024-10-31 17:53:45 +08:00
Aqr-K
db0325a59c feat(settings): AccountSettingRule
- 增加防抖。
- `card` 从父组件获取到的值改为深复制,解决 `card` 内修改数据,会直接导致在父组件中同步更新的问题。
- 修复 规则id与name 只缺少一项时,仍能正常确定的问题。
- 保存前增加一次检查,避免通过分享导入的规则存在重名与空名引发的错误。
2024-10-31 17:08:23 +08:00
Aqr-K
eab2f0df20 feat(settings): AccountSettingNotification
- 增加防抖。
- `card` 从父组件获取到的值改为深复制,解决 `card` 内修改数据,会直接导致在父组件中同步更新的问题。
- 微调图标位置。
2024-10-31 16:46:05 +08:00
jxxghp
2d1fbff2c5 更新 AccountSettingRule.vue 2024-10-31 13:50:18 +08:00
jxxghp
75c3ac71ae Merge pull request #223 from thsrite/v2 2024-10-31 12:13:16 +08:00
thsrite
61ffd222cc fix jxxghp/MoviePilot#2979 2024-10-31 09:21:42 +08:00
jxxghp
3499327984 fix #222 2024-10-31 08:22:14 +08:00
Aqr-K
c90ed003f7 feat(settings): add systemSettingsDialog
- 增加 AdvancedNetworkSettingsDialog 与 AdvancedSystemSettingsDialog,适配 system 预设的高级设置弹窗。
2024-10-31 04:15:12 +08:00
Aqr-K
bd9169bcd1 feat(settings): add new AccountSettingsSystem.vue
- 将 原system更名为service,原service更名为scheduler
- 增加新的 AccountSettingSystem.vue。
- 调整 menu.ts 与settings.vue,适配新的 system 标签页
2024-10-31 03:38:01 +08:00
jxxghp
0c46ab7d5a Refactor labels and hints in AccountSettingSubscribe.vue 2024-10-30 18:54:25 +08:00
jxxghp
3bc464011a Merge pull request #221 from thsrite/v2
fix 增加开启检查本地媒体库是否存在资源开关,按需开启
2024-10-30 18:51:59 +08:00
jxxghp
74fe67fe4d Refactor background image rotation in login.vue 2024-10-30 18:49:24 +08:00
thsrite
48fcce54dc fix 增加开启检查本地媒体库是否存在资源开关,按需开启 2024-10-30 16:20:34 +08:00
Aqr-K
b91be6bb2f Merge branch 'jxxghp:v2' into v2 2024-10-30 15:47:24 +08:00
jxxghp
181ad39e18 Refactor PluginCard.vue and login.vue components 2024-10-30 13:20:22 +08:00
jxxghp
4af6e5e91f Merge pull request #220 from thsrite/v2 2024-10-30 12:03:26 +08:00
thsrite
d67c6acfa2 fix 已安装插件显示作者头像 2024-10-30 09:52:05 +08:00
jxxghp
f4633a5832 Refactor button layout in AccountSettingRule.vue 2024-10-30 07:07:04 +08:00
jxxghp
36841f6f8f Merge pull request #219 from thsrite/v2 2024-10-29 13:26:07 +08:00
thsrite
86b4df871a fix 导入自定义规则时资源体积未导入 2024-10-29 12:47:25 +08:00
jxxghp
03ad8cc9e8 Merge pull request #218 from thsrite/v2 2024-10-29 08:01:44 +08:00
thsrite
de39ffa260 Merge remote-tracking branch 'origin/v2' into v2 2024-10-28 13:59:55 +08:00
thsrite
becccb8368 format 2024-10-28 13:59:08 +08:00
thsrite
1b75bb2cec format 2024-10-28 13:52:19 +08:00
thsrite
2a9f9b725e fix log 2024-10-28 12:47:15 +08:00
thsrite
5f15e84065 feat 自定义规则 && 优先级规则组 整体导入导出 2024-10-28 12:36:03 +08:00
jxxghp
deeb5f9d62 Merge pull request #217 from wikrin/v2 2024-10-26 08:10:40 +08:00
jxxghp
b715198a02 Merge pull request #216 from InfinityPacer/v2 2024-10-26 08:10:25 +08:00
Attente
005b1a9715 fix: 同步后端修正msg => message 2024-10-26 07:16:57 +08:00
InfinityPacer
d120bb794c fix(test): ensure rule group is specified before testing priority 2024-10-26 00:06:37 +08:00
jxxghp
e625d56c65 Merge pull request #215 from boeto/v2 2024-10-25 17:49:10 +08:00
machine
2d1d19e457 fix: 无法导出规则分享 2024-10-25 17:42:20 +08:00
boeto
c5d0a7fd74 Merge pull request #1 from jxxghp/v2
pr
2024-10-25 17:36:10 +08:00
Aqr-K
c72fcbd10d feat: 网络增加高级设置弹窗 2024-10-25 11:31:10 +08:00
jxxghp
5f388c8b09 Merge pull request #214 from InfinityPacer/v2 2024-10-25 06:59:20 +08:00
InfinityPacer
b02c3c8e5c fix(dashboard): filter and load only enabled media servers 2024-10-25 00:32:33 +08:00
Aqr-K
1d9e0eb3a3 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2-config 2024-10-24 17:17:44 +08:00
Aqr-K
0a5b553bb8 feat: 增加 network 标签页,调整标签对齐方式 2024-10-24 17:16:47 +08:00
jxxghp
865d57b4d3 refactor: 调整网格布局样式 2024-10-24 11:13:55 +08:00
jxxghp
8e40c38730 Merge pull request #213 from InfinityPacer/v2 2024-10-24 06:53:29 +08:00
InfinityPacer
ddee496c73 fix(wallpapers): remove cache 2024-10-24 00:02:29 +08:00
jxxghp
9c4d12d18b 更新 main.ts 2024-10-23 20:06:41 +08:00
jxxghp
6efa0e307e refactor: 调整导入顺序和删除无用代码 2024-10-23 19:44:09 +08:00
jxxghp
f0ac2d739d refactor: 更新构建工作流,仅在v2分支上的package.json更改时触发构建 2024-10-23 16:31:31 +08:00
jxxghp
1cb78b4ccd refactor: 调整SubscribeShareCard.vue中的卡片布局,将修复了文本溢出的问题。 2024-10-23 16:30:02 +08:00
jxxghp
fc6f41a549 refactor: 调整卡片布局的列宽为22rem 2024-10-23 16:21:35 +08:00
jxxghp
db86d075f0 Merge pull request #212 from thsrite/v2 2024-10-23 16:09:05 +08:00
jxxghp
3db4e12bb2 refactor: 添加了卡版折叠状态和展开按钮。 2024-10-23 16:07:48 +08:00
thsrite
f4a7372b4f feat 通知支持拖拽调整顺序 2024-10-23 16:03:57 +08:00
jxxghp
877d2f77bd refactor: 更新规则组选择功能
调整了快捷栏中的规则组选择功能,将原来的“优先级”改为“规则”,并更新了相关文本。

修改文件:
- src/layouts/components/ShortcutBar.vue
2024-10-23 15:38:57 +08:00
jxxghp
02334489ed feat: 添加规则组选择功能
为规则测试页面添加了规则组选择功能,用户可以从下拉列表中选择规则组进行测试。

- 添加了规则组选择表单项
- 加载规则组列表的函数
- 调用API识别时传递规则组名称

Fixes #209
2024-10-23 15:35:42 +08:00
jxxghp
cd714d954f Merge pull request #211 from thsrite/v2 2024-10-23 14:56:49 +08:00
thsrite
9e4655070c feat 自定义规则 && 优先级规则组支持拖拽调整顺序 2024-10-23 14:33:49 +08:00
jxxghp
f84d69feb7 Merge pull request #210 from thsrite/v2 2024-10-23 13:11:49 +08:00
thsrite
3c91ad2f59 feat 目录自定义是否通知 2024-10-23 12:57:48 +08:00
jxxghp
873848b9a7 Merge pull request #209 from thsrite/v2 2024-10-23 11:44:45 +08:00
thsrite
f906a172dd feat 目录监控可选监控模式 2024-10-23 11:34:09 +08:00
jxxghp
6b9c74dcea Merge pull request #205 from Aqr-K/dev-login 2024-10-22 12:31:17 +08:00
jxxghp
539cf9ada4 fix build 2024-10-22 11:54:36 +08:00
jxxghp
a69d2dfd71 build beta 2024-10-22 11:37:32 +08:00
jxxghp
3f9b9a6903 fix 普通用户权限视图 2024-10-22 10:43:50 +08:00
Aqr-K
9949a16f34 fix: bug
- 修复超管判断条件。
2024-10-22 01:24:57 +08:00
jxxghp
7e30cf40a9 Merge pull request #208 from boeto/dev 2024-10-22 00:04:35 +08:00
machine
fba0df8cb9 fix: 订阅历史记录不请求后端 2024-10-21 22:46:04 +08:00
machine
7a97005524 fix: 订阅历史记录不请求后端 2024-10-21 22:44:15 +08:00
jxxghp
ee8b57da91 Merge pull request #207 from thsrite/dev 2024-10-21 13:37:14 +08:00
thsrite
e63f19a00d fix 先选择媒体库存储,再选择整理方式 2024-10-21 13:34:49 +08:00
jxxghp
deabf23475 fix ui layout 2024-10-19 20:19:30 +08:00
Aqr-K
0b3fc938ae Update UserCard.vue 2024-10-19 13:44:19 +08:00
jxxghp
bdd0cdbe55 Refactor DefaultLayout component to conditionally show back button based on app mode and screen size 2024-10-19 12:28:00 +08:00
jxxghp
ae261cb684 Refactor Footer component to use responsive display and inject app mode 2024-10-19 12:24:30 +08:00
Aqr-K
0036a895e9 feat(login): add userID
- 修改按钮判断的逻辑,将 `userName` 替换成 `userID` 判断;解决不管是主程序还是插件修改用户名,都会存在的条件判断导致的渲染异常显示。(拆分自user的pr)
2024-10-19 11:58:01 +08:00
jxxghp
f317d15580 Refactor DirectoryCard and PathField components 2024-10-19 11:53:10 +08:00
jxxghp
76a487854b Refactor navigator utility functions and add isPWA check 2024-10-19 10:39:31 +08:00
jxxghp
b3f616ddc6 Refactor SubscribeFilesDialog component to improve layout and text size handling 2024-10-19 08:10:05 +08:00
jxxghp
9b19cbefc8 fix user ui 2024-10-19 07:58:46 +08:00
jxxghp
a4ba6b947b Refactor font size in VerticalNav component 2024-10-18 17:47:34 +08:00
jxxghp
fb510ff180 Refactor permission utility function 2024-10-18 14:24:16 +08:00
jxxghp
2710cbc85a Refactor SubscribeShareCard component to improve layout and text overflow handling 2024-10-18 14:02:05 +08:00
jxxghp
b82f17bcf1 Merge pull request #203 from Aqr-K/dev-user
fix(user)
2024-10-18 14:01:47 +08:00
Aqr-K
f9c33394a9 Merge branch 'dev-user' of https://github.com/Aqr-K/MoviePilot-Frontend into dev-user 2024-10-18 12:58:50 +08:00
Aqr-K
0a15a6eb64 fix(user)
- `更新` 与 `新建` 用户时,增加防抖;
- 去除 `UserProfileView` ( `个人信息` )中的冗余代码。
- `个人信息` 增加提交时的 `保存` 按钮的文本变化与点击禁用。
2024-10-18 12:58:45 +08:00
Aqr-K
45777c01ee fix(user)
- `更新` 与 `新建` 用户时,增加500ms防抖;
- 去除 `UserProfileView` 中的冗余代码。
- `个人信息` 增加提交时的 `按钮文本`变化与点击禁用。
2024-10-18 12:54:07 +08:00
jxxghp
b331cc55ce Merge pull request #202 from InfinityPacer/dev 2024-10-18 11:56:13 +08:00
jxxghp
74ef5a8083 Merge pull request #201 from Aqr-K/dev-user 2024-10-18 11:55:30 +08:00
Aqr-K
8dd82aacf2 Update UserProfileView.vue 2024-10-18 11:06:15 +08:00
Aqr-K
35d130a01b Merge branch 'dev' into dev-user 2024-10-18 11:03:01 +08:00
InfinityPacer
15319bf586 style(VSelect): add clearable option 2024-10-18 10:55:38 +08:00
jxxghp
832cae635e Merge pull request #200 from InfinityPacer/dev 2024-10-18 06:56:06 +08:00
Aqr-K
1c83752f56 feat(user): add username modification function. 2024-10-18 02:50:52 +08:00
jxxghp
7973457417 refactor: 优化站点卡片组件 2024-10-17 21:39:26 +08:00
InfinityPacer
8083e94ecd fix(message): add delay to prevent 403 2024-10-17 16:59:51 +08:00
jxxghp
b3485af14c fix: 修复站点数据展示 2024-10-17 16:10:33 +08:00
jxxghp
01eaef2bf9 feat:站点数据展示 2024-10-17 12:15:49 +08:00
jxxghp
3e241cf8bc Merge pull request #199 from Aqr-K/dev-user 2024-10-17 06:55:52 +08:00
Aqr-K
2df4dc0535 fix(user): bug 2024-10-17 01:47:18 +08:00
Aqr-K
135a1e3d52 style(user): No line breaks.
- 禁止 `电影订阅` 与 `电视剧订阅` 名称在不同的设备比例下,会出现换行的情况。
2024-10-16 23:20:04 +08:00
Aqr-K
4366fdd4a6 fix(user): bug
- 通过 `用户管理` 修改头像后,切换到 `个人信息` 时头像不同步。
2024-10-16 23:13:59 +08:00
Aqr-K
a47d3f10f9 feat(user): UserSettings function adjustment.
- 增加更新头像时,立刻同步更新localStorage;解决当前头像替换后,必须重新登录才能刷新localStorage的问题。
- 删除个人信息页面上传图片时,立刻触发更新的功能,统一保存后再更新。
- 增加 `还原当前头像` 按钮,允许 `上传新头像`、`重置默认头像` 后,进行回退;解决重置后后悔了,但其他参数已经填写时,必须刷新页面才能还原当前头像的问题。
2024-10-16 22:48:23 +08:00
jxxghp
004c9eadd5 feat:优化站点卡片 2024-10-16 18:35:27 +08:00
jxxghp
b483a5f4e8 refactor(cards): update MediaServerCard.vue 2024-10-16 15:45:25 +08:00
jxxghp
06e0f4234f refactor(setting): update TorrentPriorityItems in AccountSettingRule.vue 2024-10-16 15:23:54 +08:00
jxxghp
e9a5c0ae69 refactor(setting): remove unnecessary function call in AccountSettingDirectory.vue 2024-10-15 20:18:38 +08:00
jxxghp
0c17702f65 Merge pull request #198 from InfinityPacer/dev 2024-10-14 06:45:14 +08:00
InfinityPacer
ddf682d66a feat(security): update douban image proxy URL 2024-10-14 01:35:27 +08:00
jxxghp
018c5f857b feat(downloading): add NoDataFound component for empty downloaders
Add the NoDataFound component to the downloading page to display a 404 error message when there are no enabled downloaders. This component will show an error code, title, and description to guide users on how to configure and enable downloaders in the settings.

Closes #197
2024-10-12 12:30:56 +08:00
jxxghp
b5d89ff082 Merge pull request #197 from InfinityPacer/dev 2024-10-10 16:53:47 +08:00
InfinityPacer
54046a4717 feat(vite): add server proxy to handle CORS for API requests 2024-10-10 15:41:02 +08:00
InfinityPacer
505773043b feat(security): remove unnecessary token 2024-10-10 15:40:06 +08:00
jxxghp
e9bb811244 Update version number in VerticalNav component 2024-10-10 14:42:43 +08:00
jxxghp
6ef6ea1479 fix ui 2024-10-10 13:09:20 +08:00
jxxghp
93bd4002db fix ui 2024-10-10 12:59:53 +08:00
jxxghp
b9ec829747 fix ui 2024-10-09 20:44:30 +08:00
jxxghp
f307327af3 add subscribe share cards 2024-10-09 19:47:31 +08:00
jxxghp
936be9928d fix ui 2024-10-09 17:07:18 +08:00
jxxghp
b639369846 支持更多订阅自定义属性 2024-10-09 15:20:25 +08:00
jxxghp
5577e4cf62 Merge pull request #196 from InfinityPacer/dev 2024-10-09 06:44:42 +08:00
InfinityPacer
63206fea2e fix(download): support downloader and save_path parameters 2024-10-09 02:32:02 +08:00
jxxghp
40727dac2d Merge pull request #194 from InfinityPacer/dev 2024-10-06 17:52:47 +08:00
InfinityPacer
d703909177 refactor(search): optimize sorting logic for season filter options 2024-10-06 17:37:01 +08:00
jxxghp
fc61060b7f Merge pull request #193 from InfinityPacer/dev 2024-10-02 20:19:05 +08:00
jxxghp
73e21e77ec Merge pull request #192 from Aqr-K/dev-downloader 2024-10-02 20:17:57 +08:00
InfinityPacer
6be05819b0 fix(dashboard): handle MediaServerLatest.vue rendering failures 2024-10-02 11:39:02 +08:00
Aqr-K
0e116ad1b9 feat(downloader): Default downloader automatic selection and checking
- 增加保存时的默认下载器不存在的自动选择与去重
2024-10-02 02:36:55 +08:00
jxxghp
016c232ef2 Merge pull request #191 from InfinityPacer/dev 2024-10-01 20:36:37 +08:00
InfinityPacer
9856419292 feat(downloader): support first_last_piece 2024-10-01 18:35:57 +08:00
jxxghp
cf3a204eac refactor: Remove unused import in SiteTorrentTable.vue
Remove the unused import of MediaInfo in SiteTorrentTable.vue to improve code cleanliness and reduce potential confusion.
2024-09-30 16:00:32 +08:00
jxxghp
dc3e364b90 fix ui 2024-09-30 11:11:01 +08:00
jxxghp
d22ef17b95 Merge pull request #190 from Aqr-K/dev-cards 2024-09-27 00:17:04 +08:00
Aqr-K
4126692c5a style: Unified card style.
- 统一禁止全部弹窗式设置的点击功能区以外区域的close功能。
2024-09-27 00:03:05 +08:00
Aqr-K
d22f1c97ae feat: Add duplicate name judgment for notification
- 增加通知渠道的重名判断
2024-09-27 00:00:11 +08:00
jxxghp
735023330a Merge pull request #189 from Aqr-K/dev-directory 2024-09-26 23:33:10 +08:00
Aqr-K
6301cb287e 更新 DirectoryCard.vue 2024-09-26 20:58:42 +08:00
Aqr-K
85ebb0242a Merge branch 'dev' of https://github.com/jxxghp/MoviePilot-Frontend into dev 2024-09-26 20:34:02 +08:00
Aqr-K
81a670d608 feat: Automatically generate optional transferType
- 自动结合两个储存方式,生成出可选的整理方式,降低使用门槛
2024-09-26 20:27:42 +08:00
Aqr-K
a547e5c34b feat: Add new card with duplicate name judgment
- 给添加新卡片时,自动生成的名称增加一层重名判断,避免出现重名。
- 目录卡片特化处理,在保存时,增加一层重名检查。
2024-09-26 20:24:28 +08:00
jxxghp
cf6b6dd4dd Refactor AccountSettingSearch.vue to update the label for filter rule group to "优先级规则组"
Fix AccountSettingSite.vue to set COOKIECLOUD_ENABLE_LOCAL to false by default
Refactor AccountSettingSubscribe.vue to add support for selecting best version rule group for subscription filtering
2024-09-26 12:49:39 +08:00
jxxghp
574464c1ea Refactor AddDownloadDialog.vue component and update download confirmation dialog UI 2024-09-24 12:07:48 +08:00
jxxghp
816dfa4e3b Refactor SiteTorrentTable.vue and AddDownloadDialog.vue components 2024-09-23 21:04:37 +08:00
jxxghp
9d7e52c25e Refactor AccountSettingNotification.vue, AccountSettingRule.vue, and AccountSettingSystem.vue
Remove unused event listeners and save functions in various components.
2024-09-23 08:07:20 +08:00
jxxghp
d41b6ca459 fix RuleGroupCard 2024-09-21 21:28:57 +08:00
jxxghp
4d1b5209e7 fix FileList.vue 2024-09-21 19:59:48 +08:00
jxxghp
7da21f23aa fix profile 2024-09-21 19:26:58 +08:00
jxxghp
40a9caceb8 add sitedata refresh setting 2024-09-21 19:23:01 +08:00
jxxghp
7e4f21ff33 add from_history 2024-09-21 19:11:24 +08:00
jxxghp
cd6f5090d7 fix bug 2024-09-21 17:53:01 +08:00
jxxghp
1efd0a3d5b fix TransferHistoryView 2024-09-21 17:33:32 +08:00
jxxghp
4434d7b8c9 fix ReoranizeDialog 2024-09-21 17:04:54 +08:00
jxxghp
24e184eace refactor: 优化ReorganizeDialog组件 2024-09-21 08:49:02 +08:00
jxxghp
8ccd9cfd85 Merge pull request #188 from Aqr-K/dev-downloader 2024-09-20 19:30:43 +08:00
Aqr-K
cb2c23dc96 refactor: Adjust the logical sequence
- 调整逻辑顺序,增加提示框显示。
- 禁用点击功能区以外区域自动退回上级功能。该退出方式下,会无法激活默认下载器的判断。
2024-09-20 19:22:06 +08:00
Aqr-K
29912cac8d style: The height of the cards is unified.
- 将tr与qb的卡片高度统一。
2024-09-20 19:00:29 +08:00
Aqr-K
6376a81c4a feat: Add the judgment of the switch startup for the default downloader
- 增加默认下载器开关启动判断,保证唯一性。
2024-09-20 18:38:02 +08:00
jxxghp
aff4b2f9b7 refactor: 优化ReorganizeDialog组件
为ReorganizeDialog组件进行优化,移除了props中的storage属性,并将其替换为target_storage和target_path属性。同时更新了相关的表单和逻辑处理。
2024-09-20 13:39:33 +08:00
jxxghp
153fe8fcd0 feat: 添加完成事件触发
为CustomRuleCard、FilterRuleGroupCard、NotificationChannelCard和AccountSettingDirectory组件添加done事件触发,以便在完成相关操作后通知其他组件。
2024-09-19 13:21:12 +08:00
jxxghp
95d8b3d1a6 fix #186 2024-09-18 18:14:33 +08:00
jxxghp
19ce869763 fix ui 2024-09-18 18:07:12 +08:00
jxxghp
e6b6d3ca27 fix bug 2024-09-18 08:29:06 +08:00
jxxghp
8e7be239ee Merge pull request #187 from InfinityPacer/dev 2024-09-16 21:53:27 +08:00
InfinityPacer
4bd97f9d81 fix: handle scenarios where avatar is empty 2024-09-16 17:09:51 +08:00
jxxghp
49d182eabc Merge pull request #185 from Aqr-K/dev 2024-09-15 06:37:26 +08:00
Aqr-K
9411a29adf feat: Control status permission judgment.
增加状态控制,如果编辑的是当前使用的用户,会隐藏状态控制栏
个人信息栏,增加分割线,做为功能区的显示区分
2024-09-14 22:46:17 +08:00
jxxghp
61bb96e1fe Merge pull request #184 from Aqr-K/dev 2024-09-14 20:56:13 +08:00
Aqr-K
6a6100a814 style: User style adjustment
隐藏已有用户二次编辑中的用户名;补全新增用户界面的默认头像显示;增加分割线。
2024-09-14 20:38:33 +08:00
jxxghp
40fcf9d0cc Merge pull request #183 from Aqr-K/dev 2024-09-14 17:59:59 +08:00
Aqr-K
65946c55d1 fix bug 2024-09-14 17:46:22 +08:00
Aqr-K
e2b4df3dcf feat: Add duplicate name judgment and null value judgment
调整部分样式,并给下载器、媒体服务器、自定义规则、优先级规则组,名称与ID增加重名警告和空值警告,
2024-09-14 17:45:07 +08:00
jxxghp
04fee167b9 auto build 2024-09-14 14:57:06 +08:00
jxxghp
243c273084 fix file preview 2024-09-14 14:27:49 +08:00
jxxghp
b43cf4dd5d fix bug 2024-09-14 13:07:42 +08:00
jxxghp
cf9c38fdd5 fix https://github.com/jxxghp/MoviePilot/pull/2712
fix https://github.com/jxxghp/MoviePilot/pull/2711
2024-09-14 11:17:00 +08:00
jxxghp
6e4e6df08f Merge pull request #181 from thsrite/dev 2024-09-14 11:05:42 +08:00
thsrite
7b5630223d fix 正在下载显示种子大小 2024-09-14 11:03:30 +08:00
jxxghp
3d985decbc Merge pull request #180 from Aqr-K/dev 2024-09-14 06:32:56 +08:00
Aqr-K
dbe23eaac7 style: Optimize the progress bar and the display of remaining storage space.
优化进度条和剩余存储空间的显示。
2024-09-14 01:25:55 +08:00
jxxghp
e38df0f319 refactor: Update FilterRuleGroupCard.vue to clear selected media category when media type changes 2024-09-12 15:53:33 +08:00
jxxghp
c2ac66fdbf refactor: Update ModuleTestView.vue to handle empty result message 2024-09-12 15:14:09 +08:00
jxxghp
5ad25ff14d refactor: Update FilterRuleGroupCard.vue to add support for selecting media categories 2024-09-12 12:52:34 +08:00
jxxghp
04e1b527b5 refactor: Update MediaServerLibrary.vue to load media server library with hidden parameter 2024-09-12 08:24:48 +08:00
jxxghp
09210f98e9 refactor: Update MediaServerCard.vue to load and display media libraries dynamically 2024-09-12 08:17:00 +08:00
jxxghp
bfe228a367 refactor: Update saveDashboardConfig function to use stringified JSON for enableConfig and orderObj 2024-09-11 12:41:57 +08:00
jxxghp
a01978196d refactor: Update action-gh-release to v2 in build workflow 2024-09-11 08:22:59 +08:00
jxxghp
f795481895 refactor: Update FileBrowser.vue and FileBrowserView.vue to support multiple storage configurations 2024-09-11 08:15:48 +08:00
jxxghp
83e199c1ea refactor: fix media server libraries 2024-09-11 08:05:15 +08:00
jxxghp
8734e7fc1b Merge pull request #178 from InfinityPacer/dev 2024-09-11 06:42:10 +08:00
InfinityPacer
b48e4adacd fix(PluginCard): improve reset plugin configuration and data prompt 2024-09-11 00:37:20 +08:00
jxxghp
a45e2b120e fix dashboard cards 2024-09-10 21:29:56 +08:00
jxxghp
52b6f103a5 fix bug 2024-09-10 11:21:57 +08:00
jxxghp
927f4a366c refactor: Update tag_name in build workflow to include 'dev_' prefix 2024-09-09 16:30:14 +08:00
jxxghp
b28347d191 refactor: Add reloadSystem function to saveDirectories, saveNotificationSetting, saveDownloaderSetting, and saveMediaServerSetting 2024-09-09 09:52:49 +08:00
jxxghp
df057ebe4d refactor: Update MessageCard.vue to conditionally render VCardTitle component based on message properties 2024-09-09 09:12:50 +08:00
jxxghp
aa7b4a0e94 refactor: Update MessageCard.vue to conditionally render VCardTitle component based on message properties 2024-09-09 09:10:48 +08:00
jxxghp
ca9d44f55f refactor: Enable lazy loading for downloading tabs 2024-09-09 08:34:32 +08:00
jxxghp
247631fd68 refactor: Add lazy loading for downloading tabs 2024-09-09 08:33:29 +08:00
jxxghp
3357928e80 feat: Update user storage options in FileBrowserView 2024-09-09 08:16:22 +08:00
jxxghp
fc263d79a8 fix usercard 2024-09-08 15:14:53 +08:00
jxxghp
ee10616acf feat: Add allowRefresh prop to DownloaderCard component 2024-09-08 13:57:58 +08:00
jxxghp
30c3ad6c90 refactor: Update image URLs to use globalSettings.TMDB_IMAGE_DOMAIN 2024-09-08 13:05:14 +08:00
jxxghp
5ad6d6d904 Merge pull request #177 from InfinityPacer/dev 2024-09-08 08:23:22 +08:00
InfinityPacer
e2c7fc0af0 chore: remove unnecessary preload for index.js 2024-09-08 02:08:16 +08:00
jxxghp
172fb06d8e Merge pull request #174 from InfinityPacer/dev 2024-09-05 06:55:22 +08:00
InfinityPacer
634522d27b fix(build): ensure app is mounted after global settings are loaded 2024-09-02 20:18:37 +08:00
InfinityPacer
03b14a0fb5 fix(build): wrap top-level await in async function for browser compatibility 2024-09-02 20:04:40 +08:00
jxxghp
ec54ec2607 login wallpapers cache 2024-08-29 16:15:42 +08:00
jxxghp
340bb08f2a feat:media image cache 2024-08-29 15:27:49 +08:00
jxxghp
022487a877 style: Optimize image URL handling in MediaDetailView 2024-08-29 08:40:56 +08:00
jxxghp
6ec1bbe1ae style: Update globalSettings injection in multiple components 2024-08-29 08:36:29 +08:00
jxxghp
9d55f8ab24 sync main 2024-08-19 12:26:10 +08:00
jxxghp
fc61f3fca1 style: Update UserCard.vue to include user subscription counts and user management actions 2024-08-18 11:44:34 +08:00
jxxghp
cca3368d8f style: Update AccountSettingSystem.vue to include change events for downloader and media server cards 2024-08-16 13:42:12 +08:00
jxxghp
57f6547b91 style: Update DownloaderCard and MediaServerCard to improve UI consistency 2024-08-16 12:35:40 +08:00
jxxghp
200b22cf0c style: Update storage card to include progress bar color based on usage 2024-08-16 11:59:38 +08:00
jxxghp
e9b8f3138c style: Add support for INI files in ACE editor 2024-08-16 11:35:53 +08:00
jxxghp
dd9663451e style: Update storage card to include Rclone configuration dialog and improve UI consistency 2024-08-16 11:31:04 +08:00
jxxghp
78e0e7dba1 style: Update PluginCard to remove redundant code and improve UI consistency 2024-08-16 10:09:26 +08:00
jxxghp
b94fb70e02 style: Update storage card to include authentication dialogs for Aliyun and U115 storage types 2024-08-15 16:15:49 +08:00
jxxghp
e94c149cd1 style: Update storage card to query storage information on mount 2024-08-15 15:28:01 +08:00
jxxghp
94ba3c4514 style: Update grid-customrule-card to use larger minimum width for columns 2024-08-15 11:45:45 +08:00
jxxghp
c129a37ccf style: Update PluginAppCard and PluginCard to improve UI consistency 2024-08-12 18:09:53 +08:00
jxxghp
6608a4266b style: Update DownloaderCard and MediaServerCard to improve UI consistency 2024-08-12 11:02:29 +08:00
jxxghp
809bfbb42a style: Update formatFileSize function to accept decimals parameter 2024-08-12 08:18:15 +08:00
jxxghp
676ff8789b style: Update FilterRuleCard and FilterRuleGroupCard to include custom_rules prop 2024-08-12 08:02:35 +08:00
jxxghp
3b1a9bd0c4 style: Update FilterRuleGroupCard.vue to use updated filter group SVG icon 2024-08-11 17:43:19 +08:00
jxxghp
202b9dc3bc style: Update CustomRuleCard.vue and FilterRuleGroupCard.vue to include filter icons 2024-08-11 17:39:20 +08:00
jxxghp
ce96deb224 style: Update CustomRuleCard.vue to include rule ID field 2024-08-11 17:23:16 +08:00
jxxghp
14afe59eeb style: Update CustomRuleCard.vue to include rule name field 2024-08-11 17:07:51 +08:00
jxxghp
790a8bdb9a style: Update AccountSettingNotification.vue, AccountSettingSystem.vue, MediaServerCard.vue, and DownloaderCard.vue to include default values for config and name properties 2024-08-11 16:06:48 +08:00
jxxghp
8bd0f7a589 style: Update styles.scss and types.ts to include config property in DownloaderConf and MediaServerConf 2024-08-11 15:09:56 +08:00
jxxghp
235eb82c45 style: Update CustomRuleCard.vue to include publish_time field 2024-08-05 18:15:04 +08:00
jxxghp
f043447e4f style: Update FileBrowserView.vue to include storage property in operItem and itemstack 2024-07-26 22:05:01 +08:00
jxxghp
e92a74a088 style: Update DownloaderCard.vue to include draggable icon button 2024-07-26 21:18:38 +08:00
jxxghp
799a385ff9 style: Update DownloaderCard, MediaServerCard, and NotificationChannelCard components to include close button functionality 2024-07-26 21:15:17 +08:00
jxxghp
2c74dc0ccd style: Update NotificationChannelCard to include web push notification support 2024-07-26 12:49:32 +08:00
jxxghp
c191b12514 style: Update MediaServerCard and NotificationChannelCard to use dynamic icons based on server and notification types 2024-07-26 09:00:41 +08:00
jxxghp
2c9e593af0 style: Add new downloader and update DownloaderCard.vue to display downloader information 2024-07-25 11:07:14 +08:00
jxxghp
f1dbab7d55 style: Update NetTestView to use webp format for Slack logo 2024-07-25 08:18:52 +08:00
jxxghp
ea77d7e76d style: add divider to DirectoryCard.vue for monitor type 2024-07-25 08:09:30 +08:00
jxxghp
64d8e3b1e1 style: save directories in AccountSettingDirectory.vue 2024-07-24 18:08:04 +08:00
jxxghp
bd4975d180 style: Update grid-template-columns in grid-directory-card to use a minimum width of 24rem 2024-07-24 18:05:51 +08:00
jxxghp
2a916a099c style: Update StorageCard component to display storage icons based on storage type 2024-07-24 16:52:01 +08:00
jxxghp
bc084922f7 style: add name field to StorageConf interface and update StorageCard component to display storage name 2024-07-20 09:36:46 +08:00
jxxghp
42f755b755 style: update UserCard to display movie and TV show subscription counts 2024-07-20 08:55:05 +08:00
jxxghp
7f2c629305 style: update MessageCard to use VCard component for consistent styling 2024-07-14 19:30:06 +08:00
jxxghp
6136095e0f style: improve LoggingView table layout and styling, add refreshing indicator using LoadingBanner component 2024-07-14 18:48:36 +08:00
jxxghp
0a34e07cc5 style: update LoggingView to use LoadingBanner component for refreshing indicator 2024-07-14 17:56:51 +08:00
jxxghp
71c6f4483f style: update LoggingView table layout and styling 2024-07-14 17:53:58 +08:00
jxxghp
731a74905c style: truncate plugin card descriptions in PluginAppCard and PluginCard 2024-07-14 17:25:56 +08:00
jxxghp
8b0e47103c style: add text-shadow to plugin card descriptions 2024-07-14 17:19:36 +08:00
jxxghp
4da24e27a4 fix plugins 2024-07-14 17:16:44 +08:00
jxxghp
169f1b327b fix icon 2024-07-14 12:28:37 +08:00
jxxghp
360f9afb54 change plugincard style 2024-07-14 12:24:19 +08:00
jxxghp
0e45a59860 fix 2024-07-14 11:09:29 +08:00
jxxghp
cfc2e407a4 fix settings layout 2024-07-14 11:07:17 +08:00
jxxghp
a467fdb43f fix 2024-07-09 20:06:48 +08:00
jxxghp
474db2be0d fix profile 2024-07-09 19:13:30 +08:00
jxxghp
e946037c57 fix user 2024-07-09 07:59:39 +08:00
jxxghp
b2e1fe314f fix user 2024-07-06 17:53:51 +08:00
jxxghp
81fb44da80 Merge pull request #164 from jxxghp/main
merge
2024-07-01 11:24:12 +08:00
jxxghp
de2ce12163 Merge pull request #163 from jxxghp/main
marge
2024-06-28 11:34:29 +08:00
jxxghp
f4b2ed4f7d fix build 2024-06-24 17:13:11 +08:00
232 changed files with 44467 additions and 12489 deletions

View File

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

45
.github/ISSUE_TEMPLATE/rfc.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: 功能提案
description: Request for Comments
title: '[RFC]'
labels: ['RFC']
body:
- type: markdown
attributes:
value: |
一份提案(RFC)定位为 **「在某功能/重构的具体开发前,用于开发者间 review 技术设计/方案的文档」**
目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论;
以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突)
因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。
如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+)
- type: textarea
id: background
attributes:
label: 背景 or 问题
description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。
validations:
required: true
- type: textarea
id: goal
attributes:
label: '目标 & 方案简述'
description: 简单描述提案此提案实现后,**预期的目标效果**,以及简单大致描述会采取的方案/步骤,可能会/不会产生什么影响。
validations:
required: true
- type: textarea
id: design
attributes:
label: '方案设计 & 实现步骤'
description: |
详细描述你设计的具体方案,可以考虑拆分列表或要点,一步步描述具体打算如何实现的步骤和相关细节。
这部份不需要一次性写完整,即使在创建完此提案 issue 后,依旧可以再次编辑修改。
validations:
required: false
- type: textarea
id: alternative
attributes:
label: '替代方案 & 对比'
description: |
[可选] 为来实现目标效果,还考虑过什么其他方案,有什么对比?
validations:
required: false

View File

@@ -1,12 +1,12 @@
name: Build Moviepilot-Frontend
name: Build Moviepilot-Frontend v2
on:
workflow_dispatch:
push:
branches:
- main
- v2
paths:
- package.json
- 'package.json'
jobs:
build:
@@ -42,13 +42,21 @@ jobs:
echo "$frontend_version" > dist/version.txt
zip -r dist.zip dist
- name: Delete Release
uses: dev-drprasad/delete-tag-and-release@v1.1
with:
tag_name: ${{ env.frontend_version }}
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.frontend_version }}
name: ${{ env.frontend_version }}
draft: false
prerelease: false
make_latest: false
files: |
dist.zip
env:

369
auto-imports.d.ts vendored
View File

@@ -3,9 +3,11 @@
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
@@ -20,13 +22,11 @@ declare global {
const createGenericProjection: typeof import('@vueuse/math')['createGenericProjection']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createLogger: typeof import('vuex')['createLogger']
const createNamespacedHelpers: typeof import('vuex')['createNamespacedHelpers']
const createPinia: typeof import('pinia')['createPinia']
const createProjection: typeof import('@vueuse/math')['createProjection']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createStore: typeof import('vuex')['createStore']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
@@ -34,9 +34,11 @@ declare global {
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
@@ -52,10 +54,11 @@ declare global {
const logicNot: typeof import('@vueuse/math')['logicNot']
const logicOr: typeof import('@vueuse/math')['logicOr']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('vuex')['mapActions']
const mapGetters: typeof import('vuex')['mapGetters']
const mapMutations: typeof import('vuex')['mapMutations']
const mapState: typeof import('vuex')['mapState']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
@@ -66,6 +69,7 @@ declare global {
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
@@ -77,6 +81,7 @@ declare global {
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
@@ -96,9 +101,12 @@ declare global {
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
@@ -190,6 +198,7 @@ declare global {
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
@@ -209,6 +218,7 @@ declare global {
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useMin: typeof import('@vueuse/math')['useMin']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
@@ -234,6 +244,7 @@ declare global {
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useProjection: typeof import('@vueuse/math')['useProjection']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
@@ -242,6 +253,7 @@ declare global {
const useRound: typeof import('@vueuse/math')['useRound']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
@@ -256,11 +268,11 @@ declare global {
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStore: typeof import('vuex')['useStore']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSum: typeof import('@vueuse/math')['useSum']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
@@ -313,15 +325,17 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
@@ -336,13 +350,11 @@ declare module 'vue' {
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 createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
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']>
@@ -350,9 +362,11 @@ declare module 'vue' {
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
@@ -368,10 +382,11 @@ declare module 'vue' {
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 mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
@@ -382,6 +397,7 @@ declare module 'vue' {
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onElementRemoval: UnwrapRef<typeof import('@vueuse/core')['onElementRemoval']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
@@ -393,6 +409,7 @@ declare module 'vue' {
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
@@ -412,9 +429,12 @@ declare module 'vue' {
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
@@ -506,6 +526,7 @@ declare module 'vue' {
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 useId: UnwrapRef<typeof import('vue')['useId']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
@@ -525,6 +546,7 @@ declare module 'vue' {
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 useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
@@ -550,6 +572,7 @@ declare module 'vue' {
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 usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
@@ -558,6 +581,7 @@ declare module 'vue' {
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 useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
@@ -572,11 +596,11 @@ declare module 'vue' {
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 useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
@@ -626,313 +650,4 @@ declare module 'vue' {
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 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']>
}
}
}

3
components.d.ts vendored
View File

@@ -1,10 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
@@ -14,6 +14,7 @@ declare module 'vue' {
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScrollToTopBtn: typeof import('./src/@core/components/ScrollToTopBtn.vue')['default']
StatIcon: typeof import('./src/@core/components/StatIcon.vue')['default']
ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default']
}

View File

@@ -1,162 +1,221 @@
<!DOCTYPE html>
<html lang="en">
<html
lang="en"
style="
overflow: hidden auto;
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
background: var(--initial-loader-bg, #fff);
"
>
<head>
<meta http-equiv="pragma" content="no-cache" />
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="expires" content="0" />
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
<title>MoviePilot</title>
<meta name="Robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="origin" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
<meta name="description" content="MoviePilot" />
<meta name="format-detection" content="telephone=no" />
<meta name="referrer" content="never" />
<meta name="msapplication-TileColor" content="#7D34FD" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" />
<script>
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
if (loaderColor) document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
if (primaryColor) document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
</script>
</head>
<head>
<meta http-equiv="pragma" content="no-cache">
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="expires" content="0">
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
<title>MoviePilot</title>
<meta name="Robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="origin" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
<meta name="description" content="MoviePilot" />
<meta name="format-detection" content="telephone=no" />
<meta name="referrer" content="never" />
<meta name="msapplication-TileColor" content="#7D34FD" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#28243D" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" />
<link rel="preload" href="index.js" as="script">
</head>
<body>
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
<svg width="10rem" height="10rem" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
<g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
<rect x="0" y="0" width="192" height="192" style="fill: none" />
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
<path
d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
style="fill: url(#_Linear1)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
style="fill: url(#_Linear2)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
style="fill: rgb(141, 81, 249)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
style="fill: url(#_Linear3)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
style="fill: rgb(165, 118, 255)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
style="fill: url(#_Linear4)" />
</g>
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
<path
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
style="fill: rgb(141, 81, 249)" />
</g>
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
<path
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
style="fill: rgb(104, 0, 197)" />
<clipPath id="_clip5">
<body style="margin: 0">
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
<svg
width="10rem"
height="10rem"
viewBox="0 0 192 192"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
<rect x="0" y="0" width="192" height="192" style="fill: none" />
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
<path
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z" />
</clipPath>
<g clip-path="url(#_clip5)">
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
style="fill: url(#_Linear1)"
/>
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
style="fill: url(#_Linear2)"
/>
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
style="fill: rgb(141, 81, 249)"
/>
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
style="fill: url(#_Linear3)"
/>
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
style="fill: rgb(165, 118, 255)"
/>
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
style="fill: url(#_Linear4)"
/>
</g>
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
<path
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
style="fill: rgb(141, 81, 249)"
/>
</g>
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
<path
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
style="fill: rgb(104, 0, 197)"
/>
<clipPath id="_clip5">
<path
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
style="fill: url(#_Linear6)" />
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
style="fill: rgb(141, 81, 249)" />
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
style="fill: url(#_Radial7)" />
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
/>
</clipPath>
<g clip-path="url(#_clip5)">
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
<path
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
style="fill: url(#_Linear6)"
/>
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
style="fill: rgb(141, 81, 249)"
/>
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
style="fill: url(#_Radial7)"
/>
</g>
</g>
</g>
</g>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)">
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
</linearGradient>
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)">
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
</linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)">
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</linearGradient>
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)">
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</linearGradient>
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)">
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
</linearGradient>
<radialGradient id="_Radial7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)">
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</radialGradient>
</defs>
</svg>
<defs>
<linearGradient
id="_Linear1"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)"
>
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
</linearGradient>
<linearGradient
id="_Linear2"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)"
>
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
</linearGradient>
<linearGradient
id="_Linear3"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)"
>
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</linearGradient>
<linearGradient
id="_Linear4"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)"
>
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</linearGradient>
<linearGradient
id="_Linear6"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)"
>
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
</linearGradient>
<radialGradient
id="_Radial7"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)"
>
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</radialGradient>
</defs>
</svg>
</div>
<div class="loading">
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
</div>
</div>
<div class="loading">
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
</div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
if (loaderColor)
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
if (primaryColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
</script>
</body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

14609
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "1.9.10",
"version": "2.3.9",
"private": true,
"bin": "dist/service.js",
"scripts": {
@@ -19,86 +19,96 @@
]
},
"dependencies": {
"@fullcalendar/core": "^6.1.8",
"@fullcalendar/daygrid": "^6.1.8",
"@fullcalendar/interaction": "^6.1.7",
"@fullcalendar/list": "^6.1.7",
"@fullcalendar/timegrid": "^6.1.7",
"@fullcalendar/vue3": "^6.1.8",
"@iconify/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",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/list": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@iconify/utils": "^2.2.1",
"@types/js-cookie": "^3.0.6",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.42.1",
"@vue-flow/minimap": "^1.5.2",
"@vue-flow/node-resizer": "^1.4.0",
"@vue-flow/node-toolbar": "^1.1.0",
"@vue-js-cron/vuetify": "^5.0.9",
"@vueuse/core": "^12.4.0",
"@vueuse/math": "^12.4.0",
"ace-builds": "^1.37.4",
"apexcharts": "^4.0.0",
"axios": "^1.7.9",
"colorthief": "^2.6.0",
"copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"js-cookie": "^3.0.5",
"lodash-es": "^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",
"vue": "^3.3.2",
"vue-router": "^4.2.0",
"vue-toast-notification": "^3",
"pinia": "^3.0.1",
"pinia-plugin-persistedstate": "^4.2.0",
"qrcode.vue": "^3.6.0",
"sass": "^1.83.4",
"tailwindcss": "^ 3.4.17",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-toast-notification": "^3.1.3",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.4.1",
"vue3-apexcharts": "^1.8.0",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "3.6.8",
"vuetify": "3.7.3",
"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/tools": "^4.0.4",
"@iconify/vue": "4.1.1",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@types/lodash": "^4.14.197",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^20.1.4",
"@types/nprogress": "^0.2.3",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.0.0",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"autoprefixer": "^10.4.14",
"eslint": "^9.0.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint": "^9.18.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.0.1",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-regex": "^1.10.0",
"eslint-plugin-sonarjs": "^0.25.1",
"eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-vue": "^9.12.0",
"postcss": "8",
"postcss": "^8.5.1",
"postcss-html": "^1.5.0",
"stylelint": "16.3.1",
"stylelint-config-idiomatic-order": "10.0.0",
"stylelint-config-standard-scss": "13.1.0",
"stylelint": "^16.13.2",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard-scss": "^14.0.0",
"stylelint-use-logical-spec": "5.0.1",
"terser": "^5.36.0",
"type-fest": "^4.15.0",
"typescript": "^5.0.4",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.2.8",
"unplugin-auto-import": "^19.0.0",
"unplugin-vue-components": "^28.0.0",
"unplugin-vue-define-options": "^1.5.3",
"vite": "^5.4.11",
"vite-plugin-pages": "^0.32.1",
"vite-plugin-pwa": "^0.20.0",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.3",
"vue-shepherd": "^3.0.0",
"vue-tsc": "^2.0.10"
"vite-plugin-vuetify": "2.0.4",
"vue-shepherd": "^4.1.0",
"vue-tsc": "^2.0.10",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18",
"resolutions": {
"postcss": "8"
}
}
"packageManager": "yarn@1.22.18"
}

View File

@@ -1,16 +1,6 @@
body {
margin: 0;
}
html {
overflow: hidden auto;
background: var(--initial-loader-bg, #fff);
min-block-size: calc(100% + env(safe-area-inset-top));
}
#loading-bg {
position: absolute;
z-index: 999;
position: fixed;
z-index: 9999;
display: block;
background: var(--initial-loader-bg, #fff);
block-size: 100vh;

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
// 控制回到顶部按钮的可见性
const showScrollToTop = ref(false)
const scrollThreshold = 200 // 滚动多少像素后显示按钮
// 滚动事件处理函数
const handleScroll = () => {
showScrollToTop.value = window.scrollY > scrollThreshold
}
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
onMounted(async () => {
// Add scroll event listener
window.addEventListener('scroll', handleScroll)
// Initial check for scroll-to-top
handleScroll()
})
onUnmounted(() => {
// Remove scroll event listener
window.removeEventListener('scroll', handleScroll)
})
</script>
<template>
<div class="global-action-buttons d-none d-sm-block">
<Transition name="scroll-fade">
<button v-show="showScrollToTop" class="global-action-button" @click="scrollToTop">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7 14L12 9L17 14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</Transition>
</div>
</template>
<style lang="scss" scoped>
/* Global Action Button Styles (FAB) */
.global-action-buttons {
position: fixed;
z-index: 100;
display: flex;
flex-direction: column;
gap: 16px;
inset-block-end: 30px;
inset-inline-end: 30px;
}
.global-action-button {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.05);
border-radius: 50%;
backdrop-filter: blur(10px);
background-color: rgba(var(--v-theme-background), 0.8);
block-size: 44px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 8%);
color: rgb(var(--v-theme-on-surface));
cursor: pointer;
inline-size: 44px;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
&:hover {
background-color: rgba(var(--v-theme-background), 0.95);
color: rgb(var(--v-theme-primary));
transform: translateY(-4px);
}
svg {
block-size: 20px;
inline-size: 20px;
transition: all 0.3s ease;
}
}
</style>

View File

@@ -5,7 +5,7 @@ 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'
import { saveLocalTheme } from '../utils/theme'
// 显示器宽度
const display = useDisplay()
@@ -18,10 +18,12 @@ const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
const { state: currentThemeName, next: getNextThemeName } = useCycleList(
props.themes.map(t => t.name),
{ initialValue: savedTheme.value },
)
const currentThemeName = ref(savedTheme.value)
const getNextThemeName = () => {
const currentIndex = props.themes.findIndex(t => t.name === currentThemeName.value)
const nextIndex = (currentIndex + 1) % props.themes.length
return props.themes[nextIndex].name
}
const $toast = useToast()
@@ -103,8 +105,7 @@ function updateTheme() {
savedTheme.value = theme
themeTransition()
// 保存主题到本地
localStorage.setItem('theme', theme)
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
saveLocalTheme(theme, globalTheme)
}
// 切换主题
@@ -114,10 +115,8 @@ function changeTheme(theme: string) {
currentThemeName.value = nextTheme
// 保存主题到服务端
try {
api.post('/user/config/theme', nextTheme, {
headers: {
'Content-Type': 'text/plain',
},
api.post('/user/config/Layout', {
theme: nextTheme,
})
} catch (e) {
console.error('保存主题到服务端失败')
@@ -178,7 +177,7 @@ async function saveCustomCSS() {
},
})
if (result.success) $toast.success('自定义CSS保存成功')
if (result.success) $toast.success('自定义CSS保存成功,请刷新页面生效')
} catch (e) {
console.error('保存自定义 CSS 到服务端失败')
}
@@ -190,31 +189,59 @@ onMounted(() => {
</script>
<template>
<VMenu v-if="props.themes">
<VMenu v-if="props.themes" class="theme-menu" scrim>
<template v-slot:activator="{ props }">
<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 class="theme-switcher-list pt-0">
<VCardItem class="theme-switcher-header">
<VCardTitle class="font-weight-medium text-primary">主题选择</VCardTitle>
</VCardItem>
<div class="theme-switcher-options px-2">
<VListItem
v-for="theme in props.themes"
:key="theme.name"
@click="changeTheme(theme.name)"
class="theme-option"
:class="{ 'theme-option-active': currentThemeName === theme.name }"
>
<template #prepend>
<div class="theme-icon-wrapper">
<VIcon :icon="theme.icon" />
</div>
</template>
<VListItemTitle>{{ theme.title }}</VListItemTitle>
<template #append v-if="currentThemeName === theme.name">
<VIcon icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
<VDivider class="my-2" />
<VListItem @click="cssDialog = true" class="theme-option custom-theme-option">
<template #prepend>
<div class="theme-icon-wrapper custom-theme-icon">
<VIcon icon="mdi-palette" />
</div>
</template>
<VListItemTitle>自定义主题</VListItemTitle>
</VListItem>
</div>
</VList>
</VMenu>
<!-- 自定义 CSS -- -->
<VDialog v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard title="自定义主题风格">
<DialogCloseBtn @click="cssDialog = false" />
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="me-2" />
自定义主题风格
</VCardTitle>
<DialogCloseBtn @click="cssDialog = false" />
</VCardItem>
<VDivider />
<VAceEditor
v-model:value="customCSS"
@@ -235,18 +262,73 @@ onMounted(() => {
</VDialog>
</template>
<style lang="sass">
// Theme transition
.app-copy
position: fixed !important
z-index: -1 !important
pointer-events: none !important
contain: size style !important
overflow: clip !important
<style lang="scss">
.theme-switcher-header {
background: linear-gradient(to right, rgba(var(--v-theme-primary), 0.04), rgba(var(--v-theme-primary), 0.01));
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 12px;
padding-inline: 16px;
}
.app-transition
--clip-size: 0
--clip-pos: 0 0
clip-path: circle(var(--clip-size) at var(--clip-pos))
transition: clip-path .35s ease-out
.theme-switcher-options {
max-block-size: 300px;
overflow-y: auto;
}
.theme-option {
border-radius: 8px;
margin-block: 4px;
margin-inline: 0;
transition: all 0.2s ease;
&:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
transform: translateX(4px);
}
&.theme-option-active {
background-color: rgba(var(--v-theme-primary), 0.08);
}
}
.theme-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background-color: rgba(var(--v-theme-primary), 0.08);
block-size: 36px;
inline-size: 36px;
margin-inline-end: 12px;
transition: all 0.2s ease;
.v-icon {
color: rgba(var(--v-theme-primary), 0.9);
}
}
.custom-theme-icon {
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.15), rgba(var(--v-theme-info), 0.15));
.v-icon {
color: rgba(var(--v-theme-primary), 0.9);
}
}
// Theme transition
.app-copy {
position: fixed !important;
z-index: -1 !important;
overflow: clip !important;
contain: size style !important;
pointer-events: none !important;
}
.app-transition {
--clip-size: 0;
--clip-pos: 0 0;
clip-path: circle(var(--clip-size) at var(--clip-pos));
transition: clip-path 0.35s ease-out;
}
</style>

View File

@@ -12,6 +12,7 @@
@extend %nav;
@at-root {
// Add styles for collapsed vertical nav
.layout-vertical-nav-collapsed#{$sl-layout-nav-type-vertical}.hovered {
@@ -60,9 +61,9 @@
z-index: 1;
background:
linear-gradient(
rgb(#{variables.$vertical-nav-background-color-rgb}) 5%,
rgba(#{variables.$vertical-nav-background-color-rgb}, 75%) 45%,
rgba(#{variables.$vertical-nav-background-color-rgb}, 20%) 80%,
rgb(var(--v-theme-surface)) 5%,
rgba(var(--v-theme-surface), 75%) 45%,
rgba(var(--v-theme-surface), 20%) 80%,
transparent
);
block-size: 55px;
@@ -85,8 +86,8 @@
}
.ps__rail-y {
// Setting z-index: 1 will make perfect scrollbar thumb appear on top of vertical nav items shadow
z-index: 1;
// Setting z-index: 1 will make perfect scrollbar thumb appear on top of vertical nav items shadow;Settingz-indexSettingz-indexSettingz-indexSettingz-index
z-index: 1z-indexz-indexz-index
}
// 👉 Nav section title

View File

@@ -212,10 +212,6 @@ h6,
}
}
.v-data-table-footer {
margin-block-start: 1rem;
}
// 👉 v-field
.v-field:hover .v-field__outline {
--v-field-border-opacity: var(--v-medium-emphasis-opacity);

View File

@@ -1,5 +1,5 @@
.auth-wrapper {
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top));
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top) + env(safe-area-inset-bottom));
}
.auth-footer-mask {

View File

@@ -176,10 +176,6 @@
th {
background: rgb(var(--v-table-header-background)) !important;
}
.v-data-table-footer {
margin-block-start: 1rem;
}
}
// 👉 Pagination

View File

@@ -1,9 +1,10 @@
@use "sass:map";
@use "vuetify/lib/styles/settings" as vuetify_settings;
@mixin avatar-font-sizes($map: $avatar-sizes) {
@each $sizeName, $multiplier in vuetify_settings.$size-scales {
/* stylelint-disable-next-line scss/no-global-function-names */
$size: map-get($map, $sizeName);
$size: map.get($map, $sizeName);
&.v-avatar--size-#{$sizeName} {
font-size: #{$size}px;

View File

@@ -92,8 +92,7 @@
.fc-header-toolbar {
flex-wrap: wrap;
margin: 1.25rem;
column-gap: 0.5rem;
row-gap: 1rem;
gap: 1rem 0.5rem;
}
.fc-toolbar-chunk {
@@ -238,7 +237,7 @@
inline-size: 1.5625rem;
margin-inline-end: 0.25rem;
@media (max-width: 1264px) {
@media (width <= 1264px) {
display: block !important;
}

View File

@@ -10,8 +10,7 @@ export function useDefer(maxFrameCount = 1) {
const refreshFrameCount = () => {
requestAnimationFrame(() => {
frameCount.value++
if (frameCount.value < maxFrameCount)
refreshFrameCount()
if (frameCount.value < maxFrameCount) refreshFrameCount()
})
}
refreshFrameCount()
@@ -19,3 +18,9 @@ export function useDefer(maxFrameCount = 1) {
return frameCount.value >= showInFrameCount
}
}
export function ensureRenderComplete(callback: () => void) {
requestAnimationFrame(() => {
requestAnimationFrame(callback)
})
}

View File

@@ -60,19 +60,25 @@ export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : valu
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
// 格式化为xx[TGMK]B
export function formatFileSize(bytes: number) {
if (bytes < 0) throw new Error('字节数不能为负数。')
export function formatFileSize(bytes: number, decimals = 2, prefix = false) {
// 负数标记
let negative = false
let size = bytes
if (bytes < 0) {
negative = true
size = Math.abs(bytes)
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
if (negative) return `-${size.toFixed(decimals)} ${units[unitIndex]}`
else
return prefix ? `+${size.toFixed(decimals)} ${units[unitIndex]}` : `${size.toFixed(decimals)} ${units[unitIndex]}`
}
// 将时间秒格式化为时分秒
@@ -147,3 +153,12 @@ export function formatDateDifference(dateString: string): string {
if (!dateString) return ''
return dayjs(dateString).fromNow()
}
// 格式化评份如为10及以下的数按原值显示否则格式化为xxM、xxK显示
export function formatRating(rating: number): string {
if (!rating) return ''
if (rating <= 10) return rating.toString()
if (rating < 1000) return rating.toLocaleString()
if (rating < 1000 * 1000) return `${(rating / 1000).toFixed(1)}K`
return `${(rating / 1000 / 1000).toFixed(1)}M`
}

View File

@@ -1,9 +1,10 @@
import copy from 'copy-to-clipboard'
// 请求和获取剪贴板内容
export async function getClipboardContent() {
if (navigator.clipboard && window.isSecureContext) {
return await navigator.clipboard.readText()
}
else {
} else {
const input = document.createElement('textarea')
document.body.appendChild(input)
input.select()
@@ -14,19 +15,10 @@ export async function getClipboardContent() {
}
}
// 将内容复制到剪贴板,兼容非安全域场景
// 将内容复制到剪贴板
export async function copyToClipboard(content: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content)
}
else {
const input = document.createElement('textarea')
input.value = content
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
}
const success = copy(content)
return success
}
// VAPID公钥转Uint8Array
@@ -42,3 +34,12 @@ export function urlBase64ToUint8Array(base64String: string) {
}
return outputArray
}
// 判断是否为PWA
export const isPWA = async (): Promise<boolean> => {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
return registrations.length > 0
}
return (window.navigator as any).standalone === true
}

6
src/@core/utils/theme.ts Normal file
View File

@@ -0,0 +1,6 @@
export function saveLocalTheme(name: string, theme: any) {
// 存储主题到本地
localStorage.setItem('theme', name)
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
}

122
src/@core/utils/workflow.ts Normal file
View File

@@ -0,0 +1,122 @@
import { useVueFlow } from '@vue-flow/core'
import { ref, watch } from 'vue'
import { cloneDeep } from 'lodash-es'
/**
* @returns {string} - A unique id.
*/
function getId() {
// 生成以act_开头的唯一id
return 'act_' + Math.random().toString(36).substr(2, 9)
}
/**
* In a real world scenario you'd want to avoid creating refs in a global scope like this as they might not be cleaned up properly.
* @type {{draggedData: Ref<any>, isDragOver: Ref<boolean>, isDragging: Ref<boolean>}}
*/
const state = {
/**
* The type of the node being dragged.
*/
draggedData: ref<any | null>({}),
isDragOver: ref(false),
isDragging: ref(false),
}
export default function useDragAndDrop() {
const { draggedData, isDragOver, isDragging } = state
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
watch(isDragging, dragging => {
document.body.style.userSelect = dragging ? 'none' : ''
})
function onDragStart(event: any, data: any) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow', data)
event.dataTransfer.effectAllowed = 'move'
}
draggedData.value = data
isDragging.value = true
document.addEventListener('drop', onDragEnd)
}
/**
* Handles the drag over event.
*
* @param {DragEvent} event
*/
function onDragOver(event: any) {
event.preventDefault()
if (draggedData.value) {
isDragOver.value = true
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
}
function onDragLeave() {
isDragOver.value = false
}
function onDragEnd() {
isDragging.value = false
isDragOver.value = false
draggedData.value = null
document.removeEventListener('drop', onDragEnd)
}
/**
* Handles the drop event.
*
* @param {DragEvent} event
*/
function onDrop(event: any) {
const position = screenToFlowCoordinate({
x: event.clientX,
y: event.clientY,
})
const nodeId = getId()
const newNode = {
id: nodeId,
type: draggedData.value?.type,
name: draggedData.value?.name,
description: draggedData.value?.description,
position,
data: draggedData.value?.data ? cloneDeep(draggedData.value.data) : {},
}
/**
* Align node position after drop, so it's centered to the mouse
*
* We can hook into events even in a callback, and we can remove the event listener after it's been called.
*/
const { off } = onNodesInitialized(() => {
updateNode(nodeId, node => ({
position: { x: node.position.x - node.dimensions.width / 2, y: node.position.y - node.dimensions.height / 2 },
}))
off()
})
addNodes(newNode)
}
return {
draggedData,
isDragOver,
isDragging,
onDragStart,
onDragLeave,
onDragOver,
onDrop,
}
}

View File

@@ -0,0 +1 @@
{"root":["./build-icons.ts"],"version":"5.7.3"}

View File

@@ -1,92 +1,209 @@
<!-- Thanks: https://markus.oberlehner.net/blog/transition-to-height-auto-with-vue/ -->
<script lang="ts">
import { Transition } from 'vue'
import { useDisplay } from 'vuetify'
import VerticalNav from '@layouts/components/VerticalNav.vue'
export default defineComponent({
name: 'TransitionExpand',
setup(_, { slots }) {
const onEnter = (element: HTMLElement) => {
const width = getComputedStyle(element).width
setup(props, { slots }) {
const isOverlayNavActive = ref(false)
const isLayoutOverlayVisible = ref(false)
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)
element.style.width = width
element.style.position = 'absolute'
element.style.visibility = 'hidden'
element.style.height = 'auto'
const route = useRoute()
const { mdAndDown } = useDisplay()
const height = getComputedStyle(element).height
// This is alternative to below two commented watcher
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
element.style.width = ''
element.style.position = ''
element.style.visibility = ''
element.style.height = '0px'
const scrollDistance = ref(window.scrollY)
// Force repaint to make sure the
// animation is triggered correctly.
// eslint-disable-next-line no-unused-expressions
getComputedStyle(element).height
// Trigger the animation.
// We use `requestAnimationFrame` because we need
// to make sure the browser has finished
// painting after setting the `height`
// to `0` in the line above.
requestAnimationFrame(() => {
element.style.height = height
onMounted(() => {
window.addEventListener('scroll', () => {
scrollDistance.value = window.scrollY
})
}
})
const onAfterEnter = (element: HTMLElement) => {
element.style.height = 'auto'
}
return () => {
// 👉 Vertical nav
const verticalNav = h(
VerticalNav,
{ isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive },
{
'nav-header': () => slots['vertical-nav-header']?.(),
'before-nav-items': () => slots['before-vertical-nav-items']?.(),
'default': () => slots['vertical-nav-content']?.(),
'after-nav-items': () => slots['after-vertical-nav-items']?.(),
},
)
const onLeave = (element: HTMLElement) => {
const height = getComputedStyle(element).height
// 👉 Navbar
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
h(
'div',
{ class: 'navbar-content-container' },
slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
}),
),
])
element.style.height = height
const main = h(
'main',
{ class: 'layout-page-content' },
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
h('section', { class: 'page-content-container' }, slots.default?.()),
),
)
// Force repaint to make sure the
// animation is triggered correctly.
// eslint-disable-next-line no-unused-expressions
getComputedStyle(element).height
// 👉 根据路由 meta 决定 footer 高度
const shouldShowFooter = !route.meta.hideFooter
requestAnimationFrame(() => {
element.style.height = '0px'
// 👉 Footer
const footer = h('footer', { class: 'layout-footer' }, [
h(
'div',
{
class: ['footer-content-container', !shouldShowFooter && 'footer-content-container-noheight'],
},
slots.footer?.(),
),
])
// 👉 Overlay
const layoutOverlay = h('div', {
class: ['layout-overlay', 'touch-none', { visible: isLayoutOverlayVisible.value }],
onClick: () => {
isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value
},
})
}
return () => h(
h(Transition),
{
name: 'expand',
onEnter,
onAfterEnter,
onLeave,
},
() => slots.default?.(),
)
return h(
'div',
{
class: [
'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
'layout-navbar-fixed',
mdAndDown.value && 'layout-overlay-nav',
route.meta.layoutWrapperClasses,
scrollDistance.value && 'window-scrolled',
],
},
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
)
}
},
})
</script>
<style>
.expand-enter-active,
.expand-leave-active {
overflow: hidden;
transition: block-size var(--expand-transition-duration, 0.25s) ease;
<style lang="scss">
@use '@configured-variables' as variables;
@use '@layouts/styles/placeholders';
@use '@layouts/styles/mixins';
.layout-wrapper.layout-nav-type-vertical {
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
block-size: 100%;
.layout-content-wrapper {
display: flex;
flex-direction: column;
flex-grow: 1;
min-block-size: calc(var(--vh, 1vh) * 100);
transition: padding-inline-start 0.2s ease-in-out;
will-change: padding-inline-start;
}
.layout-navbar {
position: fixed;
width: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
inset-block-start: 0;
.navbar-content-container {
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
}
@at-root {
.layout-wrapper.layout-nav-type-vertical {
.layout-navbar {
@if variables.$layout-vertical-nav-navbar-is-contained {
@include mixins.boxed-content;
} @else {
.navbar-content-container {
@include mixins.boxed-content;
}
}
}
}
}
}
&.layout-navbar-fixed .layout-navbar {
@extend %layout-navbar-fixed;
}
&.layout-navbar-hidden .layout-navbar {
@extend %layout-navbar-hidden;
}
// 👉 Footer
.layout-footer {
@include mixins.boxed-content;
}
// 👉 Layout overlay
.layout-overlay {
position: fixed;
z-index: variables.$layout-overlay-z-index;
background-color: rgb(0 0 0 / 60%);
cursor: pointer;
inset: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease-in-out;
will-change: transform;
&.visible {
opacity: 1;
pointer-events: auto;
}
}
&:not(.layout-overlay-nav) .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-width;
}
// Adjust right column pl when vertical nav is collapsed
&.layout-vertical-nav-collapsed .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
}
// 👉 Content height fixed
&.layout-content-height-fixed {
.layout-content-wrapper {
max-block-size: calc(var(--vh) * 100);
}
.layout-page-content {
// display: flex;
overflow: hidden;
.page-content-container {
inline-size: 100%;
> :first-child {
max-block-size: 100%;
overflow-y: auto;
}
}
}
}
}
.expand-enter-from,
.expand-leave-to {
block-size: 0;
}
</style>
<style scoped>
* {
backface-visibility: hidden;
perspective: 62.5rem;
transform: translateZ(0);
will-change: block-size;
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
.layout-navbar {
width: 100%;
}
}
</style>

View File

@@ -53,15 +53,12 @@ function handleNavScroll(evt: Event) {
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
<div class="d-flex" v-html="logo" />
<h1 class="font-weight-bold leading-normal text-2xl">
MOVIEPILOT
<h1 class="font-weight-bold leading-normal text-xl">
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>
</h1>
</RouterLink>
</slot>
</div>
<slot name="before-nav-items">
<div class="vertical-nav-items-shadow" />
</slot>
<slot name="nav-items" :update-is-vertical-nav-scrolled="updateIsVerticalNavScrolled">
<PerfectScrollbar
tag="ul"

View File

@@ -51,14 +51,23 @@ export default defineComponent({
const main = h(
'main',
{ class: 'layout-page-content' },
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true },
() => h('section', { class: 'page-content-container' }, slots.default?.()),
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
h('section', { class: 'page-content-container' }, slots.default?.()),
),
)
// 👉 根据路由 meta 决定 footer 高度
const shouldShowFooter = !route.meta.hideFooter
// 👉 Footer
const footer = h('footer', { class: 'layout-footer' }, [
h('div', { class: 'footer-content-container' }, slots.footer?.()),
h(
'div',
{
class: ['footer-content-container', !shouldShowFooter && 'footer-content-container-noheight'],
},
slots.footer?.(),
),
])
// 👉 Overlay
@@ -80,11 +89,7 @@ export default defineComponent({
scrollDistance.value && 'window-scrolled',
],
},
[
verticalNav,
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
layoutOverlay,
],
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
)
}
},
@@ -92,9 +97,16 @@ export default defineComponent({
</script>
<style lang="scss">
@use "@configured-variables" as variables;
@use "@layouts/styles/placeholders";
@use "@layouts/styles/mixins";
@use '@configured-variables' as variables;
@use '@layouts/styles/placeholders';
@use '@layouts/styles/mixins';
.layout-page-content {
position: relative;
z-index: 1;
margin-block-start: 0;
padding-block-start: 0;
}
.layout-wrapper.layout-nav-type-vertical {
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
@@ -111,14 +123,12 @@ export default defineComponent({
.layout-navbar {
position: fixed;
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
inset-block-start: 0;
.navbar-content-container {
block-size: calc(
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height
);
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
}
@at-root {
@@ -168,7 +178,7 @@ export default defineComponent({
}
&:not(.layout-overlay-nav) .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-width;
padding-inline-start: calc(variables.$layout-vertical-nav-width + 0.5rem);
}
// Adjust right column pl when vertical nav is collapsed
@@ -200,7 +210,9 @@ export default defineComponent({
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
.layout-navbar {
width: 100%;
inline-size: calc(100% - 0.5rem);
margin-inline-start: 0.5rem;
padding-inline-start: 0;
}
}
</style>

View File

@@ -7,19 +7,9 @@ defineProps<{
</script>
<template>
<li
class="nav-link"
:class="{ disabled: item.disable }"
>
<Component
:is="item.to ? 'RouterLink' : 'a'"
:to="item.to"
:href="item.href"
>
<VIcon
:icon="item.icon"
class="nav-item-icon"
/>
<li class="nav-link" :class="{ disabled: item.disable }">
<Component :is="item.to ? 'RouterLink' : 'a'" :to="item.to" :href="item.href">
<VIcon :icon="item.icon as string" class="nav-item-icon" />
<!-- 👉 Title -->
<span class="nav-item-title">
{{ item.title }}

View File

@@ -10,10 +10,7 @@ defineProps<{
<li class="nav-section-title">
<div class="title-wrapper">
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<span
class="title-text"
v-text="item.heading"
/>
<span class="title-text" v-text="item.heading" />
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
</div>
</li>

View File

@@ -5,58 +5,60 @@
@use "@configured-variables" as variables;
html {
min-height: calc(100% + env(safe-area-inset-top));
background: rgb(var(--v-theme-background));
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
overflow-y: overlay;
}
body {
overscroll-behavior-y: contain;
--webkit-overflow-scrolling: touch;
background: rgb(var(--v-theme-background));
overscroll-behavior-y: contain;
--webkit-overflow-scrolling: touch;
}
body,
#app,
.v-application {
min-height: 100%;
min-block-size: 100%;
}
.layout-vertical-nav {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
}
.navbar-content-container {
padding-top: env(safe-area-inset-top);
padding-block-start: env(safe-area-inset-top);
}
.layout-page-content {
@include mixins.boxed-content(true);
flex-grow: 1;
overflow: hidden;
// TODO: Use grid gutter variable here
flex-grow: 1;
// TODO: Use grid gutter variable here;
padding-block: 1.5rem;
padding-top: calc(env(safe-area-inset-top) + 4.25rem);
// display: flex;
padding-block-start: calc(env(safe-area-inset-top) + 4.25rem);
// display: flex;display
.page-content-container {
// flex: 1;
// flex: 1;flex
display: flex;
& > div:first-child {
flex: auto;
position: relative;
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
flex: auto;
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
}
}
}
@media screen and (max-width: 1280px){
@media screen and (width <= 1280px){
.page-content-container > div:first-child {
width: calc(100vw - 1rem) !important;
inline-size: calc(100vw - 1rem) !important;
}
}
@@ -65,6 +67,10 @@ body,
block-size: variables.$layout-vertical-nav-footer-height;
}
.footer-content-container-noheight {
block-size: 0 !important;
}
.layout-footer-sticky & {
position: sticky;
inset-block-end: 0;

View File

@@ -7,5 +7,5 @@
html {
box-sizing: border-box;
min-height: calc(100% + env(safe-area-inset-top))
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom))
}

View File

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

View File

@@ -122,8 +122,9 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
export interface NavMenu extends NavLink {
header: string
admin: boolean
description?: string
admin?: boolean
footer?: boolean
}
// 👉 Vertical nav group

View File

@@ -1,15 +1,16 @@
<script lang="ts" setup>
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
const { global: globalTheme } = useTheme()
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
// 生效主题
async function setTheme() {
let themeValue = localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
}
const { global: globalTheme } = useTheme()
let themeValue = localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 显示状态
const show = ref(false)
// ApexCharts 全局配置
declare global {
@@ -41,14 +42,24 @@ if (window.Apex) {
}
}
// 页面加载时,加载当前用户数据
onBeforeMount(async () => {
setTheme()
onMounted(() => {
ensureRenderComplete(() => {
nextTick(() => {
setTimeout(() => {
// 移除加载动画
removeEl('#loading-bg')
// 将background属性从html的style中移除
document.documentElement.style.removeProperty('background')
// 显示页面
show.value = true
}, 1500)
})
})
})
</script>
<template>
<VApp>
<VApp v-show="show">
<RouterView />
</VApp>
</template>

View File

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

85
src/api/constants.ts Normal file
View File

@@ -0,0 +1,85 @@
export const storageOptions = [
{
title: '本地',
value: 'local',
icon: 'mdi-folder-multiple-outline',
remote: false,
},
{
title: '阿里云盘',
value: 'alipan',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: '115网盘',
value: 'u115',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: 'RClone',
value: 'rclone',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: 'AList',
value: 'alist',
icon: 'mdi-cloud-outline',
remote: true,
},
]
export const innerFilterRules = [
{ title: '特效字幕', value: ' SPECSUB ' },
{ title: '中文字幕', value: ' CNSUB ' },
{ title: '国语配音', value: ' CNVOI ' },
{ title: '官种', value: ' GZ ' },
{ title: '排除: 国语配音', value: ' !CNVOI ' },
{ title: '粤语配音', value: ' HKVOI ' },
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
{ title: '促销: 免费', value: ' FREE ' },
{ title: '分辨率: 4K', value: ' 4K ' },
{ title: '分辨率: 1080P', value: ' 1080P ' },
{ title: '分辨率: 720P', value: ' 720P ' },
{ title: '排除: 720P', value: ' !720P ' },
{ title: '质量: 蓝光原盘', value: ' BLU ' },
{ title: '排除: 蓝光原盘', value: ' !BLU ' },
{ title: '质量: BLURAY', value: ' BLURAY ' },
{ title: '排除: BLURAY', value: ' !BLURAY ' },
{ title: '质量: UHD', value: ' UHD ' },
{ title: '排除: UHD', value: ' !UHD ' },
{ title: '质量: REMUX', value: ' REMUX ' },
{ title: '排除: REMUX', value: ' !REMUX ' },
{ title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
{ title: '质量: 60fps', value: ' 60FPS ' },
{ title: '排除: 60fps', value: ' !60FPS ' },
{ title: '编码: H265', value: ' H265 ' },
{ title: '排除: H265', value: ' !H265 ' },
{ title: '编码: H264', value: ' H264 ' },
{ title: '排除: H264', value: ' !H264 ' },
{ title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
{ title: '效果: HDR', value: ' HDR ' },
{ title: '排除: HDR', value: ' !HDR ' },
{ title: '效果: SDR', value: ' SDR ' },
{ title: '排除: SDR', value: ' !SDR ' },
{ title: '效果: 3D', value: ' 3D ' },
{ title: '排除: 3D', value: ' !3D ' },
]
export const storageDict = storageOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const transferTypeOptions = [
{ title: '复制', value: 'copy' },
{ title: '移动', value: 'move' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
]

View File

@@ -1,6 +1,6 @@
import axios from 'axios'
import router from '@/router'
import store from '@/store'
import { useAuthStore } from '@/stores'
// 创建axios实例
const api = axios.create({
@@ -9,10 +9,12 @@ const api = axios.create({
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store
const authStore = useAuthStore()
// 在请求头中添加token
const token = store.state.auth.token
if (token) config.headers.Authorization = `Bearer ${token}`
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
return config
})
@@ -26,8 +28,10 @@ api.interceptors.response.use(
// 请求超时
return Promise.reject(new Error(error))
} else if (error.response.status === 403) {
// 认证 Store
const authStore = useAuthStore()
// 清除登录状态信息
store.dispatch('auth/logout')
authStore.logout()
// token验证失败跳转到登录页面
router.push('/login')
}
@@ -37,3 +41,13 @@ api.interceptors.response.use(
)
export default api
export async function fetchGlobalSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global')
return result.data || {}
} catch (error) {
console.error('Failed to fetch global settings', error)
throw error
}
}

View File

@@ -1,5 +1,6 @@
// 订阅
export interface Subscribe {
// 订阅ID
id: number
// 订阅名称
name: string
@@ -13,6 +14,10 @@ export interface Subscribe {
tmdbid: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: string
// 其它媒体ID
mediaid?: string
// 季号
season?: number
// 海报
@@ -43,7 +48,7 @@ export interface Subscribe {
lack_episode?: number
// 附加信息
note?: string
// 状态N-新建 R-订阅中
// 状态N-新建 R-订阅中 P-待定 S-暂停
state: string
// 最后更新时间
last_update: string
@@ -65,12 +70,88 @@ export interface Subscribe {
show_edit_dialog: boolean
// 编辑框打开状态
page_open?: boolean
// 自定义识别词
custom_words?: string
// 自定义媒体类别
media_category?: string
// 过滤规则组
filter_groups?: string[]
// 下载器
downloader?: string
// 自定义剧集组
episode_group?: string
}
// 订阅分享
export interface SubscribeShare {
// 分享ID
id?: number
// 订阅ID
subscribe_id?: number
// 分享标题
share_title?: string
// 分享说明
share_comment?: string
// 分享人
share_user?: string
// 分享人唯一ID
share_uid?: string
// 订阅名称
name?: string
// 订阅年份
year?: string
// 订阅类型 电影/电视剧
type?: string
// 搜索关键字
keyword?: string
// TMDB ID
tmdbid?: number
// 豆瓣ID
doubanid?: string
// 季号
season?: number
// 海报
poster?: string
// 背景图
backdrop?: string
// 评分
vote?: number
// 描述
description?: string
// 过滤规则
filter?: string
// 包含
include?: string
// 排除
exclude?: string
// 质量
quality?: string
// 分辨率
resolution?: string
// 特效
effect?: string
// 总集数
total_episode?: number
// 时间
date?: string
// 自定义识别词
custom_words?: string
// 自定义媒体类别
media_category?: string
// 复用次数
count?: number
// 自定义剧集组
episode_group?: string
}
// 历史记录
export interface TransferHistory {
// ID
id: number
// 源存储
src_storage?: string
// 目标存储
dest_storage?: string
// 源目录
src?: string
// 目的目录
@@ -107,13 +188,15 @@ export interface TransferHistory {
errmsg?: string
// 日期
date?: string
// 源文件项
src_fileitem?: FileItem
}
// 媒体信息
export interface MediaInfo {
// 来源themoviedb、douban、bangumi
source?: string
// 类型 电影、电视剧
// 类型 电影、电视剧、合集
type?: string
// 媒体标题
title?: string
@@ -133,6 +216,12 @@ export interface MediaInfo {
douban_id?: string
// Bangumi ID
bangumi_id?: string
// 合集ID
collection_id?: number
// 其它媒体ID前缀
mediaid_prefix?: string
// 其它媒体ID值
media_id?: string
// 媒体原语种
original_language?: string
// 媒体原发行标题
@@ -203,6 +292,26 @@ export interface MediaInfo {
next_episode_to_air?: object
// 别名
names?: string[]
// 剧集组
episode_group?: string
}
// 季信息
export interface MediaSeason {
// 上映日期
air_date?: string
// 总集数
episode_count?: number
// 季名称
name?: string
// 描述
overview?: string
// 海报
poster_path?: string
// 季号
season_number?: number
// 评分
vote_average?: number
}
// TMDB季信息
@@ -318,6 +427,8 @@ export interface Site {
pri?: number
// RSS地址
rss?: string
// 下载器
downloader: string
// Cookie
cookie?: string
// ApiKey
@@ -366,6 +477,48 @@ export interface SiteStatistic {
note?: string
}
// 站点用户数据
export interface SiteUserData {
// 站点域名
domain?: string
// 用户名
username?: string
// 用户ID
userid?: number
// 用户等级
user_level?: string
// 加入时间
join_at?: string
// 积分
bonus?: number // 默认为 0.0
// 上传量
upload?: number // 默认为 0
// 下载量
download?: number // 默认为 0
// 分享率
ratio?: number // 默认为 0
// 做种数
seeding?: number // 默认为 0
// 下载数
leeching?: number // 默认为 0
// 做种体积
seeding_size?: number // 默认为 0
// 下载体积
leeching_size?: number // 默认为 0
// 做种人数, 种子大小
seeding_info?: any[] // 默认为空数组
// 未读消息
message_unread?: number // 默认为 0
// 未读消息内容
message_unread_contents?: any[] // 默认为空数组
// 错误信息
err_msg?: string | null // 默认为 null
// 更新日期
updated_day?: string
// 更新时间
updated_time?: string
}
// 正在下载
export interface DownloadingInfo {
// HASH
@@ -394,6 +547,8 @@ export interface DownloadingInfo {
userid?: string
// 下载用户名称
username?: string
// 剩余时间
left_time?: string
}
// 缺失剧集信息
@@ -492,6 +647,8 @@ export interface TorrentInfo {
site_proxy: boolean
// 站点优先级
site_order: number
// 站点下载器
site_downloader?: string
// 种子名称
title?: string
// 种子副标题
@@ -642,6 +799,12 @@ export interface User {
avatar: string
// 是否开启双重验证
is_otp: boolean
// 用户权限 json
permissions: { [key: string]: any }
// 用户个性化设置 json
settings: { [key: string]: string | null }
// 昵称
nickname?: string
}
// 存储空间
@@ -741,6 +904,8 @@ export interface EndPoints {
// 文件浏览项目
export interface FileItem {
// 存储
storage: string
// 类型 dir/file
type: string
// 文件名
@@ -843,22 +1008,298 @@ export interface SystemNotification {
date: string
}
// 下载目录/媒体库目录
export interface MediaDirectory {
// 类型 download/library
type?: string
// 别名
name?: string
// 路径
path?: string
// 媒体类型 电影/电视剧
media_type?: string
// 媒体类别 动画电影/国产剧
category?: string
// 刮削媒体信息
scrape?: boolean
// 自动二级分类,未指定类别时自动分类
auto_category?: boolean
// 优先级
priority?: number
// 下载器配置
export interface DownloaderConf {
// 名称
name: string
// 类型 qbittorrent/transmission
type: string
// 是否默认
default: boolean
// 配置
config: { [key: string]: any }
// 是否启用
enabled: boolean
}
// 通知配置
export interface NotificationConf {
// 名称
name: string
// 类型 telegram/wechat/vocechat/synologychat
type: string
// 配置
config: { [key: string]: any }
// 场景开关
switchs?: string[]
// 是否启用
enabled: boolean
}
// 通知场景开关配置
export interface NotificationSwitchConf {
// 场景名称
type: string
// 通知范围 all/user/admin
action: string
}
// 存储配置
export interface StorageConf {
// 名称
name: string
// 类型 local/alipan/u115/rclone
type: string
// 配置
config?: { [key: string]: any }
}
// 媒体服务器配置
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/jellyfin/plex
type: string
// 配置
config: { [key: string]: any }
// 是否启用
enabled: boolean
// 同步媒体体库列表
sync_libraries?: string[]
}
// 文件整理目录配置
export interface TransferDirectoryConf {
// 名称
name: string
// 优先级
priority: number
// 存储
storage: string
// 下载目录
download_path?: string
// 适用媒体类型
media_type?: string
// 适用媒体类别
media_category?: string
// 下载类型子目录
download_type_folder?: boolean
// 下载类别子目录
download_category_folder?: boolean
// 监控方式 downloader/monitorNone为不监控
monitor_type?: string
// 监控模式 fast/compatibility
monitor_mode?: string
// 整理方式 move/copy/link/softlink
transfer_type: string
// 文件覆盖模式 always/size/never/latest
overwrite_mode?: string
// 整理到媒体库目录
library_path?: string
// 媒体库目录存储
library_storage?: string
// 智能重命名
renaming?: boolean
// 刮削
scraping?: boolean
// 媒体库类型子目录
library_type_folder?: boolean
// 媒体库类别子目录
library_category_folder?: boolean
// 是否发送通知
notify?: boolean
}
// 自定义规则项
export interface CustomRule {
// 规则ID
id: string
// 名称
name: string
// 包含
include?: string
// 排除
exclude?: string
// 大小范围
size_range?: string
// 最少做种人数
seeders?: string
// 发布时间
publish_time?: string
}
// 过滤规则组
export interface FilterRuleGroup {
// 名称
name: string
// 规则串
rule_string?: string
// 适用类媒体类型 None-全部 电影/电视剧
media_type?: string
// # 适用媒体类别 None-全部 对应二级分类
category?: string
}
// 订阅下载文件详情
export interface SubscribeDownloadFileInfo {
// 种子名称
torrent_title?: string
// 站点名称
site_name?: string
// 下载器
downloader?: string
// hash
hash?: string
// 文件路径
file_path?: string
}
// 订阅媒体库文件详情
export interface SubscribeLibraryFileInfo {
// 存储
storage?: string
// 文件路径
file_path?: string
}
// 订阅集详情
export interface SubscribeEpisodeInfo {
// 标题
title?: string
// 描述
description?: string
// 背景图
backdrop?: string
// 下载文件信息
download?: SubscribeDownloadFileInfo[]
// 媒体库文件信息
library?: SubscribeLibraryFileInfo[]
}
// 订阅详情
export interface SubscrbieInfo {
// 订阅信息
subscribe: Subscribe
// 集信息 {集号: {download: 文件路径library: 文件路径, backdrop: url, title: 标题, description: 描述}}
episodes: Record<number, SubscribeEpisodeInfo>
}
// 整理表单
export interface TransferForm {
// 文件项
fileitem: FileItem
// 历史ID
logid: number
// 目标存储
target_storage: string
// 目标路径
target_path: string
// TMDB ID
tmdbid?: number
// 豆瓣 ID
doubanid?: string
// 季号
season?: number
// 类型
type_name?: string
// 整理方式
transfer_type: string
// 自定义格式
episode_format?: string
// 指定集数
episode_detail?: string
// 指定PART
episode_part?: string
// 集数偏移
episode_offset?: string
// 最小文件大小
min_filesize: number
// 刮削
scrape: boolean
// 复用历史识别信息
from_history: boolean
// 媒体库类型子目录
library_type_folder?: boolean
// 媒体库类别子目录
library_category_folder?: boolean
// 剧集组编号
episode_group?: string
}
// 整理队列
export interface TransferQueue {
// 媒体信息
media: MediaInfo
// 季
season?: number
// 任务列表
tasks: {
// 文件项
fileitem: FileItem
// 元数据
meta: MetaInfo
// 状态
state: string
}[]
}
// 探索的数据源
export interface DiscoverSource {
// 数据源名称
name: string
// 媒体ID的前缀不含:
mediaid_prefix: string
// 媒体数据源API地址
api_path: string
// 过滤参数
filter_params: { [key: string]: any }
// 过滤参数UI配置
filter_ui: RenderProps[]
// UI依赖关系字典
depends?: { [key: string]: string[] }
}
// 推荐的数据源
export interface RecommendSource {
// 数据源名称
name: string
// 媒体数据源API地址
api_path: string
// 类型
type: string
}
// 站点资源分类
export interface SiteCategory {
id: number
cat: string
desc: string
}
// 工作流
export interface Workflow {
// 工作流ID
id?: string
// 工作流名称
name?: string
// 工作流描述
description?: string
// 定时器
timer?: string
// 状态
state?: string
// 当前执行动作
current_action?: string
// 任务执行结果
result?: string
// 已执行次数
run_count?: number
// 动作列表
actions?: any[]
// 动作流
flows?: any[]
// 创建时间
add_time?: string
// 最后执行时间
last_time?: string
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 273.42 35.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.76" x2="273.42" y2="17.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 3</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M191.85,35.37h63.9A17.67,17.67,0,0,0,273.42,17.7h0A17.67,17.67,0,0,0,255.75,0h-63.9A17.67,17.67,0,0,0,174.18,17.7h0A17.67,17.67,0,0,0,191.85,35.37ZM10.1,35.42h7.8V6.92H28V0H0v6.9H10.1Zm28.1,0H46V8.25h.1L55.05,35.4h6L70.3,8.25h.1V35.4h7.8V0H66.45l-8.2,23.1h-.1L50,0H38.2ZM89.14.12h11.7a33.56,33.56,0,0,1,8.08,1,18.52,18.52,0,0,1,6.67,3.08,15.09,15.09,0,0,1,4.53,5.52,18.5,18.5,0,0,1,1.67,8.25,16.91,16.91,0,0,1-1.62,7.58,16.3,16.3,0,0,1-4.38,5.5,19.24,19.24,0,0,1-6.35,3.37,24.53,24.53,0,0,1-7.55,1.15H89.14Zm7.8,28.2h4a21.66,21.66,0,0,0,5-.55A10.58,10.58,0,0,0,110,26a8.73,8.73,0,0,0,2.68-3.35,11.9,11.9,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9.17,9.17,0,0,0-2.63-3.18A11.61,11.61,0,0,0,106.22,8a17.06,17.06,0,0,0-4.68-.63h-4.6ZM133.09.12h13.2a32.87,32.87,0,0,1,4.63.33,12.66,12.66,0,0,1,4.17,1.3,7.94,7.94,0,0,1,3,2.72,8.34,8.34,0,0,1,1.15,4.65,7.48,7.48,0,0,1-1.67,5,9.13,9.13,0,0,1-4.43,2.82V17a10.28,10.28,0,0,1,3.18,1,8.51,8.51,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.32,9.32,0,0,1-3.1,3A13.38,13.38,0,0,1,152.32,35a22.5,22.5,0,0,1-4.73.5h-14.5Zm7.8,14.15h5.65a7.65,7.65,0,0,0,1.78-.2,4.78,4.78,0,0,0,1.57-.65,3.43,3.43,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8A3.3,3.3,0,0,0,151,8.6a3.42,3.42,0,0,0-1.23-1.13A6.07,6.07,0,0,0,148,6.9a9.9,9.9,0,0,0-1.85-.18h-5.3Zm0,14.65h7a8.27,8.27,0,0,0,1.83-.2,4.67,4.67,0,0,0,1.67-.7,3.93,3.93,0,0,0,1.23-1.3,3.8,3.8,0,0,0,.47-1.95,3.16,3.16,0,0,0-.62-2,4,4,0,0,0-1.58-1.18,8.23,8.23,0,0,0-2-.55,15.12,15.12,0,0,0-2.05-.15h-5.9Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1,10 @@
<svg width="1252" height="1252" xmlns="http://www.w3.org/2000/svg" version="1.1">
<g>
<g id="#70c6beff">
<path id="svg_2" d="m634.37,138.38c11.88,-1.36 24.25,1.3 34.18,8.09c14.96,9.66 25.55,24.41 34.49,39.51c40.59,68.03 81.45,135.91 122.02,203.96c54.02,90.99 108.06,181.97 161.94,273.06c37.28,63 74.65,125.96 112.18,188.82c24.72,41.99 50.21,83.54 73.84,126.16c10.18,17.84 15.77,38.44 14.93,59.03c-0.59,15.92 -3.48,32.28 -11.84,46.08c-11.73,19.46 -31.39,33.2 -52.71,40.36c-11.37,4.09 -23.3,6.87 -35.43,6.89c-132.32,-0.05 -264.64,0.04 -396.95,0.03c-11.38,-0.29 -22.95,-1.6 -33.63,-5.72c-7.81,-3.33 -15.5,-7.43 -21.61,-13.42c-10.43,-10.32 -17.19,-24.96 -15.38,-39.83c0.94,-10.39 3.48,-20.64 7.76,-30.16c4.15,-9.77 9.99,-18.67 15.06,-27.97c22.13,-39.47 45.31,-78.35 69.42,-116.65c7.72,-12.05 14.44,-25.07 25.12,-34.87c11.35,-10.39 25.6,-18.54 41.21,-19.6c12.55,-0.52 24.89,3.82 35.35,10.55c11.8,6.92 21.09,18.44 24.2,31.88c4.49,17.01 -0.34,34.88 -7.55,50.42c-8.09,17.65 -19.62,33.67 -25.81,52.18c-1.13,4.21 -2.66,9.52 0.48,13.23c3.19,3 7.62,4.18 11.77,5.22c12,2.67 24.38,1.98 36.59,2.06c45,-0.01 90,0 135,0c8.91,-0.15 17.83,0.3 26.74,-0.22c6.43,-0.74 13.44,-1.79 18.44,-6.28c3.3,-2.92 3.71,-7.85 2.46,-11.85c-2.74,-8.86 -7.46,-16.93 -12.12,-24.89c-119.99,-204.91 -239.31,-410.22 -360.56,-614.4c-3.96,-6.56 -7.36,-13.68 -13.03,-18.98c-2.8,-2.69 -6.95,-4.22 -10.77,-3.11c-3.25,1.17 -5.45,4.03 -7.61,6.57c-5.34,6.81 -10.12,14.06 -14.51,21.52c-20.89,33.95 -40.88,68.44 -61.35,102.64c-117.9,198.43 -235.82,396.85 -353.71,595.29c-7.31,13.46 -15.09,26.67 -23.57,39.43c-7.45,10.96 -16.49,21.23 -28.14,27.83c-13.73,7.94 -30.69,11.09 -46.08,6.54c-11.23,-3.47 -22.09,-9.12 -30.13,-17.84c-10.18,-10.08 -14.69,-24.83 -14.17,-38.94c0.52,-14.86 5.49,-29.34 12.98,-42.1c71.58,-121.59 143.62,-242.92 215.93,-364.09c37.2,-62.8 74.23,-125.69 111.64,-188.36c37.84,-63.5 75.77,-126.94 113.44,-190.54c21.02,-35.82 42.19,-71.56 64.28,-106.74c6.79,-11.15 15.58,-21.15 26.16,-28.85c8.68,-5.92 18.42,-11 29.05,-11.94z" fill="#70c6be"/>
</g>
<g id="#1ba0d8ff">
<path id="svg_3" d="m628.35,608.38c17.83,-2.87 36.72,1.39 51.5,11.78c11.22,8.66 19.01,21.64 21.26,35.65c1.53,10.68 0.49,21.75 -3.44,31.84c-3.02,8.73 -7.35,16.94 -12.17,24.81c-68.76,115.58 -137.5,231.17 -206.27,346.75c-8.8,14.47 -16.82,29.47 -26.96,43.07c-7.37,9.11 -16.58,16.85 -27.21,21.89c-22.47,11.97 -51.79,4.67 -68.88,-13.33c-8.66,-8.69 -13.74,-20.63 -14.4,-32.84c-0.98,-12.64 1.81,-25.42 7.53,-36.69c5.03,-10.96 10.98,-21.45 17.19,-31.77c30.22,-50.84 60.17,-101.84 90.3,-152.73c41.24,-69.98 83.16,-139.55 124.66,-209.37c4.41,-7.94 9.91,-15.26 16.09,-21.9c8.33,-8.46 18.9,-15.3 30.8,-17.16z" fill="#1ba0d8"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="&#1057;&#1083;&#1086;&#1081;_1" x="0px" y="0px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve">
<linearGradient id="SVGID_1__48343" gradientUnits="userSpaceOnUse" x1="39" y1="23.25" x2="39" y2="33.0008" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<circle style="fill:url(#SVGID_1__48343);" cx="39" cy="28" r="4"/>
<linearGradient id="SVGID_2__48343" gradientUnits="userSpaceOnUse" x1="32" y1="6.75" x2="32" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<path style="fill:url(#SVGID_2__48343);" d="M58,13c0-2.757-2.243-5-5-5H19c-2.757,0-5,2.243-5,5v33H6v5c0,2.757,2.243,5,5,5h34 c2.757,0,5-2.243,5-5V18h8V13z M11,54c-1.654,0-3-1.346-3-3v-3h32v3c0,1.125,0.374,2.164,1.002,3H11z M48,51c0,1.654-1.346,3-3,3 s-3-1.346-3-3v-5H16V13c0-1.654,1.346-3,3-3h30.026C48.391,10.838,48,11.87,48,13V51z M56,16h-6v-3c0-1.654,1.346-3,3-3s3,1.346,3,3 V16z"/>
<linearGradient id="SVGID_3__48343" gradientUnits="userSpaceOnUse" x1="39" y1="6.75" x2="39" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<path style="fill:url(#SVGID_3__48343);" d="M39,23c-2.757,0-5,2.243-5,5s2.243,5,5,5s5-2.243,5-5S41.757,23,39,23z M39,31 c-1.654,0-3-1.346-3-3s1.346-3,3-3s3,1.346,3,3S40.654,31,39,31z"/>
<linearGradient id="SVGID_4__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="23" style="fill:url(#SVGID_4__48343);" width="10" height="2"/>
<linearGradient id="SVGID_5__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="27" style="fill:url(#SVGID_5__48343);" width="10" height="2"/>
<linearGradient id="SVGID_6__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="31" style="fill:url(#SVGID_6__48343);" width="10" height="2"/>
<linearGradient id="SVGID_7__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="35" style="fill:url(#SVGID_7__48343);" width="10" height="2"/>
<linearGradient id="SVGID_8__48343" gradientUnits="userSpaceOnUse" x1="39" y1="6.75" x2="39" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="34" y="35" style="fill:url(#SVGID_8__48343);" width="10" height="2"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="&#1057;&#1083;&#1086;&#1081;_1" x="0px" y="0px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve">
<linearGradient id="SVGID_1__52535" gradientUnits="userSpaceOnUse" x1="21.9994" y1="11.6667" x2="21.9994" y2="18.5839" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<circle style="fill:url(#SVGID_1__52535);" cx="21.999" cy="14.998" r="3"/>
<linearGradient id="SVGID_2__52535" gradientUnits="userSpaceOnUse" x1="35.9994" y1="4.1667" x2="35.9994" y2="15.8334" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<circle style="fill:url(#SVGID_2__52535);" cx="35.999" cy="9.998" r="4"/>
<linearGradient id="SVGID_3__52535" gradientUnits="userSpaceOnUse" x1="32" y1="20.7501" x2="32" y2="58.7632" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<path style="fill:url(#SVGID_3__52535);" d="M47.003,21H16.996C15.344,21,14,22.344,14,23.995V25v0.998v1.261 c0,0.717,0.257,1.41,0.722,1.95l10.556,12.315C25.743,42.068,26,42.763,26,43.479v6.964c0,0.652,0.32,1.264,0.854,1.634l8.016,5.569 c0.341,0.236,0.737,0.356,1.136,0.356c0.316,0,0.634-0.076,0.926-0.229C37.591,57.428,38,56.751,38,56.007V43.479 c0-0.716,0.257-1.409,0.722-1.953L49.277,29.21C49.743,28.668,50,27.975,50,27.259v-1.258V25v-1.005C50,22.344,48.655,21,47.003,21z M37.204,40.225c-0.447,0.521-0.762,1.129-0.963,1.775H33v2h3l0.001,2H33v2h3.003l0.002,2H34v2h2.007l0.003,4.002L28,50.442v-6.964 c0-1.193-0.428-2.35-1.205-3.255L17.176,29h29.648L37.204,40.225z M48,26.001C48,26.552,47.552,27,47,27H17.002 C16.449,27,16,26.551,16,25.998V25v-1.005C16,23.446,16.447,23,16.996,23h30.007C47.553,23,48,23.446,48,23.995V25V26.001z"/>
<linearGradient id="SVGID_4__52535" gradientUnits="userSpaceOnUse" x1="41.9994" y1="17.3333" x2="41.9994" y2="21.3333" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<path style="fill:url(#SVGID_4__52535);" d="M44.999,21c0-2-1.343-3-3-3s-3,1-3,3H44.999z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -2,15 +2,14 @@
import type { Axios } from 'axios'
import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue'
import type { EndPoints, FileItem } from '@/api/types'
import api from '@/api'
import AliyunAuthDialog from './dialog/AliyunAuthDialog.vue'
import U115AuthDialog from './dialog/U115AuthDialog.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import FileNavigator from './filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageOptions } from '@/api/constants'
import { useDisplay } from 'vuetify'
// 输入参数
const props = defineProps({
storages: String,
storages: Array as PropType<StorageConf[]>,
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
axios: {
@@ -22,49 +21,107 @@ const props = defineProps({
type: Object as PropType<FileItem>,
required: true,
},
itemstack: Array as PropType<FileItem[]>,
itemstack: {
type: Array as PropType<FileItem[]>,
default: () => [],
},
})
// 对外事件
const emit = defineEmits(['pathchanged'])
const availableStorages = [
{
name: '本地',
code: 'local',
icon: 'mdi-folder-multiple-outline',
},
{
name: '阿里云盘',
code: 'aliyun',
icon: 'mdi-cloud-outline',
},
{
name: '115网盘',
code: 'u115',
icon: 'mdi-cloud-outline',
},
]
// 显示器宽度
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
const fileIcons = {
// 压缩包
zip: 'mdi-folder-zip-outline',
rar: 'mdi-folder-zip-outline',
bak: 'mdi-folder-zip-outline',
tar: 'mdi-folder-zip-outline',
gz: 'mdi-folder-zip-outline',
bz2: 'mdi-folder-zip-outline',
// 开发
htm: 'mdi-language-html5',
html: 'mdi-language-html5',
vue: 'mdi-vuejs',
js: 'mdi-nodejs',
ts: 'mdi-language-typescript',
json: 'mdi-file-document-outline',
css: 'mdi-language-css3',
scss: 'mdi-language-css3',
less: 'mdi-language-css3',
php: 'mdi-language-php',
py: 'mdi-language-python',
java: 'mdi-language-java',
go: 'mdi-language-go',
c: 'mdi-language-c',
cpp: 'mdi-language-cpp',
h: 'mdi-language-c',
cs: 'mdi-language-csharp',
sql: 'mdi-database',
sh: 'mdi-language-bash',
bat: 'mdi-language-bash',
ps1: 'mdi-language-powershell',
// markdown
md: 'mdi-language-markdown-outline',
pdf: 'mdi-file-pdf',
png: 'mdi-file-image',
jpg: 'mdi-file-image',
jpeg: 'mdi-file-image',
markdown: 'mdi-language-markdown-outline',
// 图片
png: 'mdi-file-png-box',
jpg: 'mdi-file-jpg-box',
jpeg: 'mdi-file-jpg-box',
gif: 'mdi-file-gif-box',
bmp: 'mdi-file-image-box',
webp: 'mdi-file-image-box',
ico: 'mdi-file-image-box',
svg: 'mdi-file-image-box',
// 视频
mp4: 'mdi-filmstrip',
mkv: 'mdi-filmstrip',
avi: 'mdi-filmstrip',
wmv: 'mdi-filmstrip',
mov: 'mdi-filmstrip',
flv: 'mdi-filmstrip',
rmvb: 'mdi-filmstrip',
// 文档
txt: 'mdi-file-document-outline',
env: 'mdi-file-cog-outline',
yml: 'mdi-file-cog-outline',
yaml: 'mdi-file-cog-outline',
conf: 'mdi-file-cog-outline',
log: 'mdi-file-document-outline',
csv: 'mdi-file-delimited',
// office
xls: 'mdi-file-excel',
xlsx: 'mdi-file-excel',
doc: 'mdi-file-word',
docx: 'mdi-file-word',
ppt: 'mdi-file-powerpoint',
pptx: 'mdi-file-powerpoint',
pdf: 'mdi-file-pdf',
// 音频
mp2: 'mdi-music',
mp3: 'mdi-music',
m4a: 'mdi-music',
wma: 'mdi-music',
aac: 'mdi-music',
ogg: 'mdi-music',
flac: 'mdi-music',
wav: 'mdi-music',
// 字体
ttf: 'mdi-format-font',
otf: 'mdi-format-font',
woff: 'mdi-format-font',
woff2: 'mdi-format-font',
eot: 'mdi-format-font',
// 字幕
srt: 'mdi-subtitles-outline',
ass: 'mdi-subtitles-outline',
sub: 'mdi-subtitles-outline',
// 其他
other: 'mdi-file-outline',
}
@@ -76,19 +133,14 @@ const activeStorage = ref('local')
const refreshPending = ref(false)
// 排序
const sort = ref('name')
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 阿里云盘用户信息
const aliyunUserInfo = ref<{ [key: string]: any }>({})
// 115网盘认证对话框
const u115AuthDialog = ref(false)
// 115网盘用户信息
const u115UserInfo = ref<{ [key: string]: any }>({})
// 是否显示目录树
const showDirTree = ref(false)
// 计算属性
const storagesArray = computed(() => {
const storageCodes = props.storages?.split(',')
return availableStorages.filter(item => storageCodes?.includes(item.code))
const storageCodes = props.storages?.map(item => item.type)
return storageOptions.filter(item => storageCodes?.includes(item.value))
})
// 方法
@@ -97,47 +149,10 @@ function loadingChanged(loading: number) {
else if (loading > 0) loading--
}
// 查询阿里云
async function loadAliyunUserInfo() {
try {
const result: { [key: string]: any } = await api.get('aliyun/userinfo')
if (result.success) {
aliyunUserInfo.value = result
}
} catch (error) {
console.log(error)
}
}
// 查询115
async function loadU115UserInfo() {
try {
const result: { [key: string]: any } = await api.get('u115/storage')
if (result.success) {
u115UserInfo.value = result
}
} catch (error) {
console.log(error)
}
}
// 存储切换
async function storageChanged(storage: string) {
if (storage == 'aliyun') {
await loadAliyunUserInfo()
if (isNullOrEmptyObject(aliyunUserInfo.value)) {
aliyunAuthDialog.value = true
return
}
} else if (storage == 'u115') {
await loadU115UserInfo()
if (isNullOrEmptyObject(u115UserInfo.value)) {
u115AuthDialog.value = true
return
}
}
activeStorage.value = storage
emit('pathchanged', { path: '/', fileid: 'root' })
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
}
// 路径变化
@@ -151,21 +166,36 @@ function sortChanged(s: string) {
refreshPending.value = true
}
// aliyun认证完成
function aliyunAuthDone() {
aliyunAuthDialog.value = false
activeStorage.value = 'aliyun'
// 切换目录树
function switchDirTree(state: boolean) {
showDirTree.value = state
}
// u115认证完成
function u115AuthDone() {
u115AuthDialog.value = false
activeStorage.value = 'u115'
// 文件列表
const fileListItems = ref<FileItem[]>([])
// 文件列表数据更新
function fileListUpdated(items: FileItem[]) {
fileListItems.value = items
}
// 外层DIV大小控制
const scrollStyle = computed(() => {
return appMode
? 'height: calc(100vh - 10rem - env(safe-area-inset-bottom) - 6rem)'
: 'height: calc(100vh - 10rem - env(safe-area-inset-bottom)'
})
// 文件列表大小限制
const fileListStyle = computed(() => {
return appMode
? 'height: calc(100vh - 14rem - env(safe-area-inset-bottom) - 6rem)'
: 'height: calc(100vh - 14rem - env(safe-area-inset-bottom)'
})
</script>
<template>
<VCard class="mx-auto" :loading="loading > 0">
<div class="mx-auto" :loading="loading > 0">
<div v-if="activeStorage && item">
<FileToolbar
:item="item"
@@ -179,27 +209,36 @@ function u115AuthDone() {
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<FileList
:item="item"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axios"
:refreshpending="refreshPending"
:sort="sort"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
/>
<div class="flex" :style="scrollStyle">
<FileNavigator
v-if="showDirTree"
:storage="activeStorage"
:currentPath="item.path"
:items="fileListItems"
:endpoints="endpoints"
:axios="axios"
@navigate="pathChanged"
/>
<FileList
class="flex-grow"
:item="item"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axios"
:refreshpending="refreshPending"
:sort="sort"
:listStyle="fileListStyle"
:showTree="showDirTree"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
@items-updated="fileListUpdated"
@switch-tree="switchDirTree"
/>
</div>
</div>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
@close="aliyunAuthDialog = false"
@done="aliyunAuthDone"
/>
<U115AuthDialog v-if="u115AuthDialog" v-model="u115AuthDialog" @close="u115AuthDialog = false" @done="u115AuthDone" />
</div>
</template>

View File

@@ -1,31 +1,244 @@
<script setup lang="ts">
import image from '@images/no-data.svg'
const props = defineProps<Props>()
interface Props {
errorCode?: string
errorTitle?: string
errorDescription?: string
icon?: string
iconColor?: string
}
</script>
<template>
<VEmptyState :image="image" size="250">
<template #title>
<div class="mt-8 text-2xl">
{{ props.errorTitle }}
<div class="no-data-container">
<!-- 图标容器 -->
<div class="icon-wrapper">
<div class="icon-glow"></div>
<div class="icon-container">
<VIcon
:icon="props.icon || 'mdi-file-search-outline'"
:color="props.iconColor || 'white'"
size="48"
class="main-icon"
/>
</div>
</template>
<div class="pulse-ring"></div>
</div>
<template #text>
<div class="text-subtitle mt-3">
{{ props.errorDescription }}
</div>
</template>
<!-- 标题 -->
<div class="error-title">
{{ props.errorTitle || '暂无数据' }}
</div>
<template #actions>
<!-- 描述 -->
<div class="error-description">
{{ props.errorDescription || '没有找到相关内容' }}
</div>
<!-- 按钮插槽 -->
<div class="actions-container">
<slot name="button" />
</template>
</VEmptyState>
</div>
</div>
</template>
<style scoped>
.no-data-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
inline-size: 100%;
min-block-size: 300px;
padding-block: 3rem;
padding-inline: 1rem;
text-align: center;
}
/* 图标样式 */
.icon-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
block-size: 100px;
inline-size: 100px;
margin-block: 0 2rem;
margin-inline: auto;
}
.icon-glow {
position: absolute;
border-radius: 50%;
animation: pulse 3s infinite ease-in-out;
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.8) 0%, rgba(var(--v-theme-primary), 0) 70%);
block-size: 80px;
filter: blur(15px);
inline-size: 80px;
opacity: 0.8;
}
.icon-container {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.9), rgba(var(--v-theme-secondary), 0.8));
block-size: 80px;
inline-size: 80px;
}
.main-icon {
animation: slight-bounce 3s infinite ease-in-out;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 30%));
}
.pulse-ring {
position: absolute;
z-index: 1;
border: 2px solid rgba(var(--v-theme-primary), 0.5);
border-radius: 50%;
animation: ripple 2s infinite ease-out;
block-size: 100px;
inline-size: 100px;
inset-block-start: 50%;
inset-inline-start: 50%;
opacity: 0;
transform: translate(-50%, -50%);
}
.pulse-ring::before {
position: absolute;
border: 2px solid rgba(var(--v-theme-primary), 0.3);
border-radius: 50%;
animation: ripple 2s infinite 0.5s ease-out;
block-size: 85px;
content: '';
inline-size: 85px;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
}
@keyframes ripple {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(0.9);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.5);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.5;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
@keyframes slight-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
/* 文字样式 */
.error-title {
position: relative;
color: rgba(var(--v-theme-on-surface), 0.95);
font-size: 1.75rem;
font-weight: 700;
margin-block-end: 0.75rem;
text-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
}
.error-title::after {
display: block;
border-radius: 3px;
background: linear-gradient(90deg, rgba(var(--v-theme-primary), 0.8), rgba(var(--v-theme-primary), 0.2));
block-size: 3px;
content: '';
inline-size: 40px;
margin-block: 0.5rem 0;
margin-inline: auto;
}
.error-description {
color: rgba(var(--v-theme-on-surface), 0.75);
font-size: 1.1rem;
line-height: 1.6;
margin-block-end: 1.5rem;
margin-inline: auto;
max-inline-size: 80%;
}
.actions-container {
margin-block-start: 1.5rem;
}
.actions-container :deep(.v-btn) {
transform: translateY(0);
transition: transform 0.2s ease;
}
.actions-container :deep(.v-btn:hover) {
transform: translateY(-2px);
}
/* 响应式调整 */
@media (width <= 600px) {
.no-data-container {
padding-block: 2rem;
padding-inline: 1rem;
}
.icon-wrapper {
block-size: 80px;
inline-size: 80px;
margin-block-end: 1.5rem;
}
.icon-container {
block-size: 70px;
inline-size: 70px;
}
.icon-glow {
block-size: 70px;
inline-size: 70px;
}
.pulse-ring,
.pulse-ring::before {
block-size: 80px;
inline-size: 80px;
}
.error-title {
font-size: 1.4rem;
}
.error-description {
font-size: 0.95rem;
max-inline-size: 90%;
}
}
</style>

View File

@@ -37,7 +37,7 @@ const getImgUrl = computed(() => {
:width="props.width"
class="ring-gray-500"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'ring-1': imageLoaded,
}"
@click="goPlay"
@@ -73,9 +73,3 @@ const getImgUrl = computed(() => {
</template>
</VHover>
</template>
<style lang="scss">
.text-shadow {
text-shadow: 1px 1px #777;
}
</style>

View File

@@ -0,0 +1,193 @@
<script lang="ts" setup>
import { CustomRule } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import filter_svg from '@images/svg/filter.svg'
import { cloneDeep } from 'lodash-es'
import { innerFilterRules } from '@/api/constants'
// 输入参数
const props = defineProps({
// 单条规则
rule: {
type: Object as PropType<CustomRule>,
required: true,
},
// 所有规则
rules: {
type: Array as PropType<CustomRule[]>,
required: true,
},
})
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 规则详情弹窗
const ruleInfoDialog = ref(false)
// 规则详情
const ruleInfo = ref<CustomRule>({
id: '',
name: '',
include: '',
exclude: '',
size_range: '',
seeders: '',
publish_time: '',
})
// 打开详情弹窗
function openRuleInfoDialog() {
// 深复制
ruleInfo.value = cloneDeep(props.rule)
ruleInfoDialog.value = true
}
// 保存详情数据
function saveRuleInfo() {
// 有空值
if (!ruleInfo.value.id || !ruleInfo.value.name) {
if (!ruleInfo.value.id && !ruleInfo.value.name) {
$toast.error('规则ID和规则名称不能为空')
}
return
}
// 检查ID是否在内置的规则中
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
$toast.error('当前规则ID已被内置规则占用')
return
}
// 检查规则名称是否在内置的规则中
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
$toast.error('当前规则名称已被内置规则占用')
return
}
// ID已存在
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
$toast.error(`规则ID【${ruleInfo.value.id}】已存在`)
return
}
// 规则名称已存在
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
$toast.error(`规则名称【${ruleInfo.value.name}】已存在`)
return
}
// 保存数据
ruleInfoDialog.value = false
emit('change', ruleInfo.value, props.rule.id)
emit('done')
}
// 按钮点击
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="openRuleInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.rule.name }}</h5>
<div class="text-body-1 mb-3">{{ props.rule.id }}</div>
</div>
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.rule.id} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.id"
label="规则ID"
placeholder="必填不可与其他规则ID重名"
hint="字符与数字组合,不能含空格"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.name"
label="规则名称"
placeholder="必填;不可与其他规则名称重名"
hint="使用别名便于区分规则"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.include"
placeholder="关键字/正则表达式"
label="包含"
hint="必须包含的关键字或正则表达式,多个值使用|分隔"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.exclude"
placeholder="关键字/正则表达式"
label="排除"
hint="不能包含的关键字或正则表达式,多个值使用|分隔"
persistent-hint
active
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.size_range"
placeholder="0/1-10"
label="资源体积MB"
hint="最小资源文件体积或体积范围(剧集计算单集平均大小)"
persistent-hint
active
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.seeders"
placeholder="0/1-10"
label="做种人数"
hint="最小做种人数或做种人数范围"
persistent-hint
active
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.publish_time"
placeholder="0/1-10"
label="发布时间(分钟)"
hint="距离资源发布的最小时间间隔或时间区间"
persistent-hint
active
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,12 +1,14 @@
<script lang="ts" setup>
import type { MediaDirectory } from '@/api/types'
import { VTextField } from 'vuetify/lib/components/index.mjs'
import type { TransferDirectoryConf } from '@/api/types'
import api from '@/api'
import { nextTick } from 'vue'
import { storageOptions } from '@/api/constants'
// 输入参数
const props = defineProps({
type: String, // download/library
directory: {
type: Object as PropType<MediaDirectory>,
type: Object as PropType<TransferDirectoryConf>,
required: true, // 必填参数
},
categories: {
@@ -17,8 +19,8 @@ const props = defineProps({
height: String,
})
// 路径
const path = ref<string>('')
// 卡版是否折叠状态
const isCollapsed = ref(true)
// 类型下拉字典
const typeItems = [
@@ -27,6 +29,98 @@ const typeItems = [
{ title: '电视剧', value: '电视剧' },
]
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
const resourceStorageOptions = computed(() => {
return storageOptions.filter(item => !item.remote || props.directory.monitor_type !== 'downloader')
})
// 自动整理方式下拉字典
const transferSourceItems = [
{ title: '不整理', value: '' },
{ title: '下载器监控', value: 'downloader' },
{ title: '目录监控', value: 'monitor' },
{ title: '手动整理', value: 'manual' },
]
// 监控模式下拉字典
const MonitorModeItems = [
{ title: '性能模式', value: 'fast' },
{ title: '兼容模式', value: 'compatibility' },
]
// 整理方式下拉字典
const transferTypeItems = ref<{ title: string; value: string }[]>([])
// 调用API查询支持的整理方式
async function loadTransferTypeItems() {
// 参数不全时不查询
if (!props.directory.library_storage || !props.directory.storage) return
try {
// 下载器储存整理方法
const storage_res = await api.get(`storage/transtype/${props.directory.storage}`)
const storage_transtype = (storage_res as any).transtype
// 媒体库储存整理方法
const library_storage_res = await api.get(`storage/transtype/${props.directory.library_storage}`)
const library_storage_transtype = (library_storage_res as any).transtype
// 为空终止
if (!library_storage_transtype || !storage_transtype) return
// 取并集
const transtype: { [key: string]: string } = {}
Object.keys(storage_transtype).forEach(key => {
if (key in library_storage_transtype) {
transtype[key] = storage_transtype[key]
}
})
// 非空时设置整理方式下拉字典
if (transtype && Object.keys(transtype).length > 0) {
transferTypeItems.value = Object.keys(transtype).map(key => ({
title: transtype[key],
value: key,
}))
// 如果整理方式下拉字典不为空且当前值不在新的transferTypeItems里则设置整理方式为第一个
if (
transferTypeItems.value.length > 0 &&
!transferTypeItems.value.find(item => item.value === props.directory.transfer_type)
) {
nextTick(() => {
props.directory.transfer_type = transferTypeItems.value[0].value
})
}
// 如果整理方式下拉字典为空,清空整理方式
if (transferTypeItems.value.length === 0) {
props.directory.transfer_type = ''
}
} else {
// 无可用整理方式,清除已选值
transferTypeItems.value = []
props.directory.transfer_type = ''
}
} catch (e) {
console.log(e)
}
}
// 整理方式无数据提示
const computedNoDataText = computed(() => {
if (!props.directory.library_storage && !props.directory.storage) {
return '请选择储存'
} else if (!props.directory.library_storage) {
return '请选择媒体库储存'
} else if (!props.directory.storage) {
return '请选择下载器储存'
} else {
return '选择的存储类型没有支持的整理方式'
}
})
// 覆盖模式下拉字典
const overwriteModeItems = [
{ title: '从不', value: 'never' },
{ title: '总是', value: 'always' },
{ title: '按文件大小', value: 'size' },
{ title: '仅保留最新版本', value: 'latest' },
]
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed', 'update:modelValue'])
@@ -35,18 +129,48 @@ 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 ?? ''])
})
// 监听 资源存储与媒体库储存 变化,重新加载整理方式下拉字典
watch(
[() => props.directory.library_storage, () => props.directory.storage],
([newLibraryStorage, newStorage], [oldLibraryStorage, oldStorage]) => {
if (newLibraryStorage !== oldLibraryStorage || newStorage !== oldStorage) {
loadTransferTypeItems()
}
},
{ immediate: true },
)
// 媒体类别和类型变更非空时将按类型分类和按类别分类置为false
watch(
[() => props.directory.media_type, () => props.directory.media_category],
([newMediaType, newMediaCategory], [oldMediaType, oldMediaCategory]) => {
if (newMediaType && newMediaType !== oldMediaType) {
props.directory.download_type_folder = false
props.directory.library_type_folder = false
}
if (newMediaCategory && newMediaCategory !== oldMediaCategory) {
props.directory.download_category_folder = false
props.directory.library_category_folder = false
}
},
)
// 监听monitor_type变化如果为downloader则设置为本地
watch(
() => props.directory.monitor_type,
newMonitorType => {
if (newMonitorType === 'downloader') {
props.directory.storage = 'local'
}
},
)
</script>
<template>
@@ -65,40 +189,124 @@ const getCategories = computed(() => {
</IconBtn>
</span>
</VCardItem>
<VCardText>
<VCardText v-if="!isCollapsed">
<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">
<VCol cols="6">
<VSelect
v-model="props.directory.media_type"
variant="underlined"
:items="typeItems"
label="媒体类型"
@update:modelValue="props.directory.category = ''"
@update:modelValue="props.directory.media_category = ''"
/>
</VCol>
<VCol>
<VSelect v-model="props.directory.category" variant="underlined" :items="getCategories" label="媒体类别" />
<VCol cols="6">
<VSelect
v-model="props.directory.media_category"
variant="underlined"
:items="getCategories"
label="媒体类别"
/>
</VCol>
<VCol cols="4">
<VSelect
v-model="props.directory.storage"
variant="underlined"
:items="resourceStorageOptions"
label="资源存储"
/>
</VCol>
<VCol cols="8">
<VPathField
v-model="props.directory.download_path"
:storage="props.directory.storage"
variant="underlined"
label="资源目录"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
<VSwitch v-model="props.directory.download_type_folder" label="按类型分类"></VSwitch>
</VCol>
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
<VSwitch v-model="props.directory.download_category_folder" label="按类别分类"></VSwitch>
</VCol>
</VRow>
<VDivider v-if="$props.directory.monitor_type" class="my-3 bg-primary" />
<VRow>
<VCol v-if="!props.directory.category || props.directory.category === ''">
<VSwitch v-model="props.directory.auto_category" label="自动分类"></VSwitch>
<VCol>
<VSelect
v-model="props.directory.monitor_type"
variant="underlined"
:items="transferSourceItems"
label="自动整理"
/>
</VCol>
<VCol v-if="type === 'library'">
<VSwitch v-model="props.directory.scrape" label="刮削元数据"></VSwitch>
</VRow>
<VRow v-if="$props.directory.monitor_type">
<VCol cols="12" v-if="$props.directory.monitor_type == 'monitor'">
<VSelect
v-model="props.directory.monitor_mode"
variant="underlined"
:items="MonitorModeItems"
label="监控模式"
/>
</VCol>
<VCol cols="4">
<VSelect
v-model="props.directory.library_storage"
variant="underlined"
:items="storageOptions"
label="媒体库存储"
/>
</VCol>
<VCol cols="8">
<VPathField
v-model="props.directory.library_path"
:storage="props.directory.library_storage"
variant="underlined"
label="媒体库目录"
/>
</VCol>
<VCol cols="4">
<VSelect
v-model="props.directory.transfer_type"
variant="underlined"
:items="transferTypeItems"
label="整理方式"
:no-data-text="computedNoDataText"
/>
</VCol>
<VCol cols="8">
<VSelect
v-model="props.directory.overwrite_mode"
variant="underlined"
:items="overwriteModeItems"
label="覆盖模式"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
<VSwitch v-model="props.directory.library_type_folder" label="按类型分类"></VSwitch>
</VCol>
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
<VSwitch v-model="props.directory.library_category_folder" label="按类别分类"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.renaming" label="智能重命名"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.scraping" label="刮削元数据"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.notify" label="发送通知"></VSwitch>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="text-center py-0">
<VSpacer />
<VBtn :icon="isCollapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'" @click.stop="isCollapsed = !isCollapsed" />
<VSpacer />
</VCardActions>
</VCard>
</template>

View File

@@ -0,0 +1,324 @@
<script setup lang="ts">
import api from '@/api'
import { formatFileSize } from '@/@core/utils/formatters'
import { DownloaderConf } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import type { DownloaderInfo } from '@/api/types'
import qbittorrent_image from '@images/logos/qbittorrent.png'
import transmission_image from '@images/logos/transmission.png'
import { cloneDeep } from 'lodash-es'
// 定义输入
const props = defineProps({
// 单个下载器
downloader: {
type: Object as PropType<DownloaderConf>,
required: true,
},
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
// 所有下载器
downloaders: {
type: Array as PropType<DownloaderConf[]>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done', 'change'])
// 提示框
const $toast = useToast()
// timeout定时器
let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 上传速率
const upload_rate = ref(0)
// 下载速度
const download_rate = ref(0)
// 下载器详情弹窗
const downloaderInfoDialog = ref(false)
// 下载器详情
const downloaderInfo = ref<DownloaderConf>({
name: '',
type: '',
default: false,
enabled: false,
config: {},
})
// 调用API查询下载器数据
async function loadDownloaderInfo() {
if (!props.allowRefresh) {
return
}
try {
const res: DownloaderInfo = await api.get('dashboard/downloader', {
params: {
name: props.downloader.name,
},
})
if (res) {
upload_rate.value = res.upload_speed
download_rate.value = res.download_speed
// 定时查询
clearTimeout(timeoutTimer)
if (props.downloader.enabled) {
timeoutTimer = setTimeout(loadDownloaderInfo, 3000)
}
}
} catch (e) {
console.log(e)
}
}
// 打开详情弹窗
function openDownloaderInfoDialog() {
// 深复制
downloaderInfo.value = cloneDeep(props.downloader)
downloaderInfoDialog.value = true
}
// 保存详情数据
function saveDownloaderInfo() {
// 为空不保存,跳出警告框
if (!downloaderInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
return
}
// 重名判断
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
$toast.error(`${downloaderInfo.value.name}】已存在,请替换为其他名称`)
return
}
// 默认下载器去重
if (downloaderInfo.value.default) {
props.downloaders.forEach(item => {
if (item.default && item !== props.downloader) {
item.default = false
$toast.info(`存在默认下载器【${item.name}】,已替换成【${downloaderInfo.value.name}`)
}
})
}
// 执行保存
downloaderInfoDialog.value = false
emit('change', downloaderInfo.value, props.downloader.name)
emit('done')
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.downloader.type) {
case 'qbittorrent':
return qbittorrent_image
case 'transmission':
return transmission_image
default:
return qbittorrent_image
}
})
// 按钮点击
function onClose() {
emit('close')
}
onMounted(async () => {
if (props.downloader.enabled) {
await loadDownloaderInfo()
}
})
onUnmounted(() => {
if (timeoutTimer) clearTimeout(timeoutTimer)
})
</script>
<template>
<div>
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
variant="tonal"
@click="openDownloaderInfoDialog"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<DialogCloseBtn @click="onClose" />
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="flex justify-space-between align-center gap-4">
<div class="align-self-start flex-1">
<div class="flex items-center">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
inline
color="success"
class="me-1"
/>
<span class="text-h6">{{ downloader.name }}</span>
</div>
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
<span class="me-2">{{ `${formatFileSize(upload_rate, 1)}/s ` }}</span>
<span>{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
</div>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</div>
</VCardText>
</VCard>
</VHover>
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" label="启用下载器" />
</VCol>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.default" label="默认下载器" :disabled="!downloaderInfo.enabled" />
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="下载器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
hint="登录使用的用户名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
label="自动分类管理"
hint="由下载器自动管理分类和下载目录"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
label="顺序下载"
hint="按顺序依次下载文件"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
label="强制继续"
hint="强制继续、强制上传模式"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
label="优先首尾文件"
hint="优先下载首尾文件块"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="下载器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
hint="登录使用的用户名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
persistent-hint
active
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
const props = defineProps({
@@ -17,16 +18,21 @@ function getPercentage() {
// 速度
function getSpeedText() {
return `${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${props.info?.left_time}`
return `${formatFileSize(props.info?.size || 0)} ${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${
props.info?.left_time
}`
}
// 下载状态
const isDownloading = ref(props.info?.state === 'downloading')
// 监听props.info?.state的变化
watch(() => props.info?.state, (newValue) => {
isDownloading.value = newValue === 'downloading'
})
watch(
() => props.info?.state,
newValue => {
isDownloading.value = newValue === 'downloading'
},
)
// 图片是否加载完成
const imageLoaded = ref(false)
@@ -45,14 +51,10 @@ function getTextClass() {
async function toggleDownload() {
const operation = isDownloading.value ? 'stop' : 'start'
try {
const result: { [key: string]: any } = await api.get(
`download/${operation}/${props.info?.hash}`,
)
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`)
if (result.success)
isDownloading.value = !isDownloading.value
}
catch (error) {
if (result.success) isDownloading.value = !isDownloading.value
} catch (error) {
console.error(error)
}
}
@@ -62,67 +64,42 @@ async function deleteDownload() {
try {
await api.delete(`download/${props.info?.hash}`)
cardState.value = false
}
catch (error) {
} catch (error) {
console.error(error)
}
}
</script>
<template>
<VCard
v-if="cardState"
:key="props.info?.hash"
>
<VCard v-if="cardState" :key="props.info?.hash">
<template #image>
<VImg
:src="props.info?.media.image"
aspect-ratio="2/3"
cover
class="brightness-50"
@load="imageLoadHandler"
/>
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover class="brightness-50" @load="imageLoadHandler" />
</template>
<VCardTitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
<VCardTitle class="break-words whitespace-normal" :class="getTextClass()">
{{ props.info?.media.title || props.info?.name }}
{{ props.info?.media.episode ? `${props.info?.media.season} ${props.info?.media.episode}` : props.info?.season_episode }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
</VCardTitle>
<VCardSubtitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()">
{{ props.info?.title }}
</VCardSubtitle>
<VCardText
class="text-subtitle-1 pt-3 pb-1"
:class="getTextClass()"
>
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()">
{{ getSpeedText() }}
</VCardText>
<VCardText
v-if="getPercentage() > 0"
:class="getTextClass()"
>
<VCardText v-if="getPercentage() > 0" :class="getTextClass()">
<VProgressLinear :model-value="getPercentage()" />
</VCardText>
<VCardActions class="justify-space-between">
<VBtn
:icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`"
@click="toggleDownload"
/>
<VBtn
color="error"
icon="mdi-trash-can-outline"
@click="deleteDownload"
/>
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
</VCard>
</template>

View File

@@ -1,11 +1,13 @@
<script lang="ts" setup>
import { innerFilterRules } from '@/api/constants'
import { CustomRule } from '@/api/types'
import { cloneDeep } from 'lodash-es'
// 输入参数
const props = defineProps({
pri: String,
maxpri: String,
rules: Array as PropType<string[]>,
width: String,
height: String,
custom_rules: Array as PropType<CustomRule[]>,
})
// 定义触发的自定义事件
@@ -22,50 +24,24 @@ function filtersChanged(value: string[]) {
}
// 过滤规则下拉框
const selectFilterOptions = ref<{ [key: string]: string }[]>([
{ title: '特效字幕', value: ' SPECSUB ' },
{ title: '中文字幕', value: ' CNSUB ' },
{ title: '国语配音', value: ' CNVOI ' },
{ title: '官种', value: ' GZ ' },
{ title: '排除: 国语配音', value: ' !CNVOI ' },
{ title: '粤语配音', value: ' HKVOI ' },
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
{ title: '促销: 免费', value: ' FREE ' },
{ title: '分辨率: 4K', value: ' 4K ' },
{ title: '分辨率: 1080P', value: ' 1080P ' },
{ title: '分辨率: 720P', value: ' 720P ' },
{ title: '排除: 720P', value: ' !720P ' },
{ title: '质量: 蓝光原盘', value: ' BLU ' },
{ title: '排除: 蓝光原盘', value: ' !BLU ' },
{ title: '质量: BLURAY', value: ' BLURAY ' },
{ title: '排除: BLURAY', value: ' !BLURAY ' },
{ title: '质量: UHD', value: ' UHD ' },
{ title: '排除: UHD', value: ' !UHD ' },
{ title: '质量: REMUX', value: ' REMUX ' },
{ title: '排除: REMUX', value: ' !REMUX ' },
{ title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
{ title: '质量: 60fps', value: ' 60FPS ' },
{ title: '排除: 60fps', value: ' !60FPS ' },
{ title: '编码: H265', value: ' H265 ' },
{ title: '排除: H265', value: ' !H265 ' },
{ title: '编码: H264', value: ' H264 ' },
{ title: '排除: H264', value: ' !H264 ' },
{ title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
{ title: '效果: HDR', value: ' HDR ' },
{ title: '排除: HDR', value: ' !HDR ' },
{ title: '效果: SDR', value: ' SDR ' },
{ title: '排除: SDR', value: ' !SDR ' },
{ title: '效果: 3D', value: ' 3D ' },
{ title: '排除: 3D', value: ' !3D ' },
])
const selectFilterOptions = ref<{ [key: string]: string }[]>([])
onMounted(() => {
selectFilterOptions.value = cloneDeep(innerFilterRules)
if (props.custom_rules) {
console.log(props.custom_rules)
props.custom_rules.map(rule => {
selectFilterOptions.value.push({
title: rule.name,
value: rule.id,
})
})
}
})
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height">
<VCard variant="tonal">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -83,6 +59,7 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
chips
label=""
multiple
clearable
@update:modelValue="filtersChanged"
/>
</VCol>

View File

@@ -0,0 +1,307 @@
<script lang="ts" setup>
import draggable from 'vuedraggable'
import { copyToClipboard } from '@/@core/utils/navigator'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import { useToast } from 'vue-toast-notification'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
import filter_group_svg from '@images/svg/filter-group.svg'
import { cloneDeep } from 'lodash-es'
// 输入参数
const props = defineProps({
// 单个规则组
group: {
type: Object as PropType<FilterRuleGroup>,
required: true,
},
// 所有规则组
groups: {
type: Array as PropType<FilterRuleGroup[]>,
required: true,
},
// 媒体类型字典
categories: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
// 自定义规则列表
custom_rules: Array as PropType<CustomRule[]>,
})
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string
// 已选规则
rules: string[]
}
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 规则详情弹窗
const groupInfoDialog = ref(false)
// 规则详情
const groupInfo = ref<FilterRuleGroup>({
name: props.group?.name ?? '',
rule_string: props.group?.rule_string ?? '',
media_type: props.group?.media_type ?? '',
category: props.group?.category ?? '',
})
// 媒体类型字典
const mediaTypeItems = [
{ title: '通用', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
]
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
return default_value
}
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
})
// 规则组规则卡片列表
const filterRuleCards = ref<FilterCard[]>([])
// 规则组类型,仅用于导入判断
const filterRuleCardsType = ref<FilterCard>({
pri: '',
rules: [],
})
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入代码类型
const importCodeType = ref('')
// 更新规则卡片的值
function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterRuleCards.value.find(card => card.pri === pri)
if (card && Array.isArray(rules)) card.rules = rules
}
// 移除卡片
function filterCardClose(pri: string) {
filterRuleCards.value = filterRuleCards.value
.filter(card => card.pri !== pri)
.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
}
// 分享规则
async function shareRules() {
if (filterRuleCards.value.length === 0) return
const value = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
try {
let success
success = copyToClipboard(value)
if (await success) $toast.success('优先级规则已复制到剪贴板!')
else $toast.error('优先级规则复制失败:可能是浏览器不支持或被用户阻止!')
} catch (error) {
$toast.error('优先级规则复制失败!')
console.error(error)
}
}
// 导入规则
async function importRules(ruleType: string) {
importCodeType.value = ruleType
importCodeDialog.value = true
}
// 保存导入的代码,直接覆盖原有值
function saveCodeString(type: string, code: any) {
try {
code = code.value
if (type === 'priority') {
// 解析值
if (!code) return
// 首尾增加空格
if (!code.startsWith(' ')) code = ` ${code}`
if (!code.endsWith(' ')) code = `${code} `
const groups = code.split('>')
filterRuleCards.value = groups.map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
} catch (error) {
$toast.error('导入失败!')
console.error(error)
}
}
// 增加卡片
function addFilterCard() {
const pri = (filterRuleCards.value.length + 1).toString()
const newCard: FilterCard = { pri, rules: [] }
filterRuleCards.value.push(newCard)
}
// 根据列表的拖动顺序更新优先级
function dragOrderEnd() {
filterRuleCards.value.forEach((card, index) => {
card.pri = (index + 1).toString()
})
}
// 打开详情弹窗
function opengroupInfoDialog() {
groupInfo.value = cloneDeep(props.group)
if (props.group.rule_string) {
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
groupInfoDialog.value = true
}
// 保存详情数据
function saveGroupInfo() {
if (!groupInfo.value.name.trim()) {
$toast.error('规则组名称不能为空')
return
}
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
$toast.error(`规则组名称【${groupInfo.value.name}】已存在,请替换`)
return
}
groupInfoDialog.value = false
groupInfo.value.rule_string = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
emit('change', groupInfo.value, props.group.name)
emit('done')
}
// 按钮点击
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="opengroupInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
<div class="text-body-1 mb-3">
<span v-if="!props.group.category">{{ props.group.media_type || '通用' }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
</div>
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
<VRow class="mt-1">
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
label="规则组名称"
placeholder="必填;不可与其他规则组重名"
hint="自定义规则组名称"
persistent-hint
active
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
v-model="groupInfo.media_type"
label="适用媒体类型"
:items="mediaTypeItems"
hint="选择规则组适用的媒体类型"
persistent-hint
active
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
v-model="groupInfo.category"
:items="getCategories"
label="适用媒体类别"
hint="选择规则组适用的媒体类别"
persistent-hint
active
/>
</VCol>
</VRow>
</VCardItem>
<VCardText>
<draggable
v-model="filterRuleCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="filterRuleCards.length.toString()"
:rules="element.rules"
:custom_rules="props.custom_rules"
@changed="updateFilterCardValue"
@close="filterCardClose(element.pri)"
/>
</template>
</draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">请添加或导入规则</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" variant="tonal" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" variant="tonal" @click="importRules('priority')">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" variant="tonal" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
title="导入规则优先级"
:dataType="importCodeType"
@close="importCodeDialog = false"
@save="saveCodeString"
/>
</div>
</template>

View File

@@ -3,6 +3,7 @@ import type { MediaServerLibrary } from '@/api/types'
import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png'
import trimemedia from '@images/logos/trimemedia.png'
// 输入参数
const props = defineProps({
@@ -38,6 +39,7 @@ function getDefaultImage() {
if (props.media?.server === 'plex') return plex
else if (props.media?.server === 'emby') return emby
else if (props.media?.server === 'jellyfin') return jellyfin
else if (props.media?.server === 'trimemedia') return trimemedia
else return plex
}
@@ -91,7 +93,17 @@ async function drawImages(imageList: string[]) {
const img = new Image()
img.setAttribute('crossorigin', 'anonymous')
img.src = imgSrc
await new Promise(resolve => (img.onload = resolve))
try {
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error(`Failed to load image: ${imgSrc}`))
})
} catch (error) {
console.error(error)
ctx.fillStyle = '#e5e7eb'
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), MARGIN_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT)
return
}
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
const y = MARGIN_HEIGHT
@@ -146,7 +158,7 @@ onMounted(async () => {
:height="props.height"
:width="props.width"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
}"
@click="goPlay"
>
@@ -171,9 +183,3 @@ onMounted(async () => {
</template>
</VHover>
</template>
<style lang="scss">
.text-shadow {
text-shadow: 1px 1px #777;
}
</style>

View File

@@ -1,16 +1,18 @@
<script lang="ts" setup>
import type { PropType, Ref } from 'vue'
import { useToast } from 'vue-toast-notification'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import { formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
import router from '@/router'
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'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
import router, { registerAbortController } from '@/router'
import { useUserStore } from '@/stores'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
// 输入参数
const props = defineProps({
@@ -19,7 +21,11 @@ const props = defineProps({
height: String,
})
const store = useStore()
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 用户 Store
const userStore = useUserStore()
// 提示框
const $toast = useToast()
@@ -30,18 +36,12 @@ const isImageLoaded = ref(false)
// 图片加载失败
const imageLoadError = ref(false)
// TMDB识别标志
const tmdbFlag = ref(true)
// 当前订阅状态
const isSubscribed = ref(false)
// 本地存在状态
const isExists = ref(false)
// 各季缺失状态0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
const seasonsNotExisted = ref<{ [key: number]: number }>({})
// 订阅季弹窗
const subscribeSeasonDialog = ref(false)
@@ -51,11 +51,8 @@ const subscribeEditDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
// 季详情
const seasonInfos = ref<TmdbSeason[]>([])
// 选中的订阅季
const seasonsSelected = ref<TmdbSeason[]>([])
const seasonsSelected = ref<MediaSeason[]>([])
// 来源角标字典
const sourceIconDict: { [key: string]: any } = {
@@ -64,19 +61,55 @@ const sourceIconDict: { [key: string]: any } = {
bangumi: bangumiImage,
}
// 绑定MediaCard元素
const mediaCardRef = ref<HTMLElement | null>(null)
// 创建Intersection Observer实例
const observer = ref<IntersectionObserver | null>(null)
// 所有站点
const allSites = ref<Site[]>([])
// 选中的站点
const selectedSites = ref<number[]>([])
// 搜索菜单显示状态
const searchMenuShow = ref(false)
// 选择站点对话框
const chooseSiteDialog = ref(false)
// 选择的剧集组
const episodeGroup = ref('')
// 查询所有站点
async function querySites() {
try {
const data: Site[] = await api.get('site/')
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active)
} catch (error) {
console.log(error)
}
}
// 查询用户选中的站点
async function querySelectedSites() {
try {
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
selectedSites.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 获得mediaid
function getMediaId() {
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
else return `bangumi:${props.media?.bangumi_id}`
}
// 订阅弹窗选择的多季
function subscribeSeasons() {
subscribeSeasonDialog.value = false
seasonsSelected.value.forEach(season => {
addSubscribe(season.season_number)
})
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
}
// 角标颜色
@@ -87,33 +120,11 @@ function getChipColor(type: string) {
}
// 添加订阅处理
async function handleAddSubscribe() {
if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
// TMDB电视剧
// 查询TMDB所有季信息
await getMediaSeasons()
if (!seasonInfos.value) {
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
return
}
// 检查各季的缺失状态
await checkSeasonsNotExists()
if (!tmdbFlag.value) return
if (seasonInfos.value.length === 1) {
// 添加订阅
addSubscribe(1)
} else {
// 弹出季选择列表,支持多选
seasonsSelected.value = []
subscribeSeasonDialog.value = true
}
} else if (props.media?.type === '电视剧') {
// 豆瓣电视剧,只会有一季
const season = props.media?.season ?? 1
// 添加订阅
addSubscribe(season)
if (props.media?.type === '电视剧') {
// 弹出季选择列表,支持多选
seasonsSelected.value = []
subscribeSeasonDialog.value = true
} else {
// 电影
addSubscribe()
@@ -121,15 +132,12 @@ async function handleAddSubscribe() {
}
// 调用API添加订阅电视剧的话需要指定季
async function addSubscribe(season = 0) {
async function addSubscribe(season: number = 0, best_version: number = 0) {
// 开始处理
startNProgress()
try {
// 是否洗版
let best_version = isExists.value ? 1 : 0
if (season && props.media?.tmdb_id)
// 全部存在时洗版
best_version = !seasonsNotExisted.value[season] ? 1 : 0
if (!best_version && props.media?.type == '电影') best_version = isExists.value ? 1 : 0
// 请求API
const result: { [key: string]: any } = await api.post('subscribe/', {
name: props.media?.title,
@@ -138,8 +146,10 @@ async function addSubscribe(season = 0) {
tmdbid: props.media?.tmdb_id,
doubanid: props.media?.douban_id,
bangumiid: props.media?.bangumi_id,
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
season,
best_version,
episode_group: episodeGroup.value,
})
// 订阅状态
@@ -216,6 +226,9 @@ async function handleCheckSubscribe() {
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
@@ -224,6 +237,7 @@ async function handleCheckExists() {
season: props.media?.season,
mtype: props.media?.type,
},
signal,
})
if (result.success) isExists.value = true
@@ -235,13 +249,16 @@ async function handleCheckExists() {
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) {
try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season,
title: props.media?.title,
},
signal,
})
return result.id || null
@@ -252,51 +269,15 @@ async function checkSubscribe(season = 0) {
return null
}
// 检查所有季的缺失状态(数据库)
async function checkSeasonsNotExists() {
// 开始处理
startNProgress()
try {
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
if (result) {
result.forEach(item => {
// 0-已入库 1-部分缺失 2-全部缺失
let state = 0
if (item.episodes.length === 0) state = 2
else if (item.episodes.length < item.total_episode) state = 1
seasonsNotExisted.value[item.season] = state
})
}
} catch (error) {
$toast.error(`${props.media?.title}无法识别TMDB媒体信息`)
tmdbFlag.value = false
} finally {
// 处理完成
doneNProgress()
}
}
// 查询TMDB的所有季信息
async function getMediaSeasons() {
try {
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
} catch (error) {
console.error(error)
}
}
// 查询订阅弹窗规则
async function queryDefaultSubscribeConfig() {
// 非管理员不显示
if (!store.state.auth.superUser) return false
if (!userStore.superUser) return false
try {
let subscribe_config_url = ''
if (props.media?.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) return result.data.value.show_edit_dialog
} catch (error) {
console.log(error)
@@ -310,39 +291,55 @@ function handleSubscribe() {
else handleAddSubscribe()
}
// 计算存在状态的颜色
function getExistColor(season: number) {
const state = seasonsNotExisted.value[season]
if (!state) return 'success'
if (state === 1) return 'warning'
else if (state === 2) return 'error'
else return 'success'
}
// 计算存在状态的文本
function getExistText(season: number) {
const state = seasonsNotExisted.value[season]
if (!state) return '已入库'
if (state === 1) return '部分缺失'
else if (state === 2) return '缺失'
else return '已入库'
// 订阅多季
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
subscribeSeasonDialog.value = false
episodeGroup.value = groudId
seasonsSelected.value = seasons || []
seasonsSelected.value.forEach(season => {
let best_version = 0
if (season && props.media?.tmdb_id)
// 全部存在时洗版
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
addSubscribe(season.season_number, best_version)
})
}
// 打开详情页
function goMediaDetail(isHovering = false) {
if (isHovering) {
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
type: props.media?.type,
},
})
if (props.media?.collection_id) {
// 跳转到合集列表
router.push({
path: `/browse/tmdb/collection/${props.media?.collection_id}`,
query: {
title: props.media?.title,
},
})
} else {
// 跳转到媒体详情页
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
title: props.media?.title,
year: props.media?.year,
type: props.media?.type,
},
})
}
}
}
// 点击搜索
async function clickSearch() {
if (allSites.value?.length == 0) {
querySites()
querySelectedSites()
}
chooseSiteDialog.value = true
}
// 开始搜索
function handleSearch() {
router.push({
@@ -351,185 +348,172 @@ function handleSearch() {
keyword: getMediaId(),
type: props.media?.type,
area: 'title',
title: props.media?.title,
year: props.media?.year,
season: props.media?.season,
sites: selectedSites.value.join(','),
},
})
}
// 装载时检查是否已订阅
onBeforeMount(() => {
// 搜索多站点
function searchSites(sites: number[]) {
chooseSiteDialog.value = false
selectedSites.value = sites
handleSearch()
}
// 懒加载检查
function handleCheckLazy() {
if (props.media?.collection_id) {
return
}
handleCheckSubscribe()
handleCheckExists()
}
// 在元素进入视窗时触发懒加载函数
function setupIntersectionObserver() {
if (mediaCardRef.value) {
observer.value = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 只要MediaCard进入视窗就调用懒加载的操作
handleCheckLazy()
// 加载后销毁观察者实例
observer.value?.disconnect()
observer.value = null
}
})
},
{ threshold: 0.1 },
)
observer.value.observe(mediaCardRef.value)
}
}
onMounted(() => {
setupIntersectionObserver()
})
onBeforeUnmount(() => {
observer.value?.disconnect()
observer.value = null
})
// 计算图片地址
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}douban/img?imgurl=${encodeURIComponent(url)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
})
// 拼装季图片地址
function getSeasonPoster(posterPath: string) {
if (!posterPath) return ''
return `https://image.tmdb.org/t/p/w500${posterPath}`
}
// 将yyyy-mm-dd转换为yyyy年mm月dd日
function formatAirDate(airDate: string) {
if (!airDate) return ''
const date = new Date(airDate.replaceAll(/-/g, '/'))
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
// 从yyyy-mm-dd中提取年份
function getYear(airDate: string) {
if (!airDate) return ''
const date = new Date(airDate.replaceAll(/-/g, '/'))
return date.getFullYear()
// 移除订阅
function onRemoveSubscribe() {
subscribeEditDialog.value = false
}
</script>
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500 rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering)"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
<div ref="mediaCardRef">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500 media-card"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering ?? false)"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError || searchMenuShow"
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
>
<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 v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
<div v-else class="flex align-center justify-between">
<IconBtn icon="mdi-magnify" color="white" @click.stop="clickSearch" />
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
</div>
</template>
</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>
</VCardText>
<!-- 类型角标 -->
<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"
>
{{ formatRating(props.media?.vote_average) }}
</VChip>
<!--来源图标-->
<VAvatar
size="24"
density="compact"
class="absolute bottom-1 right-1"
tile
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
>
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>
</VCard>
</div>
</template>
</VHover>
<!-- 订阅季弹窗 -->
<VBottomSheet v-if="subscribeSeasonDialog" v-model="subscribeSeasonDialog" inset scrollable>
<VCard class="rounded-t">
<DialogCloseBtn @click="subscribeSeasonDialog = false" />
<VCardItem>
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
<template #prepend>
<VImg
height="90"
width="60"
:src="getSeasonPoster(item.poster_path || '')"
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> {{ item.season_number }} </VListItemTitle>
<VListItemSubtitle class="mt-1 me-2">
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
<VIcon icon="mdi-star" /> {{ item.vote_average }}
</VChip>
{{ getYear(item.air_date || '') }} {{ item.episode_count }}
</VListItemSubtitle>
<VListItemSubtitle>
{{ media?.title }} {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播
</VListItemSubtitle>
<VListItemSubtitle>
<VChip v-if="seasonsNotExisted" class="mt-2" size="small" :color="getExistColor(item.season_number || 0)">
{{ getExistText(item.season_number || 0) }}
</VChip>
</VListItemSubtitle>
<template #append="{ isSelected }">
<VListItemAction start>
<VSwitch :model-value="isSelected" />
</VListItemAction>
</template>
</VListItem>
</VList>
</VCardText>
<div class="my-2 text-center">
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
</VBtn>
</div>
</VCard>
</VBottomSheet>
<subscribeSeasonDialog
v-if="subscribeSeasonDialog"
v-model="subscribeSeasonDialog"
:media="media"
@subscribe="subscribeSeasons"
@close="subscribeSeasonDialog = false"
/>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
@@ -537,17 +521,15 @@ function getYear(airDate: string) {
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="
() => {
subscribeEditDialog = false
handleCheckSubscribe()
}
"
@remove="onRemoveSubscribe"
/>
<!-- 站点选择对话框 -->
<SearchSiteDialog
v-if="chooseSiteDialog"
v-model="chooseSiteDialog"
:sites="allSites"
:selected="selectedSites"
@search="searchSites"
@close="chooseSiteDialog = false"
/>
</template>
<style lang="scss">
.on-hover img {
@apply brightness-50;
}
</style>

View File

@@ -4,7 +4,7 @@ import type { Context } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数
const props = defineProps({
defineProps({
context: Object as PropType<Context>,
})
@@ -45,8 +45,7 @@ function openTmdbPage(type: string, tmdbId: number) {
</template>
</VImg>
</div>
<div>
<div class="flex-grow">
<VCardItem class="pb-1">
<VCardTitle class="text-center text-md-left">
{{ context?.media_info?.title || context?.meta_info?.name }}

View File

@@ -0,0 +1,399 @@
<script setup lang="ts">
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import emby_image from '@images/logos/emby.png'
import jellyfin_image from '@images/logos/jellyfin.png'
import plex_image from '@images/logos/plex.png'
import trimemedia_image from '@images/logos/trimemedia.png'
import api from '@/api'
import { cloneDeep } from 'lodash-es'
// 定义输入
const props = defineProps({
// 单个媒体服务器
mediaserver: {
type: Object as PropType<MediaServerConf>,
required: true,
},
// 所有媒体服务器
mediaservers: {
type: Array as PropType<MediaServerConf[]>,
required: true,
},
})
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done', 'change'])
// 媒体统计数据
const infoItems = ref([
{
avatar: 'mdi-movie-roll',
title: '电影',
amount: '0',
},
{
avatar: 'mdi-television-box',
title: '电视剧',
amount: '0',
},
{
avatar: 'mdi-account',
title: '用户',
amount: '0',
},
])
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: '全部',
value: 'all',
},
])
// 媒体服务器详情弹窗
const mediaServerInfoDialog = ref(false)
// 媒体服务器详情
const mediaServerInfo = ref<MediaServerConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 打开详情弹窗
function openMediaServerInfoDialog() {
loadLibrary(props.mediaserver.name)
// 深复制
mediaServerInfo.value = cloneDeep(props.mediaserver)
mediaServerInfoDialog.value = true
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
}
}
// 保存详情数据
function saveMediaServerInfo() {
// 为空不保存,跳出警告框
if (!mediaServerInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
return
}
// 重名判断
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
$toast.error(`${mediaServerInfo.value.name}】已存在,请替换为其他名称`)
return
}
// 执行保存
mediaServerInfoDialog.value = false
emit('change', mediaServerInfo.value, props.mediaserver.name)
emit('done')
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.mediaserver.type) {
case 'emby':
return emby_image
case 'jellyfin':
return jellyfin_image
case 'trimemedia':
return trimemedia_image
default:
return plex_image
}
})
// 按钮点击
function onClose() {
emit('close')
}
// 调用API加载媒体统计数据
async function loadMediaStatistic() {
try {
const res: MediaStatistic = await api.get('dashboard/statistic', {
params: {
name: props.mediaserver.name,
},
})
if (res) {
infoItems.value = [
{
avatar: 'mdi-movie-roll',
title: '电影',
amount: res.movie_count.toLocaleString(),
},
{
avatar: 'mdi-television-box',
title: '电视剧',
amount: res.tv_count.toLocaleString(),
},
{
avatar: 'mdi-account',
title: '用户',
amount: res.user_count.toLocaleString(),
},
]
}
} catch (e) {
console.log(e)
}
}
// 调用API查询媒体库
async function loadLibrary(server: string) {
try {
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
if (result && result.length > 0) {
librariesOptions.value = result.map(item => ({
title: item.name,
value: item.id?.toString(),
}))
} else {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: '全部',
value: 'all',
})
} catch (e) {
console.log(e)
}
}
onMounted(() => {
loadMediaStatistic()
})
</script>
<template>
<div>
<VCard variant="tonal" @click="openMediaServerInfoDialog">
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
<div class="text-sm mt-5 flex flex-wrap">
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
</span>
</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="mediaServerInfo.enabled" label="启用媒体服务器" />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'emby'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Emby设置->高级->API密钥中生成的密钥"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'jellyfin'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Jellyfin设置->高级->API密钥中生成的密钥"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'trimemedia'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
label="用户名"
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
label="密码"
active
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.token"
label="X-Plex-Token"
hint="浏览器F12->网络从Plex请求URL中获取的X-Plex-Token"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect
v-model="mediaServerInfo.sync_libraries"
label="同步媒体库"
:items="librariesOptions"
chips
multiple
clearable
hint="只有选中的媒体库才会被同步"
persistent-hint
active
append-inner-icon="mdi-refresh"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { isNullOrEmptyObject } from '@/@core/utils'
import type { Message } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters'
@@ -22,8 +23,7 @@ async function imageLoaded() {
// 链接打开新窗口
function openLink() {
if (props.message?.link)
window.open(props.message.link, '_blank')
if (props.message?.link) window.open(props.message.link, '_blank')
}
// 将note转换为json
@@ -31,9 +31,8 @@ function noteToJson() {
if (props.message?.note) {
try {
return JSON.parse(props.message.note)
}
catch (error) {
console.error(error)
} catch (error) {
return props.message.note
}
}
return {}
@@ -41,58 +40,53 @@ function noteToJson() {
// 将\n转换为html属性的换行符
function replaceNewLine(value: string) {
if (!value)
return ''
if (!value) return ''
return value.replace(/\n/g, '<br/>')
}
</script>
<template>
<VCard
:width="props.width"
:height="props.height"
variant="tonal"
@click="openLink"
>
<div
v-if="props.message?.image"
class="relative text-center card-cover-blurred"
>
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
<VImg
:src="props.message?.image"
aspect-ratio="4/3"
aspect-ratio="3/2"
cover
position="top"
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</div>
<VCardTitle v-if="props.message?.title" class="whitespace-break-spaces">
<div
v-if="
props.message?.title &&
!props.message?.text &&
!props.message?.image &&
isNullOrEmptyObject(props.message?.note) &&
props.message?.action === 0
"
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
>
<p class="mb-0">{{ props.message?.title }}</p>
</div>
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
{{ props.message?.title }}
</VCardTitle>
<VAlert
<div
v-if="props.message?.text && props.message?.action === 0"
variant="tonal"
type="success"
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
>
<template #prepend />
{{ props.message?.text }}
</VAlert>
<VCardText
v-if="props.message?.text && props.message?.action === 1"
v-html="replaceNewLine(props.message?.text)"
/>
<VCardText v-if="props.message?.note">
<p class="mb-0">{{ props.message?.text }}</p>
</div>
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
<VList>
<VListItem
v-for="(value, key) in noteToJson()"
:key="key"
two-line
>
<VListItemTitle v-if="value.title_year" class="font-bold">
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
{{ key + 1 }}. {{ value.title_year }}
</VListItemTitle>
<VListItemTitle v-if="value.enclosure" class="font-bold whitespace-break-spaces">
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
</VListItemTitle>
<VListItemSubtitle v-if="value.type">
@@ -104,9 +98,11 @@ function replaceNewLine(value: string) {
</VListItem>
</VList>
</VCardText>
<div class="text-end">
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
<span class="text-sm italic me-2">{{ formatDateDifference(props.message?.reg_time || props.message?.date || '') }}</span>
</div>
</VCard>
<div class="text-end">
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
<span class="text-sm italic me-2">{{
formatDateDifference(props.message?.reg_time || props.message?.date || '')
}}</span>
</div>
</template>

View File

@@ -0,0 +1,401 @@
<script setup lang="ts">
import { NotificationConf } from '@/api/types'
import wechat_image from '@images/logos/wechat.png'
import telegram_image from '@images/logos/telegram.webp'
import vocechat_image from '@images/logos/vocechat.png'
import synologychat_image from '@images/logos/synologychat.png'
import slack_image from '@images/logos/slack.webp'
import chrome_image from '@images/logos/chrome.png'
import { useToast } from 'vue-toast-notification'
import { cloneDeep } from 'lodash-es'
// 定义输入
const props = defineProps({
// 单个通知
notification: {
type: Object as PropType<NotificationConf>,
required: true,
},
// 所有通知
notifications: {
type: Array as PropType<NotificationConf[]>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 提示框
const $toast = useToast()
// 通知详情弹窗
const notificationInfoDialog = ref(false)
// 通知详情
const notificationInfo = ref<NotificationConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 各通知类型的名称字典
const notificationTypeNames: { [key: string]: string } = {
wechat: '企业微信',
telegram: 'Telegram',
vocechat: 'VoceChat',
synologychat: 'Synology Chat',
slack: 'Slack',
webpush: 'WebPush',
}
// 消息类型下拉字典
const notificationTypes = [
{ value: '资源下载', title: '资源下载' },
{ value: '整理入库', title: '整理入库' },
{ value: '订阅', title: '订阅' },
{ value: '站点', title: '站点' },
{ value: '媒体服务器', title: '媒体服务器' },
{ value: '手动处理', title: '手动处理' },
{ value: '插件', title: '插件' },
{ value: '其它', title: '其它' },
]
// 打开详情弹窗
function openNotificationInfoDialog() {
// 替换成深复制,避免修改时影响原数据
notificationInfo.value = cloneDeep(props.notification)
console.log(`当前卡片的通知信息:${JSON.stringify(notificationInfo.value)}`)
notificationInfoDialog.value = true
}
// 保存详情数据
function saveNotificationInfo() {
// 为空不保存,跳出警告框
if (!notificationInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
return
}
// 重名判断
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
$toast.error(`通知渠道【${notificationInfo.value.name}】已存在,请替换`)
return
}
notificationInfoDialog.value = false
emit('change', notificationInfo.value, props.notification.name)
emit('done')
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.notification.type) {
case 'wechat':
return wechat_image
case 'telegram':
return telegram_image
case 'vocechat':
return vocechat_image
case 'synologychat':
return synologychat_image
case 'slack':
return slack_image
case 'webpush':
return chrome_image
default:
return wechat_image
}
})
// 按钮点击
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="openNotificationInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<div class="flex items-center">
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
<span class="text-h6">{{ props.notification.name }}</span>
</div>
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.notification.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="notificationInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="notificationInfo.enabled" label="启用通知" />
</VCol>
<VCol cols="12">
<VSelect
v-model="notificationInfo.switchs"
:items="notificationTypes"
label="消息类型"
hint="开启通知的消息类型"
multiple
clearable
chips
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'wechat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_CORPID"
label="企业ID"
hint="企业微信后台企业信息中的企业ID"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_ID"
label="应用 AgentId"
hint="企业微信自建应用的AgentId"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_SECRET"
label="应用 Secret"
hint="企业微信自建应用的Secret"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_PROXY"
label="代理地址"
hint="微信消息的转发代理地址2022年6月20日后创建的自建应用才需要不使用代理时需要保留默认值"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_TOKEN"
label="Token"
hint="微信企业自建应用->API接收消息配置中的Token"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
label="EncodingAESKey"
hint="微信企业自建应用->API接收消息配置中的EncodingAESKey"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'telegram'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_TOKEN"
label="Bot Token"
hint="Telegram机器人token格式123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_CHAT_ID"
label="Chat ID"
hint="接受消息通知的用户、群组或频道Chat ID"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_USERS"
label="用户白名单"
placeholder="多个用,分隔"
hint="可使用Telegram机器人的用户ID清单多个用户用,分隔,不填写则所有用户都能使用"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'slack'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_OAUTH_TOKEN"
label="Slack Bot User OAuth Token"
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_APP_TOKEN"
label="Slack App-Level Token"
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`App-Level Token`"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_CHANNEL"
label="频道名称"
placeholder="全体"
hint="消息发送频道,默认`全体`"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'synologychat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_WEBHOOK"
label="机器人传入URL"
hint="Synology Chat机器人传入URL"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_TOKEN"
label="令牌"
hint="Synology Chat机器人令牌"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'vocechat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_HOST"
label="地址"
hint="VoceChat服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_API_KEY"
label="机器人密钥"
hint="VoceChat机器人密钥"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_CHANNEL_ID"
label="频道ID"
placeholder="不包含#号"
hint="VoceChat的频道ID不包含#号"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'webpush'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WEBPUSH_USERNAME"
label="登录用户名"
hint="只有对应的用户登录后才会推送消息"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveNotificationInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -9,6 +9,9 @@ const personProps = defineProps({
height: String,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 当前人物
const personInfo = ref(personProps.person)
@@ -17,22 +20,26 @@ const isImageLoaded = ref(false)
// 人物图片地址
function getPersonImage() {
let url = ''
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}`
url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/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
url = personInfo.value?.avatar?.normal
} else {
return personInfo.value?.avatar
url = personInfo.value?.avatar
}
} else if (personProps.person?.source === 'bangumi') {
if (!personInfo.value?.images) return personIcon
return personInfo.value?.images?.medium
url = personInfo.value?.images?.medium
} else {
return personIcon
}
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}
// 人物姓名
@@ -70,9 +77,8 @@ function goPersonDetail() {
v-bind="hover.props"
:height="personProps.height"
:width="personProps.width"
class="rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
@click.stop="goPersonDetail"
>
@@ -109,7 +115,7 @@ function goPersonDetail() {
</VHover>
</template>
<style lang="scss">
<style lang="scss" scoped>
.person-card {
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
}

View File

@@ -43,11 +43,8 @@ const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 计算插件标签
const pluginLabels = computed(() => {
if (!props.plugin?.plugin_label) return []
return props.plugin.plugin_label.split(',')
})
// 插件详情弹窗
const detailDialog = ref(false)
// 图片加载完成
async function imageLoaded() {
@@ -76,7 +73,7 @@ async function installPlugin() {
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
detailDialog.value = false
// 通知父组件刷新
emit('install')
} else {
@@ -149,86 +146,150 @@ const dropdownItems = ref([
</script>
<template>
<VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
<div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" class="text-white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
@click="item.props.click"
<div>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="detailDialog = true"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
}"
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
></div>
<div class="relative flex-1 min-w-0">
<VCardTitle
class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ..."
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<VAvatar size="6rem">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
<VCardTitle>
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="pb-2">
<div>{{ props.plugin?.plugin_desc }}</div>
<div>
<VChip v-for="label in pluginLabels" variant="tonal" size="small" class="me-1 my-1" color="info" label>
{{ label }}
</VChip>
</div>
</VCardText>
<VCardText class="flex align-self-baseline pb-2 w-full align-end">
<span>
<VIcon icon="mdi-account" class="me-1" />
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
</VCardText>
</VCard>
<!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
</VCard>
</VDialog>
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="text-white text-sm px-2 py-0 text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" class="me-1" />
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
<div class="me-n3 absolute bottom-1 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem v-for="(item, i) in dropdownItems" v-show="item.show" :key="i" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</VCardText>
</VCard>
</template>
</VHover>
<!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
</VCard>
</VDialog>
<!-- 插件详情-->
<VDialog v-if="detailDialog" v-model="detailDialog" max-width="30rem">
<VCard>
<DialogCloseBtn @click="detailDialog = false" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="mx-auto mt-5">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle class="text-center text-md-left">
{{ props.plugin?.plugin_name }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
>
{{ props.plugin?.plugin_desc }}
</VCardSubtitle>
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">版本</span>
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">作者</span>
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</span>
</VListItemTitle>
</VListItem>
</VList>
<div class="text-center text-md-left">
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download"> 安装到本地 </VBtn>
<div class="text-xs mt-2" v-if="props.count">
<VIcon icon="mdi-fire" /> {{ props.count?.toLocaleString() }} 次下载
</div>
</div>
</VCardItem>
</div>
</div>
</VCol>
</VCardText>
</VCard>
</VDialog>
</div>
</template>
<style lang="scss" scoped>
.card-cover-blurred::before {
position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
background: rgba(29, 39, 59, 48%);
content: '';
inset: 0;
}
</style>

View File

@@ -1,21 +1,15 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { VIcon } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import type { Plugin } from '@/api/types'
import FormRender from '@/components/render/FormRender.vue'
import PageRender from '@/components/render/PageRender.vue'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import { isNullOrEmptyObject } from '@core/utils'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
import store from '@/store'
import { useDisplay } from 'vuetify'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
// 输入参数
const props = defineProps({
@@ -47,23 +41,20 @@ const isVisible = ref(true)
// 插件配置页面
const pluginConfigDialog = ref(false)
// 插件配置表单数据
const pluginConfigForm = ref({})
// 菜单显示状态
const menuVisible = ref(false)
// 进度框
const progressDialog = ref(false)
// 插件表单配置项
let pluginFormItems = reactive([])
// 插件数据页面
const pluginInfoDialog = ref(false)
// 进度框文本
const progressText = ref('正在更新插件...')
// 插件数据页面配置项
let pluginPageItems = ref([])
// 用户头像是否加载完成
const isAvatarLoaded = ref(false)
// 图片是否加载完成
const isImageLoaded = ref(false)
@@ -132,75 +123,14 @@ async function uninstallPlugin() {
}
}
// 调用API读取表单页面
async function loadPluginForm() {
try {
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (result) {
pluginFormItems = result.conf
if (result.model) pluginConfigForm.value = result.model
}
} catch (error) {
console.error(error)
}
}
// 调用API读取数据页面
async function loadPluginPage() {
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
if (result) pluginPageItems.value = result
} catch (error) {
console.error(error)
}
}
// 调用API读取配置数据
async function loadPluginConf() {
try {
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
} catch (error) {
console.error(error)
}
}
// 调用API保存配置数据
async function savePluginConf() {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
try {
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
if (result.success) {
progressDialog.value = false
pluginConfigDialog.value = false
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
// 通知父组件刷新
emit('save')
} else {
progressDialog.value = false
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
}
} catch (error) {
console.error(error)
}
}
// 显示插件数据
async function showPluginInfo() {
// 加载数据
await loadPluginPage()
pluginConfigDialog.value = false
pluginInfoDialog.value = true
}
// 显示插件配置
async function showPluginConfig() {
// 加载表单
await loadPluginForm()
// 加载配置
await loadPluginConf()
// 显示对话框
pluginInfoDialog.value = false
pluginConfigDialog.value = true
@@ -216,11 +146,19 @@ const iconPath: Ref<string> = computed(() => {
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
// 插件作者头像路径
const authorPath: Ref<string> = computed(() => {
// 网络图片则使用代理后返回
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.author_url + '.png',
)}`
})
// 重置插件
async function resetPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认重置插件 ${props.plugin?.plugin_name}配置数据?`,
content: `此操作将恢复插件 ${props.plugin?.plugin_name}默认设置,并清除所有相关数据,确定要继续吗?`,
})
if (!isConfirmed) return
@@ -277,10 +215,9 @@ function visitAuthorPage() {
// 查看日志URL
function openLoggerWindow() {
const token = store.state.auth.token
const url = `${
import.meta.env.VITE_API_BASE_URL
}system/logging?token=${token}&length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
}
@@ -290,6 +227,12 @@ function openPluginDetail() {
else showPluginConfig()
}
// 配置完成
function configDone() {
pluginConfigDialog.value = false
emit('save')
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -381,113 +324,138 @@ watch(
</script>
<template>
<!-- 插件卡片 -->
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
<div class="relative pa-3 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 left-1">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" class="text-white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<VAvatar size="6rem">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
<VCardItem class="py-2">
<VCardTitle class="flex items-center flex-row">
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" />
{{ props.plugin?.plugin_name }}
<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
</VCardItem>
<VCardText class="pb-1">
{{ props.plugin?.plugin_desc }}
</VCardText>
<VCardText class="flex justify-end align-self-baseline p-1 w-full align-end">
<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>
<div>
<!-- 插件卡片 -->
<VHover>
<template #default="hover">
<VCard
v-if="isVisible"
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="openPluginDetail"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
}"
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
/>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
<VBadge v-if="props.plugin?.state" dot inline color="success" />
{{ props.plugin?.plugin_name }}
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
</VCardTitle>
<VCardText class="px-2 py-0 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span class="author-info">
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
<VIcon v-if="!isAvatarLoaded" icon="mdi-github" class="me-1" />
</VImg>
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
<div class="me-n3 absolute bottom-1 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</VCardText>
<div v-if="hover.isHovering" class="me-n3 absolute top-0 right-5">
<VIcon class="cursor-move text-white">mdi-drag</VIcon>
</div>
<div v-else-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
</VCard>
</template>
</VHover>
<!-- 插件配置页面 -->
<VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="pluginConfigDialog" />
<VDivider />
<VCardText>
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
查看数据
</VBtn>
<VSpacer />
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 插件配置页面 -->
<PluginConfigDialog
v-if="pluginConfigDialog"
v-model="pluginConfigDialog"
:plugin="props.plugin"
@save="configDone"
@close="pluginConfigDialog = false"
@switch="showPluginInfo"
/>
<!-- 插件数据页面 -->
<VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
<DialogCloseBtn v-model="pluginInfoDialog" />
<VCardText class="min-h-40">
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
</VCardText>
<VFab icon="mdi-cog" location="bottom" size="x-large" fixed app appear @click="showPluginConfig" />
</VCard>
</VDialog>
<!-- 插件数据页面 -->
<PluginDataDialog
v-if="pluginInfoDialog"
v-model="pluginInfoDialog"
:plugin="props.plugin"
@close="pluginInfoDialog = false"
@switch="showPluginConfig"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
<VDivider />
<VCardText>
<VBtn @click="updatePlugin" block>
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
更新到最新版本
</VBtn>
</VCardText>
</VCard>
</VDialog>
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
<VDivider />
<VCardItem>
<VBtn @click="updatePlugin" block>
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
更新到最新版本
</VBtn>
</VCardItem>
</VCard>
</VDialog>
</div>
</template>
<style lang="scss" scoped>
@@ -500,4 +468,17 @@ watch(
content: '';
inset: 0;
}
.author-info {
display: flex;
align-items: center;
}
.author-avatar {
border-radius: 50%;
block-size: 24px;
inline-size: 24px;
margin-inline-end: 8px;
object-fit: cover;
}
</style>

View File

@@ -31,7 +31,7 @@ const getImgUrl = computed(() => {
})
// 跳转播放
function goPlay(isHovering = false) {
function goPlay(isHovering: boolean | null = false) {
if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
}
</script>
@@ -43,18 +43,16 @@ function goPlay(isHovering = false) {
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500 rounded-lg"
class="outline-none shadow ring-gray-500"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goPlay(hover.isHovering)"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
@@ -78,7 +76,9 @@ function goPlay(isHovering = false) {
<!-- 详情 -->
<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"
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
@click.stop="goPlay(hover.isHovering)"
>
<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 ...">
@@ -89,9 +89,3 @@ function goPlay(isHovering = false) {
</template>
</VHover>
</template>
<style lang="scss">
.on-hover img {
@apply brightness-50;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { StorageConf } from '@/api/types'
import { formatBytes } from '@core/utils/formatters'
import storage_png from '@images/misc/storage.png'
import alipan_png from '@images/misc/alipan.webp'
import u115_png from '@images/misc/u115.png'
import rclone_png from '@images/misc/rclone.png'
import alist_png from '@images/misc/alist.svg'
import api from '@/api'
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
import { useToast } from 'vue-toast-notification'
import { isNullOrEmptyObject } from '@/@core/utils'
// 定义输入
const props = defineProps({
storage: {
type: Object as PropType<StorageConf>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done'])
// 提示信息
const $toast = useToast()
// 存储总空间
const total = ref(0)
// 存储可用空间
const available = ref(0)
// 储存已用空间
const used = computed(() => {
return total.value - available.value
})
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 115网盘认证对话框
const u115AuthDialog = ref(false)
// Rclone配置对话框
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// 打开存储对话框
function openStorageDialog() {
switch (props.storage.type) {
case 'alipan':
aliyunAuthDialog.value = true
break
case 'u115':
u115AuthDialog.value = true
break
case 'rclone':
rcloneConfigDialog.value = true
break
case 'alist':
aListConfigDialog.value = true
break
default:
$toast.info('此存储类型无需配置参数,请直接配置目录!')
break
}
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.storage.type) {
case 'local':
return storage_png
case 'alipan':
return alipan_png
case 'u115':
return u115_png
case 'rclone':
return rclone_png
case 'alist':
return alist_png
default:
return storage_png
}
})
// 计算进度条颜色
const progressColor = computed(() => {
if (usage.value > 90) {
return 'error'
} else if (usage.value > 70) {
return 'warning'
} else {
return 'success'
}
})
// 计算存储使用率
const usage = computed(() => {
return Math.round((used.value / (total.value || 1)) * 1000) / 10
})
// 查询存储信息
async function queryStorage() {
try {
const data: { total: number; available: number } = await api.get(`storage/usage/${props.storage.type}`)
total.value = data.total
available.value = data.available
} catch (error) {
console.error(error)
}
}
// 完成配置后的处理
function handleDone() {
aliyunAuthDialog.value = false
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
emit('done')
}
onMounted(() => {
queryStorage()
})
</script>
<template>
<VCard variant="tonal" @click="openStorageDialog">
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">未配置</div>
</div>
<VImg :src="getIcon" cover class="mt-5" max-width="3rem" min-width="3rem" />
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
</div>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
:conf="props.storage.config || {}"
@close="aliyunAuthDialog = false"
@done="handleDone"
/>
<U115AuthDialog
v-if="u115AuthDialog"
v-model="u115AuthDialog"
:conf="props.storage.config || {}"
@close="u115AuthDialog = false"
@done="handleDone"
/>
<RcloneConfigDialog
v-if="rcloneConfigDialog"
v-model="rcloneConfigDialog"
:conf="props.storage.config || {}"
@close="rcloneConfigDialog = false"
@done="handleDone"
/>
<AlistConfigDialog
v-if="aListConfigDialog"
v-model="aListConfigDialog"
:conf="props.storage.config || {}"
@close="aListConfigDialog = false"
@done="handleDone"
/>
</template>

View File

@@ -2,8 +2,9 @@
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import { formatDateDifference } from '@/@core/utils/formatters'
import { formatSeason } from '@/@core/utils/formatters'
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import router from '@/router'
@@ -13,6 +14,9 @@ const props = defineProps({
media: Object as PropType<Subscribe>,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
@@ -28,21 +32,23 @@ const imageLoaded = ref(false)
// 订阅弹窗
const subscribeEditDialog = ref(false)
// 订阅文件信息弹窗
const subscribeFilesDialog = ref(false)
// 分享订阅弹窗
const subscribeShareDialog = ref(false)
// 当前的订阅状态
const subscribeState = ref<string>(props.media?.state ?? 'P')
// 上一次更新时间
const lastUpdateText = ref(props.media && props.media.last_update ? formatDateDifference(props.media.last_update) : '')
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 根据 type 返回不同的图标
function getIcon() {
if (props.media?.type === '电影') return 'mdi-movie-open'
else if (props.media?.type === '电视剧') return 'mdi-television-play'
else return 'mdi-help-circle'
}
// 计算百分比
function getPercentage() {
if (props.media?.total_episode === 0) return 0
@@ -78,13 +84,39 @@ async function searchSubscribe() {
}
}
// 切换订阅状态
async function toggleSubscribeStatus(state: 'R' | 'S') {
try {
// 根据传入的 state 判断对应的操作文字
const action = state === 'S' ? '暂停' : '启用'
// 弹出确认框
const isConfirmed = await createConfirm({
title: `确认${action}`,
content: `是否${action}订阅 ${props.media?.name}`,
})
if (!isConfirmed) return
// 调用 API 更新订阅状态
const result: { [key: string]: any } = await api.put(`subscribe/status/${props.media?.id}?state=${state}`)
// 提示
if (result.success) {
$toast.success(`${props.media?.name}${action}`)
subscribeState.value = state
emit('save')
} else {
$toast.error(`${action}失败:${result.message}`)
}
} catch (e) {
console.log(e)
}
}
// 重置订阅
async function resetSubscribe() {
// 确认
try {
const isConfirmed = await createConfirm({
title: '确认',
content: `重置后 ${props.media?.name} 已下载记录将被清除,未入库的剧集将会重新下载,是否确认?`,
content: `重置后 ${props.media?.name} 将恢复初始状态,已下载记录将被清除,未入库的内容将会重新下载,是否确认?`,
})
if (!isConfirmed) return
// 重置
@@ -92,6 +124,7 @@ async function resetSubscribe() {
// 提示
if (result.success) {
$toast.success(`${props.media?.name} 重置成功!`)
subscribeState.value = 'R'
emit('save')
} else $toast.error(`${props.media?.name} 重置失败:${result.message}`)
} catch (e) {
@@ -99,24 +132,44 @@ async function resetSubscribe() {
}
}
// 分享订阅
async function shareSubscribe() {
subscribeShareDialog.value = true
}
// 编辑订阅响应
async function editSubscribeDialog() {
subscribeEditDialog.value = true
}
// 查看详情
// 获得mediaid
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
else return props.media?.mediaid
}
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
mediaid: getMediaId(),
title: props.media?.name,
year: props.media?.year,
type: props.media?.type,
},
})
}
// 查看文件详情
async function viewSubscribeFiles() {
subscribeFilesDialog.value = true
}
// 弹出菜单
const dropdownItems = ref([
const dropdownItems = computed(() => [
{
title: '编辑',
value: 1,
@@ -134,26 +187,52 @@ const dropdownItems = ref([
},
},
{
title: '查看详情',
title: '详情',
value: 3,
props: {
prependIcon: 'mdi-open-in-new',
prependIcon: 'mdi-information-outline',
click: viewMediaDetail,
},
},
{
title: '重置',
title: '文件',
value: 4,
props: {
prependIcon: 'mdi-file-document-outline',
click: viewSubscribeFiles,
},
},
{
title: subscribeState.value === 'S' ? '启用' : '暂停',
value: 5,
props: {
prependIcon: subscribeState.value === 'S' ? 'mdi-play' : 'mdi-pause',
click: () => toggleSubscribeStatus(subscribeState.value === 'S' ? 'R' : 'S'),
color: subscribeState.value === 'S' ? 'success' : 'info',
},
},
{
title: '重置',
value: 6,
props: {
prependIcon: 'mdi-restore-alert',
click: resetSubscribe,
color: 'warning',
},
},
{
title: '分享',
value: 7,
props: {
prependIcon: 'mdi-share',
click: shareSubscribe,
color: 'success',
},
show: props.media?.type === '电视剧',
},
{
title: '取消订阅',
value: 5,
value: 8,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
@@ -169,135 +248,175 @@ watch(
if (newOpenState) editSubscribeDialog()
},
)
// 监听订阅状态
watch(
() => props.media?.state,
newState => {
subscribeState.value = newState ?? 'P'
},
)
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 订阅编辑保存
function onSubscribeEditSave() {
subscribeEditDialog.value = false
emit('save')
}
// 订阅编辑取消
function onSubscribeEditRemove() {
subscribeEditDialog.value = false
emit('remove')
}
</script>
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg"
:class="{
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
min-height="170"
@click="editSubscribeDialog"
>
<div class="me-n3 absolute top-1 right-2">
<IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem
v-if="item.show !== false"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image>
<VImg
:src="props.media?.backdrop || props.media?.poster"
aspect-ratio="3/2"
cover
@load="imageLoadHandler"
position="top"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div v-if="imageLoaded">
<VCardText class="flex items-center">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg">
<VImg :src="props.media?.poster" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div class="mr-2 min-w-0 text-lg font-bold text-white">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn
v-if="props.media?.total_episode"
v-bind="props"
icon="mdi-progress-download"
color="white"
class="me-1"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-4 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
</div>
<IconBtn v-if="props.media?.username" icon="mdi-account" color="white" class="me-1" />
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
/>
<div>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
:class="{
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'opacity-70': subscribeState === 'S',
}"
min-height="170"
@click="editSubscribeDialog"
:ripple="false"
>
<div class="me-n3 absolute top-1 right-2">
<IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
</div>
</VCard>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="props.media?.id"
@remove="
() => {
emit('remove')
subscribeEditDialog = false
}
"
@save="
() => {
emit('save')
subscribeEditDialog = false
}
"
@close="subscribeEditDialog = false"
/>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
<div v-if="subscribeState === 'P'" class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none" />
</template>
<div>
<VCardText class="flex items-center">
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div class="mr-2 min-w-0 text-lg font-bold text-white">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn
v-if="props.media?.total_episode"
v-bind="props"
icon="mdi-progress-download"
color="white"
class="me-1"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-4 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
</div>
<IconBtn v-if="props.media?.username" icon="mdi-account" color="white" class="me-1" />
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
/>
</div>
<div v-if="hover.isHovering" class="me-n3 absolute top-1 right-10">
<IconBtn><VIcon class="cursor-move text-white">mdi-drag</VIcon></IconBtn>
</div>
</div>
</VCard>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="props.media?.id"
@remove="onSubscribeEditRemove"
@save="onSubscribeEditSave"
@close="subscribeEditDialog = false"
/>
<!-- 订阅文件信息弹窗 -->
<SubscribeFilesDialog
v-if="subscribeFilesDialog"
v-model="subscribeFilesDialog"
:subid="props.media?.id"
@close="subscribeFilesDialog = false"
/>
<!-- 分享订阅弹窗 -->
<SubscribeShareDialog
v-if="subscribeShareDialog"
v-model="subscribeShareDialog"
:sub="props.media"
@close="subscribeShareDialog = false"
/>
</div>
</template>
<style lang="scss">
<style lang="scss" scoped>
.subscribe-card-background {
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}

View File

@@ -0,0 +1,184 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
// 输入参数
const props = defineProps({
media: Object as PropType<SubscribeShare>,
})
// 定义删除事件
const emit = defineEmits(['delete'])
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 图片是否加载完成
const imageLoaded = ref(false)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 复用订阅弹窗
const forkSubscribeDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 分享时间
const dateText = ref(props.media && props.media?.date ? formatDateDifference(props.media.date) : '')
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 获得mediaid
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
}
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
title: props.media?.name,
year: props.media?.year,
type: props.media?.type,
},
})
}
// 复用订阅
function showForkSubscribe() {
forkSubscribeDialog.value = true
}
// 完成复用订阅
function finishForkSubscribe(subid: number) {
subscribeId.value = subid
forkSubscribeDialog.value = false
subscribeEditDialog.value = true
}
// 删除订阅分享时处理
function doDelete() {
forkSubscribeDialog.value = false
// 通知父组件刷新
emit('delete')
}
</script>
<template>
<div class="h-full">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
}"
min-height="170"
@click="showForkSubscribe"
>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div class="h-full flex flex-col">
<VCardText class="flex items-center pb-1 grow">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center pl-2 xl:pl-4">
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.share_title }}
</div>
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
{{ props.media?.share_comment }}
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
<div class="text-subtitle-2 me-4 text-white">
{{ props.media?.share_user }}
</div>
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
{{ props.media?.count.toLocaleString() }}
</span>
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-calcdar" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="subscribeEditDialog = false"
/>
<!-- 复用订阅弹窗 -->
<ForkSubscribeDialog
v-if="forkSubscribeDialog"
v-model="forkSubscribeDialog"
:media="props.media"
@close="forkSubscribeDialog = false"
@fork="finishForkSubscribe"
@delete="doDelete"
/>
</div>
</template>
<style lang="scss" scoped>
.subscribe-card-background {
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
</style>

View File

@@ -1,11 +1,10 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数
const props = defineProps({
@@ -15,12 +14,6 @@ const props = defineProps({
height: String,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 更多来源界面
const showMoreTorrents = ref(false)
@@ -33,66 +26,47 @@ const media = ref(props.torrent?.media_info)
// 识别元数据
const meta = ref(props.torrent?.meta_info)
// 当前下载项
const downloadItem = ref(props.torrent)
// 站点图标
const siteIcon = ref('')
const siteIcons = ref<Record<number, string>>({})
// 存储是否已经下载过的记录
const downloaded = ref<String[]>([])
const downloaded = ref<string[]>([])
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
// 添加下载成功
downloaded.value.push(url)
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
}
// 查询站点图标
async function getSiteIcon() {
async function getSiteIcon(site: number | undefined) {
if (!site) return
try {
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
} catch (error) {
console.error(error)
}
}
// 询问并添加下载
async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name
_media = media.value
_torrent = torrent.value
async function handleAddDownload(item: Context | null = null) {
if (item && !isNullOrEmptyObject(item)) {
downloadItem.value = item
}
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`,
})
if (!isConfirmed) return
addDownload(_media, _torrent)
}
// 添加下载
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress()
try {
let result: { [key: string]: any }
if (_media) {
result = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
} else {
result = await api.post('download/add', _torrent)
}
if (result && result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
downloaded.value.push(_torrent?.enclosure || '')
} else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败:${result?.message}`)
}
} catch (error) {
console.error(error)
}
doneNProgress()
// 打开下载对话框
addDownloadDialog.value = true
}
// 打开种子详情页面
@@ -105,142 +79,582 @@ async function downloadTorrentFile() {
window.open(torrent.value?.enclosure, '_blank')
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
// 获取优惠类型样式
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return 'free-discount'
if (downloadVolumeFactor === 0) return 'free-discount'
else if (downloadVolumeFactor < 1) return 'percent-discount'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
else return ''
}
// 打开更多来源对话框
async function openMoreTorrentsDialog() {
props.more?.forEach(t => {
return getSiteIcon(t.torrent_info?.site)
})
showMoreTorrents.value = true
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
getSiteIcon(props.torrent?.torrent_info?.site)
})
</script>
<template>
<VCard
:width="props.width"
:height="props.height"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
@click="handleAddDownload"
>
<template v-if="!showMoreTorrents" #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem class="py-1">
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VCardTitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VCardItem>
<VCardText class="text-subtitle-2">
{{ torrent?.title }}
</VCardText>
<VCardText>{{ torrent?.description }}</VCardText>
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
<div class="h-full">
<VCard
:width="props.width || '100%'"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
@click="handleAddDownload(props.torrent)"
class="torrent-card h-full"
:class="{ 'downloaded-card': downloaded.includes(torrent?.enclosure || '') }"
>
<!-- 优惠标签 -->
<div
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
class="discount-banner"
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
>
{{ torrent?.volume_factor }}
</VChip>
</VCardItem>
<VCardActions>
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
<template #append>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</template>
更多来源
</VBtn>
</VCardActions>
<VExpandTransition>
<div v-show="showMoreTorrents">
</div>
<!-- 媒体标题 -->
<div class="card-header">
<div class="media-title-wrapper flex flex-row flex-wrap justify-start">
<span class="media-title me-2">
{{ media?.title ?? meta?.name }}
</span>
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
</div>
<!-- 站点信息条 -->
<div class="site-info">
<div class="d-flex align-center">
<img
:alt="torrent?.site_name"
v-if="siteIcons[torrent?.site || 0]"
:src="siteIcons[torrent?.site || 0]"
class="site-icon"
/>
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
<span class="site-name">{{ torrent?.site_name }}</span>
</div>
<div class="seeder-peers">
<span v-if="torrent?.seeders" class="seed-info">
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
</span>
<span v-if="torrent?.peers" class="peer-info">
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
</span>
</div>
</div>
</div>
<!-- 种子内容 -->
<div class="card-content">
<!-- 种子标题 -->
<div class="torrent-title" :title="torrent?.title">
{{ torrent?.title }}
</div>
<!-- 种子描述 -->
<div
v-if="meta?.subtitle || torrent?.description"
class="torrent-desc grow"
:title="meta?.subtitle || torrent?.description"
>
{{ meta?.subtitle || torrent?.description }}
</div>
<!-- 资源标签区 -->
<div class="tags-container">
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
</div>
</div>
<!-- 卡片底部信息 -->
<div class="card-footer">
<div class="more-sources-wrapper" v-if="props.more && props.more.length > 0">
<div class="more-sources-toggle" @click.stop="openMoreTorrentsDialog">
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" size="small" class="me-1"></VIcon>
<span>更多来源 ({{ props.more.length }})</span>
</div>
</div>
<VSpacer />
<!-- 体积和详情按钮并排 -->
<div class="card-actions">
<div v-if="torrent?.size" class="size-badge">
{{ formatFileSize(torrent.size) }}
</div>
<VBtn
size="small"
icon="mdi-information-outline"
variant="text"
color="primary"
class="detail-btn"
@click.stop="openTorrentDetail"
></VBtn>
</div>
</div>
</VCard>
<!-- 更多来源对话框 - 改为独立对话框 -->
<VDialog v-model="showMoreTorrents" max-width="380px" location="center">
<VCard>
<VCardTitle class="py-2 d-flex align-center">
<span>其他来源</span>
<VSpacer />
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="showMoreTorrents = false"></VBtn>
</VCardTitle>
<VDivider />
<VChipGroup class="p-3" column>
<VChip
<VCardText class="more-sources-content">
<div
v-for="(item, index) in props.more"
:key="index"
@click.stop="handleAddDownload(item.torrent_info?.site_name, item.media_info, item.torrent_info)"
@click.stop="handleAddDownload(item)"
class="more-source-item cursor-pointer"
>
<template #append>
<VBadge color="primary" :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"
inline
size="small"
<div class="source-site-info">
<img
:alt="item.torrent_info?.site_name"
v-if="siteIcons[item.torrent_info?.site || 0]"
:src="siteIcons[item.torrent_info?.site || 0]"
class="source-site-icon"
/>
</template>
{{ item.torrent_info.site_name }}
</VChip>
</VChipGroup>
</div>
</VExpandTransition>
</VCard>
<span v-else class="source-site-fallback">{{ item.torrent_info?.site_name?.substring(0, 1) }}</span>
<span class="source-site-name">{{ item.torrent_info.site_name }}</span>
<span v-if="item.meta_info?.season_episode" class="season-tag source-season-tag">
{{ item.meta_info.season_episode }}
</span>
<span
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
class="source-discount"
:class="
getPromotionClass(item.torrent_info?.downloadvolumefactor, item.torrent_info?.uploadvolumefactor)
"
>
{{ item.torrent_info?.volume_factor }}
</span>
</div>
<div class="source-stats">
<span class="source-size">{{ formatFileSize(item.torrent_info?.size) }}</span>
<span class="source-seeders">
<VIcon size="x-small" color="success" icon="mdi-arrow-up"></VIcon>
{{ item.torrent_info?.seeders }}
</span>
</div>
</div>
</VCardText>
</VCard>
</VDialog>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${downloadItem?.media_info?.title_year || downloadItem?.meta_info?.name} ${
downloadItem?.meta_info?.season_episode
}`"
:media="downloadItem?.media_info"
:torrent="downloadItem?.torrent_info"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</div>
</template>
<style scoped>
.torrent-card {
overflow: hidden;
border-radius: 12px;
box-shadow: none;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
cursor: pointer;
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
position: relative;
}
.torrent-card:hover {
transform: translateY(-4px);
border-color: rgba(var(--v-theme-primary), 0.3);
}
.discount-banner {
position: absolute;
top: 0;
right: 0;
color: white;
padding: 4px 10px;
font-weight: 600;
font-size: 0.9rem;
border-radius: 0 0 0 12px;
z-index: 2;
}
.free-discount {
background-color: #4caf50;
font-weight: 700;
}
.percent-discount {
background-color: #ff5722;
}
.upload-bonus {
background-color: #9c27b0;
}
.size-badge {
background-color: rgba(var(--v-theme-primary), 0.9);
color: white;
padding: 2px 8px;
font-weight: 600;
font-size: 0.8rem;
border-radius: 4px;
margin-right: 6px;
display: flex;
align-items: center;
}
.card-header {
padding: 12px 16px 0;
}
.media-title-wrapper {
margin-bottom: 8px;
padding-right: 2rem;
}
.media-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), 0.87);
}
.season-tag {
font-size: 0.875rem;
background-color: #5c6bc0;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.site-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.site-icon {
width: 20px;
height: 20px;
margin-right: 8px;
border-radius: 2px;
}
.site-fallback {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 8px;
font-weight: 700;
color: rgba(var(--v-theme-on-surface), 0.8);
background-color: rgba(var(--v-theme-on-surface), 0.1);
border-radius: 2px;
}
.site-name {
font-size: 0.875rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), 0.85);
}
.seeder-peers {
display: flex;
align-items: center;
gap: 12px;
}
.seed-info {
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 4px;
font-weight: 600;
}
.peer-info {
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 4px;
font-weight: 600;
}
.card-content {
padding: 0 16px;
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
}
.torrent-title {
font-size: 0.9rem;
font-weight: 500;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
color: rgba(var(--v-theme-on-surface), 0.87);
margin-bottom: 8px;
}
.torrent-desc {
font-size: 0.85rem;
color: rgba(var(--v-theme-on-surface), 0.6);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.resource-tag {
font-size: 0.8rem;
padding: 3px 8px;
border-radius: 4px;
color: white;
font-weight: 700;
}
.edition {
background-color: #f44336;
}
.resolution {
background-color: #e91e63;
}
.codec {
background-color: #ff9800;
}
.team {
background-color: #03a9f4;
}
.expire {
background-color: #9c27b0;
}
.label {
background-color: #3f51b5;
}
.hr {
background-color: #000000;
}
.card-footer {
padding: 8px 16px;
display: flex;
align-items: center;
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.08);
margin-top: auto;
}
.more-sources-wrapper {
position: relative;
}
.more-sources-toggle {
font-size: 0.875rem;
color: rgb(var(--v-theme-primary));
display: flex;
align-items: center;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.more-sources-toggle:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
.more-sources-content {
max-height: 60vh;
overflow-y: auto;
}
.more-source-item {
padding: 8px 0;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
.more-source-item:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
}
.source-site-info {
display: flex;
align-items: center;
gap: 6px;
}
.source-site-icon {
width: 16px;
height: 16px;
border-radius: 2px;
}
.source-site-fallback {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.7rem;
color: rgba(var(--v-theme-on-surface), 0.8);
background-color: rgba(var(--v-theme-on-surface), 0.1);
border-radius: 2px;
}
.source-site-name {
font-size: 0.875rem;
font-weight: 600;
}
.source-season-tag {
font-size: 0.75rem;
padding: 1px 4px;
margin-left: 4px;
background-color: #5c6bc0;
}
.source-discount {
font-weight: 700;
font-size: 0.8rem;
margin-left: 6px;
padding: 1px 5px;
border-radius: 3px;
color: white;
}
.source-stats {
display: flex;
align-items: center;
gap: 10px;
}
.source-size {
font-size: 0.8rem;
font-weight: 600;
color: rgb(var(--v-theme-primary));
}
.source-seeders {
display: flex;
align-items: center;
gap: 2px;
font-weight: 600;
font-size: 0.8rem;
}
.card-actions {
display: flex;
align-items: center;
}
.detail-btn {
border-radius: 50%;
min-width: 36px;
height: 36px;
}
.downloaded-card {
border: 2px solid #4caf50 !important;
opacity: 0.85;
}
@media (max-width: 640px) {
.resource-tag {
font-size: 0.75rem;
padding: 2px 6px;
}
}
.full-text {
white-space: normal;
word-break: break-word;
font-size: 14px;
line-height: 1.5;
}
.menu-activator {
width: 100%;
cursor: pointer;
}
.break-words {
word-wrap: break-word;
word-break: break-word;
}
.overflow-visible {
overflow: visible !important;
}
.whitespace-break-spaces {
white-space: normal !important;
}
</style>

View File

@@ -1,26 +1,15 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
// 输入参数
const props = defineProps({
torrent: Object as PropType<Context>,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 更多来源界面
const showMoreTorrents = ref(false)
// 种子信息
const torrent = ref(props.torrent?.torrent_info)
@@ -33,63 +22,65 @@ const meta = ref(props.torrent?.meta_info)
// 站点图标
const siteIcon = ref('')
// 站点图标加载状态
const iconLoading = ref(false)
const iconError = ref(false)
// 存储是否已经下载过的记录
const downloaded = ref<String[]>([])
const downloaded = ref<string[]>([])
// 添加下载对话框
const addDownloadDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
try {
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
} catch (error) {
console.error(error)
if (!torrent?.value?.site || iconLoading.value) {
return
}
iconLoading.value = true
iconError.value = false
try {
const response = await api.get(`site/icon/${torrent.value.site}`)
if (response && response.data && response.data.icon) {
siteIcon.value = response.data.icon
} else {
iconError.value = true
}
} catch (error) {
console.error('Failed to load site icon:', error)
iconError.value = true
} finally {
iconLoading.value = false
}
}
// 获取优惠类型样式
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return 'free-discount'
if (downloadVolumeFactor === 0) return 'free-discount'
else if (downloadVolumeFactor < 1) return 'percent-discount'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
else return ''
}
// 询问并添加下载
async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name
_media = media.value
_torrent = torrent.value
}
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`,
})
if (!isConfirmed) return
addDownload(_media, _torrent)
async function handleAddDownload() {
// 打开下载对话框
addDownloadDialog.value = true
}
// 添加下载
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress()
try {
let result: { [key: string]: any }
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
// 添加下载成功
downloaded.value.push(url)
}
if (_media) {
result = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
} else {
result = await api.post('download/add', _torrent)
}
if (result && result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
downloaded.value.push(_torrent?.enclosure || '')
} else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败:${result?.message}`)
}
} catch (error) {
console.error(error)
}
doneNProgress()
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
}
// 打开种子详情页面
@@ -97,19 +88,6 @@ function openTorrentDetail() {
window.open(torrent.value?.page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile() {
window.open(torrent.value?.enclosure, '_blank')
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
@@ -117,88 +95,386 @@ onMounted(() => {
</script>
<template>
<VListItem @click="handleAddDownload" :variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'">
<template v-if="!showMoreTorrents" #prepend>
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
{{ torrent?.title }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
<VListItemSubtitle>
{{ torrent?.description }}
</VListItemSubtitle>
<div v-if="torrent?.labels" class="pt-2">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</div>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
<div class="list-item-wrapper">
<VListItem
:value="props.torrent?.torrent_info?.enclosure"
class="torrent-item rounded"
:class="{ 'downloaded-item': downloaded.includes(torrent?.enclosure || '') }"
@click="handleAddDownload"
>
<template v-slot:prepend>
<div class="site-wrapper">
<img :alt="torrent?.site_name" v-if="siteIcon" :src="siteIcon" class="site-icon" />
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
<div class="site-name d-none d-sm-block">{{ torrent?.site_name }}</div>
<span
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
class="free-tag"
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
>
{{ torrent?.volume_factor }}
</span>
</div>
</template>
<VListItemTitle class="item-content">
<div class="item-header">
<div class="media-info flex flex-row flex-wrap justify-start">
<span class="media-title me-2">{{ media?.title ?? meta?.name }}</span>
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
</div>
</div>
<div class="torrent-title" :title="torrent?.title">
{{ torrent?.title }}
</div>
<div class="torrent-description" :title="meta?.subtitle || torrent?.description || '暂无描述'">
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
</div>
<div class="tags-container">
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
</div>
</VListItemTitle>
<template v-slot:append>
<div class="item-actions">
<div class="torrent-stats">
<span v-if="torrent?.seeders" class="seed-info">
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
</span>
<span v-if="torrent?.peers" class="peer-info">
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
</span>
</div>
<div class="action-buttons">
<div v-if="torrent?.size" class="size-badge">
{{ formatFileSize(torrent.size) }}
</div>
<VBtn
density="comfortable"
variant="text"
color="primary"
icon="mdi-information-outline"
size="small"
class="detail-btn"
@click.stop="openTorrentDetail"
></VBtn>
</div>
</div>
</template>
</VListItem>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
:media="media"
:torrent="torrent"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</div>
</template>
<style scoped>
.list-item-wrapper {
inline-size: 100%;
}
.torrent-item {
padding: 12px;
box-shadow: none;
margin-block-end: 8px;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.torrent-item:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
background-color: rgba(var(--v-theme-primary), 0.04);
transform: translateY(-2px);
}
.site-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
min-inline-size: 100px;
}
.site-icon {
border-radius: 4px;
block-size: 32px;
inline-size: 32px;
margin-inline-end: 8px;
}
.site-fallback {
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background-color: rgba(var(--v-theme-primary), 0.1);
block-size: 24px;
color: rgb(var(--v-theme-primary));
font-size: 0.8rem;
font-weight: 700;
inline-size: 24px;
margin-inline-end: 8px;
}
.site-name {
color: rgba(var(--v-theme-on-surface), 0.8);
font-size: 0.9rem;
font-weight: 600;
margin-inline-end: 8px;
}
.season-tag {
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background-color: #5c6bc0;
color: white;
font-size: 0.875rem;
font-weight: 600;
margin-inline-end: 8px;
padding-block: 2px;
padding-inline: 6px;
}
.free-tag {
position: absolute;
z-index: 1;
border-radius: 4px;
color: white;
font-size: 0.7rem;
font-weight: 700;
inset-block-start: 0;
inset-inline-end: 0;
padding-block: 2px;
padding-inline: 6px;
}
.free-discount {
background-color: #4caf50;
font-weight: 700;
}
.percent-discount {
background-color: #ff5722;
}
.upload-bonus {
background-color: #9c27b0;
}
.item-content {
display: flex;
flex-direction: column;
gap: 8px;
inline-size: 100%;
}
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
inline-size: 100%;
}
.media-info {
align-items: center;
}
.media-title {
color: rgba(var(--v-theme-on-surface), 0.87);
font-size: 1.125rem;
font-weight: 600;
}
.item-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.torrent-stats {
display: flex;
align-items: center;
gap: 12px;
}
.action-buttons {
display: flex;
align-items: center;
}
.seed-info {
display: flex;
align-items: center;
font-size: 0.95rem;
font-weight: 600;
gap: 4px;
}
.peer-info {
display: flex;
align-items: center;
font-size: 0.95rem;
font-weight: 600;
gap: 4px;
}
.size-badge {
border-radius: 4px;
background-color: rgba(var(--v-theme-primary), 0.1);
color: rgb(var(--v-theme-primary));
font-size: 0.9rem;
font-weight: 600;
margin-inline-end: 6px;
padding-block: 2px;
padding-inline: 8px;
}
.torrent-title {
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.87);
font-size: 0.9rem;
margin-block-end: 6px;
max-inline-size: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.torrent-description {
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.65);
font-size: 0.8rem;
inline-size: 100%;
margin-block-end: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.resource-tag {
border-radius: 4px;
color: white;
font-size: 0.8rem;
font-weight: 700;
padding-block: 3px;
padding-inline: 8px;
}
.edition {
background-color: #f44336;
}
.resolution {
background-color: #e91e63;
}
.codec {
background-color: #ff9800;
}
.team {
background-color: #03a9f4;
}
.expire {
background-color: #9c27b0;
}
.label {
background-color: #3f51b5;
}
.hr {
background-color: #000;
}
.detail-btn {
border-radius: 50%;
}
.downloaded-item {
border-inline-start: 4px solid #4caf50;
opacity: 0.85;
}
.break-words {
word-break: break-word;
word-wrap: break-word;
}
.overflow-visible {
overflow: visible !important;
}
.whitespace-break-spaces {
white-space: normal !important;
}
@media (width <= 600px) {
.torrent-item {
padding: 8px;
}
.site-icon,
.site-fallback {
block-size: 24px;
inline-size: 24px;
}
.site-wrapper {
flex-wrap: wrap;
margin-inline-end: 10px;
min-inline-size: 24px;
}
.site-name {
font-size: 0.8rem;
margin-inline-end: 4px;
}
.size-badge {
font-size: 0.7rem;
}
.resource-tag {
font-size: 0.75rem;
padding-block: 2px;
padding-inline: 6px;
}
.action-buttons {
display: flex;
flex-direction: row;
align-items: center;
}
.torrent-description {
max-inline-size: calc(100vw - 150px);
}
}
</style>

View File

@@ -0,0 +1,784 @@
<script setup lang="ts">
import api from '@/api'
import { Subscribe, User } from '@/api/types'
import { useUserStore } from '@/stores'
import avatar1 from '@images/avatars/avatar-1.png'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
// 扩展User类型以包含昵称字段
interface ExtendedUser extends User {
nickname?: string
}
// 定义输入变量
const props = defineProps({
// 用户信息
user: {
type: Object as PropType<ExtendedUser>,
required: true,
},
// 所有用户
users: {
type: Array as PropType<User[]>,
required: true,
},
})
// 当前用户的ID
const currentLoginUserId = computed(() => useUserStore().userID)
// 当前用户是否是管理员
const currentUserIsSuperuser = computed(() => useUserStore().superUser)
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
// 确认框
const createConfirm = useConfirm()
// 用户信息弹窗
const userEditDialog = ref(false)
// 提示框
const $toast = useToast()
// 用户电影订阅数量
const movieSubscriptions = ref(0)
// 用户电视剧订阅数量
const tvShowSubscriptions = ref(0)
// 是否显示更多操作菜单
const showMenu = ref(false)
// 鼠标悬停状态
const isHovered = ref(false)
// 是否为移动设备
const isMobile = ref(window.innerWidth < 600)
// 显示名称 - 如果有昵称则优先显示昵称
const displayName = computed(() => {
const settingsNickname = props.user.settings?.nickname as string | undefined
const nickname = props.user.nickname || settingsNickname
return nickname || props.user.name
})
// 计算用户卡片状态类
const cardStatusClass = computed(() => {
if (!props.user.is_active) return 'user-card-inactive'
if (props.user.is_superuser) return 'user-card-admin'
return ''
})
// 按用户查询订阅数量
async function fetchSubscriptions() {
try {
const result: Subscribe[] = await api.get(`subscribe/user/${props.user.name}`)
if (result) {
movieSubscriptions.value = result.filter(item => item.type === '电影').length
tvShowSubscriptions.value = result.filter(item => item.type === '电视剧').length
}
} catch (error) {
console.log(error)
}
}
// 删除用户
async function removeUser() {
if (props.user.id === currentLoginUserId.value) {
$toast.error('不能删除当前登录用户!')
return
}
try {
const isConfirmed = await createConfirm({
title: '注意',
content: `删除用户 ${props.user?.name} 的所有数据,是否确认?`,
})
if (!isConfirmed) return
const result: { [key: string]: any } = await api.delete(`user/id/${props.user.id}`)
if (result.success) {
$toast.success('用户删除成功')
emit('remove')
} else {
$toast.error('用户删除失败!')
}
} catch (error) {
console.log(error)
}
}
// 编辑用户
function editUser() {
userEditDialog.value = true
}
// 用户更新完成时
function onUserUpdate() {
userEditDialog.value = false
emit('save')
}
// 更新窗口大小监听
function handleResize() {
isMobile.value = window.innerWidth < 600
}
onMounted(() => {
fetchSubscriptions()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<VCard
class="user-card"
:class="[{ 'user-card-hover': isHovered }, cardStatusClass, { 'mobile-card': isMobile }]"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<!-- 管理员卡片装饰 -->
<div v-if="user.is_superuser" class="admin-decoration">
<div class="decoration-line"></div>
<div class="decoration-circle"><VIcon icon="mdi-shield-star" size="x-small" color="warning" /></div>
<div class="decoration-line"></div>
</div>
<!-- 用户头像和基本信息 -->
<div class="user-card-header" :class="{ 'admin-header': user.is_superuser }">
<div class="user-avatar-container">
<VAvatar
:size="isMobile ? 50 : 74"
rounded="lg"
class="user-avatar"
:class="{ 'admin-avatar': user.is_superuser, 'inactive-avatar': !user.is_active }"
>
<VImg :src="user.avatar || avatar1" :alt="user.name" />
<div v-if="!user.is_active" class="avatar-overlay">
<VIcon icon="mdi-account-lock" color="white" size="small" />
</div>
</VAvatar>
<div v-if="user.is_superuser" class="admin-crown">
<VIcon icon="mdi-crown" color="warning" size="small" />
</div>
</div>
<div class="user-info">
<div class="user-name-section">
<div class="name-and-badges">
<h3 class="user-name" :class="{ 'admin-name': user.is_superuser, 'inactive-name': !user.is_active }">
{{ displayName }}
<VIcon
v-if="user.nickname || user.settings?.nickname"
icon="mdi-format-quote-close"
size="x-small"
color="info"
class="nickname-icon"
/>
</h3>
<div class="user-badges">
<VChip v-if="user.is_superuser" size="x-small" color="error" class="user-badge admin-badge">管理员</VChip>
<VChip v-else size="x-small" color="default" class="user-badge">普通用户</VChip>
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" class="user-badge">
{{ user.is_active ? '激活' : '已停用' }}
</VChip>
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" class="user-badge"> 2FA </VChip>
</div>
</div>
</div>
<!-- 移动端订阅数据信息 -->
<div v-if="isMobile" class="mobile-stats">
<div class="mobile-stat-item">
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" />
<span>{{ movieSubscriptions }}</span>
</div>
<div class="mobile-stat-item">
<VIcon size="x-small" icon="mdi-television-classic" color="primary" />
<span>{{ tvShowSubscriptions }}</span>
</div>
</div>
</div>
<!-- 头部操作按钮 -->
<div class="user-actions" :class="{ 'mobile-actions': isMobile }">
<VBtn
icon
size="small"
:color="user.is_superuser ? 'warning' : 'primary'"
variant="text"
@click="editUser"
class="action-btn"
>
<VIcon icon="mdi-pencil" />
</VBtn>
<VBtn
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
icon
size="small"
color="error"
variant="text"
@click="removeUser"
class="action-btn"
>
<VIcon icon="mdi-delete" />
</VBtn>
</div>
</div>
<!-- 独立的邮箱显示 -->
<div class="email-container" :class="{ 'admin-email': user.is_superuser, 'inactive-email': !user.is_active }">
<VIcon icon="mdi-email-outline" size="small" color="primary" class="email-icon" />
<span class="email-text">{{ user.email || '未设置邮箱' }}</span>
</div>
<!-- PC端显示订阅统计信息 -->
<div v-if="!isMobile" class="user-card-body">
<div class="user-stats-container">
<div class="stat-item">
<div class="stat-icon-container" :class="{ 'admin-stat': user.is_superuser }">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
</div>
<div class="stat-content">
<div class="stat-value">{{ movieSubscriptions }}</div>
<div class="stat-label">电影订阅</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon-container" :class="{ 'admin-stat': user.is_superuser }">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" size="20" />
</div>
<div class="stat-content">
<div class="stat-value">{{ tvShowSubscriptions }}</div>
<div class="stat-label">剧集订阅</div>
</div>
</div>
</div>
</div>
</VCard>
<!-- 用户编辑弹窗 -->
<UserAddEditDialog
v-if="userEditDialog"
v-model="userEditDialog"
:username="props.user?.name"
:usernames="props.users.map(item => item.name)"
oper="edit"
@save="onUserUpdate"
@close="userEditDialog = false"
/>
</template>
<style scoped>
.user-card {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.user-card-hover {
transform: translateY(-5px);
}
.user-card-admin {
border: 2px solid transparent;
background-clip: content-box, border-box;
background-image: linear-gradient(rgb(var(--v-theme-surface)), rgb(var(--v-theme-surface))),
linear-gradient(120deg, rgba(var(--v-theme-warning), 0.5), rgba(var(--v-theme-error), 0.5));
background-origin: border-box;
}
.user-card-inactive {
position: relative;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
background-color: rgba(var(--v-theme-surface), 0.95);
opacity: 0.85;
}
.user-card-inactive::before {
position: absolute;
z-index: 1;
backdrop-filter: grayscale(30%);
content: '';
inset: 0;
pointer-events: none;
}
.admin-decoration {
position: absolute;
z-index: 1;
display: flex;
align-items: center;
inset-block-start: 0;
inset-inline: 0;
padding-block: 4px;
padding-inline: 12px;
}
.decoration-line {
flex: 1;
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.1), rgba(var(--v-theme-warning), 0.7));
block-size: 1px;
}
.decoration-line:last-child {
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.7), rgba(var(--v-theme-warning), 0.1));
}
.decoration-circle {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-warning), 0.5);
border-radius: 50%;
block-size: 18px;
inline-size: 18px;
margin-block: 0;
margin-inline: 8px;
}
.user-card-header {
position: relative;
z-index: 2;
display: flex;
padding-block: 20px 12px;
padding-inline: 16px;
}
.admin-header {
background: linear-gradient(to bottom, rgba(var(--v-theme-warning), 0.05), transparent);
}
.user-avatar-container {
position: relative;
margin-inline-end: 16px;
}
.user-avatar {
border: 4px solid rgb(var(--v-theme-surface));
box-shadow: 0 4px 8px rgba(var(--v-theme-on-surface), 0.1);
transition: all 0.3s ease;
}
.admin-avatar {
border: 4px solid rgba(var(--v-theme-warning), 0.1);
box-shadow: 0 5px 15px rgba(var(--v-theme-warning), 0.2);
}
.admin-avatar::after {
position: absolute;
border: 1px solid rgba(var(--v-theme-warning), 0.3);
border-radius: 12px;
animation: pulse 2.5s infinite;
content: '';
inset: -5px;
pointer-events: none;
}
@keyframes pulse {
0% {
opacity: 0.6;
transform: scale(0.95);
}
70% {
opacity: 0.2;
transform: scale(1.05);
}
100% {
opacity: 0.6;
transform: scale(0.95);
}
}
.inactive-avatar {
border-color: rgba(var(--v-theme-on-surface), 0.1);
filter: grayscale(50%);
opacity: 0.9;
}
.avatar-overlay {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
backdrop-filter: blur(1px);
background: rgba(var(--v-theme-on-surface), 0.2);
inset: 0;
}
.otp-badge {
position: absolute;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
animation: glow 2s infinite alternate;
inset-block-end: 0;
inset-inline-end: 0;
}
.otp-badge .v-icon {
color: #4caf50 !important;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 40%));
font-size: 18px;
}
@keyframes glow {
from {
opacity: 0.9;
transform: scale(1);
}
to {
opacity: 1;
transform: scale(1.15);
}
}
.mobile-otp {
inset-block-end: 0 !important;
inset-inline-end: 0 !important;
}
.mobile-otp .v-icon {
font-size: 16px;
}
.admin-crown {
position: absolute;
z-index: 5;
animation: float 3s ease-in-out infinite;
background: transparent;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 40%));
inset-block-start: -10px;
inset-inline-start: -6px;
transform: rotate(-25deg);
}
.admin-crown .v-icon {
color: #ffc107 !important;
font-size: 24px;
}
@keyframes float {
0% {
transform: rotate(-25deg) translateY(0);
}
50% {
transform: rotate(-25deg) translateY(-3px);
}
100% {
transform: rotate(-25deg) translateY(0);
}
}
.nickname-icon {
animation: pulse-nickname 2s ease infinite;
filter: brightness(1.1);
margin-inline-start: 4px;
opacity: 0.9;
vertical-align: middle;
}
@keyframes pulse-nickname {
0%,
100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.drag-handle {
cursor: move;
margin-inline-end: 6px;
opacity: 0.3;
transition: opacity 0.2s ease;
}
.user-card:hover .drag-handle {
opacity: 0.8;
}
.user-info {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
min-inline-size: 0;
}
.user-name-section {
margin-block-end: 8px;
}
.name-and-badges {
display: flex;
flex-direction: column;
margin-block-end: 4px;
}
.user-name {
display: flex;
overflow: hidden;
align-items: center;
font-size: 1.2rem;
font-weight: 600;
margin-block: 0 4px;
margin-inline: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-name {
color: rgb(var(--v-theme-warning));
font-weight: 700;
text-shadow: 0 1px 2px rgba(var(--v-theme-warning), 0.1);
}
.inactive-name {
color: rgba(var(--v-theme-on-surface), 0.6);
}
.user-badges {
display: flex;
flex-wrap: nowrap;
gap: 4px;
margin-block-end: 4px;
-ms-overflow-style: none;
overflow-x: auto;
scrollbar-width: none;
}
.user-badges::-webkit-scrollbar {
display: none;
}
.user-badge {
flex-shrink: 0;
font-size: 0.7rem;
white-space: nowrap;
}
.admin-badge {
border: 1px solid rgba(var(--v-theme-error), 0.3);
}
.user-account,
.user-email {
position: absolute;
display: flex;
overflow: hidden;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.8rem;
inline-size: 100%;
inset-block-start: 100%;
inset-inline-start: 0;
margin-block-start: 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-label {
color: rgba(var(--v-theme-on-surface), 0.5);
margin-inline-end: 4px;
}
.account-value {
font-weight: 500;
}
.info-icon {
margin-inline-end: 4px;
opacity: 0.6;
}
.email-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-actions {
display: flex;
align-items: flex-start;
}
.mobile-actions {
position: absolute;
display: flex;
gap: 4px;
inset-block-start: 10px;
inset-inline-end: 10px;
}
.action-btn {
opacity: 0.7;
transition: all 0.3s ease;
}
.action-btn:hover {
opacity: 1;
transform: scale(1.1);
}
.mobile-card {
border-radius: 12px;
}
.mobile-stats {
position: relative;
z-index: 5;
display: flex;
justify-content: flex-start;
gap: 20px;
margin-block-start: 8px;
padding-block: 4px;
padding-inline: 0;
}
.mobile-stat-item {
display: flex;
align-items: center;
font-size: 0.95rem;
gap: 6px;
}
.mobile-stat-item .v-icon {
font-size: 18px !important;
}
.mobile-stat-item span {
font-weight: 500;
}
.user-card-body {
padding-block: 0 16px;
padding-inline: 16px;
}
.user-stats-container {
display: flex;
justify-content: space-around;
padding: 12px;
border-radius: 10px;
background-color: rgba(var(--v-theme-on-surface), 0.02);
margin-block-start: 8px;
}
.stat-item {
display: flex;
align-items: center;
gap: 10px;
}
.stat-icon-container {
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background-color: rgba(var(--v-theme-primary), 0.1);
block-size: 40px;
box-shadow: 0 2px 6px rgba(var(--v-theme-on-surface), 0.05);
inline-size: 40px;
}
.admin-stat {
background-color: rgba(var(--v-theme-warning), 0.1);
box-shadow: 0 2px 6px rgba(var(--v-theme-warning), 0.2);
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.1rem;
font-weight: 600;
}
.stat-label {
color: rgba(var(--v-theme-on-surface), 0.6);
font-size: 0.75rem;
}
.menu-item {
font-size: 0.9rem;
}
.text-error {
color: rgb(var(--v-theme-error));
}
.email-container {
display: flex;
overflow: hidden;
align-items: center;
background-color: transparent;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.05);
padding-block: 8px;
padding-inline: 16px;
white-space: nowrap;
}
.admin-email {
background-color: transparent;
}
.inactive-email {
background-color: transparent;
opacity: 0.9;
}
.email-container .email-icon {
flex-shrink: 0;
margin-inline-end: 8px;
opacity: 0.7;
}
.email-container .email-text {
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.8);
font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-card .email-container {
padding-block: 6px;
padding-inline: 12px;
}
.mobile-card .email-container .email-text {
font-size: 0.8rem;
}
.mobile-card .user-avatar-container {
position: relative;
}
.mobile-card .otp-badge {
position: absolute;
z-index: 10;
inset-block-end: 0 !important;
inset-inline-end: 0 !important;
}
</style>

View File

@@ -0,0 +1,317 @@
<script lang="ts" setup>
import { Workflow } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
import api from '@/api'
// 定义输入参数
const props = defineProps({
workflow: {
required: true,
type: Object as PropType<Workflow>,
},
})
// 定义事件
const emit = defineEmits(['refresh'])
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 编辑对话框
const editDialog = ref(false)
// 流程对话框
const flowDialog = ref(false)
// 加载中
const loading = ref(false)
// 编辑任务
function handleEdit(item: Workflow) {
editDialog.value = true
}
// 编辑流程
function handleFlow(item: Workflow) {
flowDialog.value = true
}
// 计算已完成的动作数
function resolveDoneActions(item: Workflow) {
return item.current_action?.split(',').length || 0
}
// 编辑完成
function editDone() {
editDialog.value = false
flowDialog.value = false
emit('refresh')
}
// 删除任务
async function handleDelete(item: Workflow) {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认删除任务 ${item.name} ?`,
})
if (!isConfirmed) return
try {
const result: { [key: string]: string } = await api.delete(`workflow/${item.id}`)
if (result.success) {
$toast.success('删除任务成功!')
emit('refresh')
} else {
$toast.error(`删除任务失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
}
// 开始任务
async function handleEnable(item: Workflow) {
loading.value = true
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/start`)
if (result.success) {
$toast.success('启用任务成功!')
emit('refresh')
} else {
$toast.error(`启用任务失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
loading.value = false
}
// 停用任务
async function handlePause(item: Workflow) {
loading.value = true
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/pause`)
if (result.success) {
$toast.success('停用任务成功!')
emit('refresh')
} else {
$toast.error(`停用任务失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
loading.value = false
}
// 立即执行任务
async function handleRun(item: Workflow, from_begin: boolean) {
loading.value = true
try {
setTimeout(() => {
emit('refresh')
}, 500)
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/run?from_begin=${from_begin}`, {
from_begin,
})
if (result.success) {
$toast.success('任务执行完成!')
emit('refresh')
} else {
$toast.error(`任务执行失败:${result.message}`)
emit('refresh')
}
} catch (error) {
console.error(error)
}
loading.value = false
}
// 重置任务
async function handleReset(item: Workflow) {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认重置任务 ${item.name} ?`,
})
if (!isConfirmed) return
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/reset`)
if (result.success) {
$toast.success('重置任务成功!')
emit('refresh')
} else {
$toast.error(`重置任务失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
}
// 计算状态颜色
const resolveStatusVariant = (status: string | undefined) => {
if (status === 'S') return { color: 'success', text: '成功' }
else if (status === 'R') return { color: 'primary', text: '运行中' }
else if (status === 'F') return { color: 'error', text: '失败' }
else if (status === 'P') return { color: 'secondary', text: '暂停' }
else return { color: 'info', text: '等待' }
}
// 计算当前动作占比
const resolveProgress = (item: Workflow) => {
const current_action_length = item.current_action?.split(',').length || 0
return item.actions?.length ? Math.round((current_action_length / (item.actions.length || 1)) * 100) : 0
}
</script>
<template>
<div class="h-full">
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
class="mx-auto h-full"
@click="handleFlow(workflow)"
:ripple="false"
:loading="loading"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VCardItem class="py-3" :class="`bg-${resolveStatusVariant(workflow?.state).color}`">
<template #prepend>
<VAvatar variant="text" class="me-2">
<VIcon
v-if="workflow?.state === 'P'"
color="success"
size="x-large"
icon="mdi-play"
@click.stop="handleEnable(workflow)"
/>
<VIcon v-else color="warning" icon="mdi-pause" size="x-large" @click.stop="handlePause(workflow)" />
</VAvatar>
</template>
<VCardTitle class="text-white">
{{ workflow?.name }}
</VCardTitle>
<VCardSubtitle class="text-white">{{ workflow?.description }}</VCardSubtitle>
<template #append>
<IconBtn>
<VIcon icon="mdi-vector-polyline-edit" @click.stop="handleFlow(workflow)" />
</IconBtn>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem base-color="primary" @click="handleEdit(workflow)">
<template #prepend>
<VIcon icon="mdi-note-edit" />
</template>
<VListItemTitle>编辑任务</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, false)">
<template #prepend>
<VIcon icon="mdi-play-speed" />
</template>
<VListItemTitle>继续执行</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-replay" />
</template>
<VListItemTitle>重新执行</VListItemTitle>
</VListItem>
<VListItem v-else base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-run" />
</template>
<VListItemTitle>立即执行</VListItemTitle>
</VListItem>
<VListItem base-color="warning" @click="handleReset(workflow)">
<template #prepend>
<VIcon icon="mdi-restore-alert" />
</template>
<VListItemTitle>重置任务</VListItemTitle>
</VListItem>
<VListItem base-color="error" @click="handleDelete(workflow)">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>删除任务</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
</VCardItem>
<VDivider />
<VCardText>
<div class="d-flex flex-column gap-y-4">
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">定时</div>
<h5 class="text-h6">{{ workflow?.timer }}</h5>
</div>
<div class="flex-1">
<div class="mb-1">状态</div>
<h5 class="text-h6" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
{{ resolveStatusVariant(workflow?.state).text }}
</h5>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">动作数</div>
<div>
<VAvatar size="32" color="primary" variant="tonal">
<span class="text-sm">{{ workflow?.actions?.length }}</span>
</VAvatar>
</div>
</div>
<div class="flex-1">
<div class="mb-1">已执行次数</div>
<h5 class="text-h6">{{ workflow?.run_count }}</h5>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">进度</div>
<div class="d-flex align-center gap-5">
<div class="flex-grow-1">
<VProgressLinear color="info" rounded :model-value="resolveProgress(workflow)" />
</div>
<span> {{ resolveProgress(workflow) }}% </span>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6" v-if="workflow?.result">
<div class="flex-1">
<div class="mb-1">错误信息</div>
<div class="text-error">{{ workflow?.result }}</div>
</div>
</div>
</div>
</VCardText>
</VCard>
</VHover>
<!-- 流程对话框 -->
<WorkflowActionsDialog
v-if="flowDialog"
v-model="flowDialog"
@close="flowDialog = false"
@save="editDone"
:workflow="workflow"
/>
<!-- 编辑对话框 -->
<WorkflowAddEditDialog
v-if="editDialog"
v-model="editDialog"
@close="editDialog = false"
@save="editDone"
:workflow="workflow"
/>
</div>
</template>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
// 输入参数
const props = defineProps({
title: String,
media: Object as PropType<MediaInfo>,
torrent: Object as PropType<TorrentInfo>,
})
// 定义成功和失败事件
const emit = defineEmits(['done', 'error', 'close'])
// 提示框
const $toast = useToast()
// 选择的下载器
const selectedDownloader = ref<string | null>(null)
// 选择的保存目录
const selectedDirectory = ref<string | null>(null)
// 下载器
const downloaders = ref<DownloaderConf[]>([])
// 所有目录设置
const directories = ref<TransferDirectoryConf[]>([])
// 是否正在加载
const loading = ref(false)
// 计算按钮图标
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
// 计算按钮文字
const buttonText = computed(() => (loading.value ? '下载中...' : '开始下载'))
// 加载目录设置
async function loadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Directories')
directories.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 获取保存目录
const targetDirectories = computed(() => {
const downloadDirectories = directories.value.map(item => item.download_path)
return [...new Set(downloadDirectories)]
})
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
downloaders.value = await api.get('download/clients')
} catch (error) {
console.log(error)
}
}
// 下载器可选项
const downloaderOptions = computed(() => {
return downloaders.value.map(item => ({
title: item.name,
value: item.name,
}))
})
// 添加下载
async function addDownload() {
startNProgress()
loading.value = true
try {
let result: { [key: string]: any }
const payload: any = {
torrent_in: props.torrent,
downloader: selectedDownloader.value,
save_path: selectedDirectory.value,
}
if (props.media) {
payload.media_in = props.media
}
const endpoint = props.media ? 'download/' : 'download/add'
result = await api.post(endpoint, payload)
if (result && result.success) {
// 添加下载成功
$toast.success(`${props.torrent?.site_name} ${props.torrent?.title} 下载成功!`)
// 下载成功,返回链接
emit('done', props.torrent?.enclosure)
} else {
// 添加下载失败
$toast.error(`${props.torrent?.site_name} ${props.torrent?.title} 下载失败:${result?.message}`)
// 下载失败,返回错误原因
emit('error', result?.message)
}
} catch (error) {
console.error(error)
}
loading.value = false
doneNProgress()
}
onMounted(() => {
loadDirectories()
loadDownloaderSetting()
})
</script>
<template>
<VDialog max-width="35rem" scrollable>
<VCard>
<VCardTitle class="py-3 me-12">
<VIcon icon="mdi-download" class="me-2" />
<span v-if="title">{{ torrent?.site_name }} - {{ title }}</span>
<span v-else>确认下载</span>
</VCardTitle>
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VList lines="one">
<VListItem>
<template #prepend>
<VIcon icon="mdi-web"></VIcon>
</template>
<VListItemTitle>
<span class="whitespace-break-spaces me-2">{{ torrent?.title }}</span>
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="torrent?.description">
<template #prepend>
<VIcon icon="mdi-subtitles-outline"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-2 whitespace-break-spaces">{{ torrent?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="torrent?.size">
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-2">
<VChip variant="tonal" label>
{{ formatFileSize(torrent?.size || 0) }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
<VRow class="px-7">
<VCol cols="12" md="4">
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
size="small"
label="下载器(默认)"
variant="underlined"
placeholder="留空默认"
density="compact"
/>
</VCol>
<VCol cols="12" md="8">
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
label="保存目录(自动)"
size="small"
placeholder="留空自动匹配"
variant="underlined"
density="compact"
/>
</VCol>
</VRow>
<VCardText class="text-center">
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
{{ buttonText }}
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,60 @@
<script lang="ts" setup>
import api from '@/api'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done', 'close'])
// 完成
async function handleDone() {
await savaAlistConfig()
emit('done')
}
// 保存rclone设置
async function savaAlistConfig() {
try {
await api.post(`storage/save/alist`, props.conf)
} catch (e) {
console.error(e)
}
}
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="AList配置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="props.conf.url" hint="AList服务地址" label="地址" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="props.conf.username" hint="AList登录用户名" label="用户名" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="props.conf.password"
hint="AList登录密码"
label="密码"
persistent-hint
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,18 +1,19 @@
<script lang="ts" setup>
import QrcodeVue from 'qrcode.vue'
import api from '@/api'
// 定义输入
defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done', 'close'])
// 二维码内容
const qrCodeContent = ref('')
// ck参数
const ck = ref('')
// t参数
const t = ref('')
const qrCodeUrl = ref('')
// 下方的提示信息
const text = ref('请用阿里云盘 App 扫码')
@@ -25,17 +26,17 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
emit('done')
}
// 调用/aliyun/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/aliyun/qrcode')
const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
ck.value = result.data.ck
t.value = result.data.t
qrCodeUrl.value = result.data.codeUrl
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
text.value = result.message
}
@@ -47,26 +48,21 @@ async function getQrcode() {
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
try {
const result: { [key: string]: any } = await api.get('/aliyun/check', {
params: {
ck: ck.value,
t: t.value,
},
})
const result: { [key: string]: any } = await api.get('/storage/check/alipan')
if (result.success && result.data) {
const qrCodeStatus = result.data.qrCodeStatus
const qrCodeStatus = result.data.status
text.value = result.data.tip
if (qrCodeStatus == 'CONFIRMED') {
// 已确认完成
if (qrCodeStatus == 'LoginSuccess') {
// 登录成功
alertType.value = 'success'
handleDone()
} else if (qrCodeStatus == 'NEW' || qrCodeStatus == 'SCANED') {
} else if (qrCodeStatus == 'WaitLogin' || qrCodeStatus == 'ScanSuccess') {
// 等待登录扫码成功
alertType.value = 'info'
// 新建、待扫码
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
// 过期或者已取消
// 二维码过期
alertType.value = 'error'
}
} else {
@@ -80,7 +76,6 @@ async function checkQrcode() {
onMounted(async () => {
await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000)
})
onUnmounted(() => {
@@ -94,7 +89,13 @@ onUnmounted(() => {
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 shadow-lg rounded text-center p-3 border">
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
<VImg class="mx-auto" :src="qrCodeUrl" width="200" height="200">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
</div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<template #prepend />

View File

@@ -0,0 +1,281 @@
<script setup lang="ts">
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useToast } from 'vue-toast-notification'
import { VBtn } from 'vuetify/lib/components/index.mjs'
// 输入参数
const props = defineProps({
media: Object as PropType<SubscribeShare>,
})
// 定义事件
const emit = defineEmits(['fork', 'delete', 'close'])
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 提示框
const $toast = useToast()
// 处理中
const processing = ref(false)
// 删除中
const deleting = ref(false)
// 是否折叠
const isExpanded = ref(false)
// follow用户列表
const followUsers = ref<string[]>([])
// 当前用户是否已follow
const isFollowed = computed(() => followUsers.value.includes(props.media?.share_uid || ''))
// 折叠展开
function toggleExpand() {
isExpanded.value = !isExpanded.value
}
// 加载follow用户列表
async function queryFollowUsers() {
try {
const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')
followUsers.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// follow用户
async function followUser() {
try {
const result: { [key: string]: any } = await api.post(`subscribe/follow?share_uid=${props.media?.share_uid}`)
if (result.success) {
queryFollowUsers()
}
} catch (error) {
console.log(error)
}
}
// unfollow用户
async function unfollowUser() {
try {
const result: { [key: string]: any } = await api.delete('subscribe/follow', {
params: {
share_uid: props.media?.share_uid,
},
})
if (result.success) {
queryFollowUsers()
}
} catch (error) {
console.log(error)
}
}
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 获得mediaid
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
}
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
title: props.media?.name,
year: props.media?.year,
type: props.media?.type,
},
})
}
// 复用订阅
async function doFork() {
// 开始处理
startNProgress()
try {
processing.value = true
// 请求API
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
// 完成
emit('fork', result.data.id)
} else {
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}`)
}
} catch (error) {
console.error(error)
} finally {
processing.value = false
doneNProgress()
}
}
// 删除订阅分享
async function doDelete() {
// 开始处理
startNProgress()
try {
deleting.value = true
// 请求API
const result: { [key: string]: any } = await api.delete(`subscribe/share/${props.media?.id}`, {
params: {
share_uid: globalSettings.USER_UNIQUE_ID,
},
})
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 取消分享成功!`)
// 完成
emit('delete', result.data.id)
} else {
$toast.error(`${props.media?.share_title} 取消分享失败:${result.message}`)
}
} catch (error) {
console.error(error)
} finally {
deleting.value = false
doneNProgress()
}
}
onMounted(() => {
queryFollowUsers()
})
</script>
<template>
<VDialog max-width="40rem" scrollable>
<VCard>
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="ma-auto">
<VImg
width="10rem"
aspect-ratio="2/3"
class="object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500"
:src="posterUrl"
@click="viewMediaDetail"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
>
{{ props.media?.share_title }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
>
{{ props.media?.share_comment }}
</VCardSubtitle>
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">分享人</span>
<span class="text-body-1"> {{ media?.share_user }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0" v-if="media?.keyword">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">搜索词</span>
<span class="text-body-1"> {{ media?.keyword }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0" v-if="media?.custom_words" @click.stop="toggleExpand">
<VListItemTitle
class="text-center text-md-left break-words whitespace-break-spaces"
:class="{
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
}"
>
<span class="font-weight-medium">识别词</span>
<span class="text-body-1"> {{ media?.custom_words }}</span>
</VListItemTitle>
</VListItem>
</VList>
<div class="text-center text-md-left">
<div>
<VBtn
color="primary"
:disabled="processing"
@click="doFork"
prepend-icon="mdi-heart"
:loading="processing"
class="mb-2 me-2"
>
订阅
</VBtn>
<VBtn
v-if="isFollowed && props.media?.share_uid"
color="warning"
@click="unfollowUser"
prepend-icon="mdi-account-remove"
class="mb-2 me-2"
>
取消关注
</VBtn>
<VBtn
v-else-if="props.media?.share_uid"
@click="followUser"
color="info"
prepend-icon="mdi-account-plus"
class="mb-2 me-2"
>
关注
</VBtn>
<VBtn
v-if="
(props.media?.share_uid && props.media?.share_uid === globalSettings.USER_UNIQUE_ID) ||
globalSettings.SUBSCRIBE_SHARE_MANAGE
"
color="error"
:disabled="deleting"
@click="doDelete"
prepend-icon="mdi-delete"
:loading="deleting"
class="mb-2 me-2"
>
取消分享
</VBtn>
</div>
<div class="text-xs mt-2" v-if="props.media?.count">
<VIcon icon="mdi-fire" /> {{ props.media?.count?.toLocaleString() }} 次复用
</div>
</div>
</VCardItem>
</div>
</div>
</VCol>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -2,23 +2,24 @@
// 输入参数
const props = defineProps({
title: String,
dataType: String,
})
// 定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 代码
const codeString = ref('')
// 定义事件
const emit = defineEmits(['close', 'save'])
// 导入
function handleImport() {
emit('update:modelValue', codeString.value)
emit('save', props.dataType, codeString)
emit('close')
}
</script>
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VDialog width="40rem" scrollable max-height="85vh" persistent>
<VCard :title="props.title" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { Context } from '@/api/types'
import MediaInfoCard from '../cards/MediaInfoCard.vue'
// 输入参数
defineProps({
context: Object as PropType<Context>,
})
// 定义事件
const emit = defineEmits(['close'])
</script>
<template>
<VDialog max-width="50rem">
<VCard>
<DialogCloseBtn @click="emit('close')" />
<VCardItem>
<MediaInfoCard :context="context" />
</VCardItem>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import FormRender from '../render/FormRender.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 输入参数
const props = defineProps({
plugin: {
type: Object as PropType<Plugin>,
},
})
// 定义事件
const emit = defineEmits(['close', 'save', 'switch'])
// 显示器宽度
const display = useDisplay()
// 插件配置表单数据
const pluginConfigForm = ref({})
// 插件表单配置项
let pluginFormItems = reactive([])
// 进度框
const progressDialog = ref(false)
// 进度文字
const progressText = ref('')
// 提示框
const $toast = useToast()
// 是否刷新
const isRefreshed = ref(false)
// 调用API读取表单页面
async function loadPluginForm() {
try {
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (result) {
pluginFormItems = result.conf
if (result.model) pluginConfigForm.value = result.model
}
} catch (error) {
console.error(error)
}
isRefreshed.value = true
}
// 调用API读取配置数据
async function loadPluginConf() {
try {
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
} catch (error) {
console.error(error)
}
isRefreshed.value = true
}
// 调用API保存配置数据
async function savePluginConf() {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
try {
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
if (result.success) {
progressDialog.value = false
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
// 通知父组件刷新
emit('save')
} else {
progressDialog.value = false
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
}
} catch (error) {
console.error(error)
}
}
onBeforeMount(async () => {
await loadPluginForm()
await loadPluginConf()
})
</script>
<template>
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText v-if="isRefreshed">
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info"> 查看数据 </VBtn>
<VSpacer />
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
</VCardActions>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</VDialog>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import PageRender from '@/components/render/PageRender.vue'
import api from '@/api'
// 输入参数
const props = defineProps({
plugin: {
type: Object as PropType<Plugin>,
},
})
// 定义事件
const emit = defineEmits(['close', 'save', 'switch'])
// 显示器宽度
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// 是否刷新
const isRefreshed = ref(false)
// 插件数据页面配置项
let pluginPageItems = ref([])
// 调用API读取数据页面
async function loadPluginPage() {
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
if (result) pluginPageItems.value = result
} catch (error) {
console.error(error)
}
isRefreshed.value = true
}
onMounted(() => {
loadPluginPage()
})
</script>
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else class="min-h-40">
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
</VCardText>
<VFab
icon="mdi-cog"
location="bottom"
size="x-large"
fixed
app
appear
@click="emit('switch')"
:class="{ 'mb-10': appMode }"
/>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import api from '@/api'
import { useToast } from 'vue-toast-notification'
const $toast = useToast()
// 插件仓库设置字符串
const repoString = ref('')
// 定义事件
const emit = defineEmits(['save', 'close'])
// 查询已设置的插件仓库
async function queryMarketRepoSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
if (result && result.data && result.data.value) repoString.value = result.data.value
} catch (error) {
console.log(error)
}
}
// 保存设置
async function saveHandle() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoString.value)
if (result.success) {
$toast.success('插件仓库保存成功')
emit('save')
} else $toast.error(`插件仓库保存失败:${result?.message}`)
} catch (error) {
console.log(error)
}
}
onMounted(() => {
queryMarketRepoSetting()
})
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard class="rounded-t">
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-store-cog" class="me-2" />
插件仓库设置
</VCardTitle>
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<VCardText class="pt-2">
<VTextarea
v-model="repoString"
placeholder="格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/"
hint="多个地址使用逗号分隔仅支持Github仓库"
persistent-hint
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
保存
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

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

View File

@@ -0,0 +1,66 @@
<script lang="ts" setup>
import api from '@/api'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
if (!props.conf.filepath) {
props.conf.filepath = '/moviepilot/.config/rclone/rclone.conf'
}
if (!props.conf.content) {
props.conf.content = '# 请在此处填写rclone配置文件内容 \n# 请参考 https://rclone.org/docs/ \n# 存储节点名必须为MP'
}
// 定义事件
const emit = defineEmits(['done', 'close'])
// 完成
async function handleDone() {
await savaRcloneConfig()
emit('done')
}
// 保存rclone设置
async function savaRcloneConfig() {
try {
await api.post(`storage/save/rclone`, props.conf)
} catch (e) {
console.error(e)
}
}
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="RClone配置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="props.conf.filepath" label="rclone配置文件路径" />
</VCol>
<VCol cols="12">
<VAceEditor
v-model:value="props.conf.content"
lang="ini"
theme="monokai"
style="block-size: 30rem"
class="rounded"
>
</VAceEditor>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,27 +1,30 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import store from '@/store'
import api from '@/api'
import { storageOptions, transferTypeOptions } from '@/api/constants'
import { numberValidator } from '@/@validators'
import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { FileItem, MediaDirectory } from '@/api/types'
import { FileItem, TransferDirectoryConf, TransferForm } from '@/api/types'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
storage: {
type: String,
default: () => 'local',
},
logids: Array<number>,
items: Array<FileItem>,
target: String,
target_storage: String,
target_path: String,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 当前识别类型
const mediaSource = ref(globalSettings.data?.RECOGNIZE_SOURCE || 'themoviedb')
// 定义事件
const emit = defineEmits(['done', 'close'])
@@ -33,9 +36,6 @@ const seasonItems = ref(
})),
)
// 当前识别类型
const mediaSource = ref('themoviedb')
// 提示框
const $toast = useToast()
@@ -49,7 +49,7 @@ const progressEventSource = ref<EventSource>()
const progressDialog = ref(false)
// 整理进度文本
const progressText = ref('请稍候 ...')
const progressText = ref('正在处理 ...')
// 整理进度
const progressValue = ref(0)
@@ -65,56 +65,102 @@ const dialogTitle = computed(() => {
return '手动整理'
})
// 禁用指定集数
const disableEpisodeDetail = computed(() => {
if (props.items) {
if (transferForm.episode_format) return false
return !(props.items.length === 1 && props.items[0].type !== 'dir')
}
})
// 表单
const transferForm = reactive({
storage: props.storage,
const transferForm = reactive<TransferForm>({
fileitem: {} as FileItem,
logid: 0,
path: '',
drive_id: '',
fileid: '',
filetype: '',
target: props.target ?? null,
tmdbid: null,
doubanid: null,
season: null,
type_name: '',
target_storage: props.target_storage ?? 'local',
transfer_type: '',
episode_format: '',
episode_detail: '',
episode_part: '',
episode_offset: null,
target_path: '',
min_filesize: 0,
scrape: false,
from_history: false,
})
// 所有媒体库目录
const libraryDirectories = ref<MediaDirectory[]>([])
const directories = ref<TransferDirectoryConf[]>([])
// 查询目录
async function loadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Directories')
directories.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 目的目录下拉框
const targetDirectories = computed(() => {
const directories = libraryDirectories.value.map(item => item.path)
return [...new Set(directories)]
const libraryDirectories = directories.value.map(item => item.library_path)
return [...new Set(libraryDirectories)]
})
// 监听目的路径变化,自动查询目录的刮削配置
watch(transferForm, async () => {
if (transferForm.target) {
const directory = libraryDirectories.value.find(item => item.path === transferForm.target)
if (directory) {
transferForm.scrape = directory.scrape ?? false
// 监听目的路径变化,配置默认值
watch(
() => transferForm.target_path,
async newPath => {
if (newPath) {
const directory = directories.value.find(item => item.library_path === newPath)
if (directory) {
transferForm.target_storage = directory.library_storage ?? 'local'
transferForm.transfer_type = transferForm.transfer_type || directory.transfer_type
transferForm.scrape = directory.scraping ?? false
transferForm.library_category_folder = directory.library_category_folder ?? false
transferForm.library_type_folder = directory.library_type_folder ?? false
} else {
transferForm.transfer_type = transferForm.transfer_type || 'copy'
transferForm.scrape = false
transferForm.library_category_folder = false
transferForm.library_type_folder = false
}
} else {
// 路径为空时, 恢复到`自动`条件
transferForm.transfer_type = ''
transferForm.library_type_folder = undefined
transferForm.library_category_folder = undefined
}
},
)
// 整理文件
async function handleTransfer(item: FileItem, background: boolean = false) {
transferForm.fileitem = item
transferForm.logid = 0
try {
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
if (!result.success) $toast.error(result.message)
else if (background) $toast.success(`文件 ${item.name} 已加入整理队列!`)
} catch (e) {
console.log(e)
}
})
}
// 整理日志
async function handleTransferLog(logid: number, background: boolean = false) {
transferForm.logid = logid
transferForm.fileitem = {} as FileItem
try {
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
if (!result.success) $toast.error(result.message)
else if (background) $toast.success(`历史记录 ${logid} 已加入整理队列!`)
} catch (e) {
console.log(e)
}
}
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer?token=${token}`,
)
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
@@ -130,101 +176,85 @@ function stopLoadingProgress() {
}
// 整理文件
async function transfer() {
async function transfer(background: boolean = false) {
if (!props.logids && !props.items) return
// 显示进度条
progressDialog.value = true
// 开始监听进度
startLoadingProgress()
if (!background) {
// 开始监听进度
startLoadingProgress()
}
// 文件整理
if (props.items) {
for (const item of props.items) {
await handleTransfer(item)
await handleTransfer(item, background)
}
}
// 日志整理
if (props.logids) {
for (const logid of props.logids) {
await handleTransferLog(logid)
await handleTransferLog(logid, background)
}
}
// 停止监听进度
stopLoadingProgress()
if (!background) {
// 停止监听进度
stopLoadingProgress()
}
// 关闭进度条
progressDialog.value = false
// 重新加载
emit('done')
}
// 整理文件
async function handleTransfer(item: FileItem) {
transferForm.path = item.path
transferForm.fileid = item.fileid || ''
transferForm.drive_id = item.drive_id || ''
transferForm.filetype = item.type || 'dir'
try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
if (!result.success) $toast.error(`文件 ${item.path} 整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
// 整理日志
async function handleTransferLog(logid: number) {
transferForm.logid = logid
try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
// 调用API加载当前系统环境设置
async function loadSystemSettings() {
try {
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()
loadDirectories()
})
onUnmounted(() => {
stopLoadingProgress()
})
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="dialogTitle" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol v-if="props.storage == 'local'" cols="12" md="8">
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.target_storage"
:items="storageOptions"
label="目的存储"
placeholder="留空自动"
hint="整理目的存储"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
:items="transferTypeOptions"
hint="文件操作整理方式"
persistent-hint
>
<template v-slot:selection="{ item }">
{{ transferForm.transfer_type === '' ? '自动' : item.title }}
</template>
</VSelect>
</VCol>
<VCol cols="12">
<VCombobox
v-model="transferForm.target"
v-model="transferForm.target_path"
:items="targetDirectories"
label="目的路径"
placeholder="留空自动"
@@ -232,26 +262,9 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol v-if="props.storage == 'local'" cols="12" md="4">
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
:items="[
{ title: '默认', value: '' },
{ title: '移动', value: 'move' },
{ title: '复制', value: 'copy' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
{ title: 'Rclone复制', value: 'rclone_copy' },
{ title: 'Rclone移动', value: 'rclone_move' },
]"
hint="文件操作整理方式"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.type_name"
label="类型"
@@ -264,7 +277,7 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" md="6">
<VTextField
v-if="mediaSource === 'themoviedb'"
v-model="transferForm.tmdbid"
@@ -290,19 +303,37 @@ onMounted(() => {
@click:append-inner="mediaSelectorDialog = true"
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-show="transferForm.type_name === '电视剧'"
v-model.number="transferForm.season"
label="季"
:items="seasonItems"
hint="指定季数"
</VRow>
<VRow v-show="transferForm.type_name === '电视剧'">
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_group"
label="剧集组编号"
placeholder="手动查询剧集组"
hint="指定剧集组"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="8">
<VCol cols="12" md="3">
<VSelect
v-model.number="transferForm.season"
label="季"
:items="seasonItems"
hint="第几季"
persistent-hint
/>
</VCol>
<VCol cols="12" md="3">
<VTextField
v-model="transferForm.episode_detail"
:disabled="disableEpisodeDetail"
label="集"
placeholder="起始集,终止集"
hint="集数或范围如1或1,2"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_format"
label="集数定位"
@@ -311,16 +342,18 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_detail"
label="指定集数"
placeholder="起始集,终止集如1或1,2"
hint="指定集数或范围如1或1,2"
v-model="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
hint="集数偏移运算,如-10或EP*2"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
</VRow>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_part"
label="指定Part"
@@ -329,16 +362,7 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model.number="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
hint="集数偏移运算,如-10或EP*2"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" md="6">
<VTextField
v-model.number="transferForm.min_filesize"
label="最小文件大小MB"
@@ -350,6 +374,22 @@ onMounted(() => {
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6" v-if="transferForm.target_path">
<VSwitch
v-model="transferForm.library_type_folder"
label="按类型分类"
hint="整理时目的路径下按媒体类型添加子目录"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="transferForm.target_path">
<VSwitch
v-model="transferForm.library_category_folder"
label="按类别分类"
hint="整理时在目的路径下按媒体类别添加子目录"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="transferForm.scrape"
@@ -358,12 +398,25 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="props.logids">
<VSwitch
v-model="transferForm.from_history"
label="复用历史识别信息"
hint="使用历史整理记录中已识别的媒体信息"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn variant="elevated" @click="transfer" prepend-icon="mdi-arrow-right-bold" class="px-5"> 开始整理 </VBtn>
<VBtn variant="elevated" color="success" @click="transfer(true)" prepend-icon="mdi-plus" class="px-5">
加入整理队列
</VBtn>
<VBtn variant="elevated" @click="transfer(false)" prepend-icon="mdi-arrow-right-bold" class="px-5">
立即整理
</VBtn>
</VCardActions>
</VCard>
<!-- 手动整理进度框 -->

View File

@@ -0,0 +1,855 @@
<script setup lang="ts">
import api from '@/api'
import type { Site, Plugin, Subscribe } from '@/api/types'
import { SystemNavMenus, SettingTabs } from '@/router/menu'
import { NavMenu } from '@/@layouts/types'
import { useUserStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
// 定义props接收modelValue
const props = defineProps<{
modelValue: boolean
}>()
// 路由
const router = useRouter()
// 用户 Store
const userStore = useUserStore()
// 超级用户
const superUser = userStore.superUser
// 当前用户名
const userName = userStore.userName
// 所有订阅数据
const SubscribeItems = ref<Subscribe[]>([])
// 站点选择对话框
const chooseSiteDialog = ref(false)
const selectedSites = ref<number[]>([])
const allSites = ref<Site[]>([])
// 定义事件
const emit = defineEmits(['close', 'update:modelValue'])
// 对话框状态的本地计算属性
const dialog = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val),
})
// 搜索词
const searchWord = ref<string | null>(null)
// ref
const searchWordInput = ref<HTMLElement | null>(null)
// 近期搜索词条
const recentSearches = ref<string[]>([])
// 保存近期搜索到本地
function saveRecentSearches(keyword: string) {
if (!keyword) return
if (recentSearches.value.includes(keyword)) return
recentSearches.value.unshift(keyword)
localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))
}
// 从本地加载近期搜索
function loadRecentSearches() {
const recentSearchesStr = localStorage.getItem('MP_RecentSearches')
if (recentSearchesStr) {
recentSearches.value = JSON.parse(recentSearchesStr)
// 只保留最近的 5 条
if (recentSearches.value.length > 5) {
recentSearches.value = recentSearches.value.slice(0, 5)
}
}
}
// 所有菜单功能
function getMenus(): NavMenu[] {
let menus: NavMenu[] = []
// 导航菜单
SystemNavMenus.forEach(
item =>
item &&
menus.push({
title: item.full_title ?? item.title,
icon: item.icon,
to: item.to,
header: item.header,
admin: item.admin,
}),
)
// 设置标签页
SettingTabs.forEach(
item =>
item &&
menus.push({
title: '设定 -> ' + item.title,
icon: item.icon,
to: `/setting?tab=${item.tab}`,
header: '',
admin: true,
description: item.description,
}),
)
return menus
}
// 匹配的菜单列表
const matchedMenuItems = computed(() => {
if (!searchWord.value) return []
if (!superUser) return []
const lowerWord = (searchWord.value as string).toLowerCase()
const menuItems = getMenus()
if (menuItems)
return menuItems.filter(
item =>
item.title.toLowerCase().includes(lowerWord) ||
(item.description && item.description.toLowerCase().includes(lowerWord)),
)
return []
})
// 所有插件(已安装)
const pluginItems = ref<Plugin[]>([])
// 获取插件列表数据
async function fetchInstalledPlugins() {
try {
pluginItems.value = await api.get('plugin/', {
params: {
state: 'installed',
},
})
} catch (error) {
console.error(error)
}
}
// 区配的插件列表
const matchedPluginItems = computed(() => {
if (!searchWord.value) return []
if (!superUser) return []
const lowerWord = (searchWord.value as string).toLowerCase()
return pluginItems.value.filter((item: Plugin) => {
if (!item.plugin_name && !item.plugin_desc) return false
return item.plugin_name?.toLowerCase().includes(lowerWord) || item.plugin_desc?.toLowerCase().includes(lowerWord)
})
})
// 获取订阅列表数据
async function fetchSubscribes() {
try {
SubscribeItems.value = await api.get('subscribe/')
} catch (error) {
console.error(error)
}
}
// 从接口加载用户站点偏好设置
const loadUserSitePreferences = async () => {
try {
const result = await api.get('system/setting/IndexerSites')
if (result && result.data && result.data.value) {
selectedSites.value = result.data.value
return
}
} catch (err) {
console.error(err)
}
}
// 查询所有站点
async function queryAllSites() {
try {
const data: Site[] = await api.get('site/')
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active)
// 如果没有选择任何站点并且有可用站点,才默认选择全部
if (selectedSites.value.length === 0 && allSites.value.length > 0) {
selectedSites.value = allSites.value.map((site: Site) => site.id)
}
} catch (error) {
console.log(error)
}
}
// 打开站点选择对话框
const openSiteDialog = () => {
chooseSiteDialog.value = true
}
// 匹配的订阅列表
const matchedSubscribeItems = computed(() => {
if (!searchWord.value) return []
const lowerWord = (searchWord.value as string).toLowerCase()
return SubscribeItems.value.filter((item: Subscribe) => {
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
})
})
// 搜索多站点
function searchSites(sites: number[]) {
chooseSiteDialog.value = false
selectedSites.value = sites
searchTorrent()
}
// 搜索资源
function searchTorrent() {
if (!searchWord.value) return
// 记录搜索词
saveRecentSearches(searchWord.value)
// 跳转到搜索页面
router.push({
path: '/resource',
query: {
keyword: searchWord.value,
area: 'title',
sites: selectedSites.value.join(','),
},
})
// 关闭搜索对话框
dialog.value = false
emit('close')
}
// 跳转媒体搜索页面
function searchMedia(searchType: string) {
// 搜索类型 media/person
if (!searchWord.value) return
saveRecentSearches(searchWord.value)
router.push({
path: '/browse/media/search',
query: {
title: searchWord.value,
type: searchType,
},
})
emit('close')
}
// 跳转到历史记录页面
function searchHistory() {
if (!searchWord.value) return
saveRecentSearches(searchWord.value)
router.push({
path: '/history',
query: {
search: searchWord.value,
},
})
emit('close')
}
// 跳转插件页面
function showPlugin(pluginId: string) {
router.push({
path: `/plugins/`,
query: {
tab: 'installed',
id: pluginId,
},
})
emit('close')
}
// 跳转菜单页面
function goPage(to: string) {
router.push(to)
emit('close')
}
// 跳转订阅页面
function goSubscribe(subscribe: Subscribe) {
if (subscribe.type === '电影') {
router.push({
path: '/subscribe/movie',
query: {
id: subscribe.id,
},
})
} else {
router.push({
path: '/subscribe/tv',
query: {
id: subscribe.id,
},
})
}
emit('close')
}
onMounted(() => {
setTimeout(() => {
searchWordInput.value?.focus()
}, 500)
fetchInstalledPlugins()
fetchSubscribes()
loadRecentSearches()
loadUserSitePreferences()
if (superUser) queryAllSites()
})
</script>
<template>
<VDialog v-model="dialog" max-width="42rem" scrollable>
<VCard class="search-dialog">
<!-- 搜索输入框 -->
<VCardItem class="pa-4 pa-sm-5 search-box-container">
<template #prepend>
<VIcon icon="mdi-magnify" color="primary" size="x-large" />
</template>
<VCombobox
ref="searchWordInput"
v-model="searchWord"
density="comfortable"
variant="outlined"
class="search-input"
placeholder="输入关键词搜索..."
@keydown.enter="searchMedia('media')"
hide-details
clearable
/>
<template #append>
<IconBtn>
<VIcon icon="mdi-close" color="primary" @click="emit('close')" size="x-large" />
</IconBtn>
</template>
</VCardItem>
<VDivider />
<!-- 主搜索结果区域 -->
<VCardText class="search-results-container pa-0">
<!-- 有搜索词时显示结果 -->
<VList lines="two" v-if="searchWord" class="search-list py-2">
<!-- 搜索结果分组标题 -->
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 媒体 </VListSubheader>
<!-- 媒体搜索选项 -->
<VHover>
<template #default="hover">
<VListItem
density="comfortable"
link
rounded="xl"
v-bind="hover.props"
@click="searchMedia('media')"
class="search-option mx-2 mx-sm-4 my-1"
>
<template #prepend>
<div class="option-icon-wrapper d-flex align-center justify-center">
<VIcon
icon="mdi-movie-search"
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
size="small"
/>
</div>
</template>
<VListItemTitle class="font-weight-medium"> 电影电视剧 </VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的影视作品
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
</template>
</VListItem>
</template>
</VHover>
<VHover>
<template #default="hover">
<VListItem
density="comfortable"
link
rounded="xl"
v-bind="hover.props"
@click="searchMedia('collection')"
class="search-option mx-2 mx-sm-4 my-1"
>
<template #prepend>
<div class="option-icon-wrapper d-flex align-center justify-center">
<VIcon
icon="mdi-movie-filter"
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
size="small"
/>
</div>
</template>
<VListItemTitle class="font-weight-medium"> 系列合集 </VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的系列作品
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
</template>
</VListItem>
</template>
</VHover>
<VHover>
<template #default="hover">
<VListItem
density="comfortable"
link
rounded="xl"
v-bind="hover.props"
@click="searchMedia('person')"
class="search-option mx-2 mx-sm-4 my-1"
>
<template #prepend>
<div class="option-icon-wrapper d-flex align-center justify-center">
<VIcon
icon="mdi-account-search"
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
size="small"
/>
</div>
</template>
<VListItemTitle class="font-weight-medium"> 演职人员 </VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的演员导演等
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
</template>
</VListItem>
</template>
</VHover>
<VHover v-if="superUser">
<template #default="hover">
<VListItem
density="comfortable"
link
rounded="xl"
v-bind="hover.props"
@click="searchHistory"
class="search-option mx-2 mx-sm-4 my-1"
>
<template #prepend>
<div class="option-icon-wrapper d-flex align-center justify-center">
<VIcon icon="mdi-history" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
</div>
</template>
<VListItemTitle class="font-weight-medium"> 整理记录 </VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的历史记录
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
</template>
</VListItem>
</template>
</VHover>
<!-- 其他搜索结果 -->
<template v-if="matchedSubscribeItems.length > 0">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 订阅 </VListSubheader>
<VHover v-for="subscribe in matchedSubscribeItems" :key="subscribe.id">
<template #default="hover">
<VListItem
density="comfortable"
link
rounded="xl"
v-bind="hover.props"
@click="goSubscribe(subscribe)"
class="search-option mx-2 mx-sm-4 my-1"
>
<template #prepend>
<div class="option-icon-wrapper d-flex align-center justify-center">
<VIcon
:icon="subscribe.type === '电影' ? 'mdi-movie-roll' : 'mdi-television-classic'"
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
size="small"
/>
</div>
</template>
<VListItemTitle class="font-weight-medium">
{{ subscribe.name
}}<span v-if="subscribe.season" class="text-body-2"> {{ subscribe.season }} </span>
</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
{{ subscribe.type }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
</template>
</VListItem>
</template>
</VHover>
</template>
<template v-if="matchedMenuItems.length > 0">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 功能 </VListSubheader>
<VHover v-for="menu in matchedMenuItems" :key="menu.title">
<template #default="hover">
<VListItem
density="comfortable"
link
rounded="xl"
v-bind="hover.props"
@click="goPage(menu.to as string)"
class="search-option mx-2 mx-sm-4 my-1"
>
<template #prepend>
<div class="option-icon-wrapper d-flex align-center justify-center">
<VIcon
:icon="menu.icon as string"
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
size="small"
/>
</div>
</template>
<VListItemTitle class="font-weight-medium">
{{ menu.title }}
</VListItemTitle>
<VListItemSubtitle v-if="menu.description" class="text-body-2 text-medium-emphasis mt-1">
{{ menu.description }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
</template>
</VListItem>
</template>
</VHover>
</template>
<template v-if="matchedPluginItems.length > 0">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 插件 </VListSubheader>
<VHover v-for="plugin in matchedPluginItems" :key="plugin.id">
<template #default="hover">
<VListItem
density="comfortable"
link
rounded="xl"
v-bind="hover.props"
@click="showPlugin(plugin.id ?? '')"
class="search-option mx-2 mx-sm-4 my-1"
>
<template #prepend>
<div class="option-icon-wrapper d-flex align-center justify-center">
<VIcon icon="mdi-puzzle" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
</div>
</template>
<VListItemTitle class="font-weight-medium">
{{ plugin.plugin_name }}
</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
{{ plugin.plugin_desc }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
</template>
</VListItem>
</template>
</VHover>
</template>
<!-- 将站点资源搜索移到最底部 -->
<template v-if="searchWord">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 站点资源 </VListSubheader>
<VCard class="mx-3 mx-sm-6 mb-4 mt-2 site-search-card">
<VCardText class="pa-3 pa-sm-4">
<div class="d-flex flex-column">
<div class="d-flex align-center">
<div class="search-icon-wrapper mr-3">
<VIcon icon="mdi-file-search" color="primary" size="small" />
</div>
<div class="flex-grow-1">
<div class="font-weight-medium text-body-1">在站点中搜索种子资源</div>
<div class="text-caption text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关资源
</div>
</div>
<VBtn
color="primary"
@click="searchTorrent"
prepend-icon="mdi-magnify"
size="small"
variant="flat"
class="search-btn"
>
搜索
</VBtn>
</div>
<div
v-if="superUser"
class="d-flex align-center flex-wrap site-chips-container mt-4 py-2 px-2 px-sm-3"
>
<div class="d-flex align-center flex-wrap flex-grow-1">
<VChip
v-if="selectedSites.length > 0"
color="primary"
size="small"
variant="flat"
class="mr-2 mb-1 font-weight-medium"
>
{{ selectedSites.length }}/{{ allSites.length }}
</VChip>
<VChip
v-for="(site, index) in allSites.filter(s => selectedSites.includes(s.id)).slice(0, 5)"
:key="site.id"
size="x-small"
variant="outlined"
class="mr-1 mb-1 site-chip"
>
{{ site.name }}
</VChip>
<VChip
v-if="selectedSites.length > 5"
size="x-small"
variant="outlined"
class="mr-1 mb-1 site-chip text-medium-emphasis"
>
+{{ selectedSites.length - 5 }}
</VChip>
</div>
<VBtn
size="small"
variant="tonal"
color="primary"
@click="openSiteDialog"
class="ml-auto site-select-btn"
rounded="pill"
>
选择站点
<VIcon size="small" class="ml-1">mdi-cog-outline</VIcon>
</VBtn>
</div>
</div>
</VCardText>
</VCard>
</template>
</VList>
<!-- 无搜索词时显示最近搜索和提示 -->
<div v-else class="recent-searches py-6 px-4 px-sm-6">
<div v-if="recentSearches.length > 0" class="mb-6">
<div class="text-h6 font-weight-medium mb-3">最近搜索</div>
<div class="d-flex flex-wrap">
<VChip
v-for="(word, index) in recentSearches"
:key="index"
class="me-2 mb-2"
variant="flat"
color="primary"
size="small"
@click="searchWord = word"
>
<VIcon start size="x-small">mdi-history</VIcon>
{{ word }}
</VChip>
</div>
</div>
<div class="text-center mt-6 py-6 empty-search-state">
<div class="search-icon-wrapper mx-auto mb-4">
<VIcon icon="mdi-magnify" size="large" color="primary" />
</div>
<div class="text-h6 font-weight-medium mb-2">输入关键词开始搜索</div>
<div class="text-body-2 text-medium-emphasis">可搜索电影电视剧演员资源等</div>
</div>
</div>
</VCardText>
</VCard>
</VDialog>
<!-- 站点选择对话框 -->
<SearchSiteDialog
v-if="chooseSiteDialog"
v-model="chooseSiteDialog"
:sites="allSites"
:selected="selectedSites"
@search="searchSites"
@close="chooseSiteDialog = false"
@reload="queryAllSites"
/>
</template>
<style scoped>
.search-dialog {
overflow: hidden;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 8%);
}
.site-dialog {
overflow: hidden;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 8%);
}
.search-divider {
opacity: 0.08;
}
.close-btn {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: rgba(var(--v-theme-on-surface), 0.04);
block-size: 36px;
inline-size: 36px;
inset-block-start: 1.4rem;
inset-inline-end: 1.2rem;
transition: background-color 0.2s ease;
}
.close-btn:hover {
background-color: rgba(var(--v-theme-error), 0.1);
}
.search-input {
border-radius: 12px;
font-size: 16px;
}
.search-icon {
color: rgb(var(--v-theme-primary));
}
.option-icon-wrapper {
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.12);
block-size: 32px;
inline-size: 32px;
margin-inline-end: 12px;
}
.search-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background-color: rgba(var(--v-theme-primary), 0.08);
block-size: 36px;
inline-size: 36px;
}
.search-icon-wrapper.warning {
background-color: rgba(var(--v-theme-warning), 0.08);
}
.primary-text {
color: rgb(var(--v-theme-primary));
}
.search-option {
border: 1px solid transparent;
margin-block-end: 2px;
transition: transform 0.2s ease, background-color 0.2s ease;
}
.search-option:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
transform: translateX(4px);
}
.recent-searches {
min-block-size: 200px;
}
.site-search-card {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 14px;
}
.site-chip {
font-weight: normal;
transition: all 0.2s ease;
}
.site-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.1);
color: rgb(var(--v-theme-primary));
}
.search-btn {
font-weight: 500;
letter-spacing: 0.5px;
min-inline-size: 70px;
}
.empty-search-state,
.empty-site-state {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.clear-icon {
opacity: 0.7;
}
.clear-icon:hover {
opacity: 1;
}
.site-select-btn {
font-size: 12px;
letter-spacing: 0.5px;
min-block-size: 32px;
padding-block: 0;
padding-inline: 12px;
}
.site-chips-container {
border-radius: 10px;
background-color: rgba(var(--v-theme-surface-variant), 0.06);
}
@media (width <= 600px) {
.search-box-container {
padding: 16px;
}
.search-input {
font-size: 14px;
}
.close-btn {
block-size: 32px;
inline-size: 32px;
inset-block-start: 1rem;
inset-inline-end: 0.8rem;
}
.site-chips-container {
padding-block: 6px;
padding-inline: 8px;
}
.site-select-btn {
font-size: 11px;
min-block-size: 28px;
}
}
</style>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import type { Site, Plugin, Subscribe } from '@/api/types'
import { popScopeId, PropType } from 'vue'
const props = defineProps({
sites: {
type: Array as PropType<Site[]>,
required: true,
},
selected: Array as PropType<Number[]>,
})
// 定义事件
const emit = defineEmits(['close', 'search', 'reload'])
// 过滤词
const siteFilter = ref('')
// 已选择站点
const selectedSites = ref<any[]>(props.selected || [])
watch(
() => props.selected,
value => {
if (selectedSites.value.length == 0 && value) {
selectedSites.value = value
}
},
)
// 全选/全不选按钮文字
const checkAllText = computed(() => {
return selectedSites.value.length < props.sites?.length ? '选择全部' : '取消全选'
})
// 全选/全不选
const checkAllSitesorNot = () => {
if (selectedSites.value.length < props.sites?.length) {
selectedSites.value = props.sites?.map((item: Site) => item.id)
} else {
selectedSites.value = []
}
}
// 根据筛选条件过滤站点
const filteredSites = computed(() => {
if (!siteFilter.value) return props.sites
const filter = siteFilter.value.toLowerCase()
return props.sites?.filter((site: Site) => site.name.toLowerCase().includes(filter))
})
</script>
<template>
<!-- 手动整理进度框 -->
<VDialog max-width="40rem" fullscreen-mobile>
<VCard class="site-dialog">
<VCardTitle class="d-flex align-center pa-4">
<span class="text-h6 font-weight-medium">选择搜索站点</span>
<VSpacer />
<VTextField
v-model="siteFilter"
placeholder="过滤站点..."
density="compact"
variant="outlined"
hide-details
class="ml-4"
style="max-width: 200px"
prepend-inner-icon="mdi-magnify"
clearable
/>
</VCardTitle>
<VDivider class="search-divider" />
<VCardText style="max-height: 420px" class="overflow-y-auto px-4 py-4">
<!-- 站点列表 -->
<div v-if="filteredSites.length > 0">
<!-- 选择操作 -->
<div class="d-flex align-center mb-4">
<VBtn
size="small"
:color="selectedSites.length < sites.length ? 'primary' : 'error'"
@click="checkAllSitesorNot"
class="me-2"
rounded="pill"
variant="flat"
>
<VIcon start size="small">
{{ selectedSites.length < sites.length ? 'mdi-check-all' : 'mdi-close-circle-outline' }}
</VIcon>
{{ checkAllText }}
</VBtn>
<div
class="text-body-2 font-weight-medium"
:class="selectedSites.length > 0 ? 'text-primary' : 'text-medium-emphasis'"
>
已选择 {{ selectedSites.length }}/{{ sites.length }} 个站点
</div>
</div>
<!-- 站点选择器 -->
<VRow dense>
<VCol v-for="site in filteredSites" :key="site.id" cols="6" sm="6" md="4">
<VHover v-slot="{ isHovering, props }">
<div
v-bind="props"
:class="[
'site-checkbox-wrapper pa-2 pa-sm-3 rounded-lg d-flex align-center',
{
'site-selected': selectedSites.includes(site.id),
'site-hover': isHovering && !selectedSites.includes(site.id),
},
]"
@click="
() => {
const index = selectedSites.indexOf(site.id)
if (index === -1) {
selectedSites.push(site.id)
} else {
selectedSites.splice(index, 1)
}
}
"
>
<VIcon
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
:color="selectedSites.includes(site.id) ? 'primary' : 'medium-emphasis'"
class="me-2"
size="small"
/>
<span :class="['text-body-2 site-name', { 'font-weight-medium': selectedSites.includes(site.id) }]">
{{ site.name }}
</span>
</div>
</VHover>
</VCol>
</VRow>
</div>
<div v-else class="text-center py-8 empty-site-state">
<div class="search-icon-wrapper mb-4 mx-auto warning">
<VIcon icon="mdi-alert-circle-outline" size="large" color="warning" />
</div>
<div class="text-h6 font-weight-medium mb-2">没有找到匹配的站点</div>
<div class="text-subtitle-1 text-medium-emphasis mb-4">
{{ siteFilter ? '请尝试修改过滤条件' : '站点数据加载失败,请刷新页面重试' }}
</div>
<VBtn
v-if="siteFilter"
color="primary"
variant="flat"
class="mt-3"
prepend-icon="mdi-refresh"
@click="siteFilter = ''"
>
重置
</VBtn>
<VBtn v-else color="primary" variant="flat" class="mt-3" prepend-icon="mdi-refresh" @click="emit('reload')">
重新加载站点
</VBtn>
</div>
</VCardText>
<VDivider class="search-divider" />
<VCardActions class="pa-4">
<VSpacer />
<VBtn
color="grey-darken-1"
variant="text"
@click="emit('close')"
class="mr-2 d-flex align-center justify-center"
>
取消
</VBtn>
<VBtn
color="primary"
variant="flat"
:disabled="selectedSites.length === 0"
@click="emit('search', selectedSites)"
prepend-icon="mdi-magnify"
class="d-flex align-center justify-center px-5"
>
搜索
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.site-checkbox-wrapper {
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.site-checkbox-wrapper:hover {
transform: translateY(-2px);
}
.site-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.site-selected {
background-color: rgba(var(--v-theme-primary), 0.08);
color: rgb(var(--v-theme-primary));
border-color: rgba(var(--v-theme-primary), 0.2);
}
.site-hover {
background-color: rgba(var(--v-theme-primary), 0.04);
}
</style>

View File

@@ -1,18 +1,14 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import type { Site } from '@/api/types'
import type { DownloaderConf, Site } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { numberValidator, requiredValidator } from '@/@validators'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { useConfirm } from 'vuetify-use-dialog'
// 显示器宽度
const display = useDisplay()
// 确认框
const createConfirm = useConfirm()
// 输入参数
const props = defineProps({
siteid: Number,
@@ -35,11 +31,18 @@ const siteForm = ref<Site>({
limit_seconds: 0,
name: '',
domain: '',
downloader: '',
})
// 提示框
const $toast = useToast()
// 维护类型
const siteType = ref('cookie')
// 是否限流
const isLimit = ref(false)
// 状态下拉项
const statusItems = [
{ title: '启用', value: true },
@@ -54,10 +57,23 @@ const priorityItems = ref(
})),
)
// 监控输入参数
watchEffect(async () => {
if (props.siteid) fetchSiteInfo()
})
// 下载器选项
const downloaderOptions = ref<{ title: string; value: string }[]>([])
async function loadDownloaderSetting() {
try {
const downloaders: DownloaderConf[] = await api.get('download/clients')
downloaderOptions.value = [
{ title: '默认', value: '' },
...downloaders.map((item: { name: any }) => ({
title: item.name,
value: item.name,
})),
]
} catch (error) {
console.error('加载下载器设置失败:', error)
}
}
// 查询站点信息
async function fetchSiteInfo() {
@@ -88,29 +104,19 @@ async function addSite() {
doneNProgress()
}
// 调用API删除站点信息
async function deleteSiteInfo() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认删除站点?`,
})
if (!isConfirmed) return
try {
const result: { [key: string]: any } = await api.delete(`site/${siteForm.value?.id}`)
if (result.success) emit('remove')
else $toast.error(`${siteForm.value?.name} 删除失败:${result.message}`)
} catch (error) {
$toast.error(`${siteForm.value?.name} 删除失败!`)
console.error(error)
}
}
// 调用API更新站点信息
async function updateSiteInfo() {
startNProgress()
try {
if (isLimit.value) {
siteForm.value.limit_interval = siteForm.value.limit_interval || 0
siteForm.value.limit_count = siteForm.value.limit_count || 0
siteForm.value.limit_seconds = siteForm.value.limit_seconds || 0
} else {
siteForm.value.limit_interval = 0
siteForm.value.limit_count = 0
siteForm.value.limit_seconds = 0
}
const result: { [key: string]: any } = await api.put('site/', siteForm.value)
if (result.success) {
$toast.success(`${siteForm.value?.name} 更新成功!`)
@@ -124,10 +130,20 @@ async function updateSiteInfo() {
}
doneNProgress()
}
onMounted(async () => {
if (props.oper !== 'add') {
await fetchSiteInfo()
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
isLimit.value = true
if (siteForm.value.apikey) siteType.value = 'api'
}
await loadDownloaderSetting()
})
</script>
<template>
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable :close-on-back="false" eager max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
class="rounded-t"
@@ -167,7 +183,7 @@ async function updateSiteInfo() {
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="9">
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.rss"
label="RSS地址"
@@ -176,37 +192,85 @@ async function updateSiteInfo() {
/>
</VCol>
<VCol cols="12" md="3">
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
</VCol>
<VCol cols="12">
<VTextarea v-model="siteForm.cookie" label="站点Cookie" hint="站点请求头中的Cookie信息" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="站点请求头中的Authorization信息特殊站点需要"
v-model="siteForm.timeout"
label="超时时间(秒"
hint="站点请求超时时间为0时不限制"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
<VCol cols="6" md="3">
<VSelect
v-model="siteForm.downloader"
label="下载器"
:items="downloaderOptions"
hint="此站点使用的下载器"
persistent-hint
/>
</VCol>
</VRow>
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
<VTab selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
Cookie
</div>
</VTab>
<VTab selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-api" value="api" />
API
</div>
</VTab>
</VTabs>
<VWindow v-model="siteType" class="my-3 disable-tab-transition" :touch="false">
<VWindowItem value="cookie">
<VRow>
<VCol cols="12">
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
hint="站点请求头中的Cookie信息"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
persistent-hint
/>
</VCol>
</VRow>
</VWindowItem>
<VWindowItem value="api">
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="站点请求头中的Authorization信息特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
persistent-hint
/>
</VCol>
</VRow>
</VWindowItem>
</VWindow>
<VRow>
<VCol cols="12" md="4">
<VSwitch v-model="isLimit" label="限制站点访问频率" />
</VCol>
</VRow>
<VRow v-if="isLimit">
<VCol cols="12" md="4">
<VTextField
v-model="siteForm.limit_interval"
@@ -237,18 +301,20 @@ async function updateSiteInfo() {
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.proxy" label="代理" hint="使用代理服务器访问该站点" persistent-hint />
<VSwitch v-model="siteForm.proxy" label="使用代理访问" hint="使用代理服务器访问该站点" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.render" label="仿真" hint="使用浏览器模拟真实访问该站点" persistent-hint />
<VSwitch
v-model="siteForm.render"
label="浏览器仿真"
hint="使用浏览器模拟真实访问该站点"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.oper !== 'add'" color="error" @click="deleteSiteInfo" variant="outlined" class="me-3">
删除
</VBtn>
<VSpacer />
<VBtn
v-if="props.oper === 'add'"

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