Compare commits

...

154 Commits

Author SHA1 Message Date
jxxghp
59b5e4a330 更新 package.json 2025-03-30 01:16:20 +08:00
jxxghp
f8f7275438 Merge pull request #315 from madrays/v2 2025-03-30 00:55:25 +08:00
madrays
6eec2e97f9 Merge branch 'v2' of https://github.com/madrays/MoviePilot-Frontend into v2 2025-03-30 00:47:22 +08:00
madrays
9020494f65 改进搜索体验:优化搜索对话框、进度条及无数据提示 2025-03-30 00:46:12 +08:00
madrays
43fbc7abd7 改进搜索体验:优化搜索对话框、进度条及无数据提示 2025-03-30 00:37:48 +08:00
jxxghp
d65a4b747d Merge pull request #314 from madrays/v2 2025-03-29 22:35:55 +08:00
madrays
849fad8a8a 优化移动端界面:修复列表视图筛选栏固定问题,优化头部布局减少空间占用 2025-03-29 22:31:46 +08:00
jxxghp
f0b2d14502 fix: 更新 TorrentCard 组件以支持多个站点图标的加载和显示 2025-03-29 20:50:55 +08:00
jxxghp
fa169fb785 fix: 优化初始加载状态的条件判断 2025-03-29 20:07:48 +08:00
jxxghp
49acf7fba3 fix: 重置加载进度值并调整搜索进度卡的内边距 2025-03-29 20:05:38 +08:00
jxxghp
80d55dae8d 更新 resource.vue 2025-03-29 19:41:55 +08:00
jxxghp
76aa5407a2 更新 TorrentRowListView.vue 2025-03-29 19:03:30 +08:00
jxxghp
d70789934f 更新 TorrentCardListView.vue 2025-03-29 19:03:03 +08:00
jxxghp
398e8b6afc feat: 优化过滤器按钮显示逻辑,支持动态显示和已选择过滤项 2025-03-29 18:28:56 +08:00
jxxghp
593fede47c feat: 更新主题颜色和背景色 2025-03-29 17:04:49 +08:00
jxxghp
40c7e9c126 chore: 更新版本号至 2.3.6 2025-03-29 15:42:20 +08:00
jxxghp
54e2f70ee0 feat: 优化 TorrentCard 组件标题显示,支持多行文本截断 2025-03-29 08:27:03 +08:00
jxxghp
81f85b9e46 feat: 优化搜索结果界面UI,感谢 @madrays 2025-03-29 08:11:13 +08:00
jxxghp
60a5476e59 Merge pull request #313 from cddjr/trimemedia
初步支持飞牛影视
2025-03-28 19:28:02 +08:00
景大侠
4271b63530 初步支持飞牛影视 2025-03-28 15:51:55 +08:00
jxxghp
8aca17f0c6 fix: 更新 AliyunAuthDialog 以使用二维码 URL 并调整状态处理逻辑 2025-03-28 13:39:36 +08:00
jxxghp
4f238dc1a3 fix: 移除 AliyunAuthDialog 和 U115AuthDialog 中的 refreshToken 相关代码 2025-03-25 12:57:42 +08:00
jxxghp
d4777fde70 fix: 移除新建文件夹对话框的 v-if 条件 2025-03-24 13:32:30 +08:00
jxxghp
b6c823c386 chore: 更新版本号至2.3.5 2025-03-23 16:39:56 +08:00
jxxghp
b7488214fc feat: 添加全选/全不选功能及按钮文本更新 2025-03-23 12:28:30 +08:00
jxxghp
06b6c3f3cb fix: 调整对话框最大宽度至45rem 2025-03-22 14:11:27 +08:00
jxxghp
abfaf926c4 fix #305 2025-03-22 10:03:11 +08:00
jxxghp
6eabeb09c9 fix #293 2025-03-22 09:53:21 +08:00
jxxghp
a15afabfa7 fix #310 2025-03-22 09:27:27 +08:00
jxxghp
30276d5022 fixhttps://github.com/jxxghp/MoviePilot/issues/4002 2025-03-22 08:08:50 +08:00
jxxghp
683ddc3fce fix: 更新二维码获取逻辑,修复定时器设置位置并优化提示信息 2025-03-21 20:32:05 +08:00
jxxghp
f00f79279b Merge pull request #309 from Aqr-K/fix/settings 2025-03-21 09:55:11 +08:00
Aqr-K
7989965b1a fix: VTextarea no longer displays all rows 2025-03-16 22:19:52 +08:00
jxxghp
5b84ce307b fix: 移除对话框的 persistent 属性 2025-03-15 11:52:16 +08:00
jxxghp
d13264b10e Merge pull request #307 from Aqr-K/fix/types 2025-03-10 19:13:34 +08:00
Aqr-K
29a1c4ae35 fix: 增加 @types/mousetrap 修复 mousetrap 类型缺失警告 2025-03-10 18:59:16 +08:00
jxxghp
9ac15e530a feat: 更新工作流操作对话框,移除节点和连接线的删除逻辑,改为使用删除键处理 2025-03-10 11:03:02 +08:00
jxxghp
d4b446280a feat: 优化媒体服务器播放列表的添加逻辑,避免重复项 2025-03-10 10:43:53 +08:00
jxxghp
4593898549 feat: 优化媒体服务器库和播放列表的添加逻辑,避免重复项 2025-03-10 10:42:12 +08:00
jxxghp
c030d1a309 feat: 在保存通知发送时间后添加系统重载功能 2025-03-10 09:02:16 +08:00
jxxghp
fd71e471b2 feat: 优化发现页面标签页的激活逻辑并初始化选中标签 2025-03-10 08:55:10 +08:00
jxxghp
bc245e0a7a feat: 在发现页面激活时添加排序订阅顺序功能 2025-03-10 08:22:28 +08:00
jxxghp
8236461c37 feat: 根据路由 meta 动态调整 footer 高度 2025-03-10 08:15:24 +08:00
jxxghp
e1e8344764 feat: 注册 Pinia 状态管理并提供全局设置 2025-03-10 08:08:52 +08:00
jxxghp
14398e083e 更新 PathField.vue 2025-03-10 07:48:40 +08:00
jxxghp
f36fe075ce chore: 更新版本号至 2.3.4 2025-03-09 21:44:15 +08:00
jxxghp
25cf9d7fce feat: 根据路由 meta 决定是否显示 footer 2025-03-09 21:43:52 +08:00
jxxghp
9355788221 feat: 添加组件激活时的数据加载功能 2025-03-09 19:43:34 +08:00
jxxghp
64042b51e9 feat: 通和发送时间设置 2025-03-09 18:52:05 +08:00
jxxghp
7145af48ad fix: 优化发现标签页的拖拽图标显示 2025-03-08 18:37:19 +08:00
jxxghp
ddb5468656 feat: 添加可拖拽的发现标签页并实现顺序保存功能 2025-03-08 16:12:21 +08:00
jxxghp
793cdd8f4c feat: 添加进度框以显示系统配置重载状态 2025-03-08 08:07:04 +08:00
jxxghp
faafbb59c6 Merge pull request #304 from Aqr-K/fix/workflow 2025-03-05 06:43:46 +08:00
Aqr-K
cd0ea07c2f fix: 修复创建同类型的节点时,数据未隔离的问题 2025-03-05 04:28:50 +08:00
Aqr-K
f6e3807a3d fix: 完善连接 workflow 节点时的合法性校验 2025-03-05 04:24:54 +08:00
jxxghp
fc36496aee chore: 更新版本号至 2.3.3 2025-03-02 12:32:15 +08:00
jxxghp
1c8881d7a4 feat: 添加重置任务功能 2025-03-02 12:27:58 +08:00
jxxghp
f6e8aacd0f feat: 优化任务执行功能,添加继续执行和重新开始选项;移除媒体过滤器中的类别选择 2025-03-02 11:17:28 +08:00
jxxghp
79ddc39492 feat: 添加标签输入框,优化下载器和 RSS 获取操作的界面布局 2025-03-02 09:45:44 +08:00
jxxghp
e63c5fb8e5 feat: 更新发送事件和发送消息的副标题,明确任务执行内容 2025-03-01 18:26:32 +08:00
jxxghp
695f4827fd chore: 更新版本号至 2.3.2 2025-03-01 15:38:39 +08:00
jxxghp
5a8b183c0f feat: 添加来源类型下拉框,优化媒体获取操作界面 2025-03-01 14:07:07 +08:00
jxxghp
2845a889ed feat: 修复导入工作流代码时的 JSON 解析问题 2025-02-28 22:08:49 +08:00
jxxghp
6333103050 feat: 为工作流组件添加外层 div 包裹,优化布局结构 2025-02-28 22:05:48 +08:00
jxxghp
cb6be91538 feat: 添加扫描目录功能,支持将目录文件扫描到队列 2025-02-28 21:11:21 +08:00
jxxghp
8cdd4b4af5 feat: 修改任务执行成功提示信息,增加延迟刷新 2025-02-28 19:03:00 +08:00
jxxghp
f4ec2029d9 feat: 移除工作流任务卡片的禁用状态 2025-02-28 18:18:24 +08:00
jxxghp
b84b0f229f feat: 添加搜索方式下拉框,优化工作流操作对话框布局 2025-02-28 18:13:13 +08:00
jxxghp
ef6a01a32f feat: 调整导航栏底部高度,禁用卡片点击涟漪效果 2025-02-28 13:02:38 +08:00
jxxghp
b451b8066a feat: 添加仅下载缺失资源的开关选项 2025-02-28 12:15:38 +08:00
jxxghp
57efd516c5 feat: 优化工作流任务卡片和列表视图的布局 2025-02-28 11:19:07 +08:00
jxxghp
d5979e6bf3 feat: 修复进度计算逻辑,添加加载状态和禁用功能 2025-02-27 20:39:22 +08:00
jxxghp
d75970cb2a feat: 更新工作流任务卡片 2025-02-27 20:15:24 +08:00
jxxghp
ad4bb07cd7 feat: 更新工作流组件,优化界面布局,添加消息和媒体过滤功能 2025-02-27 18:56:05 +08:00
jxxghp
9c558c3625 feat: 添加工作流组件的边缘处理和循环执行功能,优化订阅和RSS获取操作 2025-02-27 17:09:01 +08:00
jxxghp
b467bb6c56 feat: 重构工作流组件,动态加载节点类型,移除旧的侧边栏和背景组件 2025-02-27 13:55:06 +08:00
jxxghp
5cd021ea85 feat: 优化插件弹窗加载速度 2025-02-27 12:44:39 +08:00
jxxghp
3d64382c9b feat: 更新拖放功能,重构状态管理,优化工作流组件,添加节点和边的确认删除功能 2025-02-26 21:11:24 +08:00
jxxghp
6d5d4354d9 feat: 重构工作流对话框,合并添加和编辑功能,新增流程操作对话框 2025-02-26 19:07:00 +08:00
jxxghp
1b43446b5c feat: 添加自动刷新功能,每30秒更新工作流数据 2025-02-26 18:24:38 +08:00
jxxghp
7a9984f392 feat: 添加已完成动作数计算和优化工作流列表视图 2025-02-26 18:09:11 +08:00
jxxghp
3c6fbfb106 feat: 添加工作流任务卡片组件,支持编辑、删除和执行功能 2025-02-26 13:58:52 +08:00
jxxghp
bab46964ff feat: 优化用户界面和交互提示 2025-02-25 21:06:43 +08:00
jxxghp
661919f27a feat: 优化用户界面和交互提示 2025-02-25 20:52:43 +08:00
jxxghp
f3a03349b4 feat: 添加工作流新增对话框和编辑功能,优化工作流列表视图 2025-02-25 17:28:09 +08:00
jxxghp
29791bf986 fix #303 2025-02-25 08:35:28 +08:00
jxxghp
a06f0f29c6 Merge pull request #303 from Aqr-K/build/reduce-size 2025-02-25 06:57:48 +08:00
Aqr-K
b426d94180 fix: relevant settings of pinia and lodash-es 2025-02-24 19:50:47 +08:00
Aqr-K
5618d87e58 refactor: replace lodash with lodash-es 2025-02-24 19:49:10 +08:00
Aqr-K
721d4f7685 refactor: replace Vuex with Pinia 2025-02-24 19:26:56 +08:00
jxxghp
7a025bcd38 feat(Workflow): add modules 2025-02-23 13:16:01 +08:00
jxxghp
24a8125621 feat(package): 添加 @vue-flow/core 依赖及相关更新 2025-02-23 12:04:20 +08:00
jxxghp
468584a906 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2025-02-22 12:11:42 +08:00
jxxghp
c056ec9377 feat(Workflow): 添加工作流功能,包含工作流列表和相关接口定义 2025-02-22 12:11:38 +08:00
jxxghp
87239994ae feat(SiteCard): 优化按钮位置 2025-02-21 13:10:48 +08:00
jxxghp
da09860a53 fix(AccountSettingRule): 更新删除按钮图标为mdi-delete-empty-outline 2025-02-21 13:02:12 +08:00
jxxghp
195ee5b2a6 feat(AccountSetting): 重构规则验证逻辑 2025-02-21 12:54:32 +08:00
jxxghp
32621ee299 feat(Search): 添加站点搜索功能,支持选择和过滤 2025-02-21 10:12:16 +08:00
jxxghp
40645180a0 fix(styles): 修复滚动阻止时的样式问题 2025-02-21 09:29:10 +08:00
jxxghp
59d4b1e544 feat(SearchBar): 添加站点搜索功能,支持多选和过滤 2025-02-20 13:03:37 +08:00
jxxghp
8962a2c4ac fix 2025-02-20 11:23:46 +08:00
jxxghp
6955f35ad1 chore(package): 更新版本号至 2.3.0 2025-02-18 16:49:37 +08:00
jxxghp
1f722e7d7f fix(styles): 移除数据表页脚的上边距 2025-02-18 16:44:30 +08:00
jxxghp
5e587dfd88 feat(SiteResource): 添加站点资源分类功能并优化资源查询逻辑 2025-02-18 16:27:31 +08:00
jxxghp
2c687e5648 feat(UserProfile): 添加功能视图菜单 2025-02-18 13:39:54 +08:00
jxxghp
fdb0f63283 fix(AppCenter): 修改菜单列表过滤逻辑以优化用户权限管理 2025-02-18 12:57:43 +08:00
jxxghp
002e675b47 fix(SiteCard): 修改站点卡片的点击事件和菜单项文本以优化用户体验 2025-02-18 08:34:54 +08:00
jxxghp
114f2a2dd0 fix(Footer): 优化更多菜单的激活状态计算逻辑 2025-02-18 08:12:16 +08:00
jxxghp
c314d49e11 feat(Footer): 使用 VMenu 组件替代 VBottomSheet 以优化更多菜单的展示 2025-02-18 07:56:11 +08:00
jxxghp
f5d0556808 fix(Footer): 修改对话框内容类以调整样式;添加隐藏滚动条的样式混合 2025-02-17 20:54:54 +08:00
jxxghp
27bc2a488f feat(SiteResourceDialog): 改进对话框布局 2025-02-17 20:41:52 +08:00
jxxghp
3a5999c341 fix(SubscribeShareCard): 添加 h-full 类以优化卡片布局 2025-02-16 16:15:25 +08:00
jxxghp
a80a099ee7 fix(Footer): 移除底部导航的 active 属性 2025-02-15 13:04:33 +08:00
jxxghp
68f458738a feat(Footer): 优化底部导航样式 2025-02-15 12:58:13 +08:00
jxxghp
0f08f69738 fix(MediaCard): 修改按钮大小并优化底部填充样式 2025-02-15 08:50:05 +08:00
jxxghp
a664066465 Merge pull request #301 from InfinityPacer/v2 2025-02-15 08:11:01 +08:00
jxxghp
e6c11665a5 更新 package.json 版本号至 2.2.9 2025-02-14 19:33:52 +08:00
InfinityPacer
c119384c22 fix(settings): remove invalid githubMirrorsItems 2025-02-14 14:19:48 +08:00
jxxghp
787cccb89f feat:在多个组件中添加onActivated钩子以优化数据加载逻辑 2025-02-11 17:15:55 +08:00
jxxghp
3df5d75c46 更新 package.json 2025-02-10 22:26:12 +08:00
jxxghp
de6ad2479e feat:为DiscoverSource接口添加依赖关系字典,优化过滤参数的watch逻辑 2025-02-10 22:05:33 +08:00
jxxghp
632dfbaf10 feat:优化TheMovieDbView组件的watch逻辑 2025-02-10 16:46:43 +08:00
jxxghp
68c14c24b8 feat:为TMDB排序和风格字典添加类型定义,优化过滤参数的逻辑,确保参数有效性 2025-02-09 22:21:43 +08:00
jxxghp
d343d6d54d feat:优化TheMovieDbView组件的watch逻辑,分离类型和过滤参数的监听,确保列表刷新更高效 2025-02-09 22:00:38 +08:00
jxxghp
391a160f97 更新 package.json 2025-02-09 11:59:53 +08:00
jxxghp
2d95110f75 feat:优化推荐页面,修复MediaCardSlideView的key绑定,使用电影标题作为唯一标识 2025-02-09 11:57:46 +08:00
jxxghp
e2ced8d36d feat:更新TheMovieDbView组件,添加电影和电视剧风格字典,优化类型过滤逻辑 2025-02-09 11:55:40 +08:00
jxxghp
a2b4511602 feat:优化FormRender组件的属性解析逻辑,支持动态表达式绑定 2025-02-09 11:41:35 +08:00
jxxghp
bdccc71b64 feat:优化FormRender组件的事件处理逻辑,支持动态函数绑定 2025-02-09 11:28:28 +08:00
jxxghp
d7038a7d18 feat:优化FormRender组件,增强v-model和v-show支持,改进属性绑定逻辑;在TheMovieDbView中添加儿童类别 2025-02-09 11:21:46 +08:00
jxxghp
3998e1f685 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2025-02-08 21:48:03 +08:00
jxxghp
5def9d5f81 feat:重构推荐页面,添加推荐数据源接口并更新路由和视图 2025-02-08 21:47:57 +08:00
jxxghp
c62937371e 更新 package.json 2025-02-08 20:19:12 +08:00
jxxghp
52843dcf97 feat:在排名页面中添加TMDB和豆瓣的热门电影及电视剧链接 2025-02-08 20:14:45 +08:00
jxxghp
ef5680d5ad feat:在ExtraSourceView中添加默认过滤参数支持,确保过滤条件的完整性 2025-02-08 12:53:26 +08:00
jxxghp
bd3f24c84b feat:添加媒体季信息接口,更新相关组件以支持季信息 2025-02-08 12:46:36 +08:00
jxxghp
399f85c52e chore:更新版本号至2.2.7-1 2025-02-07 18:21:13 +08:00
jxxghp
14430e5c89 feat:为选中的v-chip添加自定义颜色样式 2025-02-07 18:09:40 +08:00
jxxghp
b703757d28 feat:添加评分格式化功能,优化媒体卡片中的评分显示 2025-02-07 17:00:35 +08:00
jxxghp
b642eabbb3 feat:在媒体相关组件中添加媒体ID、标题和年份的支持 2025-02-06 20:33:14 +08:00
jxxghp
673596d8f9 feat:在媒体信息中添加媒体ID前缀和媒体ID 2025-02-06 19:21:02 +08:00
jxxghp
b14e927e6c feat:支持探索扩展 2025-02-06 18:04:49 +08:00
jxxghp
b03ae41ac7 feat:在index.html中添加初始加载背景样式 2025-02-06 16:26:10 +08:00
jxxghp
92a0a9fe2f feat:重构主题存储逻辑,优化加载背景和颜色设置 2025-02-06 16:00:32 +08:00
jxxghp
2511acfea1 feat:优化加载背景样式 2025-02-06 13:48:03 +08:00
jxxghp
361a4e0414 feat:优化分组逻辑,使用元信息增强分组键 2025-02-06 11:42:32 +08:00
jxxghp
7e310236fe feat:在转移历史视图中添加分组功能 2025-02-06 10:38:28 +08:00
jxxghp
8705606c70 更新 package.json 2025-02-06 08:39:54 +08:00
jxxghp
1f812a5258 feat:重构过滤选项逻辑,优化过滤表单和排序功能 2025-02-06 08:30:27 +08:00
jxxghp
e9264fa472 feat:小屏搜索结果列表模式增加过滤按钮 2025-02-05 17:40:57 +08:00
jxxghp
9164a1aefc fix #295 2025-02-04 09:59:05 +08:00
jxxghp
30351a02ee 升级版本号 2025-01-31 08:00:53 +08:00
jxxghp
7f918408a6 优化加载界面的样式,调整HTML和CSS以改善用户体验 2025-01-31 08:00:30 +08:00
jxxghp
82f69bcad0 修复在小屏幕下的返回按钮显示逻辑 2025-01-30 21:18:02 +08:00
122 changed files with 8290 additions and 1682 deletions

