Compare commits

...

295 Commits

Author SHA1 Message Date
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
104 changed files with 7359 additions and 6055 deletions

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

329
auto-imports.d.ts vendored
View File

@@ -3,6 +3,7 @@
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
@@ -66,6 +67,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 +79,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']
@@ -190,6 +193,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 +213,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 +239,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 +248,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']
@@ -261,6 +268,7 @@ declare global {
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,9 +321,10 @@ 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' {
@@ -382,6 +391,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 +403,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']>
@@ -506,6 +517,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 +537,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 +563,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 +572,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']>
@@ -577,6 +592,7 @@ declare module 'vue' {
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 +642,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']>
}
}
}

2
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']

View File

@@ -145,17 +145,15 @@
</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>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.0.2",
"version": "2.2.4",
"private": true,
"bin": "dist/service.js",
"scripts": {
@@ -19,86 +19,86 @@
]
},
"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",
"@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",
"@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",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"qrcode.vue": "^3.4.1",
"sass": "^1.59.3",
"tailwindcss": "^3.3.2",
"unplugin-vue-define-options": "^1.3.5",
"vue": "^3.3.2",
"vue-router": "^4.2.0",
"vue-toast-notification": "^3",
"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/node": "^20.1.4",
"@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

@@ -5,12 +5,12 @@ body {
html {
overflow: hidden auto;
background: var(--initial-loader-bg, #fff);
min-block-size: calc(100% + env(safe-area-inset-top));
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
}
#loading-bg {
position: absolute;
z-index: 999;
z-index: 9999;
display: block;
background: var(--initial-loader-bg, #fff);
block-size: 100vh;
@@ -82,4 +82,4 @@ html {
opacity: 1;
transform: rotate(1turn);
}
}
}

View File

@@ -5,7 +5,6 @@ import type { ThemeSwitcherTheme } from '@layouts/types'
import api from '@/api'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { useToast } from 'vue-toast-notification'
import { VAceEditor } from 'vue3-ace-editor'
// 显示器宽度
const display = useDisplay()
@@ -115,7 +114,7 @@ function changeTheme(theme: string) {
// 保存主题到服务端
try {
api.post('/user/config/Layout', {
theme: nextTheme
theme: nextTheme,
})
} catch (e) {
console.error('保存主题到服务端失败')
@@ -176,7 +175,7 @@ async function saveCustomCSS() {
},
})
if (result.success) $toast.success('自定义CSS保存成功')
if (result.success) $toast.success('自定义CSS保存成功,请刷新页面生效')
} catch (e) {
console.error('保存自定义 CSS 到服务端失败')
}

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

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

@@ -1,3 +1,5 @@
import copy from 'copy-to-clipboard'
// 请求和获取剪贴板内容
export async function getClipboardContent() {
if (navigator.clipboard && window.isSecureContext) {
@@ -13,20 +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)
// 阻止事件冒泡到其他元素,确保 focusin 事件只在 textarea 元素上处理,不会影响其他元素
input.addEventListener('focusin', e => e.stopPropagation())
input.select()
document.execCommand('copy')
document.body.removeChild(input)
}
const success = copy(content)
return success
}
// VAPID公钥转Uint8Array

View File

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

View File