40
auto-imports.d.ts vendored
View File

@@ -7,6 +7,7 @@
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
@@ -21,13 +22,11 @@ declare global {
const createGenericProjection: typeof import('@vueuse/math')['createGenericProjection']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createLogger: typeof import('vuex')['createLogger']
const createNamespacedHelpers: typeof import('vuex')['createNamespacedHelpers']
const createPinia: typeof import('pinia')['createPinia']
const createProjection: typeof import('@vueuse/math')['createProjection']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createStore: typeof import('vuex')['createStore']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
@@ -35,9 +34,11 @@ declare global {
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
@@ -53,10 +54,11 @@ declare global {
const logicNot: typeof import('@vueuse/math')['logicNot']
const logicOr: typeof import('@vueuse/math')['logicOr']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('vuex')['mapActions']
const mapGetters: typeof import('vuex')['mapGetters']
const mapMutations: typeof import('vuex')['mapMutations']
const mapState: typeof import('vuex')['mapState']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
@@ -99,9 +101,12 @@ declare global {
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
@@ -263,7 +268,6 @@ declare global {
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStore: typeof import('vuex')['useStore']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSum: typeof import('@vueuse/math')['useSum']
const useSupported: typeof import('@vueuse/core')['useSupported']
@@ -331,6 +335,7 @@ declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
@@ -345,13 +350,11 @@ declare module 'vue' {
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createLogger: UnwrapRef<typeof import('vuex')['createLogger']>
readonly createNamespacedHelpers: UnwrapRef<typeof import('vuex')['createNamespacedHelpers']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createStore: UnwrapRef<typeof import('vuex')['createStore']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
@@ -359,9 +362,11 @@ declare module 'vue' {
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
@@ -377,10 +382,11 @@ declare module 'vue' {
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('vuex')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('vuex')['mapGetters']>
readonly mapMutations: UnwrapRef<typeof import('vuex')['mapMutations']>
readonly mapState: UnwrapRef<typeof import('vuex')['mapState']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
@@ -423,9 +429,12 @@ declare module 'vue' {
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
@@ -587,7 +596,6 @@ declare module 'vue' {
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStore: UnwrapRef<typeof import('vuex')['useStore']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.2.5",
"version": "2.3.7",
"private": true,
"bin": "dist/service.js",
"scripts": {
@@ -26,6 +26,12 @@
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@iconify/utils": "^2.2.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.42.1",
"@vue-flow/minimap": "^1.5.2",
"@vue-flow/node-resizer": "^1.4.0",
"@vue-flow/node-toolbar": "^1.1.0",
"@vue-js-cron/vuetify": "^5.0.9",
"@vueuse/core": "^12.4.0",
"@vueuse/math": "^12.4.0",
@@ -37,9 +43,11 @@
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"pinia-plugin-persistedstate": "^4.2.0",
"qrcode.vue": "^3.6.0",
"sass": "^1.83.4",
"tailwindcss": "^ 3.4.17",
@@ -52,8 +60,6 @@
"vuedraggable": "^4.1.0",
"vuetify": "3.7.3",
"vuetify-use-dialog": "^0.6.11",
"vuex": "^4.1.0",
"vuex-persistedstate": "^4.1.0",
"webfontloader": "^1.6.28"
},
"devDependencies": {
@@ -62,7 +68,8 @@
"@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@types/lodash": "^4.14.197",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^20.1.4",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
@@ -101,4 +108,4 @@
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
}
}

View File

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

View File

@@ -5,6 +5,7 @@ import type { ThemeSwitcherTheme } from '@layouts/types'
import api from '@/api'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { useToast } from 'vue-toast-notification'
import { saveLocalTheme } from '../utils/theme'
// 显示器宽度
const display = useDisplay()
@@ -102,8 +103,7 @@ function updateTheme() {
savedTheme.value = theme
themeTransition()
// 保存主题到本地
localStorage.setItem('theme', theme)
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
saveLocalTheme(theme, globalTheme)
}
// 切换主题
@@ -209,7 +209,7 @@ onMounted(() => {
</VList>
</VMenu>
<!-- 自定义 CSS -- -->
<VDialog v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard title="自定义主题风格">
<DialogCloseBtn @click="cssDialog = false" />
<VDivider />

View File

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

View File

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

View File

@@ -153,3 +153,12 @@ export function formatDateDifference(dateString: string): string {
if (!dateString) return ''
return dayjs(dateString).fromNow()
}
// 格式化评份如为10及以下的数按原值显示否则格式化为xxM、xxK显示
export function formatRating(rating: number): string {
if (!rating) return ''
if (rating <= 10) return rating.toString()
if (rating < 1000) return rating.toLocaleString()
if (rating < 1000 * 1000) return `${(rating / 1000).toFixed(1)}K`
return `${(rating / 1000 / 1000).toFixed(1)}M`
}

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

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

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

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

View File

@@ -51,14 +51,23 @@ export default defineComponent({
const main = h(
'main',
{ class: 'layout-page-content' },
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true },
() => h('section', { class: 'page-content-container' }, slots.default?.()),
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
h('section', { class: 'page-content-container' }, slots.default?.()),
),
)
// 👉 根据路由 meta 决定 footer 高度
const shouldShowFooter = !route.meta.hideFooter
// 👉 Footer
const footer = h('footer', { class: 'layout-footer' }, [
h('div', { class: 'footer-content-container' }, slots.footer?.()),
h(
'div',
{
class: ['footer-content-container', !shouldShowFooter && 'footer-content-container-noheight'],
},
slots.footer?.(),
),
])
// 👉 Overlay
@@ -80,11 +89,7 @@ export default defineComponent({
scrollDistance.value && 'window-scrolled',
],
},
[
verticalNav,
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
layoutOverlay,
],
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
)
}
},
@@ -92,9 +97,9 @@ export default defineComponent({
</script>
<style lang="scss">
@use "@configured-variables" as variables;
@use "@layouts/styles/placeholders";
@use "@layouts/styles/mixins";
@use '@configured-variables' as variables;
@use '@layouts/styles/placeholders';
@use '@layouts/styles/mixins';
.layout-wrapper.layout-nav-type-vertical {
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
@@ -116,9 +121,7 @@ export default defineComponent({
inset-block-start: 0;
.navbar-content-container {
block-size: calc(
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height
);
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
}
@at-root {

View File

@@ -65,6 +65,10 @@ body,
block-size: variables.$layout-vertical-nav-footer-height;
}
.footer-content-container-noheight {
block-size: 0px !important;
}
.layout-footer-sticky & {
position: sticky;
inset-block-end: 0;

View File

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

View File

@@ -9,6 +9,9 @@ let themeValue = localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 显示状态
const show = ref(false)
// ApexCharts 全局配置
declare global {
interface Window {
@@ -43,7 +46,12 @@ onMounted(() => {
ensureRenderComplete(() => {
nextTick(() => {
setTimeout(() => {
// 移除加载动画
removeEl('#loading-bg')
// 将background属性从html的style中移除
document.documentElement.style.removeProperty('background')
// 显示页面
show.value = true
}, 1500)
})
})
@@ -51,7 +59,7 @@ onMounted(() => {
</script>
<template>
<VApp>
<VApp v-show="show">
<RouterView />
</VApp>
</template>

View File

@@ -3,26 +3,31 @@ export const storageOptions = [
title: '本地',
value: 'local',
icon: 'mdi-folder-multiple-outline',
remote: false,
},
{
title: '阿里云盘',
value: 'alipan',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: '115网盘',
value: 'u115',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: 'RClone',
value: 'rclone',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: 'AList',
value: 'alist',
icon: 'mdi-cloud-outline',
remote: true,
},
]

View File

@@ -1,6 +1,6 @@
import axios from 'axios'
import router from '@/router'
import store from '@/store'
import { useAuthStore } from '@/stores'
// 创建axios实例
const api = axios.create({
@@ -9,10 +9,12 @@ const api = axios.create({
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store
const authStore = useAuthStore()
// 在请求头中添加token
const token = store.state.auth.token
if (token) config.headers.Authorization = `Bearer ${token}`
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
return config
})
@@ -26,8 +28,10 @@ api.interceptors.response.use(
// 请求超时
return Promise.reject(new Error(error))
} else if (error.response.status === 403) {
// 认证 Store
const authStore = useAuthStore()
// 清除登录状态信息
store.dispatch('auth/logout')
authStore.logout()
// token验证失败跳转到登录页面
router.push('/login')
}

View File

@@ -14,6 +14,10 @@ export interface Subscribe {
tmdbid: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: string
// 其它媒体ID
mediaid?: string
// 季号
season?: number
// 海报
@@ -208,6 +212,10 @@ export interface MediaInfo {
bangumi_id?: string
// 合集ID
collection_id?: number
// 其它媒体ID前缀
mediaid_prefix?: string
// 其它媒体ID值
media_id?: string
// 媒体原语种
original_language?: string
// 媒体原发行标题
@@ -280,6 +288,24 @@ export interface MediaInfo {
names?: string[]
}
// 季信息
export interface MediaSeason {
// 上映日期
air_date?: string
// 总集数
episode_count?: number
// 季名称
name?: string
// 描述
overview?: string
// 海报
poster_path?: string
// 季号
season_number?: number
// 评分
vote_average?: number
}
// TMDB季信息
export interface TmdbSeason {
// 上映日期
@@ -1204,3 +1230,62 @@ export interface TransferQueue {
state: string
}[]
}
// 探索的数据源
export interface DiscoverSource {
// 数据源名称
name: string
// 媒体ID的前缀不含:
mediaid_prefix: string
// 媒体数据源API地址
api_path: string
// 过滤参数
filter_params: { [key: string]: any }
// 过滤参数UI配置
filter_ui: RenderProps[]
// UI依赖关系字典
depends?: { [key: string]: string[] }
}
// 推荐的数据源
export interface RecommendSource {
// 数据源名称
name: string
// 媒体数据源API地址
api_path: string
}
// 站点资源分类
export interface SiteCategory {
id: number
cat: string
desc: string
}
// 工作流
export interface Workflow {
// 工作流ID
id?: string
// 工作流名称
name?: string
// 工作流描述
description?: string
// 定时器
timer?: string
// 状态
state?: string
// 当前执行动作
current_action?: string
// 任务执行结果
result?: string
// 已执行次数
run_count?: number
// 动作列表
actions?: any[]
// 动作流
flows?: any[]
// 创建时间
add_time?: string
// 最后执行时间
last_time?: string
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -7,25 +7,62 @@ interface Props {
errorCode?: string
errorTitle?: string
errorDescription?: string
size?: number
}
</script>
<template>
<VEmptyState :image="image" size="250">
<template #title>
<div class="mt-8 text-2xl">
{{ props.errorTitle }}
</div>
</template>
<div class="no-data-container">
<VEmptyState :image="image" :size="props.size || 'auto'">
<template #title>
<div class="mt-8 text-2xl">
{{ props.errorTitle }}
</div>
</template>
<template #text>
<div class="text-subtitle mt-3">
{{ props.errorDescription }}
</div>
</template>
<template #text>
<div class="text-subtitle mt-3">
{{ props.errorDescription }}
</div>
</template>
<template #actions>
<slot name="button" />
</template>
</VEmptyState>
<template #actions>
<slot name="button" />
</template>
</VEmptyState>
</div>
</template>
<style scoped>
.no-data-container {
width: 100%;
}
.no-data-container :deep(.v-empty-state) {
width: 100%;
max-width: 100%;
}
.no-data-container :deep(.v-empty-state__media) {
width: 100%;
max-width: 100%;
}
.no-data-container :deep(.v-responsive) {
width: 100%;
max-width: 450px;
margin: 0 auto;
height: auto !important;
}
/* 移动响应式设计 */
@media (max-width: 768px) {
.no-data-container :deep(.v-responsive) {
max-width: 350px;
}
}
@media (max-width: 480px) {
.no-data-container :deep(.v-responsive) {
max-width: 250px;
}
}
</style>

View File

@@ -2,7 +2,7 @@
import { CustomRule } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import filter_svg from '@images/svg/filter.svg'
import { cloneDeep } from 'lodash'
import { cloneDeep } from 'lodash-es'
import { innerFilterRules } from '@/api/constants'
// 输入参数
@@ -104,7 +104,7 @@ function onClose() {
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.rule.id} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />

View File

@@ -29,6 +29,11 @@ const typeItems = [
{ title: '电视剧', value: '电视剧' },
]
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
const resourceStorageOptions = computed(() => {
return storageOptions.filter(item => !item.remote || props.directory.monitor_type !== 'downloader')
})
// 自动整理方式下拉字典
const transferSourceItems = [
{ title: '不整理', value: '' },
@@ -131,7 +136,7 @@ const getCategories = computed(() => {
return default_value.concat(props.categories[props.directory.media_type ?? ''])
})
// 监听 下载储存与媒体库储存 变化,重新加载整理方式下拉字典
// 监听 资源存储与媒体库储存 变化,重新加载整理方式下拉字典
watch(
[() => props.directory.library_storage, () => props.directory.storage],
([newLibraryStorage, newStorage], [oldLibraryStorage, oldStorage]) => {
@@ -156,6 +161,16 @@ watch(
}
},
)
// 监听monitor_type变化如果为downloader则设置为本地
watch(
() => props.directory.monitor_type,
newMonitorType => {
if (newMonitorType === 'downloader') {
props.directory.storage = 'local'
}
},
)
</script>
<template>
@@ -198,8 +213,8 @@ watch(
<VSelect
v-model="props.directory.storage"
variant="underlined"
:items="storageOptions"
label="下载存储/源存储"
:items="resourceStorageOptions"
label="源存储"
/>
</VCol>
<VCol cols="8">
@@ -207,7 +222,7 @@ watch(
v-model="props.directory.download_path"
:storage="props.directory.storage"
variant="underlined"
label="下载目录/源目录"
label="源目录"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">

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-es'
// 定义输入
const props = defineProps({
@@ -104,7 +104,7 @@ function saveDownloaderInfo() {
props.downloaders.forEach(item => {
if (item.default && item !== props.downloader) {
item.default = false
$toast.info(`${item.name}存在默认下载器,已替换成【${downloaderInfo.value.name}`)
$toast.info(`存在默认下载器${item.name}】,已替换成【${downloaderInfo.value.name}`)
}
})
}
@@ -172,7 +172,7 @@ onUnmounted(() => {
</div>
</VCardText>
</VCard>
<VDialog v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { innerFilterRules } from '@/api/constants'
import { CustomRule } from '@/api/types'
import { cloneDeep } from 'lodash'
import { cloneDeep } from 'lodash-es'
// 输入参数
const props = defineProps({

View File

@@ -6,7 +6,7 @@ import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import { useToast } from 'vue-toast-notification'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
import filter_group_svg from '@images/svg/filter-group.svg'
import { cloneDeep } from 'lodash'
import { cloneDeep } from 'lodash-es'
// 输入参数
const props = defineProps({
@@ -220,7 +220,7 @@ function onClose() {
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="groupInfoDialog" />
<VDivider />

View File

@@ -3,6 +3,7 @@ import type { MediaServerLibrary } from '@/api/types'
import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png'
import trimemedia from '@images/logos/trimemedia.png'
// 输入参数
const props = defineProps({
@@ -38,6 +39,7 @@ function getDefaultImage() {
if (props.media?.server === 'plex') return plex
else if (props.media?.server === 'emby') return emby
else if (props.media?.server === 'jellyfin') return jellyfin
else if (props.media?.server === 'trimemedia') return trimemedia
else return plex
}

View File

@@ -2,15 +2,16 @@
import type { PropType, Ref } from 'vue'
import { useToast } from 'vue-toast-notification'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import { formatSeason } from '@/@core/utils/formatters'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
import type { MediaInfo, NotExistMediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
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'
import bangumiImage from '@images/logos/bangumi.png'
import { useUserStore } from '@/stores'
// 输入参数
const props = defineProps({
@@ -22,7 +23,8 @@ const props = defineProps({
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
const store = useStore()
// 用户 Store
const userStore = useUserStore()
// 提示框
const $toast = useToast()
@@ -55,10 +57,10 @@ const subscribeEditDialog = ref(false)
const subscribeId = ref<number>()
// 季详情
const seasonInfos = ref<TmdbSeason[]>([])
const seasonInfos = ref<MediaSeason[]>([])
// 选中的订阅季
const seasonsSelected = ref<TmdbSeason[]>([])
const seasonsSelected = ref<MediaSeason[]>([])
// 来源角标字典
const sourceIconDict: { [key: string]: any } = {
@@ -73,11 +75,56 @@ const mediaCardRef = ref<HTMLElement | null>(null)
// 创建Intersection Observer实例
const observer = ref<IntersectionObserver | null>(null)
// 所有站点
const allSites = ref<Site[]>([])
// 选中的站点
const selectedSites = ref<number[]>([])
// 搜索菜单显示状态
const searchMenuShow = ref(false)
// 全选/全不选按钮文字
const checkAllText = computed(() => (selectedSites.value.length === allSites.value.length ? '全不选' : '全选'))
// 全选/全不选
function checkAllSitesorNot() {
if (selectedSites.value.length === allSites.value.length) {
selectedSites.value = []
} else {
selectedSites.value = allSites.value.map(item => item.id)
}
}
// 查询所有站点
async function querySites() {
try {
const data: Site[] = await api.get('site/')
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active)
} catch (error) {
console.log(error)
}
}
// 查询用户选中的站点
async function querySelectedSites() {
try {
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
selectedSites.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 获得mediaid
function getMediaId() {
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
else return `bangumi:${props.media?.bangumi_id}`
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
}
// 订阅弹窗选择的多季
@@ -97,11 +144,10 @@ function getChipColor(type: string) {
// 添加订阅处理
async function handleAddSubscribe() {
if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
// TMDB电视剧
// 查询TMDB所有季信息
if (props.media?.type === '电视剧') {
// 查询所有季信息
await getMediaSeasons()
if (!seasonInfos.value) {
if (!seasonInfos.value || seasonInfos.value.length === 0) {
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
return
}
@@ -117,11 +163,6 @@ async function handleAddSubscribe() {
seasonsSelected.value = []
subscribeSeasonDialog.value = true
}
} else if (props.media?.type === '电视剧') {
// 豆瓣电视剧,只会有一季
const season = props.media?.season ?? 1
// 添加订阅
addSubscribe(season)
} else {
// 电影
addSubscribe()
@@ -146,6 +187,7 @@ async function addSubscribe(season = 0) {
tmdbid: props.media?.tmdb_id,
doubanid: props.media?.douban_id,
bangumiid: props.media?.bangumi_id,
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
season,
best_version,
})
@@ -293,17 +335,26 @@ async function checkSeasonsNotExists() {
// 查询TMDB的所有季信息
async function getMediaSeasons() {
startNProgress()
try {
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
seasonInfos.value = await api.get('media/seasons', {
params: {
mediaid: getMediaId(),
title: props.media?.title,
year: props.media?.year,
season: props.media?.season,
},
})
} catch (error) {
console.error(error)
}
doneNProgress()
}
// 查询订阅弹窗规则
async function queryDefaultSubscribeConfig() {
// 非管理员不显示
if (!store.state.auth.superUser) return false
if (!userStore.superUser) return false
try {
let subscribe_config_url = ''
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
@@ -361,6 +412,8 @@ function goMediaDetail(isHovering = false) {
path: '/media',
query: {
mediaid: getMediaId(),
title: props.media?.title,
year: props.media?.year,
type: props.media?.type,
},
})
@@ -368,6 +421,13 @@ function goMediaDetail(isHovering = false) {
}
}
// 点击搜索
async function clickSearch() {
if (allSites.value?.length > 0) return
querySites()
querySelectedSites()
}
// 开始搜索
function handleSearch() {
router.push({
@@ -376,7 +436,10 @@ function handleSearch() {
keyword: getMediaId(),
type: props.media?.type,
area: 'title',
title: props.media?.title,
year: props.media?.year,
season: props.media?.season,
sites: selectedSites.value.join(','),
},
})
}
@@ -490,7 +553,7 @@ function onRemoveSubscribe() {
</VImg>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
v-show="hover.isHovering || imageLoadError || searchMenuShow"
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
>
@@ -503,7 +566,36 @@ function onRemoveSubscribe() {
</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" />
<VMenu close-on-content-click v-model="searchMenuShow" max-width="450">
<template v-slot:activator="{ props }">
<IconBtn v-bind="props" icon="mdi-magnify" color="white" @click.stop="clickSearch" />
</template>
<VList>
<VListItem>
<VChipGroup v-model="selectedSites" column multiple @click.stop>
<VChip
v-for="site in allSites"
:key="site.id"
:color="selectedSites.includes(site.id) ? 'primary' : ''"
filter
variant="outlined"
:value="site.id"
size="small"
>
{{ site.name }}
</VChip>
</VChipGroup>
<div>
<VBtn size="small" variant="text" @click.stop="checkAllSitesorNot">
{{ checkAllText }}
</VBtn>
</div>
</VListItem>
<VListItem>
<VBtn @click="handleSearch" block>搜索</VBtn>
</VListItem>
</VList>
</VMenu>
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
</div>
</VCardText>
@@ -527,7 +619,7 @@ function onRemoveSubscribe() {
:class="getChipColor('rating')"
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.vote_average }}
{{ formatRating(props.media?.vote_average) }}
</VChip>
<!--来源图标-->
<VAvatar
@@ -594,7 +686,7 @@ function onRemoveSubscribe() {
</VList>
</VCardText>
<div class="my-2 text-center">
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
<VBtn size="large" :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
</VBtn>
</div>

View File

@@ -4,8 +4,9 @@ import { useToast } from 'vue-toast-notification'
import emby_image from '@images/logos/emby.png'
import jellyfin_image from '@images/logos/jellyfin.png'
import plex_image from '@images/logos/plex.png'
import trimemedia_image from '@images/logos/trimemedia.png'
import api from '@/api'
import { cloneDeep } from 'lodash'
import { cloneDeep } from 'lodash-es'
// 定义输入
const props = defineProps({
@@ -101,6 +102,8 @@ const getIcon = computed(() => {
return emby_image
case 'jellyfin':
return jellyfin_image
case 'trimemedia':
return trimemedia_image
default:
return plex_image
}
@@ -185,7 +188,7 @@ onMounted(() => {
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>
<VDialog v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
@@ -278,6 +281,53 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'trimemedia'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
label="用户名"
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
label="密码"
active
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -7,7 +7,7 @@ import synologychat_image from '@images/logos/synologychat.png'
import slack_image from '@images/logos/slack.webp'
import chrome_image from '@images/logos/chrome.png'
import { useToast } from 'vue-toast-notification'
import { cloneDeep } from "lodash"
import { cloneDeep } from 'lodash-es'
// 定义输入
const props = defineProps({
@@ -132,7 +132,7 @@ function onClose() {
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.notification.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="notificationInfoDialog" />
<VDivider />

View File

@@ -3,19 +3,13 @@ import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import api from '@/api'
import type { Plugin } from '@/api/types'
import FormRender from '@/components/render/FormRender.vue'
import PageRender from '@/components/render/PageRender.vue'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import { isNullOrEmptyObject } from '@core/utils'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
import { useDisplay } from 'vuetify'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
// 输入参数
const props = defineProps({
@@ -47,18 +41,12 @@ const isVisible = ref(true)
// 插件配置页面
const pluginConfigDialog = ref(false)
// 插件配置表单数据
const pluginConfigForm = ref({})
// 菜单显示状态
const menuVisible = ref(false)
// 进度框
const progressDialog = ref(false)
// 插件表单配置项
let pluginFormItems = reactive([])
// 插件数据页面
const pluginInfoDialog = ref(false)
@@ -68,9 +56,6 @@ const progressText = ref('正在更新插件...')
// 用户头像是否加载完成
const isAvatarLoaded = ref(false)
// 插件数据页面配置项
let pluginPageItems = ref([])
// 图片是否加载完成
const isImageLoaded = ref(false)
@@ -138,75 +123,14 @@ async function uninstallPlugin() {
}
}
// 调用API读取表单页面
async function loadPluginForm() {
try {
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (result) {
pluginFormItems = result.conf
if (result.model) pluginConfigForm.value = result.model
}
} catch (error) {
console.error(error)
}
}
// 调用API读取数据页面
async function loadPluginPage() {
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
if (result) pluginPageItems.value = result
} catch (error) {
console.error(error)
}
}
// 调用API读取配置数据
async function loadPluginConf() {
try {
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
} catch (error) {
console.error(error)
}
}
// 调用API保存配置数据
async function savePluginConf() {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
try {
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
if (result.success) {
progressDialog.value = false
pluginConfigDialog.value = false
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
// 通知父组件刷新
emit('save')
} else {
progressDialog.value = false
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
}
} catch (error) {
console.error(error)
}
}
// 显示插件数据
async function showPluginInfo() {
// 加载数据
await loadPluginPage()
pluginConfigDialog.value = false
pluginInfoDialog.value = true
}
// 显示插件配置
async function showPluginConfig() {
// 加载表单
await loadPluginForm()
// 加载配置
await loadPluginConf()
// 显示对话框
pluginInfoDialog.value = false
pluginConfigDialog.value = true
@@ -303,6 +227,12 @@ function openPluginDetail() {
else showPluginConfig()
}
// 配置完成
function configDone() {
pluginConfigDialog.value = false
emit('save')
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -485,42 +415,23 @@ watch(
</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>
<VCardActions class="pt-3">
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
查看数据
</VBtn>
<VSpacer />
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<PluginConfigDialog
v-if="pluginConfigDialog"
v-model="pluginConfigDialog"
:plugin="props.plugin"
@save="configDone"
@close="pluginConfigDialog = false"
@switch="showPluginInfo"
/>
<!-- 插件数据页面 -->
<VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
<DialogCloseBtn v-model="pluginInfoDialog" />
<VCardText class="min-h-40">
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
</VCardText>
<VFab
icon="mdi-cog"
location="bottom"
size="x-large"
fixed
app
appear
@click="showPluginConfig"
:class="{ 'mb-10': appMode }"
/>
</VCard>
</VDialog>
<PluginDataDialog
v-if="pluginInfoDialog"
v-model="pluginInfoDialog"
:plugin="props.plugin"
@close="pluginInfoDialog = false"
@switch="showPluginConfig"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />

View File

@@ -26,7 +26,7 @@ const siteIcon = ref<string>('')
const $toast = useToast()
// 测试按钮文字
const testButtonText = ref('连通性测试')
const testButtonText = ref('测试连通性')
// 测试按钮可用性
const testButtonDisable = ref(false)
@@ -65,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()
@@ -153,7 +153,8 @@ onMounted(() => {
<VCard
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
class="overflow-hidden h-full flex flex-col"
@click="siteEditDialog = true"
@click="handleResourceBrowse"
:ripple="false"
>
<template #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
@@ -162,10 +163,10 @@ onMounted(() => {
</template>
<VCardItem style="padding-block-end: 0">
<VCardTitle class="font-bold">
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
{{ cardProps.site?.name }}
</VCardTitle>
<VCardSubtitle>
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
{{ cardProps.site?.url }}
</VCardSubtitle>
</VCardItem>
<VCardText class="py-1">
@@ -191,15 +192,24 @@ onMounted(() => {
</VTooltip>
</VCardText>
<VCardActions>
<div class="text-sm">
{{ formatFileSize(cardProps.data?.upload || 0) }} / {{ formatFileSize(cardProps.data?.download || 0) }}
</div>
<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">
<VListItem variant="plain" @click="siteEditDialog = true" base-color="info">
<template #prepend>
<VIcon icon="mdi-refresh" />
<VIcon icon="mdi-file-edit-outline" />
</template>
<VListItemTitle>更新 Cookie & UA</VListItemTitle>
<VListItemTitle>编辑站点</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="handleSiteUserData">
<template #prepend>
<VIcon icon="mdi-chart-bell-curve" />
</template>
<VListItemTitle>查看站点数据</VListItemTitle>
</VListItem>
<VListItem variant="plain" :disabled="testButtonDisable" @click.stop="testSite">
<template #prepend>
@@ -207,24 +217,21 @@ onMounted(() => {
</template>
<VListItemTitle>{{ testButtonText }}</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="handleResourceBrowse">
<VListItem variant="plain" v-if="!cardProps.site?.public" @click="handleSiteUpdate">
<template #prepend>
<VIcon icon="mdi-web" />
<VIcon icon="mdi-refresh" />
</template>
<VListItemTitle>资源预览</VListItemTitle>
<VListItemTitle>更新 Cookie & UA</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="handleSiteUserData">
<VListItem variant="plain" @click="openSitePage">
<template #prepend>
<VIcon icon="mdi-chart-bell-curve" />
<VIcon icon="mdi-open-in-new" />
</template>
<VListItemTitle>站点数据</VListItemTitle>
<VListItemTitle>访问站点</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span class="text-sm">
{{ formatFileSize(cardProps.data?.upload || 0) }} / {{ formatFileSize(cardProps.data?.download || 0) }}
</span>
<VSpacer />
</VCardActions>
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />

View File

@@ -142,12 +142,22 @@ async function editSubscribeDialog() {
subscribeEditDialog.value = true
}
// 获得mediaid
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
else return props.media?.mediaid
}
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
mediaid: getMediaId(),
title: props.media?.name,
year: props.media?.year,
type: props.media?.type,
},
})
@@ -293,6 +303,7 @@ function onSubscribeEditRemove() {
}"
min-height="170"
@click="editSubscribeDialog"
:ripple="false"
>
<div class="me-n3 absolute top-1 right-2">
<IconBtn>

View File

@@ -54,12 +54,20 @@ const posterUrl = computed(() => {
return url
})
// 获得mediaid
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
}
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
mediaid: getMediaId(),
title: props.media?.name,
year: props.media?.year,
type: props.media?.type,
},
})
@@ -73,28 +81,26 @@ function showForkSubscribe() {
// 完成复用订阅
function finishForkSubscribe(subid: number) {
subscribeId.value = subid
forkSubscribeDialog.value=false
forkSubscribeDialog.value = false
subscribeEditDialog.value = true
}
// 删除订阅分享时处理
function doDelete() {
forkSubscribeDialog.value=false
forkSubscribeDialog.value = false
// 通知父组件刷新
emit('delete')
}
</script>
<template>
<div>
<div class="h-full">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg"
class="flex flex-col rounded-lg h-full"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
@@ -111,8 +117,8 @@ function doDelete() {
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div>
<VCardText class="flex items-center pb-1">
<div class="h-full flex flex-col">
<VCardText class="flex items-center pb-1 grow">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>

View File

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

View File

@@ -25,6 +25,10 @@ const meta = ref(props.torrent?.meta_info)
// 站点图标
const siteIcon = ref('')
// 站点图标加载状态
const iconLoading = ref(false)
const iconError = ref(false)
// 存储是否已经下载过的记录
const downloaded = ref<string[]>([])
@@ -33,11 +37,65 @@ const addDownloadDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
try {
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
} catch (error) {
console.error(error)
if (!torrent?.value?.site || iconLoading.value) {
return
}
iconLoading.value = true
iconError.value = false
try {
const response = await api.get(`site/icon/${torrent.value.site}`)
if (response && response.data && response.data.icon) {
siteIcon.value = response.data.icon
} else {
iconError.value = true
}
} catch (error) {
console.error('Failed to load site icon:', error)
iconError.value = true
} finally {
iconLoading.value = false
}
}
// 获取站点颜色
function getSiteColor(siteId: string | number | undefined) {
if (!siteId) return '#3F51B5'
// 根据站点ID生成不同颜色
const colors = [
'#3F51B5',
'#673AB7',
'#9C27B0',
'#E91E63',
'#F44336',
'#FF5722',
'#FF9800',
'#FFC107',
'#4CAF50',
'#009688',
'#00BCD4',
'#03A9F4',
]
// 简单哈希函数
let hash = 0
const idStr = String(siteId)
for (let i = 0; i < idStr.length; i++) {
hash = idStr.charCodeAt(i) + ((hash << 5) - hash)
}
return colors[Math.abs(hash) % colors.length]
}
// 获取优惠类型样式
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return 'free-discount'
if (downloadVolumeFactor === 0) return 'free-discount'
else if (downloadVolumeFactor < 1) return 'percent-discount'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
else return ''
}
// 询问并添加下载
@@ -69,10 +127,11 @@ async function downloadTorrentFile() {
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
function getVolumeFactorClass(downloadVolume: number | undefined, uploadVolume: number | undefined) {
if (!downloadVolume) return 'text-white bg-gray-500'
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else if (uploadVolume !== undefined && uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
}
@@ -83,96 +142,88 @@ onMounted(() => {
</script>
<template>
<div>
<div class="list-item-wrapper">
<VListItem
:value="props.torrent?.torrent_info?.enclosure"
class="torrent-item"
:class="{ 'downloaded-item': downloaded.includes(torrent?.enclosure || '') }"
@click="handleAddDownload"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
>
<template v-if="!showMoreTorrents" #prepend>
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
<VImg :src="siteIcon" />
</VAvatar>
<template v-slot:prepend>
<div class="site-wrapper">
<img v-if="siteIcon" :src="siteIcon" class="site-icon" />
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
<div class="site-name">{{ torrent?.site_name }}</div>
<span
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
class="free-tag"
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
>{{ torrent?.volume_factor }}</span
>
</div>
</template>
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
{{ torrent?.title }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
<VListItemTitle class="item-content">
<div class="item-header">
<div class="media-info">
<span class="media-title">{{ media?.title ?? meta?.name }}</span>
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
</div>
</div>
<div class="torrent-title" :title="torrent?.title">
{{ torrent?.title }}
</div>
<div class="torrent-description" :title="meta?.subtitle || torrent?.description || '暂无描述'">
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
</div>
<div class="tags-container">
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
</div>
</VListItemTitle>
<VListItemSubtitle> {{ torrent?.site_name }}{{ torrent?.description }} </VListItemSubtitle>
<div v-if="torrent?.labels" class="pt-2">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</div>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
<template v-slot:append>
<div class="item-actions">
<div class="torrent-stats">
<span v-if="torrent?.seeders" class="seed-info">
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
</span>
<span v-if="torrent?.peers" class="peer-info">
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
</span>
</div>
<div class="action-buttons">
<div v-if="torrent?.size" class="size-badge">
{{ formatFileSize(torrent.size) }}
</div>
<VBtn
density="comfortable"
variant="text"
color="primary"
icon="mdi-information-outline"
size="small"
class="detail-btn"
@click.stop="openTorrentDetail"
></VBtn>
</div>
</div>
</template>
</VListItem>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode}`"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
:media="media"
:torrent="torrent"
@done="addDownloadSuccess"
@@ -181,3 +232,292 @@ onMounted(() => {
/>
</div>
</template>
<style scoped>
.list-item-wrapper {
width: 100%;
}
.torrent-item {
border-radius: 12px;
transition: background-color 0.2s ease, transform 0.2s ease;
margin-bottom: 8px;
padding: 12px;
background-color: rgb(var(--v-theme-surface));
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
box-shadow: none;
}
.torrent-item:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
transform: translateY(-2px);
border-color: rgba(var(--v-theme-primary), 0.3);
}
.site-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 50px;
margin-right: 16px;
}
.site-icon {
width: 32px;
height: 32px;
border-radius: 2px;
margin-bottom: 4px;
}
.site-fallback {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: 700;
color: rgba(var(--v-theme-on-surface), 0.8);
background-color: rgba(var(--v-theme-on-surface), 0.1);
border-radius: 2px;
margin-bottom: 4px;
}
.site-name {
font-size: 0.8rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), 0.85);
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.free-tag {
position: absolute;
top: -6px;
right: -6px;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
z-index: 1;
}
.free-discount {
background-color: #4caf50;
font-weight: 700;
}
.percent-discount {
background-color: #ff5722;
}
.upload-bonus {
background-color: #9c27b0;
}
.item-content {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.media-info {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.media-title {
font-size: 1.1rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), 0.87);
}
.season-tag {
font-size: 0.9rem;
background-color: rgba(var(--v-theme-primary), 0.08);
color: rgb(var(--v-theme-primary));
padding: 2px 8px;
border-radius: 4px;
margin-left: 4px;
font-weight: 600;
}
.item-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.torrent-stats {
display: flex;
align-items: center;
gap: 12px;
}
.action-buttons {
display: flex;
align-items: center;
}
.seed-info {
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 4px;
font-weight: 600;
}
.peer-info {
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 4px;
font-weight: 600;
}
.size-badge {
font-size: 0.9rem;
font-weight: 600;
color: rgb(var(--v-theme-primary));
background-color: rgba(var(--v-theme-primary), 0.1);
padding: 2px 8px;
border-radius: 4px;
margin-right: 6px;
}
.torrent-title {
font-size: 0.9rem;
color: rgba(var(--v-theme-on-surface), 0.87);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.torrent-description {
font-size: 0.8rem;
color: rgba(var(--v-theme-on-surface), 0.65);
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.resource-tag {
font-size: 0.8rem;
padding: 3px 8px;
border-radius: 4px;
color: white;
font-weight: 700;
}
.edition {
background-color: #f44336;
}
.resolution {
background-color: #e91e63;
}
.codec {
background-color: #ff9800;
}
.team {
background-color: #03a9f4;
}
.expire {
background-color: #9c27b0;
}
.label {
background-color: #3f51b5;
}
.hr {
background-color: #000000;
}
.detail-btn {
border-radius: 50%;
}
.downloaded-item {
border-left: 4px solid #4caf50;
opacity: 0.85;
}
.break-words {
word-wrap: break-word;
word-break: break-word;
}
.overflow-visible {
overflow: visible !important;
}
.whitespace-break-spaces {
white-space: normal !important;
}
@media (max-width: 600px) {
.torrent-item {
padding: 8px;
}
.media-title {
font-size: 0.95rem;
}
.site-icon,
.site-fallback {
width: 24px;
height: 24px;
}
.site-wrapper {
width: 40px;
margin-right: 10px;
}
.resource-tag {
font-size: 0.75rem;
padding: 2px 6px;
}
.action-buttons {
display: flex;
flex-direction: row;
align-items: center;
}
.torrent-description {
max-width: calc(100vw - 150px);
}
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import api from '@/api'
import { Subscribe, User } from '@/api/types'
import store from '@/store'
import { useUserStore } from '@/stores'
import avatar1 from '@images/avatars/avatar-1.png'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
@@ -22,10 +22,10 @@ const props = defineProps({
})
// 当前用户的ID
const currentLoginUserId = computed(() => store.state.auth.userID)
const currentLoginUserId = computed(() => useUserStore().userID)
// 当前用户是否是管理员
const currentUserIsSuperuser = computed(() => store.state.auth.superUser)
const currentUserIsSuperuser = computed(() => useUserStore().superUser)
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
@@ -161,14 +161,7 @@ onMounted(() => {
</VList>
</VCardText>
<VCardText class="flex flex-row justify-center">
<VBtn
v-if="currentUserIsSuperuser"
color="primary"
class="me-4"
@click="editUser"
>
编辑
</VBtn>
<VBtn v-if="currentUserIsSuperuser" color="primary" class="me-4" @click="editUser"> 编辑 </VBtn>
<VBtn
v-if="currentUserIsSuperuser && props.user.id != currentLoginUserId"
color="error"

View File

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

View File

@@ -138,7 +138,7 @@ onMounted(() => {
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<VListItem v-if="torrent?.description">
<template #prepend>
<VIcon icon="mdi-subtitles-outline"></VIcon>
</template>
@@ -146,7 +146,7 @@ onMounted(() => {
<span class="text-body-1 whitespace-break-spaces">{{ torrent?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<VListItem v-if="torrent?.size">
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
@@ -164,7 +164,7 @@ onMounted(() => {
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
label="指定下载器"
label="下载器(默认)"
variant="underlined"
placeholder="留空默认"
/>
@@ -173,7 +173,7 @@ onMounted(() => {
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
label="指定保存目录"
label="保存目录(自动)"
placeholder="留空自动匹配"
variant="underlined"
/>

View File

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

@@ -87,12 +87,20 @@ const posterUrl = computed(() => {
return url
})
// 获得mediaid
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
}
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
mediaid: getMediaId(),
title: props.media?.name,
year: props.media?.year,
type: props.media?.type,
},
})

View File

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

View File

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

View File

@@ -166,7 +166,7 @@ onMounted(async () => {
</script>
<template>
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable :close-on-back="false" eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
class="rounded-t"

View File

@@ -1,19 +1,31 @@
<script setup lang="ts">
import { Site } from '@/api/types'
import { useDisplay } from 'vuetify'
import api from '@/api'
import type { TorrentInfo } from '@/api/types'
import type { TorrentInfo, SiteCategory } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
})
// 关键字
const keyword = ref<string>()
// 选择分类
const selectCategory = ref<number[]>([])
// 全部分类
const siteCategoryList = ref<SiteCategory[]>()
// 分类选项
const categoryOptions = computed(() => {
return siteCategoryList.value?.map(item => {
return { title: item.desc, value: item.id }
})
})
// 注册事件
const emit = defineEmits(['close'])
@@ -55,17 +67,6 @@ async function downloadTorrentFile(enclosure: string) {
window.open(enclosure, '_blank')
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`)
resourceLoading.value = false
} catch (error) {
console.error(error)
}
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
@@ -93,17 +94,75 @@ function addDownloadError(error: string) {
addDownloadDialog.value = false
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
params: {
keyword: keyword.value,
cat: selectCategory.value?.join(','),
},
})
} catch (error) {
console.error(error)
}
resourceLoading.value = false
}
// 加载站点分类
async function getSiteCategoryList() {
try {
siteCategoryList.value = await api.get(`site/category/${props.site?.id}`)
} catch (error) {
console.error(error)
}
}
// 装载时查询站点图标
onMounted(() => {
getSiteCategoryList()
getResourceList()
})
</script>
<template>
<VDialog max-width="80rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard :title="`浏览 - ${props.site?.name}`">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText class="pt-2">
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
<VCard>
<!-- Toolbar -->
<div>
<VToolbar color="primary">
<VToolbarTitle>{{ `浏览 - ${props.site?.name}` }}</VToolbarTitle>
<VSpacer />
<VToolbarItems>
<VBtn icon variant="plain" @click="emit('close')" class="me-3">
<VIcon size="large" color="white" icon="ri-close-line" />
</VBtn>
</VToolbarItems>
</VToolbar>
</div>
<div class="p-3">
<VRow>
<VCol cols="6" md="5">
<VTextField v-model="keyword" size="small" density="compact" label="搜索关键字" clearable />
</VCol>
<VCol cols="6" md="5">
<VSelect
v-model="selectCategory"
:items="categoryOptions"
size="small"
density="compact"
chips
label="资源分类"
multiple
clearable
/>
</VCol>
<VCol cols="12" md="2" class="text-center">
<VBtn block prepend-icon="mdi-magnify" @click="getResourceList">搜索</VBtn>
</VCol>
</VRow>
</div>
<VCardText class="px-0 py-0 my-0">
<VDataTable
v-model:items-per-page="resourceItemsPerPage"
:headers="resourceHeaders"
@@ -119,6 +178,7 @@ onMounted(() => {
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
class="h-full"
>
<template #item.title="{ item }">
<a href="javascript:void(0)" @click.stop="addDownload(item)">

View File

@@ -54,6 +54,8 @@ const historyChartOptions = computed(() => {
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
background: currentTheme.value.surface, // 新增背景色同步
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
dataLabels: {
enabled: true,
},
@@ -61,6 +63,9 @@ const historyChartOptions = computed(() => {
autoScaleYaxis: true,
},
},
theme: {
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
},
tooltip: {
enabled: true,
tooltip: {
@@ -68,6 +73,10 @@ const historyChartOptions = computed(() => {
format: 'dd MMM yyyy',
},
},
style: {
background: currentTheme.value.background, // 提示框背景色同步
color: currentTheme.value.onBackground, // 文字颜色同步
},
},
grid: {
xaxis: {
@@ -140,10 +149,15 @@ const seedingChartOptions = computed(() => {
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
background: currentTheme.value.surface, // 新增背景色同步
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
zoom: {
autoScaleYaxis: true,
},
},
theme: {
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
},
tooltip: {
enabled: true,
x: {
@@ -151,6 +165,10 @@ const seedingChartOptions = computed(() => {
return '数量:' + val.toLocaleString()
},
},
style: {
background: currentTheme.value.background, // 提示框背景色同步
color: currentTheme.value.onBackground, // 文字颜色同步
},
},
grid: {
xaxis: {

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import QrcodeVue from 'qrcode.vue'
import { VCardItem, VTextField } from 'vuetify/lib/components/index.mjs'
// 定义输入
const props = defineProps({
@@ -18,7 +17,7 @@ const emit = defineEmits(['done', 'close'])
const qrCodeContent = ref('')
// 下方的提示信息
const text = ref('请使用微信或115客户端扫码或在下方输入Cookie')
const text = ref('请使用微信或115客户端扫码')
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
@@ -29,9 +28,6 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
if (props.conf?.cookie) {
await savaU115Config()
}
emit('done')
}
@@ -41,6 +37,7 @@ async function getQrcode() {
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
text.value = result.message
}
@@ -84,18 +81,8 @@ 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)
})
onUnmounted(() => {
@@ -115,13 +102,6 @@ onUnmounted(() => {
<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

@@ -5,7 +5,7 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
import api from '@/api'
import { useDisplay } from 'vuetify'
import avatar1 from '@images/avatars/avatar-1.png'
import store from '@/store'
import { useUserStore } from '@/stores'
// 显示器宽度
const display = useDisplay()
@@ -23,8 +23,11 @@ const props = defineProps({
oper: String,
})
// 用户 Store
const userStore = useUserStore()
// 当前登录用户名称
const currentLoginUser = store.state.auth.userName
const currentLoginUser = userStore.userName
// 用户名
const userName = ref('')
@@ -199,13 +202,15 @@ async function updateUser() {
if (oldUserName !== currentUserName.value) {
$toast.success(`${oldUserName}】更名【${currentUserName.value}】, 更新成功!`)
// 如果是当前登录用户,更新当前用户名称显示
if (isCurrentUser.value) store.commit('auth/setUserName', currentUserName.value)
if (isCurrentUser.value) {
userStore.setUserName(currentUserName.value)
}
} else {
$toast.success(`${userForm.value?.name}】更新成功!`)
}
// 更新本地头像显示
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
store.commit('auth/setAvatar', currentAvatar.value)
userStore.setAvatar(currentAvatar.value)
}
emit('save')
} else {
@@ -262,7 +267,7 @@ onMounted(() => {
</script>
<template>
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable :close-on-back="false" eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`${props.oper === 'add' ? '新增' : '编辑'}用户${props.oper !== 'add' ? ` - ${userName}` : ''}`"
class="rounded-t"

View File

@@ -0,0 +1,316 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { VueFlow, useVueFlow, type Connection, type GraphNode } from '@vue-flow/core'
import { MiniMap } from '@vue-flow/minimap'
import useDragAndDrop from '@core/utils/workflow'
import { Workflow } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
const { onConnect, addEdges, nodes, edges } = useVueFlow()
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
// 连接事件
onConnect((connection: Connection) => {
// 双重校验
if (!isValidConnection(connection)) {
$toast.warning('非法连接:不能连接自身或同类型端口!')
return
}
addEdges(connection)
})
// 获取指定节点端口的类型(输入/输出)
const getPortType = (node: GraphNode, handleId: string) => {
// 检查是否是输入端口(对应 handleBounds.target
const isInput = node.handleBounds?.target?.some(h => h.id === handleId)
if (isInput) return 'input'
// 检查是否是输出端口(对应 handleBounds.source
const isOutput = node.handleBounds?.source?.some(h => h.id === handleId)
return isOutput ? 'output' : null
}
// 校验连接是否合法
const isValidConnection = (connection: Connection) => {
// 获取连接的源节点和目标节点
const sourceNode = nodes.value.find(n => n.id === connection.source)
const targetNode = nodes.value.find(n => n.id === connection.target)
if (!sourceNode || !targetNode) return false
// 获取端口类型
const sourcePortType = getPortType(sourceNode, connection.sourceHandle!)
const targetPortType = getPortType(targetNode, connection.targetHandle!)
/* 同时满足三个条件,才允许连接:
* 1. 源端口是输出类型output
* 2. 目标端口是输入类型input
* 3. 不是同一节点的连接
*/
return sourcePortType === 'output' && targetPortType === 'input' && connection.source !== connection.target
}
// 自定义节点类型
const nodeTypes: Record<string, any> = ref({})
// 自动扫描目录下所有的 .vue 文件
const components = import.meta.glob('../workflow/*Action.vue')
// 动态加载某个组件
const loadComponent = async (componentName: string) => {
const component = components[`../workflow/${componentName}.vue`]
if (component) {
return ((await component()) as any).default
}
throw new Error(`组件 ${componentName} 未找到`)
}
// 将所有components中的组件加载到nodeTypes中
for (const path in components) {
const componentName = path.match(/\.\/workflow\/(.*).vue$/)?.[1]
if (!componentName) {
continue
}
loadComponent(componentName).then(component => {
nodeTypes.value[componentName] = markRaw(component)
})
}
// 定义输入参数
const props = defineProps({
workflow: Object as PropType<Workflow>,
})
// 定义事件
const emit = defineEmits(['close', 'save'])
// 站点编辑表单数据
const workflowForm = ref<any>(props.workflow || {})
// 提示框
const $toast = useToast()
// 导入代码对话框
const importCodeDialog = ref(false)
// 调用API 编辑任务
async function updateWorkflow() {
// 更新节点和流程
workflowForm.value.actions = nodes
workflowForm.value.flows = edges
try {
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
if (result.success) {
$toast.success(`保存任务流程成功!`)
emit('save')
} else {
$toast.error(`保存任务流程失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
}
// 保存导入的代码,直接覆盖原有值
function saveCodeString(type: string, code: any) {
try {
if (code) {
const codeObject = JSON.parse(code.value)
if (type === 'workflow') {
nodes.value = codeObject.actions || []
edges.value = codeObject.flows || []
}
importCodeDialog.value = false
$toast.success('导入成功!')
}
} catch (error) {
$toast.error('导入失败!')
console.error(error)
}
}
// 分享工作流程
function shareWorkflow() {
const codeString = JSON.stringify({ actions: nodes.value, flows: edges.value })
navigator.clipboard.writeText(codeString)
$toast.success('任务流程代码已复制到剪贴板!')
}
onMounted(() => {
if (props.workflow) {
nodes.value = props.workflow.actions ?? []
edges.value = props.workflow.flows ?? []
}
})
</script>
<template>
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
<VCard>
<!-- Toolbar -->
<div>
<VToolbar color="primary">
<VToolbarItems>
<VBtn icon @click="emit('close')" class="ms-3">
<VIcon size="large" color="white" icon="mdi-close" />
</VBtn>
</VToolbarItems>
<VToolbarTitle> 编辑流程 - {{ workflow?.name }} </VToolbarTitle>
<VToolbarItems>
<VBtn icon @click="importCodeDialog = true">
<VIcon size="large" color="white" icon="mdi-import" />
</VBtn>
<VBtn icon @click="shareWorkflow">
<VIcon size="large" color="white" icon="mdi-share" />
</VBtn>
<VBtn icon @click="updateWorkflow" class="mx-5">
<VIcon size="large" color="white" icon="mdi-content-save" />
</VBtn>
</VToolbarItems>
</VToolbar>
</div>
<VDivider />
<VCardText class="px-0 py-0">
<div class="dnd-flow" @drop="onDrop">
<VueFlow
:nodes="nodes"
:edges="edges"
:nodeTypes="nodeTypes"
:is-valid-connection="isValidConnection"
:default-edge-options="{ type: 'animation', animated: true }"
:edge-updater-radius="10"
@dragover="onDragOver"
@dragleave="onDragLeave"
delete-key-code="Delete"
auto-connect
>
<MiniMap />
<DropzoneBackground
:style="{
backgroundColor: isDragOver ? '#e7f3ff' : 'transparent',
transition: 'background-color 0.2s ease',
}"
>
</DropzoneBackground>
</VueFlow>
<WorkflowSidebar />
</div>
</VCardText>
</VCard>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
title="导入任务流程"
dataType="workflow"
@close="importCodeDialog = false"
@save="saveCodeString"
/>
</VDialog>
</template>
<style>
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/controls/dist/style.css';
@import '@vue-flow/minimap/dist/style.css';
@import '@vue-flow/node-resizer/dist/style.css';
.vue-flow__minimap {
transform: scale(75%);
transform-origin: bottom right;
}
.dnd-flow {
flex-direction: column;
display: flex;
height: 100%;
}
.dnd-flow aside {
color: #fff;
font-weight: 700;
border-right: 1px solid #eee;
padding: 15px 10px;
font-size: 12px;
background: #10b981bf;
-webkit-box-shadow: 0px 5px 10px 0px rgba(0, 0, 0, 0.3);
box-shadow: 0 5px 10px #0000004d;
}
.dnd-flow aside .nodes > * {
margin-bottom: 10px;
cursor: grab;
font-weight: 500;
-webkit-box-shadow: 5px 5px 10px 2px rgba(0, 0, 0, 0.25);
box-shadow: 5px 5px 10px 2px #00000040;
}
.dnd-flow aside .description {
margin-bottom: 10px;
}
.dnd-flow .vue-flow-wrapper {
flex-grow: 1;
height: 100%;
}
@media screen and (min-width: 640px) {
.dnd-flow {
flex-direction: row;
}
.dnd-flow aside {
max-width: 25%;
}
}
@media screen and (max-width: 639px) {
.dnd-flow aside .nodes {
display: flex;
flex-direction: row;
gap: 5px;
}
}
.dropzone-background {
position: relative;
height: 100%;
width: 100%;
}
.dropzone-background .overlay {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
pointer-events: none;
}
.vue-flow__handle {
height: 24px;
width: 8px;
border-radius: 4px;
}
.vue-flow__edge-path,
.vue-flow__connection-path {
stroke-width: 3;
}
.vue-flow__handle-left {
background-color: rgb(var(--v-theme-info));
}
.vue-flow__handle-right {
background-color: rgb(var(--v-theme-error));
}
</style>

View File

@@ -0,0 +1,133 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import type { Workflow } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import { useDisplay } from 'vuetify'
// 输入参数
const props = defineProps({
// 任务信息
workflow: Object as PropType<Workflow>,
})
// 新增或修改字样
const title = computed(() => (props.workflow ? '编辑' : '创建'))
// 显示器宽度
const display = useDisplay()
// 注册事件
const emit = defineEmits(['save', 'remove', 'close'])
// 站点编辑表单数据
const workflowForm = ref<Workflow>(
props.workflow || {
name: undefined,
timer: undefined,
description: undefined,
state: 'P',
run_count: 0,
},
)
// 提示框
const $toast = useToast()
// 调用API 新增任务
async function addWorkflow() {
if (!workflowForm.value.name || !workflowForm.value.timer) {
$toast.error('请填写完整信息!')
return
}
startNProgress()
try {
const result: { [key: string]: string } = await api.post('workflow/', workflowForm.value)
if (result.success) {
$toast.success(`创建任务成功,请编辑流程!`)
emit('save')
} else {
$toast.error(`创建任务失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
doneNProgress()
}
// 调用API 编辑任务
async function editWorkflow() {
if (!workflowForm.value.name || !workflowForm.value.timer) {
$toast.error('请填写完整信息!')
return
}
startNProgress()
try {
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
if (result.success) {
$toast.success(`修改任务成功!`)
emit('save')
} else {
$toast.error(`修改任务失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
doneNProgress()
}
</script>
<template>
<VDialog scrollable :close-on-back="false" eager max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${title}任务`" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12">
<VTextField
v-model="workflowForm.name"
label="别名"
:rules="[requiredValidator]"
persistent-hint
hint="任务名称"
/>
</VCol>
<VCol cols="12">
<VCronField
v-model="workflowForm.timer"
label="定时"
:rules="[requiredValidator]"
placeholder="5位cron表达式"
persistent-hint
hint="任务执行周期"
/>
</VCol>
<VCol cols="12">
<VTextarea v-model="workflowForm.description" label="任务描述" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn
v-if="workflow"
block
color="primary"
variant="elevated"
@click="editWorkflow"
prepend-icon="mdi-content-save"
class="px-5"
>
保存
</VBtn>
<VBtn v-else block color="primary" variant="elevated" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
创建
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -6,7 +6,7 @@ const attrs = useAttrs()
const props = defineProps({
modelValue: {
type: String,
default: '* * * * *',
default: '/',
},
storage: {
type: String,

View File

@@ -551,7 +551,7 @@ onMounted(() => {
placeholder="搜索 ..."
prepend-inner-icon="mdi-filter-outline"
class="me-2"
rounded="0"
rounded
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="!isFile" @click="changeSelectMode">

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
defineProps<{ title: string }>()
</script>
<template>
<VListSubheader>{{ title }}</VListSubheader>
<VListItem><slot /></VListItem>
</template>

View File

@@ -16,6 +16,9 @@ defineProps<{
const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) => {
const parsedProps: Record<string, any> = {}
const isExpression = (value: string) => value.startsWith('{{') && value.endsWith('}}')
const extractExpression = (value: string) => value.slice(2, -2).trim()
for (const [key, value] of Object.entries(rawProps)) {
if (key === 'modelvalue') {
// 将 modelvalue 转换为 v-model:value 的形式
@@ -23,22 +26,63 @@ const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) =
parsedProps['onUpdate:value'] = (newValue: any) => {
model[value] = newValue
}
} else if (key === 'model') {
} else if (['model', 'v-model'].includes(key)) {
// 处理 v-model
parsedProps['modelValue'] = model[value]
parsedProps['onUpdate:modelValue'] = (newValue: any) => {
model[value] = newValue
}
} else if (key.startsWith('model:')) {
} else if (['show', 'v-show'].includes(key)) {
// 处理 v-show实现显示隐藏
const expression = isExpression(value) ? extractExpression(value) : value
const isVisible = new Function('model', `with(model) { return ${expression} }`)(model)
// 动态设置 style.display
if (!parsedProps.style) {
parsedProps.style = {}
}
parsedProps.style.display = isVisible ? '' : 'none'
} else if (key.startsWith('model:') || key.startsWith('v-model:')) {
// 处理 v-model:<prop>
const propName = key.replace('model:', '')
const propName = key.split(':')[1]
parsedProps[propName] = model[value]
parsedProps[`onUpdate:${propName}`] = (newValue: any) => {
model[value] = newValue
}
} else if (key.startsWith('on')) {
// 处理事件监听,值是函数的代码 function xxx(e) { ... }
if (typeof value === 'string') {
// 创建动态函数并绑定model上下文
const handler = new Function(
'model',
'event',
`
try {
with(model) {
return (${value})(event);
}
} catch(e) {
console.error('事件处理函数执行错误:', e);
}
`,
)
// 包装事件处理器保持vue事件参数传递特性
parsedProps[key] = (...args: any[]) => {
const [event] = args
return handler(model, event)
}
}
} else {
// 普通属性直接赋值
parsedProps[key] = typeof value === 'string' && value in model ? model[value] : value
// 如果是表达式,需要绑定
if (typeof value === 'string' && isExpression(value)) {
const expression = extractExpression(value)
parsedProps[key] = new Function('model', `with(model) { return ${expression} }`)(model)
} else if (typeof value === 'string' && value in model) {
// 如果是数据模型的属性,直接绑定
parsedProps[key] = model[value]
} else {
// 其他情况直接赋值
parsedProps[key] = value
}
}
}

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import api from '@/api'
import { DownloaderConf } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
// 下载器选项
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)
}
}
onMounted(() => {
loadDownloaderSetting()
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-download-box-outline" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>添加下载</VCardTitle>
<VCardSubtitle>根据资源列表添加下载任务</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect v-model="data.downloader" :items="downloaderOptions" label="下载器" outlined dense />
</VCol>
<VCol cols="12">
<VTextField v-model="data.labels" label="标签" placeholder="多个使用,分隔" outlined dense />
</VCol>
<VCol cols="12">
<VPathField v-model="data.save_path" storage="local" label="保存路径" clearable placeholder="留空自动" />
</VCol>
<VCol cols="12">
<VSwitch v-model="data.only_lack" label="仅下载缺失的资源" />
</VCol>
</VRow>
</VCardText>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
</script>
<template>
<div>
<VCard>
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-star-check" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>添加订阅</VCardTitle>
<VCardSubtitle>根据媒体列表添加订阅</VCardSubtitle>
</VCardItem>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-progress-download" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>获取下载任务</VCardTitle>
<VCardSubtitle>获取下载队列中的任务状态</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSwitch v-model="data.loop" label="循环执行" />
</VCol>
<VCol cols="12">
<VTextField
v-model="data.loop_interval"
:disabled="!data.loop"
type="number"
label="循环间隔 (秒)"
outlined
dense
clearable
/>
</VCol>
</VRow>
</VCardText>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import api from '@/api'
import { RecommendSource } from '@/api/types'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
// 内置榜单
const innerList = [
{
'api_path': 'recommend/tmdb_trending',
'name': '流行趋势',
},
{
'api_path': 'recommend/douban_showing',
'name': '正在热映',
},
{
'api_path': 'bangumi/calendar',
'name': 'Bangumi每日放送',
},
{
'api_path': 'recommend/tmdb_movies',
'name': 'TMDB热门电影',
},
{
'api_path': 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
'name': 'TMDB热门电视剧',
},
{
'api_path': 'recommend/douban_movie_hot',
'name': '豆瓣热门电影',
},
{
'api_path': 'recommend/douban_tv_hot',
'name': '豆瓣热门电视剧',
},
{
'api_path': 'recommend/douban_tv_animation',
'name': '豆瓣热门动漫',
},
{
'api_path': 'recommend/douban_movies',
'name': '豆瓣最新电影',
},
{
'api_path': 'recommend/douban_tvs',
'name': '豆瓣最新电视剧',
},
{
'api_path': 'recommend/douban_movie_top250',
'name': '豆瓣电影TOP250',
},
{
'api_path': 'recommend/douban_tv_weekly_chinese',
'name': '豆瓣国产剧集榜',
},
{
'api_path': 'recommend/douban_tv_weekly_global',
'name': '豆瓣全球剧集榜',
},
]
// 额外的数据源
const extraRecommendSources = ref<RecommendSource[]>([])
// 加载额外的发现数据源
async function loadExtraRecommendSources() {
try {
extraRecommendSources.value = await api.get('recommend/source')
if (extraRecommendSources.value.length > 0) {
innerList.push(
...extraRecommendSources.value.map(source => ({
api_path: source.api_path,
name: source.name,
})),
)
}
} catch (error) {
console.log(error)
}
}
// 来源类型下拉框
const sourceTypeOptions = [
{ value: 'ranking', title: '推荐榜单' },
{ value: 'api', title: 'API' },
]
// 计算下拉框
const sourceOptions = computed(() => innerList.map(item => item.name))
onMounted(() => {
loadExtraRecommendSources()
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-multimedia" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>获取媒体数据</VCardTitle>
<VCardSubtitle>获取榜单等媒体数据列表</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect v-model="data.source_type" :items="sourceTypeOptions" label="来源" outlined dense />
</VCol>
</VRow>
<VRow v-if="data.source_type === 'ranking'">
<VCol cols="12">
<VSelect
v-model="data.sources"
:items="sourceOptions"
label="选择榜单"
chips
multiple
outlined
dense
clearable
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12">
<VTextField
v-model="data.api_path"
label="API地址"
placeholder="/api/v1/plugin/xxx/xxxx"
outlined
dense
clearable
/>
</VCol>
</VRow>
</VCardText>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-rss" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>获取RSS资源</VCardTitle>
<VCardSubtitle>订阅RSS地址获取资源</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="data.url" label="RSS地址" outlined dense clearable />
</VCol>
<VCol cols="12">
<VTextField v-model="data.ua" label="User-Agent" outlined dense clearable />
</VCol>
<VCol cols="12">
<VTextField v-model="data.timeout" type="number" label="超时时间" outlined dense clearable />
</VCol>
<VCol cols="6">
<VSwitch v-model="data.match_media" label="匹配媒体信息" />
</VCol>
<VCol cols="6">
<VSwitch v-model="data.proxy" label="使用代理" />
</VCol>
</VRow>
</VCardText>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import api from '@/api'
import { Site } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
// 电影/电视剧下拉框
const typeOptions = ref([
{
title: '电影',
value: '电影',
},
{
title: '电视剧',
value: '电视剧',
},
])
// 搜索方式下拉框
const searchOptions = ref([
{
title: '名称',
value: 'keyword',
},
{
title: '媒体列表',
value: 'media',
},
])
// 站点数据列表
const siteList = ref<Site[]>([])
// 获取站点列表数据
async function loadSites() {
try {
const data: Site[] = await api.get('site/rss')
// 过滤站点,只有启用的站点才显示
siteList.value = data.filter(item => item.is_active)
} catch (error) {
console.error(error)
}
}
// 站点选项
const siteOptions = computed(() => {
return siteList.value.map(item => {
return {
title: item.name,
value: item.id,
}
})
})
onMounted(() => {
loadSites()
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-search-web" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>搜索站点资源</VCardTitle>
<VCardSubtitle>搜索站点种子资源列表</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect v-model="data.search_type" label="搜索方式" :items="searchOptions" outlined dense />
</VCol>
</VRow>
<VRow v-if="data.search_type === 'keyword'">
<VCol cols="6">
<VTextField v-model="data.name" label="名称" outlined dense />
</VCol>
<VCol cols="6">
<VTextField v-model="data.year" label="年份" outlined dense />
</VCol>
<VCol cols="6">
<VSelect v-model="data.type" label="类型" :items="typeOptions" outlined dense />
</VCol>
<VCol cols="6">
<VTextField v-model="data.season" type="number" label="季" outlined dense />
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect v-model="data.sites" label="站点" :items="siteOptions" chips multiple outlined dense />
</VCol>
</VRow>
<VRow v-if="data.search_type === 'keyword'">
<VCol cols="12">
<VSwitch v-model="data.match_media" label="匹配媒体信息" />
</VCol>
</VRow>
</VCardText>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import api from '@/api'
import { Handle, Position } from '@vue-flow/core'
const props = defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
// 电影/电视剧下拉框
const typeOptions = ref([
{
title: '电影',
value: '电影',
},
{
title: '电视剧',
value: '电视剧',
},
])
// 二级分类策略
const mediaCategories = ref<{ [key: string]: any }>({})
// 调用API查询自动分类配置
async function loadMediaCategories() {
try {
mediaCategories.value = await api.get('media/category')
} catch (error) {
console.log(error)
}
}
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
if (!mediaCategories.value || !mediaCategories.value[props.data.type ?? '']) return default_value
return default_value.concat(mediaCategories.value[props.data.type ?? ''])
})
onMounted(() => {
loadMediaCategories()
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-filter-check" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>过滤媒体数据</VCardTitle>
<VCardSubtitle>对媒体数据列表进行过滤</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect v-model="data.type" label="类型" :items="typeOptions" outlined dense />
</VCol>
<VCol cols="6">
<VTextField v-model="data.year" label="年份" outlined dense />
</VCol>
<VCol cols="6">
<VTextField v-model="data.vote" type="number" label="评分" outlined dense />
</VCol>
</VRow>
</VCardText>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import api from '@/api'
import { FilterRuleGroup } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
// 质量选择框数据
const qualityOptions = ref([
{
title: '全部',
value: '',
},
{
title: '蓝光原盘',
value: 'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD',
},
{
title: 'Remux',
value: 'Remux',
},
{
title: 'BluRay',
value: 'Blu-?Ray',
},
{
title: 'UHD',
value: 'UHD|UltraHD',
},
{
title: 'WEB-DL',
value: 'WEB-?DL|WEB-?RIP',
},
{
title: 'HDTV',
value: 'HDTV',
},
{
title: 'H265',
value: '[Hx].?265|HEVC',
},
{
title: 'H264',
value: '[Hx].?264|AVC',
},
])
// 分辨率选择框数据
const resolutionOptions = ref([
{
title: '全部',
value: '',
},
{
title: '4k',
value: '4K|2160p|x2160',
},
{
title: '1080p',
value: '1080[pi]|x1080',
},
{
title: '720p',
value: '720[pi]|x720',
},
])
// 特效选择框数据
const effectOptions = ref([
{
title: '全部',
value: '',
},
{
title: '杜比视界',
value: 'Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+',
},
{
title: '杜比全景声',
value: 'Dolby[\\s.]*\\+?Atmos|Atmos',
},
{
title: 'HDR',
value: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
},
{
title: 'SDR',
value: '[\\s.]+SDR[\\s.]+',
},
])
// 所有规则组列表
const filterRuleGroups = ref<FilterRuleGroup[]>([])
// 加载规则组
async function queryFilterRuleGroups() {
try {
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
filterRuleGroups.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 计算过滤规则组选择框数据
const ruleGroupsOptions = computed(() => {
return filterRuleGroups.value.map(group => ({
title: group.name,
value: group.name,
}))
})
onMounted(() => {
queryFilterRuleGroups()
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-filter-multiple" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>过滤资源</VCardTitle>
<VCardSubtitle>对资源列表数据进行过滤</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="6">
<VSelect v-model="data.quality" label="质量" :items="qualityOptions" outlined dense />
</VCol>
<VCol cols="6">
<VSelect v-model="data.resolution" label="分辨率" :items="resolutionOptions" outlined dense />
</VCol>
<VCol cols="6">
<VSelect v-model="data.effect" label="特效" :items="effectOptions" outlined dense />
</VCol>
<VCol cols="6">
<VTextField v-model="data.size" label="大小范围" placeholder="MB" outlined dense />
</VCol>
<VCol cols="12">
<VTextField v-model="data.include" label="包含(关键字、正则式)" outlined dense />
</VCol>
<VCol cols="12">
<VTextField v-model="data.exclude" label="排除(关键字、正则式)" outlined dense />
</VCol>
<VCol cols="12">
<VSelect
v-model="data.rule_groups"
chips
multiple
label="过滤规则组"
:items="ruleGroupsOptions"
outlined
dense
/>
</VCol>
</VRow>
</VCardText>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { storageOptions } from '@/api/constants'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-file-move" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>扫描目录</VCardTitle>
<VCardSubtitle>扫描目录文件到队列</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect v-model="data.storage" label="存储" :items="storageOptions" outlined dense />
</VCol>
<VCol cols="12">
<VPathField v-model="data.directory" :storage="data.storage" label="目录" clearable />
</VCol>
</VRow>
</VCardText>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-file-find" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>刮削文件</VCardTitle>
<VCardSubtitle>刮削媒体信息和图片</VCardSubtitle>
</VCardItem>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-send-check" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>发送事件</VCardTitle>
<VCardSubtitle>发送任务执行事件</VCardSubtitle>
</VCardItem>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import api from '@/api'
import { NotificationConf } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
// 所有消息渠道
const notifications = ref<NotificationConf[]>([])
// 调用API查询通知渠道设置
async function loadNotificationSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
notifications.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 计算消息渠道选项
const sourceOptions = computed(() => {
return notifications.value.map(item => {
return {
title: item.name,
value: item.name,
}
})
})
onMounted(() => {
loadNotificationSetting()
})
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-message-arrow-right" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>发送消息</VCardTitle>
<VCardSubtitle>发送任务执行消息</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect
v-model="data.client"
:items="sourceOptions"
label="消息渠道"
chips
multiple
outlined
dense
clearable
/>
</VCol>
<VCol cols="12">
<VTextField v-model="data.userid" label="用户ID" chips multiple outlined dense clearable />
</VCol>
</VRow>
</VCardText>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
})
// 来源下拉框
const sourceOptions = ref([
{
title: '文件列表',
value: 'files',
},
{
title: '下载任务',
value: 'downloads',
},
])
</script>
<template>
<div>
<VCard max-width="20rem">
<Handle id="edge_in" type="target" :position="Position.Left" />
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-file-move" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>整理文件</VCardTitle>
<VCardSubtitle>整理重命名队列中的文件</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect v-model="data.source" label="来源" :items="sourceOptions" outlined dense />
</VCol>
</VRow>
</VCardText>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>
</div>
</template>

View File

@@ -8,7 +8,7 @@ import UserNofification from '@/layouts/components/UserNotification.vue'
import SearchBar from '@/layouts/components/SearchBar.vue'
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import store from '@/store'
import { useUserStore } from '@/stores'
import { SystemNavMenus } from '@/router/menu'
import { NavMenu } from '@/@layouts/types'
import { useDisplay } from 'vuetify'
@@ -16,8 +16,11 @@ import { useDisplay } from 'vuetify'
const display = useDisplay()
const appMode = inject('pwaMode')
// 用户 Store
const userStore = useUserStore()
// 是否超级用户
let superUser = store.state.auth.superUser
let superUser = userStore.superUser
// 开始菜单项
const startMenus = ref<NavMenu[]>([])
@@ -64,7 +67,7 @@ onMounted(() => {
<VIcon icon="mdi-menu" />
</IconBtn>
<!-- 👉 Back Button -->
<IconBtn v-if="appMode && display.mdAndDown.value" class="ms-n2" @click="goBack">
<IconBtn v-if="appMode" class="ms-n2" @click="goBack">
<VIcon icon="mdi-arrow-left" size="32" />
</IconBtn>
<!-- 👉 Search Bar -->

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
import { Background } from '@vue-flow/background'
</script>
<template>
<div class="dropzone-background">
<Background :size="2" :gap="20" pattern-color="#BDBDBD" />
<div class="overlay">
<slot />
</div>
</div>
</template>

View File

@@ -1,38 +1,49 @@
<script setup lang="ts">
import { SystemNavMenus } from '@/router/menu'
import { useDisplay } from 'vuetify'
import { VMenu } from 'vuetify/lib/components/index.mjs'
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
const route = useRoute()
// 各按钮活动状态
const moreMenuDialog = ref(false)
const moreMemus = computed(() => SystemNavMenus.filter(menu => !menu.footer))
const activeState = computed(() => {
return {
home: route.path === '/dashboard',
ranking: route.path === '/ranking',
recommend: route.path === '/recommend',
movie: route.path === '/subscribe/movie',
tv: route.path === '/subscribe/tv',
apps: route.path === '/apps',
}
})
const moreActiveState = computed(() => {
return !Object.values(activeState.value).some(v => v)
})
const currentPath = computed(() => route.path)
</script>
<template>
<div v-if="appMode" class="w-100" style="block-size: calc(3.5rem + env(safe-area-inset-bottom))">
<div v-if="appMode" class="w-100">
<VBottomNavigation
grow
horizontal
color="primary"
class="footer-nav border-t"
style="block-size: calc(3.5rem + env(safe-area-inset-bottom))"
:z-index="9998"
>
<VBtn to="/dashboard" :ripple="false">
<VIcon v-if="activeState.home" size="28">mdi-home</VIcon>
<VIcon v-else size="28">mdi-home-outline</VIcon>
</VBtn>
<VBtn to="/ranking" :ripple="false">
<VIcon v-if="activeState.ranking" size="28">mdi-star</VIcon>
<VBtn to="/recommend" :ripple="false">
<VIcon v-if="activeState.recommend" size="28">mdi-star</VIcon>
<VIcon v-else size="28">mdi-star-outline</VIcon>
</VBtn>
<VBtn to="/subscribe/movie" :ripple="false">
@@ -43,9 +54,31 @@ const activeState = computed(() => {
<VIcon v-if="activeState.tv" size="28">mdi-television-play</VIcon>
<VIcon v-else size="28">mdi-television</VIcon>
</VBtn>
<VBtn to="/apps" :ripple="false">
<VIcon v-if="activeState.apps" size="28">mdi-dots-horizontal-circle</VIcon>
<VIcon v-else size="28">mdi-dots-horizontal</VIcon>
<VBtn :ripple="false">
<VIcon
size="28"
:icon="moreMenuDialog ? 'mdi-close' : 'mdi-dots-horizontal'"
:color="moreActiveState ? 'primary' : ''"
/>
<VMenu v-model="moreMenuDialog" close-on-content-click activator="parent">
<VDivider />
<VList class="font-bold" lines="one">
<VListSubheader class="bg-transparent"> 更多 </VListSubheader>
<VListItem
class="pe-20"
v-for="(menu, index) in moreMemus"
:key="index"
:prepend-icon="menu.icon"
nav
:to="menu.to"
:base-color="currentPath === menu.to ? 'primary' : undefined"
>
<VListItemTitle>
<span class="text-lg">{{ menu.title }}</span>
</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</VBottomNavigation>
</div>

View File

@@ -262,7 +262,7 @@ onMounted(() => {
<VDialog
v-if="messageDialog"
v-model="messageDialog"
max-width="60rem"
max-width="45rem"
scrollable
:fullscreen="!display.mdAndUp.value"
>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { useStore } from 'vuex'
import { useConfirm } from 'vuetify-use-dialog'
import { useToast } from 'vue-toast-notification'
import router from '@/router'
@@ -7,9 +6,12 @@ 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'
import { useAuthStore, useUserStore } from '@/stores'
// Vuex Store
const store = useStore()
// 认证 Store
const authStore = useAuthStore()
// 用户 Store
const userStore = useUserStore()
// 确认框
const createConfirm = useConfirm()
@@ -29,7 +31,7 @@ const restartDialog = ref(false)
// 执行注销操作
function logout() {
// 清除登录状态信息
store.dispatch('auth/logout')
authStore.logout()
// 重定向到登录页面或其他适当的页面
router.push('/login')
}
@@ -74,11 +76,11 @@ function siteAuthDone() {
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)
// 从用户 Store中获取信息
const superUser = computed(() => userStore.superUser)
const userName = computed(() => userStore.userName)
const avatar = computed(() => userStore.avatar || avatar1)
const userLevel = computed(() => userStore.level)
</script>
<template>
@@ -113,6 +115,13 @@ const userLevel = computed(() => store.state.auth.level)
<VListItemTitle>个人信息</VListItemTitle>
</VListItem>
<VListItem link @click="router.push('/apps')">
<template #prepend>
<VIcon class="me-2" icon="mdi-view-grid-outline" size="22" />
</template>
<VListItemTitle>功能视图</VListItemTitle>
</VListItem>
<!-- 👉 Site Auth -->
<VListItem v-if="userLevel < 2 && superUser" link @click="showSiteAuthDialog">
<template #prepend>
@@ -156,7 +165,7 @@ const userLevel = computed(() => store.state.auth.level)
<!-- 用户认证对话框 -->
<UserAuthDialog v-if="siteAuthDialog" v-model="siteAuthDialog" @done="siteAuthDone" @close="siteAuthDialog = false" />
<!-- 重启确认对话框 -->
<VDialog v-model="restartDialog" max-width="25rem">
<VDialog v-if="restartDialog" v-model="restartDialog" max-width="25rem">
<VCard>
<VCardItem>
<div class="flex items-center justify-center mt-3">

View File

@@ -0,0 +1,40 @@
<script lang="ts" setup>
import api from '@/api'
import useDragAndDrop from '@core/utils/workflow'
const { onDragStart } = useDragAndDrop()
// 组件列表
const actions = ref([])
// 加载组件列表
async function load_actions() {
try {
actions.value = await api.get('workflow/actions')
} catch (error) {
console.error(error)
}
}
onMounted(() => {
load_actions()
})
</script>
<template>
<aside>
<div class="mb-3"><VLabel>可选动作组件</VLabel></div>
<div class="nodes flex flex-wrap justify-center">
<div
class="vue-flow__node-default cursor-grab mx-1"
v-for="(action, index) in actions"
:key="index"
:draggable="true"
@dragstart="onDragStart($event, action)"
>
{{ action['name'] }}
</div>
</div>
</aside>
</template>

View File

@@ -8,7 +8,7 @@ import '@/plugins/webfontloader'
import { createApp } from 'vue'
import vuetify from '@/plugins/vuetify'
import router from '@/router'
import store from '@/store'
import pinia from '@/stores/index'
// 3. 全局组件
import App from '@/App.vue'
@@ -50,11 +50,16 @@ import '@styles/styles.scss'
// 创建Vue实例
const app = createApp(App)
// 注册pinia
app.use(pinia)
// 初始化配置
async function initializeApp() {
try {
// 是否为PWA
const pwaMode = await isPWA()
app.provide('pwaMode', pwaMode)
// 全局设置
const globalSettings = await fetchGlobalSettings()
app.provide('globalSettings', globalSettings)
@@ -65,10 +70,13 @@ async function initializeApp() {
// 注册全局组件
initializeApp().then(() => {
// 优先注册框架
// 1. 注册 UI 框架
app.use(vuetify)
// 注册全局组件
// 2. 注册路由
app.use(router)
// 3. 注册全局组件
app
.component('VAceEditor', VAceEditor)
.component('VApexChart', VueApexCharts)
@@ -84,10 +92,8 @@ initializeApp().then(() => {
.component('VCronField', CronField)
.component('VPathField', PathField)
// 注册插件
// 5. 注册其他插件
app
.use(router)
.use(store)
.use(PerfectScrollbarPlugin)
.use(ToastPlugin, {
position: 'bottom-right',
@@ -95,7 +101,7 @@ initializeApp().then(() => {
.use(VuetifyUseDialog, {
confirmDialog: {
dialogProps: {
maxWidth: '40rem',
maxWidth: '30rem',
},
confirmationButtonProps: {
variant: 'elevated',

View File

@@ -1,18 +1,18 @@
<script setup lang="ts">
import { NavMenu } from '@/@layouts/types'
import { SystemNavMenus } from '@/router/menu'
import store from '@/store'
import { useUserStore } from '@/stores'
import draggable from 'vuedraggable'
// 从Vuex Store中获取superuser信息
const superUser = store.state.auth.superUser
// 从 Store 中获取superuser信息
const superUser = useUserStore().superUser
// APP图标顺序
const appOrder = ref<string[]>([])
// 根据分类获取菜单列表
const getMenuList = () => {
return SystemNavMenus.filter((item: NavMenu) => (!item.admin || superUser) && !item.footer)
return SystemNavMenus.filter((item: NavMenu) => !item.admin || superUser)
}
// APP列表

View File

@@ -3,7 +3,7 @@ import draggable from 'vuedraggable'
import api from '@/api'
import { isNullOrEmptyObject } from '@/@core/utils'
import { DashboardItem } from '@/api/types'
import store from '@/store'
import { useUserStore } from '@/stores'
import DashboardElement from '@/components/misc/DashboardElement.vue'
import { useDisplay } from 'vuetify'
@@ -11,8 +11,8 @@ import { useDisplay } from 'vuetify'
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
// 从Vuex Store中获取superuser信息
const superUser = store.state.auth.superUser
// 从用户 Store 中获取superuser信息
const superUser = useUserStore().superUser
// 是否拉升高度
const isElevated = ref(true)
@@ -340,7 +340,7 @@ onDeactivated(() => {
/>
<!-- 弹窗根据配置生成选项 -->
<VDialog v-model="dialog" max-width="35rem" scrollable>
<VDialog v-if="dialog" v-model="dialog" max-width="35rem" scrollable>
<VCard>
<VCardItem>
<VCardTitle>设置仪表板</VCardTitle>

View File

@@ -1,26 +1,134 @@
<script setup lang="ts">
import { DiscoverTabs } from '@/router/menu'
import router from '@/router'
import draggable from 'vuedraggable'
import TheMovieDbView from '@/views/discover/TheMovieDbView.vue'
import DoubanView from '@/views/discover/DoubanView.vue'
import BangumiView from '@/views/discover/BangumiView.vue'
import ExtraSourceView from '@/views/discover/ExtraSourceView.vue'
import { DiscoverSource } from '@/api/types'
import api from '@/api'
const route = useRoute()
const activeTab = ref(route.query.tab)
const activeTab = ref('')
function jumpTab(tab: string) {
router.push('/subscribe/discover?tab=' + tab)
// 本地存储键值
const localOrderKey = 'MP_DISCOVER_TAB_ORDER'
// 顺序配置
const orderConfig = ref<{ name: string }[]>([])
// 标签页
const discoverTabs = ref<DiscoverSource[]>([])
// 额外的数据源
const extraDiscoverSources = ref<DiscoverSource[]>([])
// 初始化发现标签
function initDiscoverTabs() {
for (const tab of DiscoverTabs) {
discoverTabs.value.push({
name: tab.name,
mediaid_prefix: tab.tab,
api_path: '',
filter_params: {},
filter_ui: [],
})
}
}
// 加载额外的发现数据源
async function loadExtraDiscoverSources() {
try {
extraDiscoverSources.value = await api.get('discover/source')
if (extraDiscoverSources.value.length === 0) {
return
}
for (const source of extraDiscoverSources.value) {
if (discoverTabs.value.find(tab => tab.mediaid_prefix === source.mediaid_prefix)) {
continue
}
discoverTabs.value.push(source)
}
} catch (error) {
console.log(error)
}
}
// 按order的顺序排序
function sortSubscribeOrder() {
if (!orderConfig.value) {
return
}
if (discoverTabs.value.length === 0) {
return
}
discoverTabs.value.sort((a, b) => {
const aIndex = orderConfig.value.findIndex((item: { name: string }) => item.name === a.name)
const bIndex = orderConfig.value.findIndex((item: { name: string }) => item.name === b.name)
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
})
}
// 加载顺序
async function loadOrderConfig() {
// 顺序配置
const local_order = localStorage.getItem(localOrderKey)
if (local_order) {
orderConfig.value = JSON.parse(local_order)
} else {
const response = await api.get(`/user/config/${localOrderKey}`)
if (response && response.data && response.data.value) {
orderConfig.value = response.data.value
localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value))
}
}
}
// 保存顺序设置
async function saveTabOrder() {
// 顺序配置
const orderObj = discoverTabs.value.map(item => ({ name: item.name }))
orderConfig.value = orderObj
const orderString = JSON.stringify(orderObj)
localStorage.setItem(localOrderKey, orderString)
// 保存到服务端
try {
await api.post(`/user/config/${localOrderKey}`, orderObj)
} catch (error) {
console.error(error)
}
}
onBeforeMount(async () => {
initDiscoverTabs()
await loadOrderConfig()
await loadExtraDiscoverSources()
sortSubscribeOrder()
// 选中第一个标签页
if (discoverTabs.value.length > 0) {
activeTab.value = discoverTabs.value[0].mediaid_prefix
}
})
onActivated(async () => {
await loadExtraDiscoverSources()
sortSubscribeOrder()
})
</script>
<template>
<div>
<VTabs v-model="activeTab" show-arrows>
<VTab v-for="item in DiscoverTabs" :value="item.tab" @to="jumpTab(item.tab)">
<div class="min-w-24">
{{ item.title }}
</div>
</VTab>
<draggable v-model="discoverTabs" handle=".cursor-move" item-key="tab" tag="div" @end="saveTabOrder">
<template #item="{ element }">
<VTab :key="element.mediaid_prefix" :value="element.mediaid_prefix">
<div><VIcon class="cursor-move" start icon="mdi-drag" /></div>
<div class="min-w-24">
<div>{{ element.name }}</div>
</div>
</VTab>
</template>
</draggable>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
@@ -45,6 +153,13 @@ function jumpTab(tab: string) {
</div>
</transition>
</VWindowItem>
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
<transition name="fade-slide" appear>
<div>
<ExtraSourceView :source="item" />
</div>
</transition>
</VWindowItem>
</VWindow>
</div>
</template>

View File

@@ -29,6 +29,10 @@ function jumpTab(tab: string) {
onMounted(async () => {
await loadDownloaderSetting()
})
onActivated(async () => {
loadDownloaderSetting()
})
</script>
<template>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { debounce } from 'lodash'
import { debounce } from 'lodash-es'
import { VForm } from 'vuetify/components/VForm'
import { useStore } from 'vuex'
import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import router from '@/router'
@@ -9,11 +10,14 @@ import logo from '@images/logo.png'
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { saveLocalTheme } from '@/@core/utils/theme'
// 主题
const { global: globalTheme } = useTheme()
// Vuex Store
const store = useStore()
// 认证 Store
const authStore = useAuthStore()
//用户 Store
const userStore = useUserStore()
// 表单
const form = ref({
@@ -85,8 +89,7 @@ async function setTheme() {
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 存储主题到本地
localStorage.setItem('theme', themeValue)
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
saveLocalTheme(themeValue, globalTheme)
}
// 订阅推送通知
@@ -119,7 +122,7 @@ async function afterLogin(superuser: boolean) {
// 生效主题配置
await setTheme()
// 跳转到首页或回原始页面
router.push(store.state.auth.originalPath ?? '/')
router.push(authStore.originalPath ?? '/')
// 订阅推送通知
if (superuser) await subscribeForPushNotifications()
}
@@ -147,30 +150,25 @@ function login() {
},
})
.then((response: any) => {
// 获取token
const token = response.access_token
const superUser = response.super_user
const userID = response.user_id
const userName = response.user_name
const avatar = response.avatar
const level = response.level
const remember = form.value.remember
const permissions = response.permissions
const authPayLoad: authState = {
token: response.access_token,
remember: form.value.remember,
}
// 更新token和remember状态到Vuex Store
store.dispatch('auth/login', {
token,
remember,
superUser,
userID,
userName,
avatar,
level,
permissions,
})
const userPayload: userState = {
superUser: response.super_user,
userID: response.user_id,
userName: response.user_name,
avatar: response.avatar,
level: response.level,
permissions: response.permissions,
}
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
// 登录后处理
afterLogin(superUser)
afterLogin(userPayload.superUser)
})
.catch((error: any) => {
// 登录失败,显示错误提示
@@ -191,9 +189,9 @@ function startBackgroundRotation() {
// 自动登录
onMounted(async () => {
// 从Vuex Store中获取token和remember状态
const token = store.state.auth.token
const remember = store.state.auth.remember
// 获取token和remember状态
const token = authStore.token
const remember = authStore.remember
// 如果token存在且保持登录状态为true则跳转到首页
if (token && remember) {

View File

@@ -9,13 +9,16 @@ const mediaid = route.query?.mediaid?.toString()
// 类型
const type = route.query?.type?.toString()
// 标题
const title = route.query?.title?.toString()
// 年份
const year = route.query?.year?.toString()
</script>
<template>
<div>
<MediaDetailView
:mediaid="mediaid"
:type="type"
/>
<MediaDetailView :mediaid="mediaid" :type="type" :title="title" :year="year" />
</div>
</template>

View File

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

196
src/pages/recommend.vue Normal file
View File

@@ -0,0 +1,196 @@
<script setup lang="ts">
import api from '@/api'
import { RecommendSource } from '@/api/types'
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import { useDisplay } from 'vuetify'
// APP
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
const viewList = reactive<{ apipath: string; linkurl: string; title: string }[]>([
{
apipath: 'recommend/tmdb_trending',
linkurl: '/browse/recommend/tmdb_trending?title=流行趋势',
title: '流行趋势',
},
{
apipath: 'recommend/douban_showing',
linkurl: '/browse/recommend/douban_showing?title=正在热映',
title: '正在热映',
},
{
apipath: 'bangumi/calendar',
linkurl: '/browse/bangumi/calendar?title=Bangumi每日放送',
title: 'Bangumi每日放送',
},
{
apipath: 'recommend/tmdb_movies',
linkurl: '/browse/recommend/tmdb_movies?title=TMDB热门电影',
title: 'TMDB热门电影',
},
{
apipath: 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
linkurl: '/browse/recommend/tmdb_tvs??with_original_language=zh|en|ja|ko&title=TMDB热门电视剧',
title: 'TMDB热门电视剧',
},
{
apipath: 'recommend/douban_movie_hot',
linkurl: '/browse/recommend/douban_movie_hot?title=豆瓣热门电影',
title: '豆瓣热门电影',
},
{
apipath: 'recommend/douban_tv_hot',
linkurl: '/browse/recommend/douban_tv_hot?title=豆瓣热门电视剧',
title: '豆瓣热门电视剧',
},
{
apipath: 'recommend/douban_tv_animation',
linkurl: '/browse/recommend/douban_tv_animation?title=豆瓣热门动漫',
title: '豆瓣热门动漫',
},
{
apipath: 'recommend/douban_movies',
linkurl: '/browse/recommend/douban_movies?title=豆瓣最新电影',
title: '豆瓣最新电影',
},
{
apipath: 'recommend/douban_tvs',
linkurl: '/browse/recommend/douban_tvs?title=豆瓣最新电视剧',
title: '豆瓣最新电视剧',
},
{
apipath: 'recommend/douban_movie_top250',
linkurl: '/browse/recommend/douban_movie_top250?title=电影TOP250',
title: '豆瓣电影TOP250',
},
{
apipath: 'recommend/douban_tv_weekly_chinese',
linkurl: '/browse/recommend/douban_tv_weekly_chinese?title=豆瓣国产剧集榜',
title: '豆瓣国产剧集榜',
},
{
apipath: 'recommend/douban_tv_weekly_global',
linkurl: '/browse/recommend/douban_tv_weekly_global?title=豆瓣全球剧集榜',
title: '豆瓣全球剧集榜',
},
])
// 计算启用的视图
const enabledViews = computed(() => viewList.filter(item => enableConfig.value[item.title]))
// 榜单启用配置, 以title为key
const enableConfig = ref<{ [key: string]: boolean }>({
...Object.fromEntries(viewList.map(item => [item.title, true])),
})
// 弹窗
const dialog = ref(false)
// 额外的数据源
const extraRecommendSources = ref<RecommendSource[]>([])
// 加载额外的发现数据源
async function loadExtraRecommendSources() {
try {
extraRecommendSources.value = await api.get('recommend/source')
if (extraRecommendSources.value.length > 0) {
viewList.push(
...extraRecommendSources.value.map(source => ({
apipath: source.api_path,
linkurl: `/browse/recommend/${source.api_path}?title=${source.name}`,
title: source.name,
})),
)
}
} catch (error) {
console.log(error)
}
}
// 加载面板配置
async function loadConfig() {
// 显示配置
const local_enable = localStorage.getItem('MP_RECOMMEND')
if (local_enable) {
enableConfig.value = JSON.parse(local_enable)
} else {
const response = await api.get('/user/config/Recommend')
if (response && response.data && response.data.value) {
enableConfig.value = response.data.value
localStorage.setItem('MP_RECOMMEND', JSON.stringify(response.data.value))
}
}
}
// 设置项目
async function saveConfig() {
// 启用配置
const enableString = JSON.stringify(enableConfig.value)
localStorage.setItem('MP_RECOMMEND', enableString)
// 保存到服务端
try {
await api.post('/user/config/Recommend', enableConfig.value)
} catch (error) {
console.error(error)
}
dialog.value = false
}
onBeforeMount(async () => {
await loadConfig()
})
onMounted(async () => {
await loadExtraRecommendSources()
})
onActivated(async () => {
loadExtraRecommendSources()
})
</script>
<template>
<div>
<MediaCardSlideView v-for="item in enabledViews" :key="item.title" v-bind="item" />
<!-- 弹窗根据配置生成选项 -->
<VDialog v-if="dialog" v-model="dialog" max-width="35rem" scrollable>
<VCard>
<VCardItem>
<VCardTitle>设置推荐榜单</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol v-for="item in viewList" :key="item.title" cols="6" md="4" sm="4">
<VCheckbox v-model="enableConfig[item.title]" :label="item.title" />
</VCol>
</VRow>
</VCardText>
<VDivider />
<VCardText class="pt-5 text-end">
<VSpacer />
<VBtn variant="outlined" color="secondary" class="me-4" @click="dialog = false"> 关闭 </VBtn>
<VBtn @click="saveConfig">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
保存
</VBtn>
</VCardText>
</VCard>
</VDialog>
</div>
<!-- 底部操作按钮 -->
<VFab
icon="mdi-text-box-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="dialog = true"
:class="{ 'mb-12': appMode }"
/>
</template>

View File

@@ -22,12 +22,24 @@ const type = route.query?.type?.toString() ?? ''
// 搜索字段
const area = route.query?.area?.toString() ?? ''
// 搜索标题
const title = route.query?.title?.toString() ?? ''
// 搜索年份
const year = route.query?.year
// 搜索季
const season = route.query?.season?.toString() ?? ''
// 搜索站点,以,分离多个
const sites = route.query?.sites?.toString() ?? ''
// 视图类型从localStorage中读取
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
// 视图切换中
const isViewChanging = ref(false)
// 数据列表
const dataList = ref<Array<Context>>([])
@@ -52,25 +64,63 @@ const errorDescription = ref('未搜索到任何资源')
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '正在搜索,请稍候...'
progressValue.value = 10 // 初始进度设为10%,确保进度条显示
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/search`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
// 搜索完成条件调整:只有明确完成时才关闭
if (progress.text.includes('完成') && progress.value >= 99) {
setTimeout(() => {
stopLoadingProgress()
}, 1000) // 延迟1秒关闭确保用户能看到100%
}
}
}
// 添加错误处理
progressEventSource.value.onerror = () => {
setTimeout(() => {
stopLoadingProgress()
}, 1000)
}
// 添加安全超时,确保不会永远卡住
setTimeout(() => {
if (progressEventSource.value && progressValue.value < 100) {
stopLoadingProgress()
}
}, 60000) // 60秒超时
}
// 停止监听加载进度
function stopLoadingProgress() {
if (progressEventSource.value) progressEventSource.value?.close()
if (progressEventSource.value) {
progressEventSource.value.close()
progressEventSource.value = undefined
}
// 确保进度显示100%,然后再渐进清零
progressValue.value = 100
setTimeout(() => {
progressValue.value = 0
}, 1500) // 延长到1.5秒,让用户有足够时间看到完成状态
}
// 设置视图类型
function setViewType(type: string) {
localStorage.setItem('MPTorrentsViewType', type)
viewType.value = type
function changeViewType(newType: string) {
if (viewType.value !== newType) {
isViewChanging.value = true
viewType.value = newType
localStorage.setItem('MPTorrentsViewType', newType)
// 模拟视图切换的加载过程
setTimeout(() => {
isViewChanging.value = false
}, 600)
}
}
// 获取搜索列表数据
@@ -82,13 +132,16 @@ async function fetchData() {
} else {
startLoadingProgress()
let result: { [key: string]: any }
// 优先按TMDBID精确查询
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符则按照媒体ID格式搜索
if (/^[a-zA-Z]+:/.test(keyword)) {
result = await api.get(`search/media/${keyword}`, {
params: {
mtype: type,
area,
title,
year,
season,
sites,
},
})
} else {
@@ -96,11 +149,12 @@ async function fetchData() {
result = await api.get(`search/title`, {
params: {
keyword,
sites,
},
})
}
if (result && result.success) {
dataList.value = result.data
dataList.value = result.data || []
} else if (result && result.message) {
errorDescription.value = result.message
}
@@ -112,6 +166,8 @@ async function fetchData() {
isRefreshed.value = true
} catch (error) {
console.error(error)
stopLoadingProgress()
isRefreshed.value = true
return Promise.reject(error)
}
}
@@ -128,39 +184,403 @@ onUnmounted(() => {
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" :text="progressText" :progress="progressValue" />
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
:error-title="errorTitle"
:error-description="errorDescription"
/>
<div v-if="dataList.length > 0">
<TorrentRowListView v-if="viewType === 'list'" :items="dataList" />
<TorrentCardListView v-else :items="dataList" />
</div>
<!-- 视图切换 -->
<div v-if="isRefreshed">
<VFab
v-if="viewType === 'list'"
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>
<!-- 加载进度条 -->
<VFadeTransition>
<div v-if="progressValue > 0" class="search-progress-container">
<div class="search-progress-card">
<div class="progress-header">
<VIcon icon="mdi-movie-search" color="primary" size="small" class="me-2" />
<span class="progress-title">{{ progressText }}</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar-wrapper">
<div class="progress-bar" :style="{ width: `${progressValue}%` }"></div>
</div>
<div class="progress-percentage">{{ Math.ceil(progressValue) }}%</div>
</div>
</div>
</div>
</VFadeTransition>
<!-- 精简标题栏 -->
<div v-if="isRefreshed" class="search-header d-flex align-center mb-4">
<div class="search-info-container d-flex align-center flex-wrap">
<div class="search-title text-primary">资源搜索结果</div>
<div class="search-tags d-flex flex-wrap">
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat">
关键词: {{ keyword }}
</VChip>
<VChip v-if="title" class="search-tag" color="primary" size="small" variant="flat"> 标题: {{ title }} </VChip>
<VChip v-if="year" class="search-tag" color="primary" size="small" variant="flat"> 年份: {{ year }} </VChip>
<VChip v-if="season" class="search-tag" color="primary" size="small" variant="flat"> : {{ season }} </VChip>
</div>
</div>
<VSpacer />
<!-- 重新设计的视图切换按钮 -->
<div class="view-toggle-container">
<div class="view-toggle-buttons">
<button class="view-toggle-btn" :class="{ active: viewType === 'card' }" @click="changeViewType('card')">
<VIcon icon="mdi-view-grid-outline" :color="viewType === 'card' ? 'primary' : undefined" />
</button>
<button class="view-toggle-btn" :class="{ active: viewType === 'row' }" @click="changeViewType('row')">
<VIcon icon="mdi-view-list-outline" :color="viewType === 'row' ? 'primary' : undefined" />
</button>
</div>
</div>
</div>
<!-- 视图切换加载状态 -->
<VFadeTransition>
<div v-if="isRefreshed && isViewChanging" class="view-changing-container">
<div class="view-changing-content">
<div class="pulse-loader">
<div class="pulse-circle"></div>
<div class="pulse-circle"></div>
<div class="pulse-circle"></div>
</div>
<div class="view-changing-text">切换视图</div>
</div>
</div>
</VFadeTransition>
<!-- 搜索结果 -->
<div v-if="isRefreshed && dataList.length > 0 && !isViewChanging" class="search-results-container">
<!-- 卡片视图模式 -->
<VFadeTransition>
<TorrentCardListView v-if="viewType === 'card'" :items="dataList" />
</VFadeTransition>
<!-- 列表视图模式 -->
<VFadeTransition>
<TorrentRowListView v-if="viewType === 'row'" :items="dataList" />
</VFadeTransition>
</div>
<!-- 无数据显示 -->
<div v-else-if="isRefreshed && !isViewChanging" class="d-flex flex-column align-center justify-center py-8">
<NoDataFound
:errorTitle="errorTitle"
:errorDescription="errorDescription"
/>
<VBtn class="mt-4" color="primary" prepend-icon="mdi-magnify" to="/"> 返回首页 </VBtn>
</div>
<!-- 初始加载状态 -->
<div v-else-if="!isRefreshed && !progressValue" class="initial-loading-container">
<div class="initial-loading-content">
<div class="wave-loader">
<div class="wave-dot"></div>
<div class="wave-dot"></div>
<div class="wave-dot"></div>
<div class="wave-dot"></div>
</div>
<div class="initial-loading-text">搜索中</div>
</div>
</div>
</div>
</template>
<style scoped>
.search-progress-container {
position: fixed;
top: env(safe-area-inset-top);
left: 0;
right: 0;
z-index: 100;
display: flex;
justify-content: center;
padding-top: 4rem;
}
.search-progress-card {
max-width: 400px;
width: 90%;
background-color: rgb(var(--v-theme-surface));
border-radius: 12px;
padding: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(var(--v-theme-primary), 0.1);
backdrop-filter: blur(10px);
}
.progress-header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.progress-title {
font-size: 0.9rem;
font-weight: 500;
color: rgb(var(--v-theme-on-surface));
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 12px;
}
.progress-bar-wrapper {
flex: 1;
height: 4px;
background-color: rgba(var(--v-theme-on-surface), 0.08);
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(
90deg,
rgb(var(--v-theme-primary)) 0%,
rgb(var(--v-theme-primary)) 70%,
rgba(var(--v-theme-primary), 0.8) 100%
);
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-percentage {
font-size: 0.8rem;
font-weight: 600;
color: rgb(var(--v-theme-primary));
min-width: 36px;
text-align: right;
}
/* 精简标题栏样式 */
.search-header {
padding: 12px 16px;
background-color: rgb(var(--v-theme-surface));
border-radius: 12px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.search-info-container {
gap: 12px;
}
.search-title {
font-size: 1.1rem;
font-weight: 600;
}
.search-tags {
gap: 8px;
}
.search-tag {
font-size: 0.75rem;
}
/* 重新设计的视图切换按钮 */
.view-toggle-container {
position: relative;
}
.view-toggle-buttons {
display: flex;
background-color: rgba(var(--v-theme-surface-variant), 0.1);
border-radius: 8px;
padding: 4px;
}
.view-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 36px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
}
.view-toggle-btn.active {
background-color: rgb(var(--v-theme-surface));
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.view-toggle-btn:hover:not(.active) {
background-color: rgba(var(--v-theme-primary), 0.05);
}
/* 视图切换加载状态 */
.view-changing-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(var(--v-theme-background), 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
backdrop-filter: blur(8px);
}
.view-changing-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pulse-loader {
display: flex;
gap: 8px;
}
.pulse-circle {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: rgb(var(--v-theme-primary));
animation: pulse 1.2s ease-in-out infinite;
}
.pulse-circle:nth-child(2) {
animation-delay: 0.2s;
}
.pulse-circle:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes pulse {
0%,
100% {
transform: scale(0.8);
opacity: 0.5;
}
50% {
transform: scale(1.2);
opacity: 1;
}
}
.view-changing-text {
font-size: 0.9rem;
font-weight: 500;
color: rgb(var(--v-theme-primary));
letter-spacing: 1px;
}
/* 初始的加载状态 */
.initial-loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 50vh;
}
.initial-loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.wave-loader {
display: flex;
align-items: center;
gap: 6px;
height: 40px;
}
.wave-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgb(var(--v-theme-primary));
animation: wave 1.5s ease-in-out infinite;
}
.wave-dot:nth-child(1) {
animation-delay: 0s;
}
.wave-dot:nth-child(2) {
animation-delay: 0.2s;
}
.wave-dot:nth-child(3) {
animation-delay: 0.4s;
}
.wave-dot:nth-child(4) {
animation-delay: 0.6s;
}
@keyframes wave {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-15px);
}
}
.initial-loading-text {
font-size: 0.9rem;
font-weight: 500;
color: rgb(var(--v-theme-primary));
letter-spacing: 1px;
}
.search-results-container {
min-height: 50vh;
position: relative;
}
@media (max-width: 600px) {
.search-header {
padding: 8px 12px;
}
.search-title {
font-size: 0.95rem;
white-space: nowrap;
}
.search-info-container {
flex: 1;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.search-tags {
overflow-x: auto;
flex-wrap: nowrap;
scrollbar-width: none;
margin-right: 8px;
}
.search-tags::-webkit-scrollbar {
display: none;
}
.view-toggle-container {
flex-shrink: 0;
}
.view-toggle-buttons {
padding: 2px;
}
.view-toggle-btn {
width: 36px;
height: 32px;
}
}
</style>

7
src/pages/workflow.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import WorkflowListView from '@/views/workflow/WorkflowListView.vue'
</script>
<template>
<WorkflowListView />
</template>

View File

@@ -67,9 +67,9 @@ const theme: VuetifyOptions['theme'] = {
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#111827',
'background': '#0E1116',
'on-background': '#E7E3FC',
'surface': '#161D2C',
'surface': '#14161F',
'on-surface': '#E7E3FC',
'grey-50': '#2A2E42',
'grey-100': '#474360',
@@ -87,7 +87,7 @@ const theme: VuetifyOptions['theme'] = {
},
variables: {
'code-color': '#d400ff',
'overlay-scrim-background': '#1F2937',
'overlay-scrim-background': '#191D21',
'overlay-scrim-opacity': 0.6,
'hover-opacity': 0.04,
'focus-opacity': 0.1,
@@ -96,7 +96,7 @@ const theme: VuetifyOptions['theme'] = {
'pressed-opacity': 0.14,
'dragged-opacity': 0.1,
'border-color': '#E7E3FC',
'table-header-background': '#1F2937',
'table-header-background': '#14161F',
'custom-background': '#373452',
// Shadows
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',

View File

@@ -1,6 +1,6 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { configureNProgress } from '@/api/nprogress'
import store from '@/store'
import { useAuthStore } from '@/stores'
// Nprogress
configureNProgress()
@@ -28,8 +28,8 @@ const router = createRouter({
},
},
{
path: '/ranking',
component: () => import('../pages/ranking.vue'),
path: '/recommend',
component: () => import('../pages/recommend.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
@@ -68,6 +68,14 @@ const router = createRouter({
subType: '电视剧',
},
},
{
path: '/workflow',
component: () => import('../pages/workflow.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: '/calendar',
component: () => import('../pages/calendar.vue'),
@@ -88,6 +96,7 @@ const router = createRouter({
component: () => import('../pages/history.vue'),
meta: {
requiresAuth: true,
hideFooter: true,
},
},
{
@@ -170,6 +179,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
hideFooter: true,
},
},
{
@@ -215,9 +225,11 @@ function abortAllControllers() {
// 路由导航守卫
router.beforeEach((to: any, from: any, next: any) => {
// 认证 Store
const authStore = useAuthStore()
// 总是记录非login路由
if (to.fullPath != '/login') store.state.auth.originalPath = to.fullPath
const isAuthenticated = store.state.auth.token !== null
if (to.fullPath != '/login') authStore.originalPath = to.fullPath
const isAuthenticated = authStore.token !== null
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else {

View File

@@ -18,7 +18,7 @@ export const SystemNavMenus = [
{
title: '推荐',
icon: 'mdi-star-outline',
to: '/ranking',
to: '/recommend',
header: '发现',
admin: false,
footer: true,
@@ -49,6 +49,16 @@ export const SystemNavMenus = [
admin: false,
footer: true,
},
{
title: '工作流',
full_title: '工作流',
icon: 'mdi-state-machine',
to: '/workflow',
header: '订阅',
admin: true,
footer: false,
},
{
title: '日历',
full_title: '订阅日历',
@@ -222,17 +232,17 @@ export const PluginTabs = [
// 发现标签页
export const DiscoverTabs = [
{
title: 'TheMovieDb',
name: 'TheMovieDb',
tab: 'themoviedb',
icon: 'themoviedb',
},
{
title: '豆瓣',
name: '豆瓣',
tab: 'douban',
icon: 'douban',
},
{
title: 'Bangumi',
name: 'Bangumi',
tab: 'bangumi',
icon: 'bangumi',
},

View File

@@ -1,96 +0,0 @@
import type { Module } from 'vuex'
// 定义状态类型
interface AuthState {
token: string | null
remember: boolean
superUser: boolean
userID: number
userName: string
avatar: string
originalPath: string | null
level: number
permissions: { [key: string]: any }
}
// 定义根状态类型
interface RootState {
auth: AuthState
}
// 用户信息模块
const authModule: Module<AuthState, RootState> = {
namespaced: true,
state: {
token: null, // 用户令牌
remember: false, // 记住我
superUser: false, // 超级管理员
userID: 999, // 用户ID
userName: '', // 用户名
avatar: '', // 头像
originalPath: null, // 原始路径
level: 1, // 用户认证等级 1-未认证 2-已认证
permissions: {},
},
mutations: {
setToken(state, token: string) {
state.token = token
},
clearToken(state) {
state.token = null
},
setRemember(state, remember: boolean) {
state.remember = remember
},
setSuperUser(state, superUser: boolean) {
state.superUser = superUser
},
setUserID(state, userID: number) {
state.userID = userID
},
setUserName(state, userName: string) {
state.userName = userName
},
setAvatar(state, avatar: string) {
state.avatar = avatar
},
setOriginalPath(state, originalPath: string) {
state.originalPath = originalPath
},
setLevel(state, level: number) {
state.level = level
},
setPermissions(state, permissions: object) {
state.permissions = permissions
},
},
actions: {
login({ commit }, { token, remember, superUser, userID, userName, avatar, level, permissions }) {
commit('setToken', token)
commit('setRemember', remember)
commit('setSuperUser', superUser)
commit('setUserID', userID)
commit('setUserName', userName)
commit('setAvatar', avatar)
commit('setLevel', level)
commit('setPermissions', permissions)
},
logout({ commit }) {
commit('clearToken')
commit('setOriginalPath', null)
},
},
getters: {
getToken: state => state.token,
getRemember: state => state.remember,
getSuperUser: state => state.superUser,
getUserID: state => state.userID,
getUserName: state => state.userName,
getAvatar: state => state.avatar,
getOriginalPath: state => state.originalPath,
getLevel: state => state.level,
getPermissions: state => state.permissions,
},
}
export default authModule

View File

@@ -1,19 +0,0 @@
import { createStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate'
import authModule from './auth'
const store = createStore({
modules: {
// 用户认证store
auth: authModule,
},
plugins: [
createPersistedState({
// 配置持久化存储的选项
storage: window.localStorage, // 使用 localStorage 存储状态
key: 'moviepilot', // 存储的键名
}),
],
})
export default store

42
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import type { authState } from '@/stores/types'
export const useAuthStore = defineStore('auth', {
state: (): authState => ({
token: null,
remember: false,
originalPath: null,
}),
// 全局持久化
persist: true,
actions: {
setToken(token: string | null) {
this.token = token
},
clearToken() {
this.token = null
},
setRemember(remember: boolean) {
this.remember = remember
},
setOriginalPath(originalPath: string | null) {
this.originalPath = originalPath
},
login(payload: authState) {
this.setToken(payload.token)
this.setRemember(payload.remember)
},
logout() {
this.clearToken()
this.setOriginalPath(null)
},
},
getters: {
getToken: state => state.token,
getRemember: state => state.remember,
getOriginalPath: state => state.originalPath,
},
})

16
src/stores/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
// 创建 Pinia 实例
const pinia = createPinia()
// 使用持久化插件
pinia.use(piniaPluginPersistedstate)
export default pinia
// 所有的 store
import { useAuthStore } from './auth'
import { useUserStore } from './user'
export { useAuthStore, useUserStore }

23
src/stores/types.ts Normal file
View File

@@ -0,0 +1,23 @@
export interface authState {
// 用户令牌
token: string | null
// 记住我
remember: boolean
// 原始路径
originalPath?: string | null
}
export interface userState {
// 是否属于超级管理员
superUser: boolean
// 用户ID
userID: number
// 用户名
userName: string
// 头像
avatar: string
// 用户认证等级 1-未认证 2-已认证
level: number
// 权限
permissions: { [key: string]: any }
}

62
src/stores/user.ts Normal file
View File

@@ -0,0 +1,62 @@
import { defineStore } from 'pinia'
import type { userState } from '@/stores/types'
export const useUserStore = defineStore('user', {
state: (): userState => ({
superUser: false,
userID: -1,
userName: '',
avatar: '',
level: 1,
permissions: {},
}),
// 全局持久化
persist: true,
actions: {
setSuperUser(superUser: boolean) {
this.superUser = superUser
},
setUserID(userID: number) {
this.userID = userID
},
setUserName(userName: string) {
this.userName = userName
},
setAvatar(avatar: string) {
this.avatar = avatar
},
setLevel(level: number) {
this.level = level
},
setPermissions(permissions: object) {
this.permissions = permissions
},
loginUser(payload: userState) {
this.setSuperUser(payload.superUser)
this.setUserID(payload.userID)
this.setUserName(payload.userName)
this.setAvatar(payload.avatar)
this.setLevel(payload.level)
this.setPermissions(payload.permissions)
},
reset() {
this.setSuperUser(false)
this.setUserID(-1)
this.setUserName('')
this.setAvatar('')
this.setLevel(1)
this.setPermissions({})
},
},
getters: {
getSuperUser: state => state.superUser,
getUserID: state => state.userID,
getUserName: state => state.userName,
getAvatar: state => state.avatar,
getLevel: state => state.level,
getPermissions: state => state.permissions,
},
})

View File

@@ -3,19 +3,45 @@
@tailwind components;
@tailwind utilities;
html.v-overlay-scroll-blocked {
position: relative;
position: fixed;
}
html.v-overlay-scroll-blocked body {
--v-body-scroll-y: 0px !important;
}
@media (max-width: 768px){
html.v-overlay-scroll-blocked {
position: relative;
}
}
@mixin hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
@media (max-width: 768px) {
html,body {
@include hide-scrollbar;
}
}
#nprogress .bar {
background: rgb(var(--v-theme-primary)) !important;
inset-block-start: env(safe-area-inset-top) !important;
}
#nprogress .peg {
width: 5px;
box-shadow: 0 0 10px rgb(var(--v-theme-primary)), 0 0 5px rgb(var(--v-theme-primary)) !important;
transform: rotate(0deg) translate(0, -1px);
transform: rotate(0deg) translate(0, 0px);
}
.v-toast--bottom {
@@ -32,6 +58,10 @@ html.v-overlay-scroll-blocked {
margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
}
.v-bottom-sheet > .v-bottom-sheet__content.v-overlay__content > .v-card {
padding-block-end: env(safe-area-inset-bottom);
}
.v-dialog--fullscreen > .v-overlay__content > .v-card {
padding-block-end: calc(env(safe-area-inset-top) + env(safe-area-inset-bottom));
}

View File

@@ -43,6 +43,10 @@ async function loadMediaStatistic() {
onMounted(() => {
loadMediaStatistic()
})
onActivated(() => {
loadMediaStatistic()
})
</script>
<template>

View File

@@ -36,6 +36,10 @@ async function getStorage() {
onMounted(() => {
getStorage()
})
onActivated(() => {
getStorage()
})
</script>
<template>

View File

@@ -2,12 +2,13 @@
import { useTheme } from 'vuetify'
import api from '@/api'
import { hexToRgb } from '@layouts/utils'
import { useUserStore } from '@/stores'
const vuetifyTheme = useTheme()
// 从Vuex Store中获取信息
const store = useStore()
const superUser = store.state.auth.superUser
// 用户 Store
const userStore = useUserStore()
const superUser = userStore.superUser
const options = controlledComputed(
() => vuetifyTheme.name.value,
@@ -112,6 +113,10 @@ async function getWeeklyData() {
onMounted(() => {
getWeeklyData()
})
onActivated(() => {
getWeeklyData()
})
</script>
<template>

View File

@@ -33,12 +33,21 @@ async function loadLatest(server: string) {
}
}
onMounted(async () => {
// 加载数据
async function loadData() {
await loadMediaServerSetting()
const enabledServers = mediaServers.value.filter(server => server.enabled)
for (const server of enabledServers) {
loadLatest(server.name)
}
}
onMounted(() => {
loadData()
})
onActivated(() => {
loadData()
})
</script>

View File

@@ -26,19 +26,32 @@ async function loadLibrary(server: string) {
params: { server: server, hidden: true },
})
if (result && result.length > 0) {
libraryList.value = libraryList.value.concat(result)
// 不存在时添加
for (const item of result) {
const index = libraryList.value.findIndex(i => i.id === item.id)
if (index === -1) libraryList.value.push(item)
}
}
} catch (e) {
console.log(e)
}
}
onMounted(async () => {
// 加载数据
async function loadData() {
await loadMediaServerSetting()
const enabledServers = mediaServers.value.filter(server => server.enabled)
for (const server of enabledServers) {
loadLibrary(server.name)
}
}
onMounted(() => {
loadData()
})
onActivated(() => {
loadData()
})
</script>

View File

@@ -24,19 +24,34 @@ async function loadPlayingList(server: string) {
try {
const result: MediaServerPlayItem[] = await api.get('mediaserver/playing', { params: { server } })
if (result && result.length > 0) {
playingList.value = playingList.value.concat(result)
// 不存在时添加
for (const item of result) {
const index = playingList.value.findIndex(i => i.id === item.id)
if (index === -1) {
playingList.value.push(item)
}
}
}
} catch (e) {
console.log(e)
}
}
onMounted(async () => {
// 加载数据
async function loadData() {
await loadMediaServerSetting()
const enabledServers = mediaServers.value.filter(server => server.enabled)
for (const server of enabledServers) {
loadPlayingList(server.name)
}
}
onMounted(() => {
loadData()
})
onActivated(() => {
loadData()
})
</script>

View File

@@ -102,6 +102,6 @@ watch([filterParams], () => {
</div>
</div>
<div>
<MediaCardListView :key="currentKey" apipath="bangumi/subjects" :params="filterParams" />
<MediaCardListView :key="currentKey" apipath="discover/bangumi" :params="filterParams" />
</div>
</template>

View File

@@ -197,6 +197,6 @@ watch([type, filterParams], () => {
</div>
</div>
<div>
<MediaCardListView :key="currentKey" :apipath="`douban/${type}`" :params="filterParams" />
<MediaCardListView :key="currentKey" :apipath="`discover/douban_${type}`" :params="filterParams" />
</div>
</template>

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