@@ -5,7 +5,7 @@
@use "@configured-variables" as variables;
html {
min-height: calc(100% + env(safe-area-inset-top));
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
background: rgb(var(--v-theme-background));
overflow-y: overlay;
}

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

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
const { global: globalTheme } = useTheme()
@@ -41,9 +42,15 @@ if (window.Apex) {
}
}
// 页面加载时,加载当前用户数据
onBeforeMount(async () => {
onMounted(() => {
setTheme()
ensureRenderComplete(() => {
nextTick(() => {
setTimeout(() => {
removeEl('#loading-bg')
}, 1500)
})
})
})
</script>

View File

@@ -2,18 +2,27 @@ export const storageOptions = [
{
title: '本地',
value: 'local',
icon: 'mdi-folder-multiple-outline',
},
{
title: '阿里云盘',
value: 'alipan',
icon: 'mdi-cloud-outline',
},
{
title: '115网盘',
value: 'u115',
icon: 'mdi-cloud-outline',
},
{
title: 'Rclone网盘',
title: 'RClone',
value: 'rclone',
icon: 'mdi-cloud-outline',
},
{
title: 'AList',
value: 'alist',
icon: 'mdi-cloud-outline',
},
]
@@ -62,3 +71,10 @@ 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

@@ -44,7 +44,7 @@ export interface Subscribe {
lack_episode?: number
// 附加信息
note?: string
// 状态N-新建 R-订阅中
// 状态N-新建 R-订阅中 P-待定 S-暂停
state: string
// 最后更新时间
last_update: string
@@ -72,6 +72,8 @@ export interface Subscribe {
media_category?: string
// 过滤规则组
filter_groups?: string[]
// 下载器
downloader: string
}
// 订阅分享
@@ -86,6 +88,8 @@ export interface SubscribeShare {
share_comment?: string
// 分享人
share_user?: string
// 分享人唯一ID
share_uid?: string
// 订阅名称
name?: string
// 订阅年份
@@ -182,7 +186,7 @@ export interface TransferHistory {
export interface MediaInfo {
// 来源themoviedb、douban、bangumi
source?: string
// 类型 电影、电视剧
// 类型 电影、电视剧、合集
type?: string
// 媒体标题
title?: string
@@ -202,6 +206,8 @@ export interface MediaInfo {
douban_id?: string
// Bangumi ID
bangumi_id?: string
// 合集ID
collection_id?: number
// 媒体原语种
original_language?: string
// 媒体原发行标题
@@ -387,6 +393,8 @@ export interface Site {
pri?: number
// RSS地址
rss?: string
// 下载器
downloader: string
// Cookie
cookie?: string
// ApiKey
@@ -605,6 +613,8 @@ export interface TorrentInfo {
site_proxy: boolean
// 站点优先级
site_order: number
// 站点下载器
site_downloader?: string
// 种子名称
title?: string
// 种子副标题
@@ -1045,7 +1055,7 @@ export interface TransferDirectoryConf {
// 监控模式 fast/compatibility
monitor_mode?: string
// 整理方式 move/copy/link/softlink
transfer_type?: string
transfer_type: string
// 文件覆盖模式 always/size/never/latest
overwrite_mode?: string
// 整理到媒体库目录
@@ -1094,6 +1104,7 @@ export interface FilterRuleGroup {
category?: string
}
// 订阅下载文件详情
export interface SubscribeDownloadFileInfo {
// 种子名称
torrent_title?: string
@@ -1107,6 +1118,7 @@ export interface SubscribeDownloadFileInfo {
file_path?: string
}
// 订阅媒体库文件详情
export interface SubscribeLibraryFileInfo {
// 存储
storage?: string
@@ -1114,6 +1126,7 @@ export interface SubscribeLibraryFileInfo {
file_path?: string
}
// 订阅集详情
export interface SubscribeEpisodeInfo {
// 标题
title?: string
@@ -1127,9 +1140,67 @@ export interface SubscribeEpisodeInfo {
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
}
// 整理队列
export interface TransferQueue {
// 媒体信息
media: MediaInfo
// 季
season?: number
// 任务列表
tasks: {
// 文件项
fileitem: FileItem
// 元数据
meta: MetaInfo
// 状态
state: string
}[]
}

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

View File

@@ -3,6 +3,7 @@ import type { Axios } from 'axios'
import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageOptions } from '@/api/constants'
// 输入参数
const props = defineProps({
@@ -27,29 +28,6 @@ const props = defineProps({
// 对外事件
const emit = defineEmits(['pathchanged'])
const availableStorages = [
{
name: '本地',
code: 'local',
icon: 'mdi-folder-multiple-outline',
},
{
name: '阿里云盘',
code: 'alipan',
icon: 'mdi-cloud-outline',
},
{
name: '115网盘',
code: 'u115',
icon: 'mdi-cloud-outline',
},
{
name: 'Rclone网盘',
code: 'rclone',
icon: 'mdi-cloud-outline',
},
]
const fileIcons = {
// 压缩包
zip: 'mdi-folder-zip-outline',
@@ -151,7 +129,7 @@ const sort = ref('name')
// 计算属性
const storagesArray = computed(() => {
const storageCodes = props.storages?.map(item => item.type)
return availableStorages.filter(item => storageCodes?.includes(item.code))
return storageOptions.filter(item => storageCodes?.includes(item.value))
})
// 方法

View File

@@ -98,8 +98,8 @@ function onClose() {
<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.id }}</h5>
<div class="text-body-1 mb-3">{{ props.rule.name }}</div>
<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>
@@ -156,7 +156,7 @@ function onClose() {
v-model="ruleInfo.size_range"
placeholder="0/1-10"
label="资源体积MB"
hint="最小资源文件体积或文件体积范围"
hint="最小资源文件体积或体积范围(剧集计算单集平均大小)"
persistent-hint
active
/>
@@ -174,9 +174,9 @@ function onClose() {
<VCol cols="6">
<VTextField
v-model="ruleInfo.publish_time"
placeholder="0"
placeholder="0/1-10"
label="发布时间(分钟)"
hint="距离资源发布的最小时间间隔"
hint="距离资源发布的最小时间间隔或时间区间"
persistent-hint
active
/>

View File

@@ -1,9 +1,8 @@
<script lang="ts" setup>
import type { TransferDirectoryConf } from '@/api/types'
import { VDivider, VSpacer, VTextField } from 'vuetify/lib/components/index.mjs'
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import { nextTick } from 'vue'
import { storageOptions } from '@/api/constants'
// 输入参数
const props = defineProps({
@@ -20,12 +19,6 @@ const props = defineProps({
height: String,
})
// 下载路径
const downloadPath = ref<string>('')
// 媒体库路径
const libraryPath = ref<string>('')
// 卡版是否折叠状态
const isCollapsed = ref(true)
@@ -36,19 +29,12 @@ const typeItems = [
{ title: '电视剧', value: '电视剧' },
]
// 存储下拉字典
const storageItems = [
{ title: '本地', value: 'local' },
{ title: '阿里云盘', value: 'alipan' },
{ title: '115网盘', value: 'u115' },
{ title: 'Rclone网盘', value: 'rclone' },
]
// 自动整理方式下拉字典
const transferSourceItems = [
{ title: '不自动整理', value: '' },
{ title: '不整理', value: '' },
{ title: '下载器监控', value: 'downloader' },
{ title: '目录监控', value: 'monitor' },
{ title: '手动整理', value: 'manual' },
]
// 监控模式下拉字典
@@ -112,13 +98,13 @@ async function loadTransferTypeItems() {
// 整理方式无数据提示
const computedNoDataText = computed(() => {
if (!props.directory.library_storage && !props.directory.storage) {
return '无可用整理方式!请先选择下载器储存与媒体库储存!'
return '请选择储存'
} else if (!props.directory.library_storage) {
return '无可用整理方式!请先选择媒体库储存'
return '选择媒体库储存'
} else if (!props.directory.storage) {
return '无可用整理方式!请先选择下载器储存'
return '选择下载器储存'
} else {
return '选择的存储没有支持的整理方法!'
return '选择的存储类型没有支持的整理方'
}
})
@@ -138,24 +124,6 @@ function onClose() {
emit('close')
}
// 下载路径更新
function updateDownloadPath(value: string) {
downloadPath.value = value
emit('update:modelValue', {
download: downloadPath.value,
library: libraryPath.value,
})
}
// 媒体库路径更新
function updateLibraryPath(value: string) {
libraryPath.value = value
emit('update:modelValue', {
download: downloadPath.value,
library: libraryPath.value,
})
}
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
@@ -227,19 +195,20 @@ watch(
/>
</VCol>
<VCol cols="4">
<VSelect v-model="props.directory.storage" variant="underlined" :items="storageItems" label="下载存储" />
<VSelect
v-model="props.directory.storage"
variant="underlined"
:items="storageOptions"
label="下载存储/源存储"
/>
</VCol>
<VCol cols="8">
<VPathField @update:modelValue="updateDownloadPath" :storage="props.directory.storage">
<template #activator="{ menuprops }">
<VTextField
v-model="props.directory.download_path"
v-bind="menuprops"
variant="underlined"
label="下载目录"
/>
</template>
</VPathField>
<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>
@@ -272,21 +241,17 @@ watch(
<VSelect
v-model="props.directory.library_storage"
variant="underlined"
:items="storageItems"
:items="storageOptions"
label="媒体库存储"
/>
</VCol>
<VCol cols="8">
<VPathField @update:modelValue="updateLibraryPath" :storage="props.directory.library_storage">
<template #activator="{ menuprops }">
<VTextField
v-model="props.directory.library_path"
v-bind="menuprops"
variant="underlined"
label="媒体库目录"
/>
</template>
</VPathField>
<VPathField
v-model="props.directory.library_path"
:storage="props.directory.library_storage"
variant="underlined"
label="媒体库目录"
/>
</VCol>
<VCol cols="4">
<VSelect

View File

@@ -6,7 +6,7 @@ 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";
import { cloneDeep } from 'lodash'
// 定义输入
const props = defineProps({
@@ -211,7 +211,6 @@ onUnmounted(() => {
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
placeholder="admin"
hint="登录使用的用户名"
persistent-hint
active
@@ -289,7 +288,6 @@ onUnmounted(() => {
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
placeholder="admin"
hint="登录使用的用户名"
persistent-hint
active

View File

@@ -48,10 +48,10 @@ 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,
name: props.group?.name ?? '',
rule_string: props.group?.rule_string ?? '',
media_type: props.group?.media_type ?? '',
category: props.group?.category ?? '',
})
// 媒体类型字典
@@ -64,24 +64,30 @@ const mediaTypeItems = [
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type ?? ''])
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 ?? ''])
}
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 importCodeString = ref('')
// 导入代码类型
const importCodeType = ref('')
// 更新规则卡片的值
function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterRuleCards.value.find(card => card.pri === pri)
if (card) card.rules = rules
if (card && Array.isArray(rules)) card.rules = rules
}
// 移除卡片
@@ -95,99 +101,93 @@ function filterCardClose(pri: string) {
}
// 分享规则
function shareRules() {
// 有值才处理
async function shareRules() {
if (filterRuleCards.value.length === 0) return
// 将卡片规则接装为字符串
const value = filterRuleCards.value
.filter(card => card.rules.length > 0)
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
// 复制到剪贴板
try {
copyToClipboard(value)
$toast.success('优先级规则已复制到剪贴板')
let success
success = copyToClipboard(value)
if (await success) $toast.success('优先级规则已复制到剪贴板!')
else $toast.error('优先级规则复制失败:可能是浏览器不支持或被用户阻止!')
} catch (error) {
$toast.error('优先级规则复制失败!')
console.error(error)
}
}
// 导入规则
async function importRules() {
importCodeString.value = ''
async function importRules(ruleType: string) {
importCodeType.value = ruleType
importCodeDialog.value = true
}
// 监听导入代码变化
watchEffect(() => {
if (!importCodeString.value) return
// 导入代码需要以空格开头和结束,没有则拼接
if (!importCodeString.value.startsWith(' ')) importCodeString.value = ` ${importCodeString.value}`
if (!importCodeString.value.endsWith(' ')) importCodeString.value = `${importCodeString.value} `
// 将导入的代码转换为规则卡片
const groups = importCodeString.value.split('>')
filterRuleCards.value = groups.map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
// 保存导入代码,直接覆盖原有值
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.map((card, index) => {
filterRuleCards.value.forEach((card, index) => {
card.pri = (index + 1).toString()
return card
})
}
// 打开详情弹窗
function opengroupInfoDialog() {
// 深复制
groupInfo.value = cloneDeep(props.group)
if (props.group.rule_string) {
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
}
})
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) {
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的rule_string
groupInfo.value.rule_string = filterRuleCards.value
.filter(card => card.rules.length > 0)
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
emit('change', groupInfo.value, props.group.name)
@@ -224,8 +224,8 @@ function onClose() {
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardText>
<VRow>
<VCardItem class="pt-1">
<VRow class="mt-1">
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
@@ -257,7 +257,7 @@ function onClose() {
/>
</VCol>
</VRow>
</VCardText>
</VCardItem>
<VCardText>
<draggable
v-model="filterRuleCards"
@@ -284,7 +284,7 @@ function onClose() {
<VBtn color="primary" variant="tonal" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" variant="tonal" @click="importRules">
<VBtn color="success" variant="tonal" @click="importRules('priority')">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" variant="tonal" @click="shareRules">
@@ -295,8 +295,13 @@ function onClose() {
</VCardActions>
</VCard>
</VDialog>
<VDialog v-model="importCodeDialog" width="60rem" scrollable>
<ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
</VDialog>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
title="导入规则优先级"
:dataType="importCodeType"
@close="importCodeDialog = false"
@save="saveCodeString"
/>
</div>
</template>

View File

@@ -91,7 +91,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

View File

@@ -6,7 +6,7 @@ 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 router, { registerAbortController } from '@/router'
import noImage from '@images/no-image.jpeg'
import tmdbImage from '@images/logos/tmdb.png'
import doubanImage from '@images/logos/douban-black.png'
@@ -67,6 +67,12 @@ const sourceIconDict: { [key: string]: any } = {
bangumi: bangumiImage,
}
// 绑定MediaCard元素
const mediaCardRef = ref<HTMLElement | null>(null)
// 创建Intersection Observer实例
const observer = ref<IntersectionObserver | null>(null)
// 获得mediaid
function getMediaId() {
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
@@ -90,7 +96,6 @@ function getChipColor(type: string) {
}
// 添加订阅处理
async function handleAddSubscribe() {
if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
// TMDB电视剧
@@ -219,6 +224,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,
@@ -227,6 +235,7 @@ async function handleCheckExists() {
season: props.media?.season,
mtype: props.media?.type,
},
signal,
})
if (result.success) isExists.value = true
@@ -238,13 +247,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
@@ -267,7 +279,6 @@ async function checkSeasonsNotExists() {
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
})
}
@@ -336,13 +347,24 @@ function getExistText(season: number) {
// 打开详情页
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(),
type: props.media?.type,
},
})
}
}
}
@@ -359,10 +381,43 @@ function handleSearch() {
})
}
// 装载时检查是否已订阅
onBeforeMount(() => {
// 懒加载检查
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
})
// 计算图片地址
@@ -407,81 +462,85 @@ function onRemoveSubscribe() {
<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 ?? false)"
>
<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 rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': 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"
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="handleSearch" />
<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"
>
{{ 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"
>
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>
</VCard>
</div>
</template>
</VHover>
<!-- 订阅季弹窗 -->
@@ -551,9 +610,3 @@ function onRemoveSubscribe() {
@remove="onRemoveSubscribe"
/>
</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

@@ -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'
@@ -45,24 +46,31 @@ function replaceNewLine(value: string) {
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink">
<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>
<div
v-if="props.message?.title && !props.message?.image && !props.message?.note"
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">
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
{{ props.message?.title }}
</VCardTitle>
<div
@@ -72,13 +80,13 @@ function replaceNewLine(value: string) {
<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="props.message?.note">
<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">
<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">

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,80 +146,141 @@ const dropdownItems = ref([
</script>
<template>
<VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
<div class="me-n3 absolute bottom-0 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"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div>
<VCard :width="props.width" :height="props.height" @click="detailDialog = true" class="flex flex-col h-full">
<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 px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="text-white px-2 py-1 text-shadow line-clamp-3">
{{ props.plugin?.plugin_desc }}
</VCardText>
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 ...">
{{ 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>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" class="me-1" />
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
</VCardText>
</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" />
<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"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</VCardText>
</VCard>
</VDialog>
<!-- 安装插件进度框 -->
<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>

View File

@@ -1,7 +1,6 @@
<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'
@@ -15,6 +14,8 @@ import ProgressDialog from '../dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// 输入参数
const props = defineProps({
@@ -49,6 +50,9 @@ const pluginConfigDialog = ref(false)
// 插件配置表单数据
const pluginConfigForm = ref({})
// 菜单显示状态
const menuVisible = ref(false)
// 进度框
const progressDialog = ref(false)
@@ -390,130 +394,155 @@ watch(
</script>
<template>
<!-- 插件卡片 -->
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
<div class="me-n3 absolute bottom-0 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"
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>
<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 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-1 text-white text-shadow line-clamp-3">
{{ props.plugin?.plugin_desc }}
<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"
>
<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"
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>
</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" :model="pluginConfigForm" />
</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>
</VCardText>
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
</VCard>
<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>
<!-- 插件配置页面 -->
<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>
<!-- 插件数据页面 -->
<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"
:class="{ 'mb-10': appMode }"
/>
</VCard>
</VDialog>
<!-- 插件数据页面 -->
<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>
<!-- 进度框 -->
<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>

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>
@@ -54,7 +54,6 @@ function goPlay(isHovering = false) {
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 +77,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=""
>
<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 +90,3 @@ function goPlay(isHovering = false) {
</template>
</VHover>
</template>
<style lang="scss">
.on-hover img {
@apply brightness-50;
}
</style>

View File

@@ -8,7 +8,6 @@ import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
import api from '@/api'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { VCardActions, VExpandTransition, VProgressLinear, VSpacer } from 'vuetify/lib/components/index.mjs'
import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
@@ -27,7 +26,7 @@ const siteIcon = ref<string>('')
const $toast = useToast()
// 测试按钮文字
const testButtonText = ref('测试')
const testButtonText = ref('连通性测试')
// 测试按钮可用性
const testButtonDisable = ref(false)
@@ -44,9 +43,6 @@ const resourceDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 站点操作显示
const siteActionShow = ref(false)
// 站点使用统计
const siteStats = ref<SiteStatistic>({})
@@ -69,7 +65,7 @@ async function testSite() {
if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
testButtonText.value = '测试'
testButtonText.value = '连通性测试'
testButtonDisable.value = false
getSiteStats()
@@ -156,7 +152,7 @@ onMounted(() => {
<div>
<VCard
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
class="overflow-hidden"
class="overflow-hidden h-full flex flex-col"
@click="siteEditDialog = true"
>
<template #image>
@@ -195,44 +191,42 @@ onMounted(() => {
</VTooltip>
</VCardText>
<VCardActions>
<VBtn
:icon="siteActionShow ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click.stop="siteActionShow = !siteActionShow"
/>
<IconBtn>
<VIcon icon="mdi-chevron-down" color="primary" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" v-if="!cardProps.site?.public" @click="handleSiteUpdate">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
<VListItemTitle>更新 Cookie & UA</VListItemTitle>
</VListItem>
<VListItem variant="plain" :disabled="testButtonDisable" @click.stop="testSite">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
<VListItemTitle>{{ testButtonText }}</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="handleResourceBrowse">
<template #prepend>
<VIcon icon="mdi-web" />
</template>
<VListItemTitle>资源预览</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="handleSiteUserData">
<template #prepend>
<VIcon icon="mdi-chart-bell-curve" />
</template>
<VListItemTitle>站点数据</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span class="text-sm">
{{ formatFileSize(cardProps.data?.upload || 0) }} / {{ formatFileSize(cardProps.data?.download || 0) }}
</span>
<VSpacer />
</VCardActions>
<VDivider class="mb-1" v-if="siteActionShow" />
<VExpandTransition>
<div v-show="siteActionShow" class="py-1 pe-12">
<VBtn v-if="!cardProps.site?.public" @click.stop="handleSiteUpdate" variant="text">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
更新
</VBtn>
<VBtn :disabled="testButtonDisable" @click.stop="testSite" variant="text">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
{{ testButtonText }}
</VBtn>
<VBtn @click.stop="handleResourceBrowse" variant="text">
<template #prepend>
<VIcon icon="mdi-web" />
</template>
浏览
</VBtn>
<VBtn @click.stop="handleSiteUserData" variant="text">
<template #prepend>
<VIcon icon="mdi-chart-bell-curve" />
</template>
数据
</VBtn>
</div>
</VExpandTransition>
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
<span class="absolute top-1 right-8">
<VIcon class="cursor-move">mdi-drag</VIcon>

View File

@@ -5,11 +5,14 @@ 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({
@@ -42,6 +45,8 @@ const aliyunAuthDialog = ref(false)
const u115AuthDialog = ref(false)
// Rclone配置对话框
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// 打开存储对话框
function openStorageDialog() {
@@ -55,6 +60,9 @@ function openStorageDialog() {
case 'rclone':
rcloneConfigDialog.value = true
break
case 'alist':
aListConfigDialog.value = true
break
default:
$toast.info('此存储类型无需配置参数,请直接配置目录!')
break
@@ -72,6 +80,8 @@ const getIcon = computed(() => {
return u115_png
case 'rclone':
return rclone_png
case 'alist':
return alist_png
default:
return storage_png
}
@@ -109,6 +119,7 @@ function handleDone() {
aliyunAuthDialog.value = false
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
emit('done')
}
@@ -122,7 +133,7 @@ onMounted(() => {
<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>未配置</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">未配置</div>
</div>
<VImg :src="getIcon" cover class="mt-5" max-width="3rem" min-width="3rem" />
</VCardText>
@@ -133,10 +144,17 @@ onMounted(() => {
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
:conf="props.storage.config || {}"
@close="aliyunAuthDialog = false"
@done="handleDone"
/>
<U115AuthDialog v-if="u115AuthDialog" v-model="u115AuthDialog" @close="u115AuthDialog = 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"
@@ -144,4 +162,11 @@ onMounted(() => {
@close="rcloneConfigDialog = false"
@done="handleDone"
/>
<AlistConfigDialog
v-if="aListConfigDialog"
v-model="aListConfigDialog"
:conf="props.storage.config || {}"
@close="aListConfigDialog = false"
@done="handleDone"
/>
</template>

View File

@@ -38,8 +38,11 @@ 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() {
@@ -81,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
// 重置
@@ -95,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) {
@@ -129,7 +159,7 @@ async function viewSubscribeFiles() {
}
// 弹出菜单
const dropdownItems = ref([
const dropdownItems = computed(() => [
{
title: '编辑',
value: 1,
@@ -163,18 +193,26 @@ const dropdownItems = ref([
},
},
{
title: '重置',
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',
},
show: props.media?.type === '电视剧',
},
{
title: '分享',
value: 6,
value: 7,
props: {
prependIcon: 'mdi-share',
click: shareSubscribe,
@@ -184,7 +222,7 @@ const dropdownItems = ref([
},
{
title: '取消订阅',
value: 7,
value: 8,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
@@ -201,6 +239,14 @@ watch(
},
)
// 监听订阅状态
watch(
() => props.media?.state,
newState => {
subscribeState.value = newState ?? 'P'
},
)
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
@@ -233,129 +279,136 @@ function onSubscribeEditRemove() {
</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="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>
<VCardText class="flex items-center">
<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 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 rounded-lg h-full"
:class="{
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'opacity-70': subscribeState === 'S',
}"
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>
</div>
</VCard>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="props.media?.id"
@remove="onSubscribeEditRemove"
@save="onSubscribeEditSave"
@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-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 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"
/>
<!-- 订阅文件信息弹窗 -->
<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">
.subscribe-card-background {

View File

@@ -1,23 +1,17 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
// 输入参数
const props = defineProps({
media: Object as PropType<SubscribeShare>,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 定义删除事件
const emit = defineEmits(['delete'])
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
@@ -28,6 +22,9 @@ const imageLoaded = ref(false)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 复用订阅弹窗
const forkSubscribeDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
@@ -69,109 +66,110 @@ async function viewMediaDetail() {
}
// 复用订阅
async function forkSubscribe() {
// 开始处理
startNProgress()
try {
// 确认
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认添加来自 ${props.media?.share_user} 分享的订阅:${props.media?.share_title}`,
})
if (!isConfirmed) return
// 请求API
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
// 弹出订阅编辑弹窗
subscribeId.value = result.data.id
subscribeEditDialog.value = true
} else {
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}`)
}
} catch (error) {
console.error(error)
} finally {
doneNProgress()
}
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>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
min-height="170"
@click="forkSubscribe"
>
<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>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 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>
<VCardText class="flex items-center pb-1">
<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>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div>
<VCardText class="flex items-center pb-1">
<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 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>
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
{{ props.media?.share_comment }}
</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>
</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="error" 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"
/>
</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">
.subscribe-card-background {

View File

@@ -4,7 +4,7 @@ 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 } from 'vuetify/lib/components/index.mjs'
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
// 输入参数
const props = defineProps({
@@ -13,6 +13,9 @@ const props = defineProps({
torrent: Object as PropType<TorrentInfo>,
})
// 定义成功和失败事件
const emit = defineEmits(['done', 'error', 'close'])
// 提示框
const $toast = useToast()
@@ -22,8 +25,8 @@ const selectedDownloader = ref<string | null>(null)
// 选择的保存目录
const selectedDirectory = ref<string | null>(null)
// 定义成功和失败事件
const emit = defineEmits(['done', 'error', 'close'])
// 下载器
const downloaders = ref<DownloaderConf[]>([])
// 所有目录设置
const directories = ref<TransferDirectoryConf[]>([])
@@ -53,14 +56,10 @@ const targetDirectories = computed(() => {
return [...new Set(downloadDirectories)]
})
// 下载器
const downloaders = ref<DownloaderConf[]>([])
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Downloaders')
downloaders.value = result.data?.value ?? []
downloaders.value = await api.get('download/clients')
} catch (error) {
console.log(error)
}
@@ -119,37 +118,63 @@ onMounted(() => {
})
</script>
<template>
<VDialog max-width="40rem" scrollable>
<VDialog max-width="45rem" scrollable>
<VCard>
<VCardItem>
<VCardTitle v-if="title">下载 - {{ title }}</VCardTitle>
<VCardTitle v-if="title">{{ torrent?.site_name }} - {{ title }}</VCardTitle>
<VCardTitle v-else>确认下载</VCardTitle>
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText>
<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>
<template #prepend>
<VIcon icon="mdi-subtitles-outline"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-1 whitespace-break-spaces">{{ torrent?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-1">
<VChip variant="tonal" label>
{{ formatFileSize(torrent?.size || 0) }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
<VRow>
<VCol cols="12" class="text-lg text-high-emphasis pb-0">
<div><strong>站点</strong>{{ props.torrent?.site_name }}</div>
<div><strong>标题</strong>{{ props.torrent?.title }}</div>
<div><strong>描述</strong>{{ props.torrent?.description }}</div>
<div><strong>大小</strong>{{ formatFileSize(props.torrent?.size || 0) }}</div>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
label="下载器"
label="指定下载器"
variant="underlined"
placeholder="默认"
placeholder="留空默认"
/>
</VCol>
<VCol cols="12" md="8">
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
label="保存目录"
placeholder="自动"
label="指定保存目录"
placeholder="留空自动匹配"
variant="underlined"
/>
</VCol>

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

@@ -2,6 +2,14 @@
import QrcodeVue from 'qrcode.vue'
import api from '@/api'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done', 'close'])
@@ -25,6 +33,10 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
if (props.conf?.refreshToken) {
await savaAliPanConfig()
}
emit('done')
}
@@ -75,6 +87,15 @@ async function checkQrcode() {
}
}
// 保存cookie设置
async function savaAliPanConfig() {
try {
await api.post(`storage/save/alipan`, props.conf)
} catch (e) {
console.error(e)
}
}
onMounted(async () => {
await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000)
@@ -97,6 +118,13 @@ onUnmounted(() => {
<template #prepend />
</VAlert>
</VCardText>
<VCardText>
<VRow>
<VCol class="mt-2">
<VTextField label="自定义refreshToken" v-model="props.conf.refreshToken" outlined dense />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>

View File

@@ -0,0 +1,267 @@
<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
})
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
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">
{{ 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"
>
订阅
</VBtn>
<VBtn
v-if="props.media?.share_uid && props.media?.share_uid === globalSettings.USER_UNIQUE_ID"
color="error"
:disabled="deleting"
@click="doDelete"
prepend-icon="mdi-delete"
:loading="deleting"
class="ms-2"
>
取消分享
</VBtn>
<VBtn
v-else-if="isFollowed && props.media?.share_uid"
color="warning"
@click="unfollowUser"
prepend-icon="mdi-account-remove"
class="ms-2"
>
取消关注
</VBtn>
<VBtn
v-else-if="props.media?.share_uid"
@click="followUser"
color="info"
prepend-icon="mdi-account-plus"
class="ms-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

@@ -34,7 +34,7 @@ async function saveHandle() {
if (result.success) {
$toast.success('插件仓库保存成功')
emit('save')
} else $toast.error('插件仓库保存失败')
} else $toast.error(`插件仓库保存失败${result?.message}`)
} catch (error) {
console.log(error)
}

View File

@@ -38,7 +38,7 @@ async function savaRcloneConfig() {
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="Rclone网盘配置" class="rounded-t">
<VCard title="RClone配置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>

View File

@@ -2,11 +2,11 @@
import { useToast } from 'vue-toast-notification'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import api from '@/api'
import { storageOptions } from '@/api/constants'
import { storageOptions, transferTypeOptions } from '@/api/constants'
import { numberValidator } from '@/@validators'
import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { FileItem, TransferDirectoryConf } from '@/api/types'
import { FileItem, TransferDirectoryConf, TransferForm } from '@/api/types'
// 显示器宽度
const display = useDisplay()
@@ -49,7 +49,7 @@ const progressEventSource = ref<EventSource>()
const progressDialog = ref(false)
// 整理进度文本
const progressText = ref('请稍候 ...')
const progressText = ref('正在处理 ...')
// 整理进度
const progressValue = ref(0)
@@ -65,21 +65,21 @@ 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({
fileitem: {},
const transferForm = reactive<TransferForm>({
fileitem: {} as FileItem,
logid: 0,
target_storage: props.target_storage ?? 'local',
target_path: props.target_path ?? null,
tmdbid: null,
doubanid: null,
season: null,
type_name: '',
transfer_type: '',
episode_format: '',
episode_detail: '',
episode_part: '',
episode_offset: null,
target_path: '',
min_filesize: 0,
scrape: false,
from_history: false,
@@ -87,7 +87,6 @@ const transferForm = reactive({
// 所有媒体库目录
const directories = ref<TransferDirectoryConf[]>([])
// 查询目录
async function loadDirectories() {
try {
@@ -104,16 +103,58 @@ const targetDirectories = computed(() => {
return [...new Set(libraryDirectories)]
})
// 监听目的路径变化,自动查询目录削配置
watch(transferForm, async () => {
if (transferForm.target_path) {
const directory = directories.value.find(item => item.library_path === transferForm.target_path)
if (directory) {
transferForm.scrape = directory.scraping ?? false
transferForm.transfer_type = directory.transfer_type ?? ''
// 监听目的路径变化,配置默认值
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() {
@@ -134,63 +175,49 @@ 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.fileitem = item
transferForm.logid = 0
try {
const result: { [key: string]: any } = await api.post('transfer/manual', transferForm)
if (!result.success) $toast.error(`文件 ${item.path} 整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
// 整理日志
async function handleTransferLog(logid: number) {
transferForm.logid = logid
transferForm.fileitem = {}
try {
const result: { [key: string]: any } = await api.post('transfer/manual', transferForm)
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
onMounted(() => {
loadDirectories()
})
onUnmounted(() => {
stopLoadingProgress()
})
</script>
<template>
@@ -215,18 +242,16 @@ onMounted(() => {
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
:items="[
{ title: '默认', value: '' },
{ title: '移动', value: 'move' },
{ title: '复制', value: 'copy' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
]"
:items="transferTypeOptions"
hint="文件操作整理方式"
persistent-hint
/>
>
<template v-slot:selection="{ item }">
{{ transferForm.transfer_type === '' ? '自动' : item.title }}
</template>
</VSelect>
</VCol>
<VCol cols="12" md="12">
<VCol cols="12">
<VCombobox
v-model="transferForm.target_path"
:items="targetDirectories"
@@ -301,6 +326,7 @@ onMounted(() => {
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_detail"
:disabled="disableEpisodeDetail"
label="指定集数"
placeholder="起始集,终止集如1或1,2"
hint="指定集数或范围如1或1,2"
@@ -318,7 +344,7 @@ onMounted(() => {
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model.number="transferForm.episode_offset"
v-model="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
hint="集数偏移运算,如-10或EP*2"
@@ -337,6 +363,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"
@@ -349,7 +391,7 @@ onMounted(() => {
<VSwitch
v-model="transferForm.from_history"
label="复用历史识别信息"
hint="使用历史记录中已识别的媒体信息"
hint="使用历史整理记录中已识别的媒体信息"
persistent-hint
/>
</VCol>
@@ -358,7 +400,12 @@ onMounted(() => {
</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

@@ -1,6 +1,6 @@
<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'
@@ -35,6 +35,7 @@ const siteForm = ref<Site>({
limit_seconds: 0,
name: '',
domain: '',
downloader: '',
})
// 提示框
@@ -60,6 +61,24 @@ const priorityItems = ref(
})),
)
// 下载器选项
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() {
try {
@@ -142,6 +161,7 @@ onMounted(async () => {
isLimit.value = true
if (siteForm.value.apikey) siteType.value = 'api'
}
await loadDownloaderSetting()
})
</script>
@@ -186,7 +206,7 @@ onMounted(async () => {
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="9">
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.rss"
label="RSS地址"
@@ -195,7 +215,21 @@ onMounted(async () => {
/>
</VCol>
<VCol cols="12" md="3">
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
<VTextField
v-model="siteForm.timeout"
label="超时时间(秒)"
hint="站点请求超时时间为0时不限制"
persistent-hint
/>
</VCol>
<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">

View File

@@ -67,17 +67,18 @@ async function updateSiteCookie() {
}
</script>
<template>
<VDialog max-width="50rem">
<VDialog max-width="30rem">
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="4">
<VCol cols="12">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12" md="4">
<VCol cols="12">
<VTextField
v-model="userPwForm.password"
label="密码"
@@ -88,19 +89,19 @@ async function updateSiteCookie() {
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12">
<VTextField v-model="userPwForm.code" label="两步验证" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VCardActions class="mx-auto">
<VBtn
size="large"
variant="elevated"
@click="updateSiteCookie"
:disabled="updateButtonDisable"
:loading="updateButtonDisable"
prepend-icon="mdi-refresh"
class="px-5"
>

View File

@@ -2,9 +2,8 @@
import type { Site, SiteUserData } from '@/api/types'
import api from '@/api'
import { useDisplay, useTheme } from 'vuetify'
import { VAvatar, VCardText, VIcon } from 'vuetify/lib/components/index.mjs'
import { formatFileSize } from '@/@core/utils/formatters'
import VueApexCharts from 'vue3-apexcharts'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
@@ -17,6 +16,9 @@ const props = defineProps({
// 注册事件
const emit = defineEmits(['close'])
// 进度框
const progressDialog = ref(false)
const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(
@@ -27,8 +29,8 @@ const currentTheme = controlledComputed(
// 站点数据列表
const siteDatas = ref<SiteUserData[]>([])
// 最新一天的数据,按时间倒序排序后取第一条记录
const siteData = computed(() => siteDatas.value[0])
// 最新一天的数据
const siteData = computed(() => siteDatas.value[siteDatas.value.length - 1])
// 站点数据列表中的上传量、下载量数据生成图形使用的数据
const historySeries = computed(() => {
@@ -238,6 +240,20 @@ async function fetchSiteUserData() {
}
}
// 刷新站点数据
async function refreshSiteData() {
progressDialog.value = true
try {
const result: { [key: string]: any } = await api.post(`site/userdata/${props.site?.id}`)
if (result.success) {
await fetchSiteUserData()
}
} catch (error) {
console.log(error)
}
progressDialog.value = false
}
onBeforeMount(async () => {
await fetchSiteUserData()
})
@@ -245,9 +261,16 @@ onBeforeMount(async () => {
<template>
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`数据 - ${props.site?.name}`" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VCard class="rounded-t">
<VCardItem>
<VCardTitle
>{{ `数据 - ${props.site?.name}` }}
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh" /></IconBtn>
</VCardTitle>
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText class="pt-5">
<VRow class="match-height">
<!-- 用户信息 -->
<VCol cols="12" md="3">
@@ -416,7 +439,7 @@ onBeforeMount(async () => {
<VCol>
<VCard title="历史流量">
<VCardText>
<VueApexCharts type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
<VApexChart type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
</VCardText>
</VCard>
</VCol>
@@ -425,12 +448,14 @@ onBeforeMount(async () => {
<VCol>
<VCard title="做种分布">
<VCardText>
<VueApexCharts type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
<VApexChart type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
</VCardText>
</VCard>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在刷新站点数据..." />
</VDialog>
</template>

View File

@@ -2,7 +2,7 @@
import { useToast } from 'vue-toast-notification'
import { numberValidator } from '@/@validators'
import api from '@/api'
import type { FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useConfirm } from 'vuetify-use-dialog'
import { VTextarea, VTextField } from 'vuetify/lib/components/index.mjs'
@@ -50,6 +50,7 @@ const subscribeForm = ref<Subscribe>({
sites: [],
best_version: undefined,
current_priority: 0,
downloader: '',
date: '',
show_edit_dialog: false,
})
@@ -57,6 +58,24 @@ const subscribeForm = ref<Subscribe>({
// 提示框
const $toast = useToast()
// 下载器选项
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 queryFilterRuleGroups() {
try {
@@ -188,7 +207,7 @@ async function removeSubscribe() {
// 查询下载目录
async function loadDownloadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
const result: { [key: string]: any } = await api.get('system/setting/Directories')
if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value
}
@@ -200,8 +219,7 @@ async function loadDownloadDirectories() {
// 保存目录下拉框
const targetDirectories = computed(() => {
// 去重后的下载目录
const directories = downloadDirectories.value.map(item => item.download_path)
return [...new Set(directories)]
return downloadDirectories.value.map(item => item.download_path)
})
// 质量选择框数据
@@ -292,6 +310,7 @@ onMounted(() => {
queryFilterRuleGroups()
loadDownloadDirectories()
getSiteList()
loadDownloaderSetting()
if (props.subid) getSubscribeInfo()
if (props.default) queryDefaultSubscribeConfig()
})
@@ -392,6 +411,26 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="subscribeForm.downloader"
:items="downloaderOptions"
label="下载器"
hint="指定该订阅使用的下载器"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VCombobox
v-model="subscribeForm.save_path"
:items="targetDirectories"
label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VSwitch
@@ -461,15 +500,6 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VCombobox
v-model="subscribeForm.save_path"
:items="targetDirectories"
label="自定义保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="!props.default">
<VCol cols="12">

View File

@@ -132,59 +132,63 @@ onBeforeMount(() => {
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="download">
<transition name="fade-slide" appear>
<VDataTable
items-per-page="50"
:headers="downloadHeaders"
:items="downloadInfos"
:items-length="totalCount"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.episode_number="{ item }">
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
</template>
<template #item.torrent_title="{ item }">
<div class="text-xs" v-for="file in item.download">
{{ file.site_name }}{{ file.torrent_title }}
</div>
</template>
<template #item.file_path="{ item }">
<div class="text-xs" v-for="file in item.download">{{ file.file_path }}</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
<div>
<VDataTable
items-per-page="50"
:headers="downloadHeaders"
:items="downloadInfos"
:items-length="totalCount"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.episode_number="{ item }">
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
</template>
<template #item.torrent_title="{ item }">
<div class="text-xs" v-for="file in item.download">
{{ file.site_name }}{{ file.torrent_title }}
</div>
</template>
<template #item.file_path="{ item }">
<div class="text-xs" v-for="file in item.download">{{ file.file_path }}</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
</div>
</transition>
</VWindowItem>
<VWindowItem value="library">
<transition name="fade-slide" appear>
<VDataTable
items-per-page="50"
:headers="libraryHeaders"
:items="libraryInfos"
:items-length="totalCount"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.episode_number="{ item }">
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
</template>
<template #item.file_path="{ item }">
<div class="text-xs" v-for="file in item.library">{{ file.file_path }}</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
<div>
<VDataTable
items-per-page="50"
:headers="libraryHeaders"
:items="libraryInfos"
:items-length="totalCount"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.episode_number="{ item }">
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
</template>
<template #item.file_path="{ item }">
<div class="text-xs" v-for="file in item.library">{{ file.file_path }}</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
</div>
</transition>
</VWindowItem>
</VWindow>

View File

@@ -86,7 +86,7 @@ async function reSubscribe(item: Subscribe) {
else progressText.value = `正在重新订阅 ${item.name}${item.season} 季 ...`
progressDialog.value = true
try {
const result: { [key: string]: any } = await api.post('subscribe', item)
const result: { [key: string]: any } = await api.post('subscribe/', item)
if (result.success) {
emit('save')
}
@@ -138,14 +138,7 @@ const dropdownItems = ref([
<VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle>
</VCardItem>
<VDivider />
<DialogCloseBtn
@click="
() => {
emit('close')
}
"
/>
<!-- <VList lines="two" v-if="historyList.length > 0"> -->
<DialogCloseBtn @click="emit('close')" />
<VList lines="two">
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-hidden" @load="loadHistory">
<template #loading>
@@ -207,7 +200,7 @@ const dropdownItems = ref([
</template>
</VInfiniteScroll>
</VList>
<VCardText v-if="historyList.length === 0" class="text-center"> 没有已完成的订阅 </VCardText>
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center"> 没有已完成的订阅 </VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />

View File

@@ -4,6 +4,7 @@ import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Subscribe, SubscribeShare } from '@/api/types'
import { useDisplay } from 'vuetify'
import { formatSeason } from '@/@core/utils/formatters'
// 显示器宽度
const display = useDisplay()
@@ -16,16 +17,22 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['close'])
// 分享处理状态
const shareDoing = ref(false)
// 订阅编辑表单
const shareForm = ref<SubscribeShare>({
subscribe_id: props.sub?.id ?? 0,
share_title: `${props.sub?.name} ${formatSeason(props.sub?.season ? props.sub?.season.toString() : '')}`,
})
// 分享订阅
async function doShare() {
if (!shareForm.value.share_title || !shareForm.value.share_comment || !shareForm.value.share_user) return
try {
shareDoing.value = true
const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value)
shareDoing.value = false
// 提示
if (result.success) {
$toast.success(`${props.sub?.name} 分享成功!`)
@@ -56,8 +63,8 @@ const $toast = useToast()
<VCol cols="12">
<VTextField
v-model="shareForm.share_title"
readonly
label="标题"
hint="给分享取一个便于识别的名称"
:rules="[requiredValidator]"
persistent-hint
/>
@@ -67,7 +74,7 @@ const $toast = useToast()
v-model="shareForm.share_comment"
label="说明"
:rules="[requiredValidator]"
hint="关于该订阅的说明"
hint="填写关于该订阅的说明,订阅中的搜索词、识别词等将会默认包含在分享中"
persistent-hint
/>
</VCol>
@@ -85,7 +92,16 @@ const $toast = useToast()
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn variant="elevated" @click="doShare" prepend-icon="mdi-share" class="px-5"> 确认分享 </VBtn>
<VBtn
variant="elevated"
:disabled="shareDoing"
@click="doShare"
prepend-icon="mdi-share"
class="px-5"
:loading="shareDoing"
>
确认分享
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -0,0 +1,187 @@
<script lang="ts" setup>
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { FileItem, TransferQueue } from '@/api/types'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 定义触发的自定义事件
const emit = defineEmits(['close'])
// 数据列表
const dataList = ref<TransferQueue[]>([])
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 整理进度文本
const progressText = ref('请稍候 ...')
// 整理进度
const progressValue = ref(0)
// 数据可刷新标志
const refreshFlag = ref(false)
// 活动标签
const activeTab = ref('')
// 状态标签
const stateDict: { [key: string]: string } = {
'waiting': '等待中',
'running': '正在整理',
'completed': '完成',
'failed': '失败',
}
// 获取状态颜色
function getStateColor(state: string) {
if (state === 'waiting') return 'gray'
else if (state === 'running') return 'primary'
else if (state === 'completed') return 'success'
else return 'error'
}
// 从dataList中提取所有的媒体信息
const mediaList = computed(() => {
return dataList.value.map(item => item.media)
})
// 按media计算总数和完成数返回 x/x
function getMediaCount(title_year: string) {
// 按title_year查询出所有media列表
const medias = dataList.value.filter(item => item.media.title_year === title_year)
// 计算media下任务的总数
const total = medias.reduce((acc, cur) => acc + cur.tasks.length, 0)
// 计算media下任务的完成数
const completed = medias.reduce((acc, cur) => acc + cur.tasks.filter(task => task.state === 'completed').length, 0)
return `${completed} / ${total}`
}
// 根据媒体信息获取对应的整理任务
const activeTasks = computed(() => {
return dataList.value.find(item => item.media.title_year === activeTab.value)?.tasks
})
// 调用API获取队列信息
async function get_transfer_queue() {
try {
dataList.value = await api.get('transfer/queue')
if (dataList.value.length > 0) {
if (!activeTab.value || activeTasks.value?.length == 0) activeTab.value = dataList.value[0].media.title_year || ''
}
} catch (error) {
console.error(error)
}
}
// 移除队列任务
async function remove_queue_task(fileitem: FileItem) {
try {
await api.delete(`transfer/queue`, { data: fileitem })
get_transfer_queue()
} catch (error) {
console.error(error)
}
}
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
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) {
if (!progress.enable) {
progressText.value = '请稍候 ...'
progressValue.value = 0
if (refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
}
return
}
progressText.value = progress.text
progressValue.value = progress.value
if (progress.value >= 100 && refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
} else {
if (progress.value > 0 && refreshFlag.value && progress.text?.includes('整理完成')) {
refreshFlag.value = false
get_transfer_queue()
} else {
refreshFlag.value = true
}
}
}
}
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
}
onMounted(() => {
get_transfer_queue()
startLoadingProgress()
})
onUnmounted(() => {
stopLoadingProgress()
})
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%">
<VCardItem>
<VCardTitle>整理队列</VCardTitle>
</VCardItem>
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VProgressLinear
v-if="dataList.length > 0 && progressValue > 0"
:value="progressValue"
color="primary"
indeterminate
/>
<VCardItem v-if="dataList.length > 0 && progressValue > 0" class="text-center pt-2">
<span class="text-sm">{{ progressText }}</span>
</VCardItem>
<VCardText v-if="dataList.length === 0" class="text-center"> 没有正在整理的任务 </VCardText>
<VCardText>
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill" stacked>
<VTab
v-for="media in mediaList"
:value="media.title_year"
selected-class="v-slide-group-item--active v-tab--selected"
>
<div class="font-bold text-lg">{{ media.title }}</div>
<div>({{ getMediaCount(media.title_year || '') }})</div>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem v-for="media in mediaList" :value="media.title_year">
<VList>
<VListItem v-for="task in activeTasks">
<VListItemTitle>{{ task.fileitem.name }}</VListItemTitle>
<VListItemSubtitle>
大小{{ formatFileSize(task.fileitem.size || 0) }}
<VChip size="small" :color="getStateColor(task.state)" class="ms-2">
{{ stateDict[task.state] }}
</VChip>
</VListItemSubtitle>
<template #append>
<IconBtn size="small" icon="mdi-cancel" @click="remove_queue_task(task.fileitem)" />
</template>
</VListItem>
</VList>
</VWindowItem>
</VWindow>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,6 +1,15 @@
<script lang="ts" setup>
import QrcodeVue from 'qrcode.vue'
import api from '@/api'
import QrcodeVue from 'qrcode.vue'
import { VCardItem, VTextField } from 'vuetify/lib/components/index.mjs'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done', 'close'])
@@ -9,7 +18,7 @@ const emit = defineEmits(['done', 'close'])
const qrCodeContent = ref('')
// 下方的提示信息
const text = ref('请使用微信或115客户端扫码')
const text = ref('请使用微信或115客户端扫码或在下方输入Cookie')
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
@@ -19,6 +28,10 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
if (props.conf?.cookie) {
await savaU115Config()
}
emit('done')
}
@@ -43,15 +56,21 @@ async function checkQrcode() {
if (result.success && result.data) {
const status = result.data.status
text.value = result.data.tip
if (status == 1) {
// 已确认完成
alertType.value = 'success'
handleDone()
} else if (status == 0) {
if (status == 0) {
alertType.value = 'info'
// 新建、待扫码
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else if (status == 1) {
// 已扫码
alertType.value = 'info'
text.value = '已扫码,请确认登录'
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else if (status == 2) {
// 已确认完成
alertType.value = 'success'
handleDone()
} else {
// 过期或者已取消
alertType.value = 'error'
@@ -65,6 +84,15 @@ async function checkQrcode() {
}
}
// 保存cookie设置
async function savaU115Config() {
try {
await api.post(`storage/save/u115`, props.conf)
} catch (e) {
console.error(e)
}
}
onMounted(async () => {
await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000)
@@ -80,15 +108,20 @@ onUnmounted(() => {
<VCard title="115网盘登录" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 shadow-lg rounded border">
<VImg class="mx-auto" :src="qrCodeContent" style="block-size: 200px; inline-size: 200px">
<VSkeletonLoader v-if="!qrCodeContent" class="w-full h-full" />
</VImg>
<div class="my-6 shadow-lg rounded text-center p-3 border">
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
</div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<template #prepend />
</VAlert>
</VCardText>
<VCardText>
<VRow>
<VCol class="mt-2">
<VTextField label="自定义Cookie" v-model="props.conf.cookie" outlined dense />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>

View File

@@ -269,41 +269,48 @@ onMounted(() => {
>
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText class="d-flex">
<VCardItem>
<!-- 👉 Avatar -->
<VAvatar rounded="lg" size="100" class="me-6" :image="currentAvatar" />
<div class="flex flex-row">
<VAvatar rounded="lg" size="100" class="me-5" :image="currentAvatar" />
<!-- 👉 Upload Photo -->
<div class="flex flex-col justify-center gap-5">
<div class="flex flex-wrap gap-2">
<VBtn color="primary" @click="refInputEl?.click()">
<VIcon icon="mdi-cloud-upload-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
</VBtn>
<!-- 👉 Upload Photo -->
<form class="d-flex flex-column justify-center gap-5">
<div class="d-flex flex-wrap gap-2">
<VBtn color="primary" @click="refInputEl?.click()">
<VIcon icon="mdi-cloud-upload-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
</VBtn>
<input
ref="refInputEl"
type="file"
name="file"
accept=".jpeg,.png,.jpg,GIF"
hidden
@input="changeAvatar"
/>
<input ref="refInputEl" type="file" name="file" accept=".jpeg,.png,.jpg,GIF" hidden @input="changeAvatar" />
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
<VIcon icon="mdi-refresh" />
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
</VBtn>
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
<VIcon icon="mdi-refresh" />
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
</VBtn>
<VBtn
type="reset"
:color="props.oper === 'add' ? 'info' : 'error'"
variant="tonal"
@click="resetDefaultAvatar"
>
<VIcon icon="mdi-image-sync-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">默认</span>
</VBtn>
<VBtn
type="reset"
:color="props.oper === 'add' ? 'info' : 'error'"
variant="tonal"
@click="resetDefaultAvatar"
>
<VIcon icon="mdi-image-sync-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">默认</span>
</VBtn>
</div>
<p class="text-body-1 mb-0">允许 JPGPNGGIFWEBP 格式 最大尺寸 800KB</p>
</div>
<p class="text-body-1 mb-0">允许 JPGPNGGIFWEBP 格式 最大尺寸 800KB</p>
</form>
</VCardText>
</div>
</VCardItem>
<VCardText>
<VForm @submit.prevent="() => {}" class="mt-3">
<VForm @submit.prevent="() => {}">
<VDivider class="my-10">
<span>用户基础设置</span>
</VDivider>
@@ -355,7 +362,7 @@ onMounted(() => {
</VCol>
</VRow>
<VDivider class="my-10">
<span>消息账号绑定</span>
<span>账号绑定</span>
</VDivider>
<VRow>
<VCol cols="12" md="6">
@@ -388,6 +395,9 @@ onMounted(() => {
label="SynologyChat用户"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.settings.douban_userid" density="comfortable" clearable label="豆瓣用户" />
</VCol>
</VRow>
</VForm>
</VCardText>

View File

@@ -0,0 +1,177 @@
<script lang="ts" setup>
import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
// 定义事件
const emit = defineEmits(['done', 'close'])
// 提示框
const $toast = useToast()
// 是否加载中
const loading = ref(false)
// 用户认证表单
const authForm = ref<any>({
site: null,
params: {},
})
// 所有认证站点
const authSites = ref<{
[key: string]: {
name: string
icon: string
params: { [key: string]: any }
}
}>({})
// 生成站点拉选项
const dropdownItems = computed(() => {
return Object.keys(authSites.value).map(key => {
return {
key,
name: authSites.value[key].name,
prependAvatar: authSites.value[key].icon,
}
})
})
// 读取authSites.params生成表单配置列表
const formFields = computed(() => {
const site = authSites.value[authForm.value.site]
return Object.keys(site?.params || {})
.filter(item => {
return site.params[item].name && site.params[item].type
})
.map(key => {
return {
key,
site: authForm.value.site,
name: site.params[key].name,
type: site.params[key].type,
placeholder: site.params[key].placeholder,
tooltip: site.params[key].tooltip,
}
})
})
// 查询之前使用的认证参数
async function loadLastAuthParams() {
try {
const result: { [key: string]: any } = await api.get(`system/setting/UserSiteAuthParams`)
if (result.success) {
const ret = result.data?.value
if (ret && !isNullOrEmptyObject(ret.params)) {
authForm.value = ret
}
}
} catch (e) {
console.error(e)
}
}
// 加载认证站点配置
async function loadAuthSites() {
try {
authSites.value = (await api.get(`site/auth`)) || {}
} catch (e) {
console.error(e)
}
}
// 完成
async function handleDone() {
await checkUser()
}
// 认证处理
async function checkUser() {
if (!authForm.value.site) {
$toast.error('请选择认证站点!')
return
}
if (!authSites.value[authForm.value.site]) {
$toast.error('站点配置不存在!')
return
}
if (formFields.value.length > 0) {
for (const field of formFields.value) {
if (!authForm.value.params[field.site.toUpperCase() + '_' + field.key.toUpperCase()]) {
$toast.error(`请输入${field.name}`)
return
}
}
}
loading.value = true
try {
const result: { [key: string]: any } = await api.post(`site/auth`, authForm.value)
if (result.success) {
$toast.success('用户认证成功,请重新登录!')
// 1秒后刷新页面
setTimeout(() => {
emit('done')
}, 1000)
} else {
$toast.error(`认证失败:${result.message}`)
}
} catch (e) {
console.error(e)
}
loading.value = false
}
onMounted(async () => {
await loadAuthSites()
loadLastAuthParams()
})
</script>
<template>
<VDialog width="40rem" max-height="85vh">
<VCard title="用户认证" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect
v-model="authForm.site"
:items="dropdownItems"
item-value="key"
item-title="name"
label="选择认证站点"
item-props
>
</VSelect>
</VCol>
</VRow>
<VRow>
<VCol v-for="param in formFields" :key="param.key">
<VTextField
v-model="authForm.params[param.site.toUpperCase() + '_' + param.key.toUpperCase()]"
:type="param.type"
:label="param.name"
:placeholder="param.placeholder"
:hint="param.tooltip"
clearable
persistent-hint
/>
</VCol>
</VRow>
</VCardText>
<VCardText class="text-center">
<VBtn
variant="elevated"
@click="handleDone"
prepend-icon="mdi-check"
class="px-5"
size="large"
:disabled="loading"
>
开始认证
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import CronInput from '@/components/input/CronInput.vue'
const attrs = useAttrs()
const props = defineProps({
modelValue: {
type: String,
default: '* * * * *',
},
})
const emit = defineEmits(['update:modelValue'])
const innerValue = ref(props.modelValue)
watch(
() => props.modelValue,
value => {
innerValue.value = value
},
)
const propsWithoutModelValue = computed(() => {
const { modelValue, ...rest } = props
return { ...rest, ...attrs }
})
function updateModelValue(value: string) {
innerValue.value = value
emit('update:modelValue', value)
}
</script>
<template>
<CronInput v-model="innerValue" @update:modelValue="updateModelValue">
<template #activator="{ menuprops }">
<VTextField
:modelValue="innerValue"
@update:modelValue="updateModelValue"
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
clearable
/>
</template>
</CronInput>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import PathInput from '@/components/input/PathInput.vue'
const attrs = useAttrs()
const props = defineProps({
modelValue: {
type: String,
default: '* * * * *',
},
storage: {
type: String,
default: 'local',
},
})
const emit = defineEmits(['update:modelValue'])
const innerValue = ref(props.modelValue)
watch(
() => props.modelValue,
value => {
innerValue.value = value
},
)
const propsWithoutModelValue = computed(() => {
const { modelValue, ...rest } = props
return { ...rest, ...attrs }
})
function updateModelValue(value: string) {
innerValue.value = value
emit('update:modelValue', value)
}
</script>
<template>
<PathInput v-model="innerValue" :storage="props.storage" @update:modelValue="updateModelValue">
<template #activator="{ menuprops }">
<VTextField
:modelValue="innerValue"
@update:modelValue="updateModelValue"
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
/>
</template>
</PathInput>
</template>

View File

@@ -7,9 +7,9 @@ import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
import { formatBytes } from '@core/utils/formatters'
import type { Context, EndPoints, FileItem } from '@/api/types'
import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify'
import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
// 显示器宽度
const display = useDisplay()
@@ -106,9 +106,6 @@ const files = computed(() => items.value.filter(item => item.type === 'file' &&
// 是否文件
const isFile = computed(() => inProps.item.type == 'file')
// 是否目录
const isDir = computed(() => !isFile.value)
// 需要整理的文件项
const transferItems = ref<FileItem[]>([])
@@ -622,7 +619,8 @@ onMounted(() => {
v-if="inProps.icons && item.extension"
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
/>
<VIcon v-else icon="mdi-folder-outline" />
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder-outline" />
<VIcon v-else icon="mdi-file-outline" />
</template>
</template>
<VListItemTitle v-text="item.name" />
@@ -737,14 +735,12 @@ onMounted(() => {
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- 识别结果对话框 -->
<VDialog v-if="nameTestDialog" v-model="nameTestDialog" width="50rem">
<VCard>
<DialogCloseBtn @click="nameTestDialog = false" />
<VCardItem>
<MediaInfoCard :context="nameTestResult" />
</VCardItem>
</VCard>
</VDialog>
<MediaInfoDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
:context="nameTestResult"
@close="nameTestDialog = false"
/>
</template>
<style lang="scss" scoped>

View File

@@ -63,7 +63,7 @@ const pathSegments = computed(() => {
// 当前存储
const storageObject = computed(() => {
return inProps.storages?.find(item => item.code === inProps.storage)
return inProps.storages?.find(item => item.value === inProps.storage)
})
// 切换存储
@@ -127,19 +127,19 @@ const sortIcon = computed(() => {
<VListItem
v-for="(item, index) in storages"
:key="index"
:disabled="item.code === storageObject?.code"
@click="changeStorage(item.code)"
:disabled="item.value === storageObject?.value"
@click="changeStorage(item.value)"
>
<template #prepend>
<Icon :icon="item.icon" />
</template>
<VListItemTitle>{{ item.name }}</VListItemTitle>
<VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
<VBtn variant="text" :input-value="item.path === '/'" class="px-1" @click="changePath(inProps.itemstack[0])">
<VIcon :icon="storageObject?.icon" class="mr-2" />
{{ storageObject?.name }}
{{ storageObject?.title }}
</VBtn>
<template v-for="(segment, index) in pathSegments" :key="index">
<VBtn

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: String,
default: '* * * * *',
},
})
const emit = defineEmits(['update:modelValue'])
const currentCron = ref(props.modelValue)
watch(currentCron, newVal => {
emit('update:modelValue', newVal)
})
watch(
() => props.modelValue,
value => {
currentCron.value = value
},
)
</script>
<template>
<div>
<VMenu :close-on-content-click="false" content-class="cursor-default" persistent>
<template v-slot:activator="{ props }">
<slot name="activator" :menuprops="props" />
</template>
<VList>
<VListItem>
<VCronVuetify v-model="currentCron" locale="zh-CN" :chip-props="{ color: 'success' }" class="mt-1" />
</VListItem>
</VList>
</VMenu>
</div>
</template>

View File

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

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import api from '@/api'
import { FileItem } from '@/api/types'
const props = defineProps({
modelValue: {
type: String,
default: '/',
},
root: {
type: String,
default: '/',
required: true,
},
storage: {
type: String,
default: 'local',
},
})
const emit = defineEmits(['update:modelValue'])
const menuVisible = ref(false)
const treeItems = ref<FileItem[]>([
{
name: '/',
path: props.root,
children: [],
type: 'dir',
basename: props.root,
storage: props.storage,
},
])
const activedDirs = ref<FileItem[]>([])
const openedDirs = ref<FileItem[]>([])
// 调用API查询子目录
async function fetchDirs(item: any) {
return api
.post('/storage/list', item)
.then((data: any) => {
data = data.filter((i: any) => i.type === 'dir')
item.children?.push(...data)
})
.catch(err => console.warn(err))
}
// 递归查询路径
function findPath(item: FileItem, path: string): FileItem | null {
if (item.path === path) {
return item
}
if (item.children) {
for (const child of item.children) {
const res: FileItem | null = findPath(child, path)
if (res) {
return res
}
}
}
return null
}
// 根据路径展开所有子目录
async function expandDirs(path: string) {
// 分割路径
const paths = path.split('/').filter(i => i)
// 展开根目录
const root_item = treeItems.value[0]
await fetchDirs(root_item)
openedDirs.value.push(root_item)
// 逐级展开
let currentPath = '/'
for (const p of paths) {
currentPath += `${p}/`
// 查询当前目录
const item = findPath(root_item, currentPath)
if (!item) {
break
}
// 加载子目录
if (item.children?.length === 0) {
await fetchDirs(item)
}
// 打开当前目录
if (!openedDirs.value.includes(item) && path != currentPath) {
openedDirs.value.push(item)
}
// 选中当前目录
if (path == currentPath) {
activedDirs.value = [item]
}
}
}
// 当前选中项
const selectedPath = computed(() => {
if (activedDirs.value.length > 0) {
return activedDirs.value[0].path
}
return ''
})
watch(activedDirs, newVal => {
if (!newVal.length) return
emit('update:modelValue', selectedPath.value)
})
watch(
() => menuVisible.value,
async visible => {
if (visible) {
treeItems.value = [
{
name: '/',
path: props.root,
children: [],
type: 'dir',
basename: props.root,
storage: props.storage,
},
]
openedDirs.value = []
activedDirs.value = []
await expandDirs(props.modelValue)
}
},
)
watch(
() => props.storage,
async newVal => {
treeItems.value = [
{
name: '/',
path: props.root,
children: [],
type: 'dir',
basename: props.root,
storage: newVal,
},
]
activedDirs.value = []
openedDirs.value = []
},
)
</script>
<template>
<div>
<VMenu v-model="menuVisible" :close-on-content-click="false" content-class="cursor-default">
<template v-slot:activator="{ props }">
<slot name="activator" :menuprops="props" />
</template>
<VTreeview
v-model:activated="activedDirs"
v-model:opened="openedDirs"
:items="treeItems"
:load-children="fetchDirs"
item-key="path"
item-title="name"
item-value="path"
activatable
return-object
max-height="20rem"
expand-icon="mdi-folder"
collapse-icon="mdi-folder-open"
/>
</VMenu>
</div>
</template>

View File

@@ -6,10 +6,21 @@ import { type PropType } from 'vue'
const elementProps = defineProps({
config: Object as PropType<RenderProps>,
})
// key
const componentKey = ref(0)
onActivated(() => {
componentKey.value++
})
</script>
<template>
<Component :is="elementProps.config?.component" v-if="!elementProps.config?.html" v-bind="elementProps.config?.props">
<Component
:key="componentKey"
:is="elementProps.config?.component"
v-if="!elementProps.config?.html"
v-bind="elementProps.config?.props"
>
{{ elementProps.config?.text }}
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
<slot :name="name" v-bind="_props">
@@ -23,6 +34,7 @@ const elementProps = defineProps({
/>
</Component>
<Component
:key="componentKey"
:is="elementProps.config?.component"
v-if="elementProps.config?.html"
v-bind="elementProps.config?.props"

View File

@@ -1,57 +1,116 @@
<script lang="ts" setup>
<script setup lang="ts">
import { RenderProps } from '@/api/types'
import { type PropType, ref } from 'vue'
// 输入参数
const elementProps = defineProps({
config: Object as PropType<RenderProps>,
form: Object as PropType<any>,
})
// 定义 props
defineProps<{
config: RenderProps // JSON 配置
model: Record<string, any> // 数据模型
}>()
// 配置元素
const formItem = ref<RenderProps>(
elementProps.config ?? {
component: 'div',
text: '',
html: '',
props: {},
content: [],
},
)
/**
* 解析属性,支持 v-model 和动态绑定
* @param rawProps 原始属性
* @param model 数据模型
* @returns 解析后的属性
*/
const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) => {
const parsedProps: Record<string, any> = {}
// 配置数据
const formData = ref<any>(elementProps.form || {})
for (const [key, value] of Object.entries(rawProps)) {
if (key === 'modelvalue') {
// 将 modelvalue 转换为 v-model:value 的形式
parsedProps['value'] = model[value]
parsedProps['onUpdate:value'] = (newValue: any) => {
model[value] = newValue
}
} else if (key === 'model') {
// 处理 v-model
parsedProps['modelValue'] = model[value]
parsedProps['onUpdate:modelValue'] = (newValue: any) => {
model[value] = newValue
}
} else if (key.startsWith('model:')) {
// 处理 v-model:<prop>
const propName = key.replace('model:', '')
parsedProps[propName] = model[value]
parsedProps[`onUpdate:${propName}`] = (newValue: any) => {
model[value] = newValue
}
} else {
// 普通属性直接赋值
parsedProps[key] = typeof value === 'string' && value in model ? model[value] : value
}
}
return parsedProps
}
/**
* 渲染插槽内容
* @param slotContent 插槽配置
* @param model 数据模型
* @param slotScope 插槽作用域
*/
const renderSlotContent = (slotContent: any, model: any, slotScope: any) => {
if (Array.isArray(slotContent)) {
// 如果插槽内容是数组,递归渲染
return slotContent.map(childConfig => renderComponent(childConfig, model, slotScope))
}
// 如果插槽内容是单个配置,递归渲染
return renderComponent(slotContent, model, slotScope)
}
/**
* 渲染组件函数(递归支持嵌套)
* @param config JSON 配置
* @param model 数据模型
* @param slotScope 插槽作用域
* @returns 渲染的组件 VNode
*/
const renderComponent = (config: any, model: any, slotScope: any = {}) => {
const { component, props: componentProps = {}, content = [], slots = {}, html, text } = config
// 动态解析组件
const Component = resolveComponent(component)
// 解析属性
const parsedProps = parseProps(componentProps, model)
// 动态插槽解析
const slotNodes: Record<string, any> = {}
for (const [slotName, slotContent] of Object.entries(slots)) {
slotNodes[slotName] = (slotScopeData: any) =>
renderSlotContent(slotContent, model, { ...slotScope, ...slotScopeData })
}
// 渲染组件内容
const renderContent = () => {
// 如果配置了 `html`,直接渲染为 HTML 内容
if (html) {
return h(Component, { innerHTML: typeof html === 'string' ? html : model[html] })
}
// 如果配置了 `text`,直接渲染为文本内容
if (text) {
return typeof text === 'string' ? text : model[text]
}
// 如果配置了 `content`,递归渲染子组件
if (Array.isArray(content)) {
return content.map((childConfig: any) => renderComponent(childConfig, model, slotScope))
}
return null
}
// 渲染组件
return h(Component, parsedProps, {
...slotNodes,
default: renderContent,
})
}
</script>
<template>
<Component
:is="formItem.component"
v-if="!formItem.html && !!formItem.props?.modelvalue"
v-bind="formItem.props"
v-model:value="formData[formItem.props?.modelvalue]"
>
{{ formItem.text }}
<template v-for="(innerItem, innerIndex) in formItem.content || []" :key="innerIndex">
<FormRender
v-if="!!innerItem.props?.modelvalue"
v-model:value="formData[innerItem.props?.modelvalue]"
:config="innerItem"
:form="formData"
/>
<FormRender v-else v-model="formData[innerItem.props?.model]" :config="innerItem" :form="formData" />
</template>
</Component>
<Component :is="formItem.component" v-else-if="formItem.html" v-bind="formItem.props" v-html="formItem.html" />
<Component :is="formItem.component" v-else v-bind="formItem.props" v-model="formData[formItem.props?.model]">
{{ formItem.text }}
<template v-for="(innerItem, innerIndex) in formItem.content || []" :key="innerIndex">
<FormRender
v-if="!!innerItem.props?.modelvalue"
v-model:value="formData[innerItem.props?.modelvalue]"
:config="innerItem"
:form="formData"
/>
<FormRender v-else v-model="formData[innerItem.props?.model]" :config="innerItem" :form="formData" />
</template>
</Component>
<Component :is="renderComponent(config, model)" />
</template>

View File

@@ -9,7 +9,7 @@ import { RenderProps } from '@/api/types'
const emit = defineEmits(['action'])
// 输入参数
const elementProps = defineProps({
const props = defineProps({
config: Object as PropType<RenderProps>,
})
@@ -41,9 +41,9 @@ async function commonAction(api_path: string, method: string, params = {}) {
// 组装事件
let componentEvents = reactive<{ [key: string]: any }>({})
watchEffect(() => {
if (!isNullOrEmptyObject(elementProps.config?.events)) {
for (const key in elementProps.config?.events) {
const attr = elementProps.config?.events[key]
if (!isNullOrEmptyObject(props.config?.events)) {
for (const key in props.config?.events) {
const attr = props.config?.events[key]
const func = async () => {
await commonAction(attr['api'], attr['method'], attr['params'])
}
@@ -54,35 +54,20 @@ watchEffect(() => {
</script>
<template>
<Component
:is="elementProps.config?.component"
v-if="!elementProps.config?.html"
v-bind="elementProps.config?.props"
v-on="componentEvents"
>
{{ elementProps.config?.text }}
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
<slot :name="name" v-bind="_props">
<PageRender
v-for="(slotItem, slotIndex) in content || []"
:key="slotIndex"
:config="slotItem"
@action="emit('action')"
/>
</slot>
</template>
<Component :is="config?.component" v-if="!config?.html" v-bind="config?.props" v-on="componentEvents">
{{ config?.text }}
<PageRender
v-for="(innerItem, innerIndex) in elementProps.config?.content || []"
v-for="(innerItem, innerIndex) in config?.content || []"
:key="innerIndex"
:config="innerItem"
@action="emit('action')"
/>
</Component>
<Component
:is="elementProps.config?.component"
v-if="elementProps.config?.html"
v-bind="elementProps.config?.props"
v-html="elementProps.config?.html"
:is="config?.component"
v-if="config?.html"
v-bind="config?.props"
v-html="config?.html"
v-on="componentEvents"
/>
<!-- 进度框 -->

View File

@@ -64,7 +64,7 @@ onMounted(() => {
<VIcon icon="mdi-menu" />
</IconBtn>
<!-- 👉 Back Button -->
<IconBtn v-if="appMode && display.lgAndUp" class="ms-n2" @click="goBack">
<IconBtn v-if="appMode && display.mdAndDown.value" class="ms-n2" @click="goBack">
<VIcon icon="mdi-arrow-left" size="32" />
</IconBtn>
<!-- 👉 Search Bar -->

View File

@@ -64,4 +64,3 @@ const activeState = computed(() => {
background-color: transparent !important;
}
</style>
}

View File

@@ -6,6 +6,7 @@ import router from '@/router'
import avatar1 from '@images/avatars/avatar-1.png'
import api from '@/api'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import UserAuthDialog from '@/components/dialog/UserAuthDialog.vue'
// Vuex Store
const store = useStore()
@@ -19,6 +20,12 @@ const $toast = useToast()
// 进度框
const progressDialog = ref(false)
// 站点认证对话框
const siteAuthDialog = ref(false)
// 重启确认对话框
const restartDialog = ref(false)
// 执行注销操作
function logout() {
// 清除登录状态信息
@@ -29,13 +36,8 @@ function logout() {
// 执行重启操作
async function restart() {
// 弹出提示
const confirmed = await createConfirm({
title: '确认',
content: '确认重启系统吗?',
})
if (confirmed) {
{
restartDialog.value = false
// 调用API重启
try {
// 显示等待框
@@ -56,10 +58,27 @@ async function restart() {
}
}
// 显示重启确认对话框
async function showRestartDialog() {
restartDialog.value = true
}
// 显示站点认证对话框
function showSiteAuthDialog() {
siteAuthDialog.value = true
}
// 用户站点认证成功
function siteAuthDone() {
siteAuthDialog.value = false
logout()
}
// 从Vuex Store中获取信息
const superUser = computed(() => store.state.auth.superUser)
const userName = computed(() => store.state.auth.userName)
const avatar = computed(() => store.state.auth.avatar || avatar1)
const userLevel = computed(() => store.state.auth.level)
</script>
<template>
@@ -94,6 +113,14 @@ const avatar = computed(() => store.state.auth.avatar || avatar1)
<VListItemTitle>个人信息</VListItemTitle>
</VListItem>
<!-- 👉 Site Auth -->
<VListItem v-if="userLevel < 2 && superUser" link @click="showSiteAuthDialog">
<template #prepend>
<VIcon class="me-2" icon="mdi-lock-check-outline" size="22" />
</template>
<VListItemTitle>用户认证</VListItemTitle>
</VListItem>
<!-- 👉 FAQ -->
<VListItem href="https://wiki.movie-pilot.org" target="_blank">
<template #prepend>
@@ -106,7 +133,7 @@ const avatar = computed(() => store.state.auth.avatar || avatar1)
<VDivider v-if="superUser" class="my-2" />
<!-- 👉 restart -->
<VListItem v-if="superUser" @click="restart">
<VListItem v-if="superUser" @click="showRestartDialog">
<template #prepend>
<VIcon class="me-2" icon="mdi-restart" size="22" />
</template>
@@ -126,4 +153,27 @@ const avatar = computed(() => store.state.auth.avatar || avatar1)
</VAvatar>
<!-- 重启进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在重启 ..." />
<!-- 用户认证对话框 -->
<UserAuthDialog v-if="siteAuthDialog" v-model="siteAuthDialog" @done="siteAuthDone" @close="siteAuthDialog = false" />
<!-- 重启确认对话框 -->
<VDialog v-model="restartDialog" max-width="25rem">
<VCard>
<VCardItem>
<div class="flex items-center justify-center mt-3">
<VAvatar color="warning" variant="text" size="x-large">
<VIcon size="x-large" icon="mdi-alert" />
</VAvatar>
<div class="ms-3">
<p class="font-bold text-xl text-high-emphasis">确认重启系统吗</p>
<p>重启后您将被注销并需要重新登录</p>
</div>
</div>
</VCardItem>
<VCardActions class="mx-auto">
<VBtn variant="elevated" color="error" @click="restart" prepend-icon="mdi-restart" class="px-5"> 确定 </VBtn>
<VBtn variant="tonal" color="secondary" class="px-5" @click="restartDialog = false">取消</VBtn>
</VCardActions>
<DialogCloseBtn @click="restartDialog = false" />
</VCard>
</VDialog>
</template>

View File

@@ -1,21 +1,31 @@
// 1. 配置与兼容性
import './ace-config'
import '@/@core/utils/compatibility'
import '@/@iconify/icons-bundle'
import '@/plugins/webfontloader'
import App from '@/App.vue'
// 2. 核心插件和 UI 框架
import { createApp } from 'vue'
import vuetify from '@/plugins/vuetify'
import router from '@/router'
import store from '@/store'
import { createApp } from 'vue'
import { removeEl } from './@core/utils/dom'
import { fetchGlobalSettings } from './api'
import { isPWA } from './@core/utils/navigator'
import './ace-config'
// 3. 全局组件
import App from '@/App.vue'
import { VAceEditor } from 'vue3-ace-editor'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
import { VTreeview } from 'vuetify/labs/VTreeview'
import { CronVuetify } from '@vue-js-cron/vuetify'
// 4. 工具函数和其他辅助模块
import { fetchGlobalSettings } from './api'
import { isPWA } from './@core/utils/navigator'
// 5. 其他插件和功能模块
import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import VueApexCharts from 'vue3-apexcharts'
// 6. 注册自定义组件
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import MediaCard from './components/cards/MediaCard.vue'
import PosterCard from './components/cards/PosterCard.vue'
@@ -24,12 +34,18 @@ import PersonCard from './components/cards/PersonCard.vue'
import MediaInfoCard from './components/cards/MediaInfoCard.vue'
import TorrentCard from './components/cards/TorrentCard.vue'
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
import PathField from './components/input/PathField.vue'
import CronField from './components/field/CronField.vue'
import PathField from './components/field/PathField.vue'
// 7. 样式文件
import '@core/scss/template/libs/vuetify/index.scss'
import 'vuetify/styles'
import '@core/scss/template/index.scss'
import '@layouts/styles/index.scss'
import '@styles/styles.scss'
import 'vue-toast-notification/dist/theme-bootstrap.css'
import 'vue3-perfect-scrollbar/style.css'
import '@vue-js-cron/vuetify/dist/vuetify.css'
import '@styles/styles.scss'
// 创建Vue实例
const app = createApp(App)
@@ -49,9 +65,14 @@ async function initializeApp() {
// 注册全局组件
initializeApp().then(() => {
// 优先注册框架
app.use(vuetify)
// 注册全局组件
app
.component('VAceEditor', VAceEditor)
.component('VApexChart', VueApexCharts)
.component('VCronVuetify', CronVuetify)
.component('VDialogCloseBtn', DialogCloseBtn)
.component('VMediaCard', MediaCard)
.component('VPosterCard', PosterCard)
@@ -60,14 +81,14 @@ initializeApp().then(() => {
.component('VMediaInfoCard', MediaInfoCard)
.component('VTorrentCard', TorrentCard)
.component('VMediaIdSelector', MediaIdSelector)
.component('VTreeview', VTreeview)
.component('VCronField', CronField)
.component('VPathField', PathField)
// 注册插件
app
.use(vuetify)
.use(router)
.use(store)
.use(PerfectScrollbarPlugin)
.use(ToastPlugin, {
position: 'bottom-right',
})
@@ -91,8 +112,5 @@ initializeApp().then(() => {
cancellationText: '取消',
},
})
.use(PerfectScrollbarPlugin)
.use(VueApexCharts)
.mount('#app')
.$nextTick(() => removeEl('#loading-bg'))
})

View File

@@ -11,17 +11,12 @@ const activeTab = ref(route.query.tab)
// 下载器
const downloaders = ref<DownloaderConf[]>([])
// 获取启用的下载器
const enabledDownloaders = computed(() => downloaders.value.filter(item => item.enabled))
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Downloaders')
if (result.data?.value && result.data.value.length > 0) {
downloaders.value = result.data?.value ?? []
if (!activeTab.value) activeTab.value = downloaders.value[0].name
}
downloaders.value = await api.get('download/clients')
if (downloaders.value && downloaders.value.length > 0 && !activeTab.value)
activeTab.value = downloaders.value[0].name
} catch (error) {
console.log(error)
}
@@ -31,23 +26,25 @@ function jumpTab(tab: string) {
router.push('/subscribe/movie?tab=' + tab)
}
onMounted(() => {
loadDownloaderSetting()
onMounted(async () => {
await loadDownloaderSetting()
})
</script>
<template>
<div v-if="enabledDownloaders.length > 0">
<div v-if="downloaders.length > 0">
<VTabs v-model="activeTab">
<VTab v-for="item in enabledDownloaders" :value="item.name" @to="jumpTab(item.name)">
<VTab v-for="item in downloaders" :value="item.name" @to="jumpTab(item.name)">
<span class="mx-5">{{ item.name }}</span>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem v-for="item in enabledDownloaders" :value="item.name">
<VWindowItem v-for="item in downloaders" :value="item.name">
<transition name="fade-slide" appear>
<DownloadingListView :name="item.name" />
<div>
<DownloadingListView :name="item.name" />
</div>
</transition>
</VWindowItem>
</VWindow>

View File

@@ -130,7 +130,6 @@ function login() {
// 进行表单校验
if (!form.value.username || !form.value.password || (isOTP.value && !form.value.otp_password)) {
errorMessage.value = '请输入完整信息'
return
}
// 用户名密码
@@ -175,11 +174,11 @@ function login() {
})
.catch((error: any) => {
// 登录失败,显示错误提示
if (!error.response) errorMessage.value = '登录失败,请检查网络连接'
else if (error.response.status === 401) errorMessage.value = '登录失败,请检查用户名、密码或双重验证是否正确'
else if (error.response.status === 403) errorMessage.value = '登录失败,您没有权限访问'
else if (error.response.status === 500) errorMessage.value = '登录失败,服务器错误'
else errorMessage.value = `登录失败 ${error.response.status},请检查用户名、密码或双重验证码是否正确`
if (!error.response) errorMessage.value = '登录失败,请检查网络连接'
else if (error.response.status === 401) errorMessage.value = '登录失败,请检查用户名、密码或双重验证是否正确'
else if (error.response.status === 403) errorMessage.value = '登录失败,您没有权限访问'
else if (error.response.status === 500) errorMessage.value = '登录失败,服务器错误'
else errorMessage.value = `登录失败 ${error.response.status},请检查用户名、密码或双重验证码是否正确`
})
}
@@ -241,7 +240,7 @@ onUnmounted(() => {
<VCardTitle class="font-weight-bold text-2xl text-uppercase"> MoviePilot </VCardTitle>
</VCardItem>
<VCardText>
<VForm ref="refForm" @submit.prevent="() => {}">
<VForm ref="refForm" autocomplete="on" @submit.prevent="() => {}">
<VRow>
<!-- username -->
<VCol cols="12">
@@ -250,6 +249,8 @@ onUnmounted(() => {
v-model="form.username"
label="用户名"
type="text"
name="username"
autocomplete="username"
:rules="[requiredValidator]"
@input="fetchOTP"
/>
@@ -260,6 +261,8 @@ onUnmounted(() => {
v-model="form.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
name="current-password"
autocomplete="current-password"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@@ -275,9 +278,9 @@ onUnmounted(() => {
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login"> 登录 </VBtn>
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</div>
</VAlert>
</VCol>
</VRow>
</VForm>

View File

@@ -68,6 +68,7 @@ const viewList = reactive<{ apipath: string; linkurl: string; title: string }[]>
title: '豆瓣全球剧集榜',
},
])
</script>
<template>

View File

@@ -139,27 +139,29 @@ onUnmounted(() => {
<TorrentCardListView v-else :items="dataList" />
</div>
<!-- 视图切换 -->
<VFab
v-if="viewType === 'list'"
class="mb-12"
icon="mdi-view-grid"
location="bottom"
size="x-large"
absolute
app
appear
@click="setViewType('card')"
:class="{ 'mb-12': appMode }"
/>
<VFab
v-else
icon="mdi-view-list"
location="bottom"
size="x-large"
fixed
app
appear
@click="setViewType('list')"
:class="{ 'mb-12': appMode }"
/>
<div v-if="isRefreshed">
<VFab
v-if="viewType === 'list'"
class="mb-12"
icon="mdi-view-grid"
location="bottom"
size="x-large"
absolute
app
appear
@click="setViewType('card')"
:class="{ 'mb-12': appMode }"
/>
<VFab
v-else
icon="mdi-view-list"
location="bottom"
size="x-large"
fixed
app
appear
@click="setViewType('list')"
:class="{ 'mb-12': appMode }"
/>
</div>
</template>

View File

@@ -70,7 +70,9 @@ function jumpTab(tab: string) {
<!-- 规则 -->
<VWindowItem value="rule">
<transition name="fade-slide" appear>
<AccountSettingRule />
<div>
<AccountSettingRule />
</div>
</transition>
</VWindowItem>

View File

@@ -2,7 +2,7 @@
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
import { SubscribeMovieTabs } from '@/router/menu'
import { SubscribeMovieTabs, SubscribeTvTabs } from '@/router/menu'
import router from '@/router'
const route = useRoute()
@@ -19,28 +19,34 @@ function jumpTab(tab: string) {
<template>
<div>
<VTabs v-model="activeTab">
<VTab v-for="item in SubscribeMovieTabs" :value="item.tab" @to="jumpTab(item.tab)">
<VTab v-if="subType == '电影'" v-for="item in SubscribeMovieTabs" :value="item.tab" @to="jumpTab(item.tab)">
<span class="mx-5">{{ item.title }}</span>
</VTab>
<VTab v-if="subType == '电视剧'" value="share" @to="jumpTab('share')">
<span class="mx-5">订阅分享</span>
<VTab v-if="subType == '电视剧'" v-for="item in SubscribeTvTabs" :value="item.tab" @to="jumpTab(item.tab)">
<span class="mx-5">{{ item.title }}</span>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="mysub">
<transition name="fade-slide" appear>
<SubscribeListView :type="subType" :subid="subId" />
<div>
<SubscribeListView :type="subType" :subid="subId" />
</div>
</transition>
</VWindowItem>
<VWindowItem value="popular">
<transition name="fade-slide" appear>
<SubscribePopularView :type="subType" />
<div>
<SubscribePopularView :type="subType" />
</div>
</transition>
</VWindowItem>
<VWindowItem value="share">
<transition name="fade-slide" appear>
<SubscribeShareView />
<div>
<SubscribeShareView />
</div>
</transition>
</VWindowItem>
</VWindow>

View File

@@ -6,10 +6,6 @@ import defaults from './defaults'
import { icons } from './icons'
import theme from './theme'
// Styles
import '@core/scss/template/libs/vuetify/index.scss'
import 'vuetify/styles'
export default createVuetify({
aliases: {
IconBtn: VBtn,

View File

@@ -8,7 +8,7 @@ configureNProgress()
// Router
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) {
scrollBehavior(to: any, from: any, savedPosition: any) {
// 如果页面有缓存那么恢复其位置, 否则始终滚动到顶部
if (to.meta.keepAlive && savedPosition) return savedPosition
return { top: 0 }
@@ -190,16 +190,35 @@ const router = createRouter({
],
})
const abortControllers = new Set<AbortController>()
// 注册中止控制器
function registerAbortController(controller: AbortController) {
abortControllers.add(controller)
}
// 中止所有组件的任务
function abortAllControllers() {
for (const controller of abortControllers) {
controller.abort()
}
abortControllers.clear()
}
// 路由导航守卫
router.beforeEach((to, from, next) => {
router.beforeEach((to: any, from: any, next: any) => {
// 总是记录非login路由
if (to.fullPath != '/login') store.state.auth.originalPath = to.fullPath
const isAuthenticated = store.state.auth.token !== null
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else {
abortAllControllers()
next()
}
})
// 导出默认对象
export default router
// 另行导出其他功能
export { registerAbortController }

View File

@@ -50,15 +50,15 @@ export const SystemNavMenus = [
admin: false,
},
{
title: '正在下载',
title: '下载管理',
icon: 'mdi-download-outline',
to: '/downloading',
header: '整理',
admin: false,
},
{
title: '历史记录',
icon: 'mdi-history',
title: '媒体整理',
icon: 'mdi-folder-play-outline',
to: '/history',
header: '整理',
admin: true,
@@ -100,35 +100,6 @@ export const SystemNavMenus = [
},
]
// 常用菜单功能
export const UserfulMenus = [
{
title: '搜索设置',
icon: 'mdi-magnify',
to: 'setting?tab=search',
},
{
title: '订阅设置',
icon: 'mdi-rss',
to: 'setting?tab=subscribe',
},
{
title: '服务',
icon: 'mdi-list-box',
to: 'setting?tab=service',
},
{
title: '词表',
icon: 'mdi-file-word-box',
to: 'setting?tab=words',
},
{
title: '历史记录',
icon: 'mdi-history',
to: 'history',
},
]
// 设定标签页
export const SettingTabs = [
{
@@ -219,6 +190,11 @@ export const SubscribeTvTabs = [
tab: 'popular',
icon: 'mdi-television',
},
{
title: '订阅分享',
tab: 'share',
icon: 'mdi-television',
},
]
// 插件标签页

View File

@@ -1,6 +1,5 @@
import { createHandlerBoundToURL, cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { clientsClaim } from 'workbox-core'
declare let self: ServiceWorkerGlobalScope

View File

@@ -3,6 +3,11 @@
@tailwind components;
@tailwind utilities;
html.v-overlay-scroll-blocked {
position: relative;
--v-body-scroll-y: 0px !important;
}
#nprogress .bar {
background: rgb(var(--v-theme-primary)) !important;
inset-block-start: env(safe-area-inset-top) !important;
@@ -24,15 +29,11 @@
}
.v-dialog > .v-overlay__content {
inline-size: calc(100% - 1rem);
margin-block-start: calc(env(safe-area-inset-top) + 1rem);
max-block-size: calc(100% - env(safe-area-inset-top) - 1rem);
margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
}
.v-dialog--fullscreen > .v-overlay__content{
inline-size: 100%;
margin-block-start: env(safe-area-inset-top);
max-block-size: calc(100% - env(safe-area-inset-top));
.v-dialog--fullscreen > .v-overlay__content > .v-card {
padding-block-end: calc(env(safe-area-inset-top) + env(safe-area-inset-bottom));
}
/* router view transition fade-slide */

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import VueApexCharts from 'vue3-apexcharts'
import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
import api from '@/api'
@@ -24,6 +23,8 @@ const variableTheme = controlledComputed(
() => vuetifyTheme.current.value.variables,
)
const chartKey = ref(0)
// 定时器
let refreshTimer: NodeJS.Timeout | null = null
@@ -130,6 +131,10 @@ onUnmounted(() => {
refreshTimer = null
}
})
onActivated(() => {
chartKey.value += 1
})
</script>
<template>
@@ -143,8 +148,7 @@ onUnmounted(() => {
<VCardTitle>CPU</VCardTitle>
</VCardItem>
<VCardText>
<VueApexCharts type="line" :options="chartOptions" :series="series" :height="150" />
<VApexChart :key="chartKey" type="line" :options="chartOptions" :series="series" :height="150" />
<p class="text-center font-weight-medium mb-0">当前{{ current }}%</p>
</VCardText>
</VCard>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import VueApexCharts from 'vue3-apexcharts'
import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
import api from '@/api'
@@ -25,6 +24,8 @@ const variableTheme = controlledComputed(
() => vuetifyTheme.current.value.variables,
)
const chartKey = ref(0)
// 定时器
let refreshTimer: NodeJS.Timeout | null = null
@@ -136,6 +137,10 @@ onUnmounted(() => {
refreshTimer = null
}
})
onActivated(() => {
chartKey.value += 1
})
</script>
<template>
@@ -149,8 +154,7 @@ onUnmounted(() => {
<VCardTitle>内存</VCardTitle>
</VCardItem>
<VCardText>
<VueApexCharts type="area" :options="chartOptions" :series="series" :height="150" />
<VApexChart :key="chartKey" type="area" :options="chartOptions" :series="series" :height="150" />
<p class="text-center font-weight-medium mb-0">当前{{ formatBytes(usedMemory) }}</p>
</VCardText>
</VCard>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import VueApexCharts from 'vue3-apexcharts'
import { useTheme } from 'vuetify'
import api from '@/api'
import { hexToRgb } from '@layouts/utils'
@@ -127,8 +126,7 @@ onMounted(() => {
</VCardItem>
<VCardText>
<VueApexCharts type="bar" :options="options" :series="series" :height="160" />
<VApexChart type="bar" :options="options" :series="series" :height="160" />
<div class="d-flex align-center mb-3">
<h5 class="text-h5 me-4">
{{ totalCount }}

View File

@@ -27,7 +27,6 @@ const isRefreshed = ref(false)
// 数据列表
const dataList = ref<MediaInfo[]>([])
const currData = ref<MediaInfo[]>([])
// 拼装参数
function getParams() {
let params = {
@@ -78,6 +77,7 @@ async function fetchData({ done }: { done: any }) {
} else {
// 加载一次
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(props.apipath, {
@@ -115,15 +115,7 @@ async function fetchData({ done }: { done: any }) {
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card mx-3" tabindex="0">
<MediaCard v-for="data in dataList" :key="data.tmdb_id || data.douban_id" :media="data" />
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
error-title="没有数据"
error-description="无法获取到媒体信息"
/>
<NoDataFound v-if="dataList.length === 0 && isRefreshed" error-code="404" error-title="没有数据"
error-description="无法获取到媒体信息" />
</VInfiniteScroll>
</template>
<style lang="scss">
</style>

View File

@@ -3,6 +3,7 @@ import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import SlideView from '@/components/slide/SlideView.vue'
import { registerAbortController } from '@/router'
// 输入参数
const props = defineProps({
@@ -11,7 +12,8 @@ const props = defineProps({
title: String,
})
provide('rankingPropsKey', reactive({...props}))
// 提供给子组件的属性
provide('rankingPropsKey', reactive({ ...props }))
// 组件加载完成
const componentLoaded = ref(false)
@@ -22,36 +24,33 @@ const dataList = ref<MediaInfo[]>([])
// 获取订阅列表数据
async function fetchData() {
try {
if (!props.apipath)
return
dataList.value = await api.get(props.apipath)
if (dataList.value.length > 0)
componentLoaded.value = true
}
catch (error) {
if (!props.apipath) return
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
dataList.value = await api.get(props.apipath, { signal })
if (dataList.value.length > 0) componentLoaded.value = true
} catch (error) {
console.error(error)
}
}
// 加载时获取数据
onMounted(fetchData)
onMounted(() => {
fetchData()
})
onActivated(() => {
if (dataList.value.length == 0) {
fetchData()
}
})
</script>
<template>
<SlideView
v-if="componentLoaded"
>
<SlideView v-if="componentLoaded">
<template #content>
<template
v-for="data in dataList"
:key="data.tmdb_id || data.douban_id || data.bangumi_id"
>
<MediaCard
:media="data"
height="15rem"
width="10rem"
/>
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
<MediaCard :media="data" height="15rem" width="10rem" />
</template>
</template>
</SlideView>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import draggable from 'vuedraggable'
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { Plugin } from '@/api/types'
@@ -28,6 +29,9 @@ const pluginId = ref(route.query.id)
// 当前排序字段
const activeSort = ref(null)
// 插件顺序配置
const orderConfig = ref<{ id: string }[]>([])
// 排序选项
const sortOptions = [
{ title: '热门', value: 'count' },
@@ -104,6 +108,52 @@ const labelFilterOptions = ref<string[]>([])
// 插件库过滤项
const repoFilterOptions = ref<string[]>([])
// 加载插件顺序
async function loadPluginOrderConfig() {
// 顺序配置
const local_order = localStorage.getItem('MP_PLUGIN_ORDER')
if (local_order) {
orderConfig.value = JSON.parse(local_order)
} else {
const response2 = await api.get('/user/config/PluginOrder')
if (response2 && response2.data && response2.data.value) {
orderConfig.value = response2.data.value
localStorage.setItem('MP_PLUGIN_ORDER', JSON.stringify(orderConfig.value))
}
}
}
// 按order的顺序对插件进行排序
function sortPluginOrder() {
if (!orderConfig.value) {
return
}
if (dataList.value.length === 0) {
return
}
dataList.value.sort((a, b) => {
const aIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === a.id)
const bIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === b.id)
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
})
}
// 保存顺序设置
async function savePluginOrder() {
// 顺序配置
const orderObj = dataList.value.map(item => ({ id: item.id || '' }))
orderConfig.value = orderObj
const orderString = JSON.stringify(orderObj)
localStorage.setItem('MP_PLUGIN_ORDER', orderString)
// 保存到服务端
try {
await api.post('/user/config/PluginOrder', orderObj)
} catch (error) {
console.error(error)
}
}
// 初始化过滤选项
function initOptions(item: Plugin) {
const optionValue = (options: Array<string>, value: string | undefined) => {
@@ -208,6 +258,8 @@ async function fetchInstalledPlugins() {
state: 'installed',
},
})
// 排序
sortPluginOrder()
loading.value = false
isRefreshed.value = true
} catch (error) {
@@ -329,8 +381,9 @@ function handleRepoUrl(url: string | undefined) {
}
// 加载时获取数据
onBeforeMount(async () => {
await refreshData()
onMounted(async () => {
await loadPluginOrderConfig()
refreshData()
getPluginStatistics()
if (activeTab.value != 'market' && pluginId.value) {
// 找到这个插件
@@ -356,18 +409,26 @@ onBeforeMount(async () => {
<transition name="fade-slide" appear>
<div>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<div v-if="dataList.length > 0" class="grid gap-4 grid-plugin-card">
<template v-for="(data, index) in dataList" :key="`${data.id}_v${data.plugin_version}`">
<draggable
v-if="dataList.length > 0"
v-model="dataList"
@end="savePluginOrder"
handle=".cursor-move"
item-key="id"
tag="div"
:component-data="{ class: 'grid gap-4 grid-plugin-card' }"
>
<template #item="{ element }">
<PluginCard
:count="PluginStatistics[data.id || '0']"
:plugin="data"
:action="pluginActions[data.id || '0']"
:count="PluginStatistics[element.id || '0']"
:plugin="element"
:action="pluginActions[element.id || '0']"
@remove="refreshData"
@save="refreshData"
@action-done="pluginActions[data.id || '0'] = false"
@action-done="pluginActions[element.id || '0'] = false"
/>
</template>
</div>
</draggable>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
@@ -446,31 +507,32 @@ onBeforeMount(async () => {
</VWindow>
</div>
<!-- 插件搜索图标 -->
<VFab
icon="mdi-magnify"
color="info"
location="bottom"
size="x-large"
fixed
app
appear
@click="SearchDialog = true"
:class="appMode ? 'mb-28' : 'mb-16'"
/>
<!-- 插件市场设置图标 -->
<VFab
icon="mdi-store-cog"
color="warning"
location="bottom"
size="x-large"
fixed
app
appear
@click="MarketSettingDialog = true"
:class="{ 'mb-12': appMode }"
/>
<div v-if="isRefreshed">
<!-- 插件搜索图标 -->
<VFab
icon="mdi-magnify"
color="info"
location="bottom"
size="x-large"
fixed
app
appear
@click="SearchDialog = true"
:class="appMode ? 'mb-28' : 'mb-16'"
/>
<!-- 插件市场设置图标 -->
<VFab
icon="mdi-store-cog"
color="warning"
location="bottom"
size="x-large"
fixed
app
appear
@click="MarketSettingDialog = true"
:class="{ 'mb-12': appMode }"
/>
</div>
<!-- 插件市场设置窗口 -->
<PluginMarketSettingDialog
v-if="MarketSettingDialog"

View File

@@ -4,6 +4,7 @@ import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { TransferHistory } from '@/api/types'
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
import TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useRoute } from 'vue-router'
import router from '@/router'
@@ -20,14 +21,21 @@ const $toast = useToast()
// 路由
const route = useRoute()
// 组合式输入法状态
const isComposing = ref(false)
// 重新整理对话框
const redoDialog = ref(false)
// 整理队列对话框
const transferQueueDialog = ref(false)
// 当前操作记录
const currentHistory = ref<TransferHistory>()
// 重新整理IDS
const redoIds = ref<number[]>([])
const redoTargetStorage = ref<string>()
// 已选中的数据
const selected = ref<TransferHistory[]>([])
@@ -105,6 +113,9 @@ const progressText = ref('请稍候 ...')
// 进度值
const progressValue = ref(0)
// 是否已刷新
const isRefreshed = ref(false)
// 删除确认对话框
const deleteConfirmDialog = ref(false)
@@ -143,7 +154,7 @@ const totalPage = computed(() => {
return total
})
// 切换页签和搜索词
// 切换页签
watch(
[() => currentPage.value, () => itemsPerPage.value],
debounce(async () => {
@@ -151,10 +162,14 @@ watch(
}, 1000),
)
// 搜索监听
watch(
[() => search.value],
[() => search.value, () => isComposing.value],
debounce(async () => {
reloadPage(true)
if (!isComposing.value) {
console.log('search: ' + search.value)
reloadPage(true)
}
}, 1000),
)
@@ -170,7 +185,7 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value) {
title: search.value,
},
})
isRefreshed.value = true
dataList.value = result.data?.list
totalItems.value = result.data?.total
searchHintList.value = ['失败', '成功', ...new Set(dataList.value.map(item => item.title || ''))].filter(
@@ -301,6 +316,7 @@ const dropdownItems = ref([
prependIcon: 'mdi-redo-variant',
click: (item: TransferHistory) => {
redoIds.value = [item.id]
redoTargetStorage.value = item.dest_storage
redoDialog.value = true
},
},
@@ -359,15 +375,16 @@ onMounted(fetchData)
<VCardItem>
<VCardTitle>
<VRow>
<VCol cols="4" md="6"> 历史记录 </VCol>
<VCol cols="8" md="6" class="flex">
<VCombobox
key="search_navbar"
v-model="search"
:items="searchHintList"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
class="text-disabled"
density="compact"
label="搜索目录、状态"
label="搜索整理记录"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
single-line
@@ -377,6 +394,16 @@ onMounted(fetchData)
clearable
/>
</VCol>
<VCol cols="4" md="6" class="text-end">
<VBtn
color="primary"
prepend-icon="mdi-tray-full"
append-icon="mdi-dots-horizontal"
@click="transferQueueDialog = true"
>
<span v-if="display.mdAndUp.value" class="ms-2">整理队列</span>
</VBtn>
</VCol>
</VRow>
</VCardTitle>
</VCardItem>
@@ -479,7 +506,7 @@ onMounted(fetchData)
</VCard>
<!-- 底部操作按钮 -->
<span>
<div v-if="isRefreshed">
<VFab
v-if="selected.length > 0"
icon="mdi-trash-can-outline"
@@ -503,7 +530,7 @@ onMounted(fetchData)
appear
@click="retransferBatch"
/>
</span>
</div>
<!-- 底部弹窗 -->
<VBottomSheet v-model="deleteConfirmDialog" inset>
<VCard class="text-center rounded-t">
@@ -512,13 +539,13 @@ onMounted(fetchData)
{{ confirmTitle }}
</VCardTitle>
<div class="d-flex flex-column flex-lg-row justify-center my-3">
<VBtn color="primary" class="mb-2 mx-2" @click="deleteConfirmHandler(false, false)"> 仅删除历史记录 </VBtn>
<VBtn color="warning" class="mb-2 mx-2" @click="deleteConfirmHandler(true, false)"> 删除历史记录和源文件 </VBtn>
<VBtn color="primary" class="mb-2 mx-2" @click="deleteConfirmHandler(false, false)"> 仅删除整理记录 </VBtn>
<VBtn color="warning" class="mb-2 mx-2" @click="deleteConfirmHandler(true, false)"> 删除整理记录和源文件 </VBtn>
<VBtn color="info" class="mb-2 mx-2" @click="deleteConfirmHandler(false, true)">
删除历史记录和媒体库文件
删除整理记录和媒体库文件
</VBtn>
<VBtn color="error" class="mb-2 mx-2" @click="deleteConfirmHandler(true, true)">
删除历史记录源文件和媒体库文件
删除整理记录源文件和媒体库文件
</VBtn>
</div>
</VCard>
@@ -530,9 +557,12 @@ onMounted(fetchData)
v-if="redoDialog"
v-model="redoDialog"
:logids="redoIds"
:target_storage="redoTargetStorage"
@done="transferDone"
@close="redoDialog = false"
/>
<!-- 整理队列进度弹窗 -->
<TransferQueueDialog v-if="transferQueueDialog" v-model="transferQueueDialog" @close="transferQueueDialog = false" />
</template>
<style lang="scss">

View File

@@ -165,7 +165,7 @@ onMounted(() => {
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow undefined">
<a
href="https://github.com/jxxghp/MoviePilot/blob/main/README.md"
href="https://wiki.movie-pilot.org"
target="_blank"
rel="noreferrer"
class="text-indigo-500 transition duration-300 hover:underline"

View File

@@ -52,6 +52,17 @@ async function loadSystemSettings() {
}
}
// 重载系统生效配置
async function reloadSystem() {
try {
const result: { [key: string]: any } = await api.get('system/reload')
if (result.success) $toast.success('系统配置已生效')
else $toast.error('重载系统失败!')
} catch (error) {
console.log(error)
}
}
// 移动结束
function orderDirectoryCards() {
// 更新所有目录的优先级
@@ -109,6 +120,7 @@ async function saveDirectories() {
const result: { [key: string]: any } = await api.post('system/setting/Directories', directories.value)
if (result.success) {
$toast.success('目录设置保存成功')
await reloadSystem()
} else $toast.error('目录设置保存失败!')
} catch (error) {
console.log(error)
@@ -129,6 +141,7 @@ function addDirectory() {
monitor_type: '',
media_type: '',
media_category: '',
transfer_type: ''
})
orderDirectoryCards()
}

View File

@@ -222,6 +222,7 @@ onMounted(() => {
<VRadioGroup v-model="item.action" inline>
<VRadio value="user" label="仅操作用户" />
<VRadio value="admin" label="仅管理员" />
<VRadio value="user,admin" label="操作用户和管理员" />
<VRadio value="all" label="所有用户" />
</VRadioGroup>
</td>

View File

@@ -3,7 +3,6 @@
import { useToast } from 'vue-toast-notification'
import { copyToClipboard } from '@/@core/utils/navigator'
import draggable from 'vuedraggable'
import { VRow } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import CustomerRuleCard from '@/components/cards/CustomRuleCard.vue'
@@ -25,9 +24,6 @@ const mediaCategories = ref<{ [key: string]: any }>({})
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入的代码
const importCodeString = ref('')
// 导入代码类型
const importCodeType = ref('')
@@ -98,8 +94,6 @@ async function addCustomRule() {
customRules.value.push({
id: id,
name: name,
include: '',
exclude: '',
})
}
@@ -149,14 +143,11 @@ function addFilterRuleGroup() {
}
filterRuleGroups.value.push({
name: name,
rule_string: '',
media_type: '',
category: '',
})
}
// 分享规则
function shareRules(rules: CustomRule[] | FilterRuleGroup[]) {
async function shareRules(rules: CustomRule[] | FilterRuleGroup[], type: string) {
if (!rules || rules.length === 0) return
// 将卡片规则接装为字符串
@@ -164,55 +155,136 @@ function shareRules(rules: CustomRule[] | FilterRuleGroup[]) {
// 复制到剪贴板
try {
copyToClipboard(value)
$toast.success('优先级规则已复制到剪贴板')
} catch (error) {
$toast.error('优先级规则复制失败!')
let success
success = copyToClipboard(value)
if (await success) $toast.success(`${type === 'custom' ? '自定义规则' : '优先级规则组'}已复制到剪贴板!`)
else $toast.error(`${type === 'custom' ? '自定义规则' : '优先级规则组'}复制失败:可能是浏览器不支持或被用户阻止!`)
} catch (e) {
$toast.error(`${type === 'custom' ? '自定义规则' : '优先级规则组'}复制失败!`)
console.error(e)
}
}
// 导入规则
// 打开弹窗
async function importRules(ruleType: string) {
importCodeType.value = ruleType
importCodeString.value = ''
importCodeDialog.value = true
}
// 监听导入代码变化
watchEffect(() => {
if (!importCodeString.value) return
// 导入代码需要json格式
// 保存导入代码
function saveCodeString(type: string, codeString: any) {
// codeString从子组件传递过来从对象转换为JSON
let parsedCode
try {
if (importCodeType.value === 'custom') {
// 将导入的代码转换为规则卡片,并追加到已有的 customRules
const newCustomRules = JSON.parse(importCodeString.value).map((item: any) => {
return {
id: item.id,
name: item.name,
include: item.include,
exclude: item.exclude,
publish_time: item.publish_time,
seeders: item.seeders,
size_range: item.size_range,
}
})
customRules.value = [...customRules.value, ...newCustomRules] // 合并已有的和新导入的规则
} else if (importCodeType.value === 'group') {
// 将导入的代码转换为规则卡片,并追加到已有的 filterRuleGroups
const newFilterRuleGroups = JSON.parse(importCodeString.value).map((item: any) => {
return {
name: item.name,
rule_string: item.rule_string,
media_type: item.media_type,
category: item.category,
}
})
filterRuleGroups.value = [...filterRuleGroups.value, ...newFilterRuleGroups] // 合并已有的和新导入的规则
}
} catch (error) {
$toast.error('规则导入失败!')
parsedCode = JSON.parse(codeString.value)
} catch (e) {
$toast.error('导入规则失败!无法解析输入的数据!')
console.error(e)
return
}
})
// 更新数据
try {
if (type === 'custom') {
if (!checkValueValidity(parsedCode, type)) return false
const newCustomRules = extractCustomRules(parsedCode) || []
customRules.value = [...customRules.value, ...newCustomRules]
} else if (type === 'group') {
if (!checkValueValidity(parsedCode, type)) return false
const newFilterRuleGroups = extractFilterRuleGroups(parsedCode) || []
filterRuleGroups.value = [...filterRuleGroups.value, ...newFilterRuleGroups]
} else {
$toast.error('导入规则失败!未知的数据类型!')
}
} catch (e) {
$toast.error('导入规则失败!')
console.error(e)
}
}
// 赋值自定义规则,避免存在多余的属性
function extractCustomRules(value: any) {
try {
return value.map((item: any) => {
return {
id: item.id,
name: item.name,
include: item.include,
exclude: item.exclude,
size_range: item.size_range,
seeders: item.seeders,
publish_time: item.publish_time,
}
})
} catch (e) {
console.error(e)
}
}
// 赋值规则组,避免存在多余的属性
function extractFilterRuleGroups(value: any) {
try {
return value.map((item: any) => {
return {
name: item.name,
rule_string: item.rule_string,
media_type: item.media_type,
category: item.category,
}
})
} catch (e) {
console.error(e)
}
}
// 根据ID简单区分规则与规则组
function checkValueValidity(values: any, type: string): boolean {
try {
// 允许空值存在,不影响最终的导入
if (!values) return true
if (!type) return false
for (const value of values) {
const keys = Object.keys(value)
const uniqueKeys = new Set(keys)
const hasName = keys.includes('name')
const hasId = keys.includes('id')
const noDuplicates = keys.length === uniqueKeys.size
if (type == 'custom') {
if (!hasName || !hasId || !noDuplicates) {
if (!noDuplicates) $toast.warning(`存在重名值`)
if (!hasId) $toast.error(`导入失败发现有规则不存在ID可能属于优先级规则组`)
return false
}
} else if (type == 'group') {
if (!hasName || hasId || !noDuplicates) {
if (!noDuplicates) $toast.warning(`存在重名值`)
if (hasId) $toast.error(`导入失败发现有规则存在ID可能属于自定义规则`)
return false
}
} else {
console.error(`传入了不合法的类型!`)
return false
}
}
return true
} catch (e) {
console.error(e)
return false
}
}
// 清空规则(组)
function deleteAllRules(dateType: string) {
if (!dateType) return
if (dateType === 'custom') {
customRules.value = []
} else if (dateType === 'group') {
filterRuleGroups.value = []
} else {
console.error(`传入了不支持的类型!`)
}
}
// 规则变化时赋值
function onRuleChange(rule: CustomRule, id: string) {
@@ -310,12 +382,15 @@ onMounted(() => {
<VBtn color="success" variant="tonal" @click="addCustomRule">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="info" variant="tonal" @click="importRules('custom')">
<VBtn color="primary" variant="tonal" @click="importRules('custom')">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" variant="tonal" @click="shareRules(customRules)">
<VBtn color="info" variant="tonal" @click="shareRules(customRules, 'custom')">
<VIcon icon="mdi-share" />
</VBtn>
<VBtn color="error" variant="tonal" @click="deleteAllRules('custom')">
<VIcon icon="mdi-delete" />
</VBtn>
</VBtnGroup>
</div>
</VForm>
@@ -358,22 +433,30 @@ onMounted(() => {
<VBtn color="success" variant="tonal" @click="addFilterRuleGroup">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="info" variant="tonal" @click="importRules('group')">
<VBtn color="primary" variant="tonal" @click="importRules('group')">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" variant="tonal" @click="shareRules(filterRuleGroups)">
<VBtn color="info" variant="tonal" @click="shareRules(filterRuleGroups, 'group')">
<VIcon icon="mdi-share" />
</VBtn>
<VBtn color="error" variant="tonal" @click="deleteAllRules('group')">
<VIcon icon="mdi-delete" />
</VBtn>
</VBtnGroup>
</div>
</VForm>
</VCardText>
<VDialog v-model="importCodeDialog" width="60rem" scrollable>
<ImportCodeDialog v-model="importCodeString" title="导入规则" @close="importCodeDialog = false" />
</VDialog>
</VCard>
</VCol>
</VRow>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
:title="`导入${importCodeType === 'custom' ? '自定义规则' : '优先级规则组'}`"
:dataType="importCodeType"
@close="importCodeDialog = false"
@save="saveCodeString"
/>
<VRow>
<VCol cols="12">
<VCard>

View File

@@ -133,6 +133,11 @@ async function saveSearchSetting() {
selectedMediaSource.value.join(','),
)
if (!result1 || !result1.success) {
$toast.error(`媒体搜索数据源保存失败:${result1?.message}`)
return
}
const result2: { [key: string]: any } = await api.post(
'system/setting/SearchFilterRuleGroups',
selectedFilterGroup.value,
@@ -140,7 +145,7 @@ async function saveSearchSetting() {
const result3 = await saveSystemSetting(SystemSettings.value.Basic)
if (result1.success && result2.success && result3) {
if (result2.success && result3) {
$toast.success('搜索基础设置保存成功')
} else {
$toast.error('搜索基础设置保存失败!')

View File

@@ -29,6 +29,7 @@ const siteSetting = ref<any>({
},
Site: {
SITEDATA_REFRESH_INTERVAL: 0,
SITE_MESSAGE: false,
},
})
@@ -78,15 +79,7 @@ async function loadSiteSettings() {
// 将API返回的值赋值给SystemSettings
for (const sectionKey of Object.keys(siteSetting.value) as Array<keyof typeof siteSetting.value>) {
Object.keys(siteSetting.value[sectionKey]).forEach((key: string) => {
let v: any
if (result.data.hasOwnProperty(key)) {
v = result.data[key]
// 空字符串转为null避免空字符串导致前端显示问题
if (v === '') {
v = null
}
;(siteSetting.value[sectionKey] as any)[key] = v
}
if (result.data.hasOwnProperty(key)) (siteSetting.value[sectionKey] as any)[key] = result.data[key]
})
}
}
@@ -112,8 +105,10 @@ async function saveSiteSetting(value: { [key: string]: any }) {
try {
const result: { [key: string]: any } = await api.post('system/env', value)
if (result.success) {
$toast.success('保存设置成功')
$toast.success('保存站点设置成功')
await reloadSystem()
} else {
$toast.error('站点设置保存失败!')
}
} catch (error) {
console.log(error)
@@ -232,6 +227,16 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="siteSetting.Site.SITE_MESSAGE"
label="阅读站点消息"
hint="刷新数据时读取站点消息并发送通知"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>

View File

@@ -26,15 +26,16 @@ const SystemSettings = ref<any>({
// 全局
AUXILIARY_AUTH_ENABLE: false,
GLOBAL_IMAGE_CACHE: false,
SUBSCRIBE_STATISTIC_SHARE: true,
PLUGIN_STATISTIC_SHARE: true,
BIG_MEMORY_MODE: false,
DB_WAL_ENABLE: false,
// 媒体
TMDB_API_DOMAIN: null,
TMDB_IMAGE_DOMAIN: null,
META_CACHE_EXPIRE: 0,
FANART_ENABLE: false,
SCRAP_FOLLOW_TMDB: true,
SUBSCRIBE_STATISTIC_SHARE: true,
PLUGIN_STATISTIC_SHARE: true,
FANART_ENABLE: false,
// 网络
PROXY_HOST: null,
GITHUB_PROXY: null,
@@ -42,9 +43,16 @@ const SystemSettings = ref<any>({
DOH_ENABLE: false,
DOH_RESOLVERS: null,
DOH_DOMAINS: null,
// 开发
// 日志
DEBUG: false,
LOG_LEVEL: 'INFO',
LOG_MAX_FILE_SIZE: '5',
LOG_BACKUP_COUNT: '3',
LOG_FILE_FORMAT: '【%(levelname)s】%(asctime)s - %(message)s',
// 实验室
PLUGIN_AUTO_RELOAD: false,
ENCODING_DETECTION_PERFORMANCE_MODE: true,
TOKENIZED_SEARCH: false,
},
})
@@ -169,6 +177,9 @@ async function saveSystemSetting(value: { [key: string]: any }) {
const result: { [key: string]: any } = await api.post('system/env', value)
if (result.success) {
return true
} else {
$toast.error(`设置保存失败:${result?.message}`)
return false
}
} catch (error) {
console.log(error)
@@ -181,27 +192,36 @@ async function saveBasicSettings() {
if (await saveSystemSetting(SystemSettings.value.Basic)) {
$toast.success('基础设置保存成功')
await reloadSystem()
} else {
$toast.error('基础设置保存失败!')
}
}
// 高级设置变化,等待保存
// 保存高级设置
async function saveAdvancedSettings() {
cleanEmptyFields(SystemSettings.value.Advanced, ['LOG_FILE_FORMAT'])
if (await saveSystemSetting(SystemSettings.value.Advanced)) {
advancedDialog.value = false
$toast.success('高级设置保存成功')
await reloadSystem()
} else {
$toast.error('高级设置保存失败!')
}
}
// 当字段为空时,将其设置为 null 提交,以便后端恢复为默认值
function cleanEmptyFields(settings: any, fields: string[]) {
fields.forEach(field => {
if (settings[field]?.trim?.() === '') {
settings[field] = null
}
})
}
// 快捷复制到剪贴板
function copyValue(value: string) {
async function copyValue(value: string) {
try {
copyToClipboard(value)
$toast.success('已复制到剪贴板')
let success
success = copyToClipboard(value)
if (await success) $toast.success('已复制到剪贴板!')
else $toast.error(`复制失败:可能是浏览器不支持或被用户阻止!`)
} catch (error) {
$toast.error('复制失败!')
console.log(error)
@@ -234,6 +254,15 @@ const pipMirrorsItems = [
'https://mirrors.bfsu.edu.cn/pypi/web/simple', // 北京外国语大学
]
// 日志等级
const logLevelItems = [
{ title: 'DEBUG - 调试 ', value: 'DEBUG' },
{ title: 'INFO - 信息 ', value: 'INFO' },
{ title: 'WARNING - 警告 ', value: 'WARNING' },
{ title: 'ERROR - 错误 ', value: 'ERROR' },
{ title: 'CRITICAL - 严重 ', value: 'CRITICAL' },
]
// 创建随机字符串
function createRandomString() {
const charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'
@@ -398,7 +427,14 @@ onDeactivated(() => {
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn type="submit" @click="saveBasicSettings"> 保存 </VBtn>
<VSpacer />
<VBtn color="warning" @click="advancedDialog = true" append-icon="mdi-dots-horizontal"> 高级设置 </VBtn>
<VBtn
color="error"
@click="advancedDialog = true"
prepend-icon="mdi-cog"
append-icon="mdi-dots-horizontal"
>
高级设置
</VBtn>
</div>
</VForm>
</VCardText>
@@ -524,6 +560,9 @@ onDeactivated(() => {
<VTab value="network">
<div>网络</div>
</VTab>
<VTab value="log">
<div>日志</div>
</VTab>
<VTab value="dev">
<div>实验室</div>
</VTab>
@@ -572,6 +611,14 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.DB_WAL_ENABLE"
label="WAL模式"
hint="可提升读写并发性能,但可能在异常情况下增加数据丢失风险,更改后需重启生效"
persistent-hint
/>
</VCol>
</VRow>
</div>
</VWindowItem>
@@ -585,7 +632,7 @@ onDeactivated(() => {
placeholder="api.themoviedb.org"
hint="自定义themoviedb API域名或代理地址"
persistent-hint
:items="['api.themoviedb.org']"
:items="['api.themoviedb.org', 'api.tmdb.org']"
:rules="[(v: string) => !!v || '请输入TMDB API域名']"
/>
</VCol>
@@ -687,18 +734,64 @@ onDeactivated(() => {
</VRow>
</div>
</VWindowItem>
<VWindowItem value="dev">
<VWindowItem value="log">
<div>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.DEBUG"
label="DEBUG日志"
hint="显示DEBUG级别日志,方便排查问题"
label="调试模式"
hint="启用调试模式后,日志将以DEBUG级别记录,以便排查问题"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-if="!SystemSettings.Advanced.DEBUG"
v-model="SystemSettings.Advanced.LOG_LEVEL"
label="日志等级"
hint="设置日志记录的级别,用于控制日志输出量"
persistent-hint
:items="logLevelItems"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="SystemSettings.Advanced.LOG_MAX_FILE_SIZE"
label="日志文件最大容量(MB)"
hint="限制单个日志文件的最大容量,超出后将自动分割日志"
persistent-hint
min="1"
type="number"
suffix="MB"
:rules="[(v: any) => v === 0 || !!v || '日志文件最大大小', (v: any) => v >= 1 || '日志文件最大容量必须大于等于1']"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="SystemSettings.Advanced.LOG_BACKUP_COUNT"
label="日志文件最大备份数量"
hint="设置每个模块日志文件的最大备份数量,超过后将覆盖旧日志"
persistent-hint
min="1"
type="number"
:rules="[(v: any) => v === 0 || !!v || '请输入日志文件最大备份数量', (v: any) => v >= 1 || '日志文件最大备份数量必须大于等于1']"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="SystemSettings.Advanced.LOG_FILE_FORMAT"
label="日志文件格式"
hint="设置日志文件的输出格式,用于自定义日志的显示内容"
persistent-hint
/>
</VCol>
</VRow>
</div>
</VWindowItem>
<VWindowItem value="dev">
<div>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.PLUGIN_AUTO_RELOAD"
@@ -707,6 +800,22 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.ENCODING_DETECTION_PERFORMANCE_MODE"
label="编码探测性能模式"
hint="优先提升探测效率,但可能降低编码探测的准确性"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.TOKENIZED_SEARCH"
label="分词搜索"
hint="提升整理历史记录搜索精度,但可能增加性能开销和意外结果"
persistent-hint
/>
</VCol>
</VRow>
</div>
</VWindowItem>

View File

@@ -111,6 +111,7 @@ onActivated(() => {
/>
<!-- 新增站点按钮 -->
<VFab
v-if="isRefreshed"
icon="mdi-plus"
location="bottom"
size="x-large"

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { VPullToRefresh } from 'vuetify/labs/VPullToRefresh'
import draggable from 'vuedraggable'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
@@ -31,11 +31,74 @@ const subscribeEditDialog = ref(false)
// 历史记录弹窗
const historyDialog = ref(false)
// 订阅顺序配置
const orderConfig = ref<{ id: number }[]>([])
// 显示的订阅列表
const displayList = ref<Subscribe[]>([])
// 监听dataList变化同步更新displayList
watch(dataList, () => {
// 从Vuex Store中获取用户信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
if (superUser) displayList.value = dataList.value.filter(data => data.type === props.type)
else displayList.value = dataList.value.filter(data => data.type === props.type && data.username === userName)
})
// 加载顺序
async function loadSubscribeOrderConfig() {
// 顺序配置
const local_order = localStorage.getItem('MP_SUBSCRIBE_ORDER')
if (local_order) {
orderConfig.value = JSON.parse(local_order)
} else {
const response2 = await api.get('/user/config/SubscribeOrder')
if (response2 && response2.data && response2.data.value) {
orderConfig.value = response2.data.value
localStorage.setItem('MP_SUBSCRIBE_ORDER', JSON.stringify(orderConfig.value))
}
}
}
// 按order的顺序排序
function sortSubscribeOrder() {
if (!orderConfig.value) {
return
}
if (dataList.value.length === 0) {
return
}
dataList.value.sort((a, b) => {
const aIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === a.id)
const bIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === b.id)
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
})
}
// 保存顺序设置
async function saveSubscribeOrder() {
// 顺序配置
const orderObj = displayList.value.map(item => ({ id: item.id }))
orderConfig.value = orderObj
const orderString = JSON.stringify(orderObj)
localStorage.setItem('MP_SUBSCRIBE_ORDER', orderString)
// 保存到服务端
try {
await api.post('/user/config/SubscribeOrder', orderObj)
} catch (error) {
console.error(error)
}
}
// 获取订阅列表数据
async function fetchData() {
try {
loading.value = true
dataList.value = await api.get('subscribe/')
// 排序
sortSubscribeOrder()
loading.value = false
isRefreshed.value = true
} catch (error) {
@@ -46,22 +109,8 @@ async function fetchData() {
// 刷新状态
const loading = ref(false)
// 下拉刷新
async function onRefresh({ done }: { done: any }) {
await fetchData()
done('ok')
}
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
const filteredDataList = computed(() => {
// 从Vuex Store中获取用户信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
if (superUser) return dataList.value.filter(data => data.type === props.type)
else return dataList.value.filter(data => data.type === props.type && data.username === userName)
})
onMounted(async () => {
await loadSubscribeOrderConfig()
await fetchData()
if (props.subid) {
// 找到这个订阅
@@ -75,54 +124,58 @@ onMounted(async () => {
onActivated(async () => {
if (!loading.value) {
fetchData()
await fetchData()
}
})
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VPullToRefresh v-model="loading" @load="onRefresh">
<div v-if="filteredDataList.length > 0" class="mx-3 grid gap-4 grid-subscribe-card p-1">
<SubscribeCard
v-for="data in filteredDataList"
:key="data.id"
:media="data"
@remove="fetchData"
@save="fetchData"
/>
</div>
<NoDataFound
v-if="filteredDataList.length === 0 && isRefreshed"
error-code="404"
error-title="没有订阅"
error-description="请通过搜索添加电影电视剧订阅"
/>
</VPullToRefresh>
<draggable
v-if="displayList.length > 0"
v-model="displayList"
@end="saveSubscribeOrder"
handle=".cursor-move"
item-key="id"
tag="div"
:component-data="{ class: 'mx-3 grid gap-4 grid-subscribe-card p-1' }"
>
<template #item="{ element }">
<SubscribeCard :key="element.id" :media="element" @remove="fetchData" @save="fetchData" />
</template>
</draggable>
<NoDataFound
v-if="displayList.length === 0 && isRefreshed"
error-code="404"
error-title="没有订阅"
error-description="请通过搜索添加电影电视剧订阅"
/>
<!-- 底部操作按钮 -->
<VFab
v-if="store.state.auth.superUser"
icon="mdi-clipboard-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="subscribeEditDialog = true"
:class="{ 'mb-12': appMode }"
/>
<VFab
v-if="store.state.auth.superUser"
icon="mdi-history"
color="info"
location="bottom"
:class="appMode ? 'mb-28' : 'mb-16'"
size="x-large"
fixed
app
appear
@click="historyDialog = true"
/>
<div v-if="isRefreshed">
<VFab
v-if="store.state.auth.superUser"
icon="mdi-clipboard-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="subscribeEditDialog = true"
:class="{ 'mb-12': appMode }"
/>
<VFab
v-if="store.state.auth.superUser"
icon="mdi-history"
color="info"
location="bottom"
:class="appMode ? 'mb-28' : 'mb-16'"
size="x-large"
fixed
app
appear
@click="historyDialog = true"
/>
</div>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"

View File

@@ -97,6 +97,11 @@ async function fetchData({ done }: { done: any }) {
done('error')
}
}
// 将数据从列表中移除
function removeData(id: number) {
dataList.value = dataList.value.filter(item => item.id !== id)
}
</script>
<template>
@@ -106,7 +111,7 @@ async function fetchData({ done }: { done: any }) {
<template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-subscribe-card mx-3" tabindex="0">
<div v-for="data in dataList" :key="data.id">
<SubscribeShareCard :media="data" />
<SubscribeShareCard :media="data" @delete="removeData(data.id || 0)" />
</div>
</div>
<NoDataFound

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
// 日志列表
const logs = ref<string[]>([])
// 已解析的日志列表
const parsedLogs = ref<{ level: string; time: string; program: string; content: string }[]>([])
// 表头
const headers = [
@@ -13,55 +13,56 @@ const headers = [
// SSE消息对象
let eventSource: EventSource | null = null
// 日志颜色映射表
const logColorMap: Record<string, string> = {
DEBUG: 'secondary',
INFO: 'info',
WARNING: 'warning',
ERROR: 'error',
}
// 获取日志颜色
function getLogColor(level: string): string {
return logColorMap[level] || 'secondary'
}
// SSE持续获取日志
function startSSELogging() {
eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/logging`)
const buffer: string[] = []
let timeoutId: number | null = null
eventSource.addEventListener('message', event => {
const message = event.data
if (message) logs.value.push(message)
if (message) {
buffer.push(message)
if (!timeoutId) {
timeoutId = window.setTimeout(() => {
// 解析新日志
const newParsedLogs = buffer
.map(log => {
const logPattern = /^【(.*?)】[0-9\-:]*\s(.*?)\s-\s(.*?)\s-\s(.*)$/
const matches = log.match(logPattern)
if (matches) {
const [, level, time, program, content] = matches
return { level, time, program, content }
}
return null
})
.filter(Boolean)
// 倒序后插入parsedLogs顶部
parsedLogs.value.unshift(...(newParsedLogs.reverse() as any[]))
// 保留最新的200条日志
parsedLogs.value = parsedLogs.value.slice(0, 200)
// 重置buffer
buffer.length = 0
timeoutId = null
}, 100)
}
}
})
}
// 从日志中提取日志详情
function extractLogDetailsFromLogs(
logs: string[],
): { level: string; time: string; program: string; content: string }[] {
const logDetails: { level: string; time: string; program: string; content: string }[] = []
const logPattern = /^【(.*?)】[0-9\-:]*\s(.*?)\s-\s(.*?)\s-\s(.*)$/
for (const log of logs) {
const matches = RegExp(logPattern).exec(log)
if (matches && matches.length === 5) {
const [_, level, time, program, content] = matches
logDetails.unshift({ level, time, program, content })
}
}
return logDetails
}
// 计算日志颜色
function getLogColor(level: string): string {
switch (level) {
case 'DEBUG':
return 'primary'
case 'INFO':
return 'secondary'
case 'WARNING':
return 'warning'
case 'ERROR':
return 'error'
default:
return 'secondary'
}
}
// 拆分日志数据计算属性
const extractLogDetails = computed(() => {
return extractLogDetailsFromLogs(logs.value)
})
onMounted(() => {
startSSELogging()
})
@@ -72,13 +73,13 @@ onBeforeUnmount(() => {
</script>
<template>
<LoadingBanner v-if="logs.length === 0" class="mt-12" text="正在刷新 ..." />
<LoadingBanner v-if="parsedLogs.length === 0" class="mt-12" text="正在刷新 ..." />
<div v-else>
<VTable class="table-rounded" hide-default-footer disable-sort>
<tbody>
<VDataTableVirtual
:headers="headers"
:items="extractLogDetails"
:items="parsedLogs"
height="100%"
density="compact"
hover
@@ -94,7 +95,7 @@ onBeforeUnmount(() => {
<h6 class="text-sm font-weight-medium">{{ item.program }}</h6>
</template>
<template #item.content="{ item }">
<span class="text-sm">
<span class="text-sm" :class="`text-${getLogColor(item.level)}`">
{{ item.content }}
</span>
</template>

View File

@@ -129,6 +129,26 @@ const targets = ref<Address[]>([
message: '未测试',
btndisable: false,
},
{
image: github,
name: 'api.github.com',
url: 'https://api.github.com',
proxy: true,
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
{
image: github,
name: 'raw.githubusercontent.com',
url: 'https://raw.githubusercontent.com',
proxy: true,
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
])
const resolveStatusColor: Status = {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import api from '@/api'
import type { Plugin, Subscribe } from '@/api/types'
import { SystemNavMenus, UserfulMenus, SettingTabs } from '@/router/menu'
import { SystemNavMenus, SettingTabs } from '@/router/menu'
import { NavMenu } from '@/@layouts/types'
import store from '@/store'
@@ -272,6 +272,24 @@ onMounted(() => {
</VListItem>
</template>
</VHover>
<VHover>
<template #default="hover">
<VListItem
prepend-icon="mdi-movie-filter"
density="compact"
link
v-bind="hover.props"
@click="searchMedia('collection')"
>
<VListItemTitle class="break-words whitespace-break-spaces">
搜索 <span class="font-bold">{{ searchWord }} </span> 相关的系列合集 ...
</VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
<VHover>
<template #default="hover">
<VListItem prepend-icon="mdi-account-search" link v-bind="hover.props" @click="searchMedia('person')">
@@ -300,7 +318,7 @@ onMounted(() => {
<template #default="hover">
<VListItem prepend-icon="mdi-history" link v-bind="hover.props" @click="searchHistory">
<VListItemTitle class="break-words whitespace-break-spaces">
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的历史记录 ...
搜索 <span class="font-bold">{{ searchWord }}</span> 相关的整理记录 ...
</VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
@@ -391,54 +409,6 @@ onMounted(() => {
</div>
</VCol>
</VRow>
<VRow v-if="superUser">
<VCol cols="12" md="6">
<p class="custom-letter-spacing text-sm text-disabled text-uppercase py-2 px-4 mb-0">常用功能</p>
<VList lines="one">
<VHover v-for="(menu, index) in UserfulMenus" :key="index">
<template #default="hover">
<VListItem
:prepend-icon="menu.icon"
density="compact"
link
v-bind="hover.props"
@click="goPage(menu.to)"
>
<VListItemTitle>
{{ menu.title }}
</VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
</VList>
</VCol>
<VCol cols="12" md="6">
<p class="custom-letter-spacing text-sm text-disabled text-uppercase py-2 px-4 mb-0">常用插件</p>
<VList lines="one">
<VHover v-for="plugin in pluginItems.slice(0, 5)" :key="plugin.id">
<template #default="hover">
<VListItem
prepend-icon="mdi-puzzle"
density="compact"
link
v-bind="hover.props"
@click="showPlugin(plugin.id ?? '')"
>
<VListItemTitle> {{ plugin.plugin_name }} </VListItemTitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="ri-corner-down-left-line" />
</template>
</VListItem>
</template>
</VHover>
</VList>
</VCol>
<VCol cols="12" md="6"> </VCol>
<VCol cols="12" md="6"> </VCol>
</VRow>
</VCardText>
</div>
</VCardText>

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