Compare commits

..

158 Commits

Author SHA1 Message Date
jxxghp
af287f50bb 更新 Vite 配置,添加 dummy 远程模块,简化共享库列表,提升代码可读性和维护性。 2025-05-08 23:54:34 +08:00
jxxghp
3199392637 优化 Vite 配置,更新共享库列表,移除不必要的 Rollup 选项,并简化远程模块加载逻辑,提升代码可读性和维护性。 2025-05-08 23:32:13 +08:00
jxxghp
4e3a61b8a8 增强共享作用域管理,添加初始化和dummy远程模块功能,防止生产环境中的共享作用域问题,同时为远程模块URL添加版本标记以防止缓存。 2025-05-08 23:08:48 +08:00
jxxghp
3b1e65fc75 修复 Vite 配置中的压缩选项,将 drop_console 设置为 false,以保留控制台日志。 2025-05-08 22:53:56 +08:00
jxxghp
32b4b944cc fix remoteEntry url 2025-05-08 22:42:20 +08:00
jxxghp
22a51a524e fix ui 2025-05-08 19:48:35 +08:00
jxxghp
ac0cbbdb95 Merge pull request #331 from Seed680/v2 2025-05-08 17:50:06 +08:00
qiaoyun680
2260f23d3c feat(storge): 添加存储重置选项 2025-05-08 17:39:36 +08:00
jxxghp
d43952c0bf 优化 Vuetify 实例配置,添加默认主题和主题设置,以提升主题管理的灵活性和可维护性。 2025-05-08 17:32:28 +08:00
jxxghp
bd368123d2 更新 package.json 中的版本号,从 2.4.4-1 升级至 2.4.5 2025-05-08 17:26:31 +08:00
jxxghp
cbdd70427e fix 联邦组件样式冲突问题 2025-05-08 17:24:04 +08:00
jxxghp
d7526f5283 移除 Vite 配置中的 PostCSS 插件设置,并在 SiteCard 组件中为按钮添加统一的大小属性,以提升界面一致性。 2025-05-08 16:09:32 +08:00
jxxghp
08e914a968 优化 SiteCard 组件,调整按钮样式和图标大小,提升界面一致性和可读性。 2025-05-08 15:53:32 +08:00
jxxghp
53a8835b6d 更新 Tailwind CSS 配置,添加重要性设置;在 Vite 配置中添加 PostCSS 插件;优化多个组件的 VCard 结构,移除多余的 class 属性以提升代码整洁性。 2025-05-08 15:31:57 +08:00
jxxghp
e3bff71a91 优化 Vue 渲染模式下的组件结构,将动态组件包裹在 VCard 和 VCardText 中,以提升布局一致性和可读性,同时在 DashboardElement.vue 中为动态插件组件添加 API 属性。 2025-05-08 14:48:33 +08:00
jxxghp
6276009e88 修复文件浏览器工具栏中的“向上”按钮,仅在路径段存在时显示 2025-05-08 13:13:49 +08:00
jxxghp
ddc5320f71 fix https://github.com/jxxghp/MoviePilot/issues/4211#issuecomment-2858674237 订阅排序相互干扰的问题 2025-05-08 10:35:07 +08:00
jxxghp
15af66aaaf fix 资源筛选 2025-05-08 07:11:29 +08:00
jxxghp
fe7a080553 更新插件组件的依赖版本 2025-05-07 17:25:59 +08:00
jxxghp
66bfc3e868 更新 Vite 配置 2025-05-07 17:16:39 +08:00
jxxghp
93aa3fb95d 更新示例 2025-05-07 16:20:28 +08:00
jxxghp
4f5caf1712 更新 module-federation-guide.md 2025-05-07 15:45:42 +08:00
jxxghp
9d27e967cd 优化示例UI 2025-05-07 14:49:24 +08:00
jxxghp
eb3e035a7c 更新 Vite 配置以支持 vuetify/styles 的版本要求,优化共享依赖配置 2025-05-07 14:34:10 +08:00
jxxghp
04200e94ff 移除 Config.vue 和 Dashboard.vue 中的样式,更新 Page.vue 以使用 API 获取最近记录,增强组件功能和代码整洁性。 2025-05-07 13:28:42 +08:00
jxxghp
ae9a13e0fa 添加“综合筛选”和“清除全部”功能 2025-05-07 12:22:54 +08:00
jxxghp
df8857fb52 fix example 2025-05-07 10:56:28 +08:00
jxxghp
9642fed1f1 移除 DashboardItem 接口中的 component_url 属性,简化类型定义。 2025-05-07 10:56:28 +08:00
jxxghp
1a273ea2d6 更新 module-federation-guide.md 2025-05-07 09:32:41 +08:00
jxxghp
c0ba921a7e 更新 Config.vue 2025-05-07 09:31:58 +08:00
jxxghp
8bbad227eb 更新 Page.vue 2025-05-07 09:31:29 +08:00
jxxghp
d3f9c04209 更新插件组件文档,调整多个组件以支持关闭功能,增强用户交互体验,并修正配置示例以反映最新的代码结构和依赖关系。 2025-05-07 08:21:44 +08:00
jxxghp
d3a6703a77 添加关闭页面的功能,更新多个组件以支持新的事件通知机制,并调整文档以反映最新的功能变化,提升用户交互体验。 2025-05-07 06:59:53 +08:00
jxxghp
1100fa47be 更新 PluginConfigDialog.vue 2025-05-07 00:25:19 +08:00
jxxghp
1e33087786 更新插件组件文档 2025-05-07 00:05:25 +08:00
jxxghp
e59423e912 将样式标签修改为 scoped,以提高样式的局部作用域,增强组件的样式隔离性。 2025-05-07 00:00:02 +08:00
jxxghp
146a1fe23d 更新多个组件以支持新的事件通知机制,添加切换到配置页面的功能,调整文档以反映组件文件名的变化,提升用户交互体验。 2025-05-06 23:56:28 +08:00
jxxghp
4586f6982a 优化多个组件的远程加载逻辑,移除不必要的属性绑定,增强错误处理机制,提升用户体验。 2025-05-06 23:31:37 +08:00
jxxghp
703204c69a 优化 Vite 配置,移除不再使用的代理规则,更新多个组件以增强远程组件加载逻辑,添加错误处理和加载状态显示,提升用户体验。 2025-05-06 21:34:49 +08:00
jxxghp
05cc160311 在 Vite 配置中添加新的代理规则,支持 '/plugin_static' 路径的请求转发至本地 API 2025-05-06 13:16:35 +08:00
jxxghp
0568f8a85d 更新 Vite 配置,修改主机名称为 'MoviePilot',优化插件配置,调整共享依赖格式以简化配置。 2025-05-06 13:00:36 +08:00
jxxghp
36b113ef1c 更新 module-federation-guide.md 2025-05-06 11:58:01 +08:00
jxxghp
520180f6f5 更新模块联邦文档,调整远程组件API路径格式,优化组件加载逻辑,移除不必要的注册步骤,增强代码可读性。 2025-05-06 11:44:08 +08:00
jxxghp
d349d2b500 增强模块联邦支持,添加动态导入远程模块的声明,更新示例项目以展示新组件结构和配置,调整 Vite 配置以支持更灵活的远程组件加载。 2025-05-06 08:53:33 +08:00
jxxghp
643ca35aed 增强远程组件注册机制,添加动态注册和获取功能,更新相关文档以指导插件开发者。调整多个组件以支持新注册逻辑。 2025-05-05 22:13:07 +08:00
jxxghp
36ef7ba589 更新 README 文档,调整开发和配置部分的标题格式,以提高可读性和一致性。 2025-05-05 21:44:22 +08:00
jxxghp
b5761bd18d 更新组件声明,移除 LocaleSwitcher 和 ThemeSwitcher,更新 README 文档以增强模块联邦功能的描述,并调整 Vite 配置以支持 ESNext 目标。 2025-05-05 21:41:03 +08:00
jxxghp
047e827884 添加 @originjs/vite-plugin-federation 依赖,并在多个组件中实现远程组件加载功能 2025-05-05 21:26:53 +08:00
jxxghp
48828fd72d 更新消息模板标题,添加国际化支持 2025-05-05 20:34:13 +08:00
jxxghp
3f4165e4b1 更新国际化文件,添加消息模板相关文本,并优化模板编辑器界面 2025-05-05 20:31:15 +08:00
jxxghp
6d789ed73b Merge pull request #330 from wikrin/template
feat(setting): 添加消息推送自定义选项
2025-05-05 19:30:42 +08:00
Attente
e77297f7b2 feat(setting): 添加消息推送自定义选项
- wikrin/MoviePilot@20c1f30
2025-05-05 05:43:48 +08:00
jxxghp
bb52a4704f 更新 zh-TW.ts 2025-05-04 14:44:13 +08:00
jxxghp
127df15674 更新 zh-CN.ts 2025-05-04 14:43:45 +08:00
jxxghp
95bcc263e8 更新 en-US.ts 2025-05-04 14:43:04 +08:00
jxxghp
20b120c247 在 PluginConfigDialog.vue 和 PluginDataDialog.vue 中,将错误提示从 $toast 更改为 console.error,以便更好地调试组件加载失败的情况,并在错误信息中添加详细的错误描述。 2025-05-03 23:29:24 +08:00
jxxghp
e644f6bacc 更新 DashboardElement.vue 2025-05-03 22:55:03 +08:00
jxxghp
5e9c7124ce 更新 PluginConfigDialog.vue 2025-05-03 22:54:19 +08:00
jxxghp
84e121bc0e 更新 PluginDataDialog.vue 2025-05-03 22:53:32 +08:00
jxxghp
abff2071bd 在多个组件中优化动态加载逻辑,使用 API 获取组件并处理加载失败情况,以提升用户体验和代码健壮性 2025-05-03 22:31:38 +08:00
jxxghp
078afd5174 在多语言文件中添加管理员和用户ID列表的占位符文本,以提升用户输入体验 2025-05-03 21:36:38 +08:00
jxxghp
4a8cf16012 在多个组件中添加渲染模式支持,优化插件配置和数据加载逻辑,增强用户体验和代码可读性 2025-05-03 10:04:50 +08:00
jxxghp
04e9b68e4a 在 custom.scss 中添加 .match-height.v-row 样式,确保 .v-card 组件的高度为 100%,以提升布局一致性 2025-05-02 22:03:17 +08:00
jxxghp
f12c3dac9f 在 FileBrowser.vue 组件中,更新存储图标逻辑,添加默认图标以提升用户体验 2025-05-02 21:50:08 +08:00
jxxghp
73b9663b27 优化 resource.vue 中搜索标题栏的样式,调整字体类名和布局,以提升界面美观性和一致性 2025-05-02 21:14:24 +08:00
jxxghp
a73068069c 调整 TorrentItem.vue 组件,修改图标和名称的布局样式,以提升界面美观性和可读性 2025-05-02 21:07:39 +08:00
jxxghp
2f963ba7ab 调整 resource.vue 中搜索标题的字体大小,从 1.5rem 修改为 1.2rem,以改善界面一致性 2025-05-02 20:50:31 +08:00
jxxghp
9df70e5485 优化 StorageCard.vue 组件,简化存储信息显示逻辑,提升代码可读性 2025-05-02 20:43:37 +08:00
jxxghp
dfa34f090b 更新 resource.vue 2025-05-02 20:26:04 +08:00
jxxghp
388e9987cd 更新 resource.vue 2025-05-02 20:17:28 +08:00
jxxghp
9fe434849c 优化用户通知组件,增加通知列表容器样式以支持滚动,提升用户体验 2025-05-02 13:28:59 +08:00
jxxghp
95edaa99b6 更新国际化文本,将“类型”和“内容”替换为“渠道”和“用户ID”以提升用户体验 2025-05-02 12:09:14 +08:00
jxxghp
b3501d791e 更新 StorageCard.vue 2025-05-02 09:57:16 +08:00
jxxghp
5f2e93dde3 添加下载器和媒体服务器选项,重构相关组件以支持新功能,并更新国际化文本以提升用户体验 2025-05-02 08:01:19 +08:00
jxxghp
bf22d7f5e9 更新 package.json 2025-05-01 22:44:35 +08:00
jxxghp
08bbe8d841 优化文件重命名对话框,添加“自动识别名称”按钮,并更新国际化文本以支持新功能 2025-05-01 20:27:56 +08:00
jxxghp
572293bb4d 重构存储选项,更新为存储属性,优化存储字典和图标字典的生成逻辑,提升组件对存储配置的支持 2025-05-01 19:56:06 +08:00
jxxghp
f56d1c68c7 更新存储卡组件,添加自定义存储配置对话框,优化存储名称显示,并完善国际化文本 2025-05-01 19:35:08 +08:00
jxxghp
900dd6e958 添加自定义存储选项,更新存储卡组件以支持关闭事件,并完善国际化文本 2025-05-01 13:51:13 +08:00
jxxghp
5327c04e7e 添加自定义下载器和媒体服务器选项 2025-05-01 13:41:02 +08:00
jxxghp
f1835dd46c 更新验证器:为必填项和数字输入添加国际化支持,提升用户体验 2025-04-30 10:57:35 +08:00
jxxghp
9b620a760d 更新 FullCalendarView.vue 2025-04-30 06:50:31 +08:00
jxxghp
530174ff79 在README.md中添加语言切换链接,支持中文和英文版本,提升用户友好性 2025-04-29 19:47:01 +08:00
jxxghp
b6bb3691f0 优化消息对话框和消息视图组件:增加消息对话框和滚动容器的引用,调整滚动逻辑以确保更流畅的用户体验,更新样式以改善消息显示效果 2025-04-29 17:28:13 +08:00
jxxghp
6fd5e30fdc 优化对话框和消息视图组件:调整消息对话框最大宽度,增加全屏支持,更新消息内容引用,修改消息项的样式,提升用户体验 2025-04-29 17:08:55 +08:00
jxxghp
ba09afb744 调整对话框最大高度:将PluginCard和SearchBarDialog组件中的对话框最大高度设置为85vh,优化用户界面体验 2025-04-29 15:49:24 +08:00
jxxghp
d04aea6067 更新国际化支持:将壁纸项中的“Bing每日图片”替换为“媒体服务器”,提升用户体验 2025-04-29 15:26:26 +08:00
jxxghp
4ff9be458c 更新fetchGlobalSettings函数:在API请求中添加token参数以增强安全性 2025-04-29 14:58:45 +08:00
jxxghp
6f5dbe5808 优化图片地址获取逻辑:在使用图片缓存时增加登录状态检查,移除对doubanio.com的代理处理 2025-04-29 13:36:36 +08:00
jxxghp
b772e2d9ef 更新国际化支持:为存储、媒体类型、通知开关及操作步骤等组件添加多语言文本,提升用户体验 2025-04-29 13:24:27 +08:00
jxxghp
b75c93231e 更新 discover.vue 2025-04-29 12:19:25 +08:00
jxxghp
ca20931ed6 更新国际化支持:为目录相关组件添加多语言文本,提升用户体验 2025-04-29 11:55:43 +08:00
jxxghp
893df36c9d 更新国际化支持:为过滤媒体和资源组件添加多语言文本,提升用户体验 2025-04-29 11:51:45 +08:00
jxxghp
2a6abded08 更新国际化支持:为下载列表和工作流组件添加多语言文本,提升用户体验 2025-04-29 10:30:48 +08:00
jxxghp
675cdd5bba 更新国际化支持:为发现页面、插件卡片列表和下载列表等组件添加多语言文本,提升用户体验 2025-04-29 08:45:59 +08:00
jxxghp
b0150f25f6 更新类型定义:在auto-imports.d.ts中添加Slot和createRef类型,增强Vue组件的类型支持 2025-04-29 08:29:16 +08:00
jxxghp
87cda220ad 更新国际化支持:为豆瓣相关组件及字典添加多语言文本,提升用户体验 2025-04-29 08:29:05 +08:00
jxxghp
ce90ed84f6 更新国际化支持:为Bangumi和TMDB相关组件添加多语言文本,提升用户体验 2025-04-29 08:25:20 +08:00
jxxghp
2ae843fb3e 更新国际化支持:为工作流侧边栏及相关组件添加多语言文本,提升用户体验 2025-04-29 08:15:19 +08:00
jxxghp
48513efbe0 更新国际化支持:为工作流组件及相关对话框添加多语言文本,提升用户体验 2025-04-29 07:16:33 +08:00
jxxghp
83cb69b794 更新国际化支持:为订阅季节对话框及相关组件添加多语言文本,提升用户体验 2025-04-28 22:17:43 +08:00
jxxghp
7879a75ba8 更新国际化支持:为订阅历史对话框及相关组件添加多语言文本,提升用户体验 2025-04-28 22:14:24 +08:00
jxxghp
4682cdb1a8 更新国际化支持:为站点资源对话框及相关组件添加多语言文本,提升用户体验 2025-04-28 22:10:30 +08:00
jxxghp
b228246508 更新国际化支持:为站点Cookie更新对话框及相关组件添加多语言文本,提升用户体验 2025-04-28 22:04:41 +08:00
jxxghp
021e0b34f0 更新国际化支持:为插件配置对话框及相关组件添加多语言文本,提升用户体验 2025-04-28 22:02:14 +08:00
jxxghp
2182b3f325 更新国际化支持:为用户卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 21:59:08 +08:00
jxxghp
b5fbf7ccd8 更新国际化支持:为订阅卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 21:55:44 +08:00
jxxghp
17b8f9bddd 更新国际化支持:为存储卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 21:53:46 +08:00
jxxghp
09229ad5ef 更新国际化支持:为站点卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 21:52:04 +08:00
jxxghp
3dbfa750c9 更新国际化支持:为插件卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 20:47:21 +08:00
jxxghp
c14dfe0bee 更新国际化支持:为通知渠道卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 20:34:37 +08:00
jxxghp
fa6ba8b1fc 更新国际化支持:修正媒体类型文本函数名称并添加通知开关相关文本,提升用户体验 2025-04-28 20:29:11 +08:00
jxxghp
8854affc4c 更新国际化支持:为工作流任务卡片添加多语言文本,提升用户体验 2025-04-28 20:07:41 +08:00
jxxghp
995e07c351 更新国际化支持:为媒体服务器卡片和相关组件添加多语言文本,提升用户体验 2025-04-28 20:05:49 +08:00
jxxghp
40711fa640 更新国际化支持:为下载器和过滤规则组件添加多语言文本,提升用户体验 2025-04-28 19:54:01 +08:00
jxxghp
99212c1186 更新国际化支持:为自定义规则卡片添加多语言文本,提升用户体验 2025-04-28 19:32:04 +08:00
jxxghp
434543ce41 更新国际化支持:为转移历史视图添加多语言文本,增强用户体验 2025-04-28 17:46:58 +08:00
jxxghp
b6b19f628c 优化国际化支持:移除动态加载语言文件的代码,简化语言设置逻辑 2025-04-28 17:26:42 +08:00
jxxghp
bc841a630f 更新国际化支持:为种子列表和卡片视图添加多语言文本,优化用户体验 2025-04-28 17:19:59 +08:00
jxxghp
6f78e8196b 更新国际化支持:为仪表板各组件添加多语言文本,优化用户体验 2025-04-28 16:57:06 +08:00
jxxghp
f3af10e93e 更新国际化支持:为媒体详情视图添加多语言文本,增强用户体验 2025-04-28 16:37:49 +08:00
jxxghp
149403e5c0 更新国际化支持:将多个组件中的文本替换为国际化支持 2025-04-28 16:25:45 +08:00
jxxghp
b24c29b217 更新背景图片API路径 2025-04-28 15:56:18 +08:00
jxxghp
43460d4198 删除语言和主题切换组件,整合相关功能至用户个人资料组件中 2025-04-28 15:43:07 +08:00
jxxghp
6be4694327 更新国际化支持 2025-04-28 15:12:07 +08:00
jxxghp
308a8ab30d 优化推荐页面的列表渲染 2025-04-28 14:12:27 +08:00
jxxghp
51f7694788 优化SlideView和Footer组件 2025-04-28 14:05:24 +08:00
jxxghp
dca5885ef1 为标签头部添加左右滚动按钮功能,优化用户体验,支持平滑滚动效果。 2025-04-28 13:28:44 +08:00
jxxghp
8cf4b612d5 更新国际化支持 2025-04-28 13:23:51 +08:00
jxxghp
6b49464059 更新国际化支持 2025-04-28 12:23:05 +08:00
jxxghp
034238716a 更新国际化支持:在账户设置中引入多个新配置项,优化用户体验,支持用户辅助认证、全局图片缓存、订阅数据分享等功能的多语言显示。 2025-04-28 12:15:49 +08:00
jxxghp
7575c5acfa 添加TMDB元数据语言选项:在账户设置中引入TMDB_LANGUAGE配置,支持简体中文、繁体中文和英文选择,优化用户体验。 2025-04-28 09:16:01 +08:00
jxxghp
af7aa7d47b 更新国际化支持:在多个对话框组件中引入 vue-i18n,优化文本翻译,确保多语言显示的一致性和准确性。 2025-04-28 08:55:52 +08:00
jxxghp
daf70b6da4 更新国际化支持:在多个对话框组件中引入 vue-i18n,优化文本翻译,确保多语言显示的一致性和准确性。 2025-04-28 08:29:08 +08:00
jxxghp
819dd01d60 更新国际化支持:修正替换词格式的显示,确保英文和中文文本中的格式一致性。 2025-04-28 07:53:56 +08:00
jxxghp
947590ac91 更新国际化支持:在多个组件中优化文本翻译,确保系统配置和存储设置相关提示信息的多语言显示。 2025-04-27 22:28:34 +08:00
jxxghp
71787ece64 更新国际化支持:在多个组件中引入 vue-i18n,优化文本翻译和结构 2025-04-27 22:19:57 +08:00
jxxghp
7a3d566875 更新国际化支持:调整多个组件中的文本引入,优化站点设置相关的翻译和文本结构,确保语言切换的准确性和一致性。 2025-04-27 21:59:26 +08:00
jxxghp
082f666839 删除 NavbarThemeSwitcher 组件,移除不再使用的主题切换功能。 2025-04-27 21:25:29 +08:00
jxxghp
a641e90031 更新国际化支持:在多个组件中引入 vue-i18n 2025-04-27 21:23:29 +08:00
jxxghp
0396f180ae 更新国际化支持:调整多个组件中的文本引入,优化语言切换和翻译功能,删除不再使用的类型文件。 2025-04-27 20:49:44 +08:00
jxxghp
f809c8e538 添加国际化支持:在多个页面和组件中引入 vue-i18n 2025-04-27 20:27:45 +08:00
jxxghp
733d74ac36 添加国际化支持:在多个组件中引入 vue-i18n,更新文本以实现多语言显示 2025-04-27 18:06:15 +08:00
jxxghp
c46d556684 添加国际化支持:在多个组件中引入 vue-i18n,更新文本以支持多语言显示 2025-04-27 17:53:22 +08:00
jxxghp
d0b3bc8137 添加国际化支持:引入 vue-i18n,更新多个组件以支持语言切换和文本翻译 2025-04-27 17:44:09 +08:00
jxxghp
80ae853582 更新版本号至2.4.3 2025-04-27 12:57:24 +08:00
jxxghp
8c405d941b 调整全局磨砂层显示逻辑,仅在用户登录时显示;修改分享订阅对话框的最大宽度;为登录按钮添加图标。 2025-04-27 12:55:43 +08:00
jxxghp
79f45b8499 优化背景图片切换逻辑:添加图片预加载功能,确保图片成功加载后再切换 2025-04-27 12:41:20 +08:00
jxxghp
6ecf6bfb34 Merge pull request #328 from cddjr/fix_more_sources 2025-04-24 20:04:29 +08:00
景大侠
2a5a93bdb5 修复更多来源不能正确跳转站点 2025-04-24 19:46:11 +08:00
jxxghp
dee6503e33 fix:首次登录不显示背景 2025-04-23 12:44:43 +08:00
jxxghp
3b0123f2be 优化加载动画 2025-04-23 08:24:21 +08:00
jxxghp
74d7b2b280 更新设置页面:将选项卡组件替换为 VHeaderTab 2025-04-23 08:15:25 +08:00
jxxghp
712623806a 为加载动画和SVG图标添加新动画效果 2025-04-23 08:07:53 +08:00
jxxghp
ecb4fda5fc 更新 custom.scss 2025-04-23 07:03:35 +08:00
jxxghp
1ee577677a Merge pull request #327 from cddjr/fix_plugin_market_duplication 2025-04-22 18:46:12 +08:00
景大侠
f091cfd7be 修复插件市场重复的问题 2025-04-22 18:41:08 +08:00
jxxghp
45eee811c1 fix https://github.com/jxxghp/MoviePilot-Frontend/issues/326 2025-04-22 17:25:18 +08:00
195 changed files with 17885 additions and 20202 deletions

View File

@@ -1,16 +1,37 @@
# MoviePilot-Frontend
*中文 | [English](README_EN.md)*
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目NodeJS版本>= `v20.12.1`
## 推荐的IDE设置
## 特性
- 基于 Vue 3 和 Vuetify 3 构建的现代化界面
- 使用 Vite 作为构建工具,提供快速的开发体验
- 支持多语言(中文/英文)
- 完整的插件系统支持,包括远程组件动态加载
## 模块联邦功能
MoviePilot 现已支持模块联邦Module Federation功能允许插件开发者创建可动态加载的远程组件实现更丰富的插件用户界面。
### 相关文档
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
## 开发部署
### 推荐的IDE设置
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (并禁用 Vetur).
## 配置Vite
### 配置Vite
请参阅 [Vite 配置参考](https://vitejs.dev/config/).
## 依赖安装
### 依赖安装
```sh
yarn

59
README_EN.md Normal file
View File

@@ -0,0 +1,59 @@
# MoviePilot-Frontend
*[中文](README.md) | English*
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS version required: >= `v20.12.1`.
## Features
- Modern interface built with Vue 3 and Vuetify 3
- Fast development experience with Vite build tool
- Multi-language support (Chinese/English)
- Complete plugin system with dynamic remote component loading
## Module Federation
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
### Documentation
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components
## Development
### Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (disable Vetur).
### Configure Vite
See [Vite Configuration Reference](https://vitejs.dev/config/).
### Install Dependencies
```sh
yarn
```
### Development Server
```sh
yarn dev
```
### Build for Production
```sh
yarn build
```
### Static Deployment
1. Host the `dist` static files using a web server like `nginx`. Refer to `public/nginx.conf` for nginx configuration.
2. Alternatively, run the `service.js` directly with the `node` command. It listens on port `3000` by default. Set the `NGINX_PORT` environment variable to adjust the port.
```shell
node dist/service.js
```

8
auto-imports.d.ts vendored
View File

@@ -25,6 +25,7 @@ declare global {
const createPinia: typeof import('pinia')['createPinia']
const createProjection: typeof import('@vueuse/math')['createProjection']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createRef: typeof import('@vueuse/core')['createRef']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
@@ -159,6 +160,7 @@ declare global {
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCountdown: typeof import('@vueuse/core')['useCountdown']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
@@ -198,6 +200,7 @@ declare global {
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useI18n: typeof import('vue-i18n')['useI18n']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
@@ -325,7 +328,7 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
@@ -353,6 +356,7 @@ declare module 'vue' {
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
@@ -487,6 +491,7 @@ declare module 'vue' {
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
@@ -526,6 +531,7 @@ declare module 'vue' {
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>

2
components.d.ts vendored
View File

@@ -2,6 +2,7 @@
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
@@ -17,6 +18,5 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
ScrollToTopBtn: typeof import('./src/@core/components/ScrollToTopBtn.vue')['default']
StatIcon: typeof import('./src/@core/components/StatIcon.vue')['default']
ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default']
}
}

View File

@@ -0,0 +1,110 @@
# MoviePilot 模块联邦问题排查指南
本文档提供了针对 MoviePilot 项目中使用模块联邦时可能遇到的常见问题及解决方案。
## 远程组件注册机制
MoviePilot 使用自动注册机制来加载远程组件:
1. 对于使用 Vue 渲染模式的插件,自动注册其远程组件
2. 每个远程组件根据插件 ID 唯一标识,确保不会冲突
3. 在需要加载组件时,会优先检查已注册的组件信息
这种设计使得插件开发者只需专注于组件开发,而不需要担心加载机制的复杂性。
## 常见错误
### 1. "Module name 'vue' does not resolve to a valid URL"
**原因**:远程组件无法正确解析共享依赖的 URL通常是因为共享依赖配置不正确。
**解决方案**
1.**插件组件项目**`vite.config.js` 中正确配置共享依赖:
```js
federation({
// ...
shared: {
vue: {
singleton: true,
requiredVersion: false // 关闭版本检查
}
}
})
```
2.**主应用**`vite.config.ts` 中确保共享依赖配置正确:
```ts
federation({
name: 'host',
remotes: {},
shared: ['vue', 'vuetify']
})
```
### 2. "Top-level await is not available in the configured target environment"
**原因**:模块联邦使用了顶层 await但目标构建环境不支持此功能。
**解决方案**
**主应用****插件组件项目** 的构建配置中添加 `target: 'esnext'`
```js
build: {
target: 'esnext', // 支持顶层await
// 其他配置...
}
```
### 3. "TypeError: Failed to fetch dynamically imported module"
**原因**:远程组件 JS 文件无法被正确加载,可能是路径错误或网络问题。
**解决方案**
1. 检查网络请求是否成功状态码200
2. 确认组件 URL 是否正确
3. 确保服务器允许访问该 JS 文件CORS 配置)
4. 检查插件后端是否正确提供了静态文件服务
### 4. 组件加载后渲染为空白或出现错误
**原因**:组件内部代码错误或与主应用不兼容。
**解决方案**
1. 检查浏览器控制台错误信息
2. 确保组件代码没有语法错误
3. 避免在组件中使用主应用未提供的依赖
4. 确保所有路径如图片、API请求URL等都是正确的
## 调试技巧
### 1. 启用详细日志
在浏览器控制台中设置:
```js
localStorage.setItem('debug', 'vite:*')
```
### 2. 分析网络请求
1. 打开浏览器开发者工具
2. 转到 Network 标签页
3. 确认远程组件 JS 文件请求是否成功
4. 分析响应内容是否为有效的 JavaScript
### 3. 隔离测试远程组件
创建一个独立的简单页面来测试插件组件,排除主应用的干扰因素。
## 其他资源
- [MoviePilot 插件组件示例](../examples/plugin-component/)
- [Vite 模块联邦插件文档](https://github.com/originjs/vite-plugin-federation)
- [Vite 官方文档](https://vitejs.dev/guide/build.html)
- [Origin.js 模块联邦示例](https://github.com/originjs/vite-plugin-federation/tree/main/packages/examples)

View File

@@ -0,0 +1,384 @@
# MoviePilot前端远程模块开发指南
## 1. 概述
MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态加载和集成。本文档详细说明如何开发符合要求的远程模块以便在MoviePilot中作为插件使用。
关联阅读后端插件开发文档:[第三方插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md)
## 2. 技术要求
- Node.js 16+
- Vue 3
- Vite 4+
- TypeScript 5+
## 3. 核心概念
每个插件需要提供三个标准组件:
| 组件名称 | 文件名 | 用途 |
|---------|-------|------|
| Page | Page.vue | 插件详情页面 |
| Config | Config.vue | 插件配置页面 |
| Dashboard | Dashboard.vue | 仪表板组件 |
## 4. 快速开始
### 创建项目
```bash
# 创建项目
npm create vite@latest my-plugin -- --template vue-ts
# 进入项目目录
cd my-plugin
# 安装依赖
yarn
```
### 配置vite.config.ts
```typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'MyPlugin',
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
},
shared: {
vue: {
requiredVersion: false,
generate: false,
},
vuetify: {
requiredVersion: false,
generate: false,
singleton: true,
},
'vuetify/styles': {
requiredVersion: false,
generate: false,
singleton: true,
},
},
format: 'esm'
})
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: true, // 改为true以便能分离样式文件
rollupOptions: {
output: {
manualChunks: {
'vuetify-lib': ['vuetify'] // 将vuetify单独分离出来
}
}
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: '/* 覆盖vuetify样式 */',
}
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
if (atRule.name === 'charset') {
atRule.remove();
}
}
}
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
// 过滤掉所有vuetify相关的CSS
root.walkRules(rule => {
if (rule.selector && (
rule.selector.includes('.v-') ||
rule.selector.includes('.mdi-'))) {
rule.remove();
}
});
}
}
]
}
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
},
})
```
## 5. 组件开发规范
### 5.1 Page组件详情页面
```vue
<script setup lang="ts">
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action', 'switch', 'close'])
// 接收API对象
const props = defineProps({
api: {
type: Object,
default: () => {}
}
})
// 页面逻辑代码...
// 通知主应用刷新数据
function notifyRefresh() {
emit('action')
}
// 通知主应用切换到配置页面
function notifySwitch() {
emit('switch')
}
// 通知主应用关闭当前页面
function notifyClose() {
emit('close')
}
</script>
<template>
<div class="plugin-page">
<!-- 插件详情页面操作按钮示例 -->
<v-btn @click="notifyRefresh">刷新数据</v-btn>
<v-btn @click="notifySwitch">配置插件</v-btn>
<v-btn @click="notifyClose">关闭页面</v-btn>
</div>
</template>
```
### 5.2 Config组件配置页面
```vue
<script setup lang="ts">
// 接收初始配置和API对象
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({})
},
api: {
type: Object,
default: () => {}
}
})
// 配置数据
const config = ref({...props.initialConfig})
// 自定义事件,用于保存配置
const emit = defineEmits(['save', 'close', 'switch'])
// 保存配置
function saveConfig() {
emit('save', config.value)
}
// 通知主应用切换到详情页面
function notifySwitch() {
emit('switch')
}
// 通知主应用关闭当前页面
function notifyClose() {
emit('close')
}
</script>
<template>
<div class="plugin-config">
<!-- 配置表单示例 -->
<v-text-field v-model="config.someField" label="配置项"></v-text-field>
<!-- 保存按钮示例 -->
<v-btn color="primary" @click="saveConfig">保存配置</v-btn>
<!-- 关闭按钮示例 -->
<v-btn color="primary" @click="notifyClose">关闭页面</v-btn>
<!-- 切换按钮示例 -->
<v-btn color="primary" @click="notifySwitch">切换到详情页面</v-btn>
</div>
</template>
```
### 5.3 Dashboard组件仪表板
```vue
<script setup lang="ts">
// 接收配置和刷新控制
const props = defineProps({
config: {
type: Object,
default: () => ({})
},
allowRefresh: {
type: Boolean,
default: true
}
})
// 仪表板逻辑...
</script>
<template>
<div class="dashboard-widget">
<!-- 仪表板内容 -->
<v-card>
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
<v-card-text>
<!-- 组件内容 -->
</v-card-text>
</v-card>
</div>
</template>
```
## 6. 构建和部署
### 构建项目
```bash
yarn build
```
将生成的dist文件夹上传到插件后端目录下默认为`dist/assets`
- 在插件的后端python代码中实现以下方法来集成远程组件
```python
def get_render_mode() -> Tuple[str, str]:
"""
获取插件渲染模式
:return: 1、渲染模式支持vue/vuetify默认vuetify
:return: 2、组件路径默认 dist/assets
"""
return "vue", "dist/assets"
```
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
```typescript
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
```
```python
def get_api(self) -> List[Dict[str, Any]]:
"""
注册插件API
"""
return [
{
"path": "/history",
"endpoint": self.get_history,
"methods": ["GET"],
"auth": "bear", # 认证类型设为bear
"summary": "查询历史记录"
}
]
```
## 7. 调试与排错
### 常见问题
1. **模块无法加载**
- 检查网络请求是否成功状态码200
- 确认文件路径是否正确
- 检查CORS跨域设置
2. **模块加载但组件不显示**
- 检查控制台错误信息
- 确认组件是否正确导出
- 验证共享依赖配置
3. **"Module name 'vue' does not resolve to a valid URL"**
- 检查`shared`配置是否正确
- 设置`requiredVersion: false`尝试解决
4. **"Top-level await is not available"**
- 确保`build.target`设置为`esnext`
## 8. 高级配置
### 8.1 CSS隔离
为防止样式冲突建议使用CSS Modules或scoped样式
```vue
<style scoped>
/* 组件样式 */
</style>
```
### 8.2 共享更多依赖
如果您的插件需要共享更多依赖可以扩展shared配置
```js
shared: {
vue: { requiredVersion: false },
vuetify: { requiredVersion: false },
'@vueuse/core': { requiredVersion: false },
pinia: { requiredVersion: false }
}
```
### 8.3 开发环境测试
开发期间可以使用以下配置在本地测试:
```typescript
// vite.config.ts
export default defineConfig({
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
}
})
```
## 9. 示例代码
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [模块联邦问题排查指南](./federation-troubleshooting.md) - 常见问题排查
## 10. 参考资料
- [Vite Plugin Federation](https://github.com/originjs/vite-plugin-federation)
- [Vue 3官方文档](https://vuejs.org/)
---
如有问题请提交Issue。

7
env.d.ts vendored
View File

@@ -8,3 +8,10 @@ declare module 'vue-router' {
navActiveLink?: RouteLocationRaw
}
}
// 支持动态导入远程模块
declare module '*' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,42 @@
# MoviePilot 插件远程组件示例
这是 MoviePilot 插件远程组件的示例项目展示了如何正确配置和开发与主应用兼容的远程组件。本示例实现了三个标准组件Page详情页面、Config配置页面和Dashboard仪表板组件
## 1. 开发环境准备
### 安装依赖
```bash
npm install
# 或
yarn
```
### 开发模式运行
```bash
npm run dev
# 或
yarn dev
```
## 2. 项目结构
```
plugin-component/
├── src/
│ ├── components/
│ │ ├── Page.vue # 插件详情页面组件
│ │ ├── Config.vue # 插件配置页面组件
│ │ └── Dashboard.vue # 插件仪表板组件
│ ├── App.vue # 本地开发入口组件
│ └── main.js # 本地开发入口文件
├── vite.config.js # Vite和模块联邦配置
├── index.html # 本地开发HTML入口
└── package.json # 依赖配置
```
## 3. 开发指引
- [模块联邦开发指南](../../docs/module-federation-guide.md)
- [模块联邦问题排查指南](../../docs/federation-troubleshooting.md)。

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MoviePilot插件组件示例</title>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@6.x/css/materialdesignicons.min.css" rel="stylesheet" />
<style>
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"name": "moviepilot-plugin-component",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vuetify": "3.7.3",
"echarts": "^5.4.3",
"vue-echarts": "^6.6.1",
"@vueuse/core": "^12.4.0"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.4.1",
"@vitejs/plugin-vue": "^4.4.0",
"vite": "^5.4.11"
}
}

View File

@@ -0,0 +1,128 @@
<template>
<div class="app-container">
<v-app>
<v-app-bar color="primary" app>
<v-app-bar-title>MoviePilot插件组件示例</v-app-bar-title>
</v-app-bar>
<v-main>
<v-container>
<v-tabs v-model="activeTab" bg-color="primary">
<v-tab value="page">详情页面</v-tab>
<v-tab value="config">配置页面</v-tab>
<v-tab value="dashboard">仪表板</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="mt-4">
<v-window-item value="page">
<h2 class="text-h5 mb-4">Page组件</h2>
<div class="component-preview">
<page-component @action="handleAction"></page-component>
</div>
</v-window-item>
<v-window-item value="config">
<h2 class="text-h5 mb-4">Config组件</h2>
<div class="component-preview">
<config-component :initial-config="initialConfig" @save="handleConfigSave"></config-component>
</div>
</v-window-item>
<v-window-item value="dashboard">
<h2 class="text-h5 mb-4">Dashboard组件</h2>
<v-switch v-model="dashboardConfig.attrs.border" label="显示边框" color="primary" class="mb-4"></v-switch>
<div class="component-preview">
<dashboard-component :config="dashboardConfig" :allow-refresh="true"></dashboard-component>
</div>
</v-window-item>
</v-window>
</v-container>
</v-main>
<v-footer app color="primary" class="text-center d-flex justify-center">
<span class="text-white">MoviePilot 模块联邦示例 ©{{ new Date().getFullYear() }}</span>
</v-footer>
</v-app>
<!-- 通知弹窗 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="snackbar.timeout">
{{ snackbar.text }}
<template v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false"> 关闭 </v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import PageComponent from './components/Page.vue'
import ConfigComponent from './components/Config.vue'
import DashboardComponent from './components/Dashboard.vue'
// 活动标签页
const activeTab = ref('page')
// 配置初始值
const initialConfig = {
name: '测试插件',
description: '这是一个测试配置',
enable_notifications: true,
update_interval: 30,
api_url: 'https://api.example.com',
api_key: 'test_api_key_123',
concurrent_tasks: 2,
tags: ['电影', '测试'],
}
// 仪表板配置
const dashboardConfig = reactive({
id: 'test_plugin',
name: '测试插件',
attrs: {
title: '仪表板示例',
subtitle: '插件数据展示',
border: true,
},
})
// 通知状态
const snackbar = reactive({
show: false,
text: '',
color: 'success',
timeout: 3000,
})
// 显示通知
function showNotification(text, color = 'success') {
snackbar.text = text
snackbar.color = color
snackbar.show = true
}
// 处理详情页面操作
function handleAction() {
showNotification('Page组件触发了action事件')
}
// 处理配置保存
function handleConfigSave(config) {
console.log('配置已保存:', config)
showNotification('配置已保存')
}
</script>
<style scoped>
/* 为了使测试应用更美观 */
.app-container {
block-size: 100vh;
inline-size: 100vw;
}
.component-preview {
overflow: hidden;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<div class="plugin-config">
<v-card>
<v-card-item>
<v-card-title>插件配置</v-card-title>
<template #append>
<v-btn icon color="primary" variant="text" @click="notifyClose">
<v-icon left>mdi-close</v-icon>
</v-btn>
</template>
</v-card-item>
<v-card-text class="overflow-y-auto">
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
<v-form ref="form" v-model="isFormValid" @submit.prevent="saveConfig">
<!-- 基本设置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">基本设置</div>
<v-row>
<v-col cols="12">
<v-switch
v-model="config.enable"
label="启用插件"
color="primary"
inset
hint="启用插件后,插件将开始工作"
persistent-hint
></v-switch>
</v-col>
<v-col cols="12">
<v-text-field
v-model="config.name"
label="插件名称"
variant="outlined"
:rules="[v => !!v || '名称不能为空']"
hint="显示在插件列表中的名称"
></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea
v-model="config.description"
label="插件描述"
variant="outlined"
rows="3"
hint="简要说明插件的功能和用途"
></v-textarea>
</v-col>
</v-row>
<!-- 功能配置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">功能配置</div>
<v-row>
<v-col cols="12">
<v-select
v-model="config.update_interval"
label="更新频率"
:items="updateIntervalOptions"
variant="outlined"
item-title="text"
item-value="value"
></v-select>
</v-col>
</v-row>
<!-- API配置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">API设置</div>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="config.api_url"
label="API地址"
variant="outlined"
hint="外部服务API地址"
:rules="[v => !v || v.startsWith('http') || '请输入有效的URL']"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="config.api_key"
label="API密钥"
variant="outlined"
:append-inner-icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'"
:type="showApiKey ? 'text' : 'password'"
@click:append-inner="showApiKey = !showApiKey"
></v-text-field>
</v-col>
</v-row>
<!-- 高级选项区域 -->
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>高级选项</v-expansion-panel-title>
<v-expansion-panel-text>
<v-slider
v-model="config.concurrent_tasks"
label="并发任务数"
min="1"
max="10"
step="1"
thumb-label
></v-slider>
<v-combobox
v-model="config.tags"
label="标签"
variant="outlined"
chips
multiple
closable-chips
></v-combobox>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn color="secondary" @click="resetForm">重置</v-btn>
<v-spacer></v-spacer>
<v-btn color="primary" :disabled="!isFormValid" @click="saveConfig" :loading="saving">保存配置</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
// 接收初始配置
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({}),
},
api: {
type: Object,
default: () => {},
},
})
// 表单状态
const form = ref(null)
const isFormValid = ref(true)
const error = ref(null)
const saving = ref(false)
const showApiKey = ref(false)
// 更新频率选项
const updateIntervalOptions = [
{ text: '5分钟', value: 5 },
{ text: '15分钟', value: 15 },
{ text: '30分钟', value: 30 },
{ text: '1小时', value: 60 },
{ text: '2小时', value: 120 },
{ text: '6小时', value: 360 },
{ text: '12小时', value: 720 },
{ text: '1天', value: 1440 },
]
// 配置数据,使用默认值和初始配置合并
const defaultConfig = {
name: '我的插件',
description: '',
enable: true,
update_interval: 60,
api_url: '',
api_key: '',
concurrent_tasks: 3,
tags: [],
}
// 合并默认配置和初始配置
const config = reactive({ ...defaultConfig })
// 初始化配置
onMounted(() => {
// 加载初始配置
if (props.initialConfig) {
Object.keys(props.initialConfig).forEach(key => {
if (key in config) {
config[key] = props.initialConfig[key]
}
})
}
})
// 自定义事件,用于保存配置
const emit = defineEmits(['save', 'close', 'switch'])
// 保存配置
async function saveConfig() {
if (!isFormValid.value) {
error.value = '请修正表单错误'
return
}
saving.value = true
error.value = null
try {
// 模拟API调用等待
await new Promise(resolve => setTimeout(resolve, 1000))
// 发送保存事件
emit('save', { ...config })
} catch (err) {
console.error('保存配置失败:', err)
error.value = err.message || '保存配置失败'
} finally {
saving.value = false
}
}
// 重置表单
function resetForm() {
Object.keys(defaultConfig).forEach(key => {
config[key] = defaultConfig[key]
})
if (form.value) {
form.value.resetValidation()
}
}
// 通知主应用关闭组件
function notifyClose() {
emit('close')
}
</script>

View File

@@ -0,0 +1,298 @@
<template>
<div class="dashboard-widget">
<v-card v-if="!config?.attrs?.border" flat>
<v-card-text class="pa-0">
<div class="dashboard-content">
<!-- 加载中状态 -->
<div v-if="loading" class="d-flex justify-center align-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<!-- 数据内容 -->
<div v-else>
<!-- 数据图表 -->
<div v-if="chartData" class="chart-container">
<v-chart class="chart" :option="chartOptions" autoresize />
</div>
<!-- 数据列表 -->
<v-list v-if="items.length" density="compact" class="py-0">
<v-list-item v-for="(item, index) in items" :key="index" :title="item.title" :subtitle="item.subtitle">
<template v-slot:prepend>
<v-avatar :color="getStatusColor(item.status)" size="small">
<v-icon size="small" color="white">{{ getStatusIcon(item.status) }}</v-icon>
</v-avatar>
</template>
<template v-slot:append v-if="item.value">
<span class="text-caption">{{ item.value }}</span>
</template>
</v-list-item>
</v-list>
</div>
</div>
</v-card-text>
</v-card>
<!-- 带边框的卡片 -->
<v-card v-else>
<v-card-item>
<v-card-title>{{ config?.attrs?.title || '仪表板组件' }}</v-card-title>
<v-card-subtitle v-if="config?.attrs?.subtitle">{{ config.attrs.subtitle }}</v-card-subtitle>
</v-card-item>
<v-card-text>
<!-- 加载中状态 -->
<div v-if="loading" class="d-flex justify-center align-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<!-- 数据内容 -->
<div v-else>
<!-- 数据图表 -->
<div v-if="chartData" class="chart-container">
<v-chart class="chart" :option="chartOptions" autoresize />
</div>
<!-- 数据列表 -->
<v-list v-if="items.length" density="compact" class="rounded pa-0">
<v-list-item v-for="(item, index) in items" :key="index" :title="item.title" :subtitle="item.subtitle">
<template v-slot:prepend>
<v-avatar :color="getStatusColor(item.status)" size="small">
<v-icon size="small" color="white">{{ getStatusIcon(item.status) }}</v-icon>
</v-avatar>
</template>
<template v-slot:append v-if="item.value">
<span class="text-caption">{{ item.value }}</span>
</template>
</v-list-item>
</v-list>
</div>
</v-card-text>
</v-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, PieChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
// 注册ECharts组件
try {
use([CanvasRenderer, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent])
} catch (e) {
console.warn('ECharts components registration failed', e)
}
// 接收仪表板配置
const props = defineProps({
config: {
type: Object,
default: () => ({}),
},
allowRefresh: {
type: Boolean,
default: true,
},
})
// 组件状态
const loading = ref(true)
const items = ref([])
const chartData = ref(null)
let refreshTimer = null
// 获取状态图标
function getStatusIcon(status) {
const icons = {
'success': 'mdi-check-circle',
'warning': 'mdi-alert',
'error': 'mdi-alert-circle',
'info': 'mdi-information',
'running': 'mdi-play-circle',
'pending': 'mdi-clock-outline',
'completed': 'mdi-check-circle-outline',
}
return icons[status] || 'mdi-help-circle'
}
// 获取状态颜色
function getStatusColor(status) {
const colors = {
'success': 'success',
'warning': 'warning',
'error': 'error',
'info': 'info',
'running': 'primary',
'pending': 'secondary',
'completed': 'success',
}
return colors[status] || 'grey'
}
// 图表选项
const chartOptions = computed(() => {
if (!chartData.value) return {}
const { type, data } = chartData.value
if (type === 'line') {
return {
tooltip: {
trigger: 'axis',
},
xAxis: {
type: 'category',
data: data.xAxis,
axisLabel: {
color: '#888',
},
},
yAxis: {
type: 'value',
axisLabel: {
color: '#888',
},
},
series: data.series.map(series => ({
name: series.name,
type: 'line',
smooth: true,
data: series.data,
areaStyle: { opacity: 0.1 },
})),
}
}
if (type === 'pie') {
return {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
},
series: [
{
name: data.name,
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: '12',
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: data.items,
},
],
}
}
return {}
})
// 获取仪表板数据
async function fetchDashboardData() {
if (!props.allowRefresh) return
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 随机决定显示饼图或折线图
const showPie = Math.random() > 0.5
if (showPie) {
// 饼图数据
chartData.value = {
type: 'pie',
data: {
name: '文件分布',
items: [
{ value: Math.floor(Math.random() * 50) + 30, name: '电影' },
{ value: Math.floor(Math.random() * 40) + 20, name: '电视剧' },
{ value: Math.floor(Math.random() * 30) + 10, name: '动漫' },
{ value: Math.floor(Math.random() * 20) + 5, name: '纪录片' },
],
},
}
} else {
// 折线图数据
const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
chartData.value = {
type: 'line',
data: {
xAxis: days,
series: [
{
name: '下载量',
data: days.map(() => Math.floor(Math.random() * 10) + 1),
},
{
name: '完成量',
data: days.map(() => Math.floor(Math.random() * 8) + 1),
},
],
},
}
}
// 生成列表数据
const statuses = ['success', 'warning', 'error', 'info', 'running', 'pending', 'completed']
items.value = Array.from({ length: 5 }, (_, i) => {
const status = statuses[Math.floor(Math.random() * statuses.length)]
return {
title: `项目 ${i + 1}`,
subtitle: `上次更新: ${new Date().toLocaleTimeString()}`,
status,
value: Math.floor(Math.random() * 100) + '%',
}
})
} catch (error) {
console.error('获取仪表板数据失败:', error)
} finally {
loading.value = false
}
}
// 设置定时刷新
function setupRefreshTimer() {
if (props.allowRefresh) {
// 每30秒刷新一次
refreshTimer = setInterval(() => {
fetchDashboardData()
}, 30000)
}
}
// 初始化
onMounted(() => {
fetchDashboardData()
setupRefreshTimer()
})
// 清理
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
}
})
</script>

View File

@@ -0,0 +1,169 @@
<template>
<div class="plugin-page">
<v-card>
<v-card-item>
<v-card-title>{{ title }}</v-card-title>
<template #append>
<v-btn icon color="primary" variant="text" @click="notifyClose">
<v-icon left>mdi-close</v-icon>
</v-btn>
</template>
</v-card-item>
<v-card-text>
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
<v-skeleton-loader v-if="loading" type="card"></v-skeleton-loader>
<div v-else>
<!-- 数据统计展示 -->
<v-row v-if="stats">
<v-col v-for="(value, key) in stats" :key="key" cols="12" sm="6" md="4">
<v-card variant="outlined" class="text-center">
<v-card-text>
<div class="text-h4 font-weight-bold">{{ value }}</div>
<div class="text-subtitle-1">{{ key }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 最近记录展示 -->
<div v-if="recentItems && recentItems.length" class="mt-4">
<div class="text-h6 mb-2">最近记录</div>
<v-timeline density="compact">
<v-timeline-item
v-for="(item, index) in recentItems"
:key="index"
:dot-color="getItemColor(item.type)"
size="small"
>
<div class="d-flex align-center">
<v-icon :color="getItemColor(item.type)" size="small" class="mr-2">
{{ getItemIcon(item.type) }}
</v-icon>
<span class="font-weight-medium">{{ item.title }}</span>
</div>
<div class="text-caption text-secondary">{{ item.time }}</div>
</v-timeline-item>
</v-timeline>
</div>
<!-- 当前状态 -->
<div class="mt-4 text-subtitle-2">
<div>
<strong>状态:</strong>
<v-chip size="small" :color="status === 'running' ? 'success' : 'warning'">{{ status }}</v-chip>
</div>
<div><strong>最后更新:</strong> {{ lastUpdated }}</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-btn color="primary" @click="refreshData" :loading="loading">
<v-icon left>mdi-refresh</v-icon>
刷新数据
</v-btn>
<v-spacer></v-spacer>
<v-btn color="primary" @click="notifySwitch">
<v-icon left>mdi-cog</v-icon>
配置
</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 接收初始配置
const props = defineProps({
model: {
type: Object,
default: () => {},
},
api: {
type: Object,
default: () => {},
},
})
// 组件状态
const title = ref('插件详情页面')
const loading = ref(true)
const error = ref(null)
const stats = ref(null)
const recentItems = ref([])
const status = ref('running')
const lastUpdated = ref('')
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action', 'switch', 'close'])
// 获取状态图标
function getItemIcon(type) {
const icons = {
'movie': 'mdi-movie',
'tv': 'mdi-television-classic',
'download': 'mdi-download',
'error': 'mdi-alert-circle',
'success': 'mdi-check-circle',
}
return icons[type] || 'mdi-information'
}
// 获取状态颜色
function getItemColor(type) {
const colors = {
'movie': 'blue',
'tv': 'green',
'download': 'purple',
'error': 'red',
'success': 'success',
}
return colors[type] || 'grey'
}
// 获取和刷新数据
async function refreshData() {
loading.value = true
error.value = null
try {
// 模拟数据
stats.value = {
'电影': Math.floor(Math.random() * 100) + 50,
'电视剧': Math.floor(Math.random() * 100) + 30,
'动漫': Math.floor(Math.random() * 100) + 20,
'纪录片': Math.floor(Math.random() * 100) + 10,
'综艺': Math.floor(Math.random() * 100) + 5,
}
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
status.value = Math.random() > 0.2 ? 'running' : 'paused'
lastUpdated.value = new Date().toLocaleString()
} catch (err) {
console.error('获取数据失败:', err)
error.value = err.message || '获取数据失败'
} finally {
loading.value = false
// 通知主应用组件已更新
emit('action')
}
}
// 通知主应用切换到配置页面
function notifySwitch() {
emit('switch')
}
// 通知主应用关闭组件
function notifyClose() {
emit('close')
}
// 组件挂载时加载数据
onMounted(() => {
refreshData()
})
</script>

View File

@@ -0,0 +1,25 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import defaults from './vuetify/defaults'
import theme from './vuetify/theme'
import 'vuetify/styles'
// 创建Vuetify实例
const vuetify = createVuetify({
components,
directives,
theme,
defaults
})
// 创建应用
const app = createApp(App)
// 使用插件
app.use(vuetify)
// 挂载应用
app.mount('#app')

View File

@@ -0,0 +1,148 @@
export default {
IconBtn: {
icon: true,
color: 'default',
variant: 'text',
VIcon: {
size: 24,
},
},
VAlert: {
VBtn: {
color: undefined,
},
},
VAvatar: {
// Remove after next release
variant: 'flat',
VIcon: {
size: 24,
},
},
VBadge: {
// set v-badge default color to primary
color: 'primary',
},
VBtn: {
// set v-btn default color to primary
color: 'primary',
elevation: 0,
},
VCard: {
elevation: 0,
rounded: 'lg',
},
VMenu: {
elevation: 0,
},
VChip: {
elevation: 0,
},
VBottomSheet: {
elevation: 0,
},
VDialog: {
elevation: 0,
rounded: 'lg',
},
VExpansionPanels: {
elevation: 0,
},
VList: {
color: 'primary',
elevation: 0,
},
VListItem: {
rounded: 'md',
},
VPagination: {
activeColor: 'primary',
},
VTabs: {
// set v-tabs default color to primary
color: 'primary',
VSlideGroup: {
showArrows: true,
},
},
VTooltip: {
// set v-tooltip default location to top
location: 'top',
},
VCheckboxBtn: {
color: 'primary',
hideDetails: 'auto',
},
VCheckbox: {
// set v-checkbox default color to primary
color: 'primary',
hideDetails: 'auto',
},
VRadioGroup: {
color: 'primary',
hideDetails: 'auto',
},
VRadio: {
color: 'primary',
hideDetails: 'auto',
},
VSelect: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
menuProps: { elevation: 0 },
},
VRangeSlider: {
// set v-range-slider default color to primary
color: 'primary',
density: 'comfortable',
thumbLabel: true,
hideDetails: 'auto',
},
VRating: {
// set v-rating default color to primary
color: 'rgba(var(--v-theme-on-background),0.23)',
activeColor: 'warning',
halfIncrements: true,
},
VProgressCircular: {
// set v-progress-circular default color to primary
color: 'primary',
},
VSlider: {
// set v-slider default color to primary
color: 'primary',
hideDetails: 'auto',
},
VTextField: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
},
VAutocomplete: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
},
VCombobox: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
menuProps: { elevation: 0 },
},
VFileInput: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
},
VTextarea: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
},
VSwitch: {
// set v-switch default color to primary
color: 'primary',
hideDetails: 'auto',
},
}

View File

@@ -0,0 +1,216 @@
import type { VuetifyOptions } from 'vuetify'
const theme: VuetifyOptions['theme'] = {
defaultTheme: 'light',
themes: {
light: {
dark: false,
colors: {
'primary': '#9155FD',
'secondary': '#8A8D93',
'on-secondary': '#FFFFFF',
'success': '#56CA00',
'info': '#16B1FF',
'warning': '#FFB400',
'error': '#FF4C51',
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#F4F5FA',
'on-background': '#3A3541',
'on-surface': '#3A3541',
'grey-50': '#FAFAFA',
'grey-100': '#F0F2F8',
'grey-200': '#EEEEEE',
'grey-300': '#E0E0E0',
'grey-400': '#BDBDBD',
'grey-500': '#9E9E9E',
'grey-600': '#757575',
'grey-700': '#616161',
'grey-800': '#424242',
'grey-900': '#212121',
'perfect-scrollbar-thumb': '#DBDADE',
'skin-bordered-background': '#FFFFFF',
'skin-bordered-surface': '#FFFFFF',
},
variables: {
'code-color': '#D400FF',
'overlay-scrim-background': '#3A3541',
'overlay-scrim-opacity': 0.5,
'hover-opacity': 0.04,
'focus-opacity': 0.1,
'selected-opacity': 0.12,
'activated-opacity': 0.1,
'pressed-opacity': 0.14,
'dragged-opacity': 0.1,
'border-color': '#3A3541',
'table-header-background': '#F9FAFC',
'custom-background': '#F9F8F9',
// Shadows
'shadow-key-umbra-opacity': 'rgba(var(--v-theme-on-surface), 0.08)',
'shadow-key-penumbra-opacity': 'rgba(var(--v-theme-on-surface), 0.12)',
'shadow-key-ambient-opacity': 'rgba(var(--v-theme-on-surface), 0.04)',
},
},
dark: {
dark: true,
colors: {
'primary': '#6E66ED',
'secondary': '#8A8D93',
'on-secondary': '#FFFFFF',
'success': '#56CA00',
'info': '#16B1FF',
'warning': '#FFB400',
'error': '#FF4C51',
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#0E1116',
'on-background': '#E7E3FC',
'surface': '#14161F',
'on-surface': '#E7E3FC',
'grey-50': '#2A2E42',
'grey-100': '#474360',
'grey-200': '#4A5072',
'grey-300': '#5E6692',
'grey-400': '#7983BB',
'grey-500': '#8692D0',
'grey-600': '#AAB3DE',
'grey-700': '#B6BEE3',
'grey-800': '#CFD3EC',
'grey-900': '#E7E9F6',
'perfect-scrollbar-thumb': '#4A5072',
'skin-bordered-background': '#312d4b',
'skin-bordered-surface': '#312d4b',
},
variables: {
'code-color': '#d400ff',
'overlay-scrim-background': '#191D21',
'overlay-scrim-opacity': 0.6,
'hover-opacity': 0.04,
'focus-opacity': 0.1,
'selected-opacity': 0.12,
'activated-opacity': 0.1,
'pressed-opacity': 0.14,
'dragged-opacity': 0.1,
'border-color': '#E7E3FC',
'table-header-background': '#14161F',
'custom-background': '#373452',
// Shadows
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',
'shadow-key-penumbra-opacity': 'rgba(20, 18, 33, 0.12)',
'shadow-key-ambient-opacity': 'rgba(20, 18, 33, 0.04)',
},
},
purple: {
dark: true,
colors: {
'primary': '#9155FD',
'secondary': '#8A8D93',
'on-secondary': '#FFFFFF',
'success': '#56CA00',
'info': '#16B1FF',
'warning': '#FFB400',
'error': '#FF4C51',
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#28243D',
'on-background': '#E7E3FC',
'surface': '#312D4B',
'on-surface': '#E7E3FC',
'grey-50': '#2A2E42',
'grey-100': '#474360',
'grey-200': '#4A5072',
'grey-300': '#5E6692',
'grey-400': '#7983BB',
'grey-500': '#8692D0',
'grey-600': '#AAB3DE',
'grey-700': '#B6BEE3',
'grey-800': '#CFD3EC',
'grey-900': '#E7E9F6',
'perfect-scrollbar-thumb': '#4A5072',
'skin-bordered-background': '#312d4b',
'skin-bordered-surface': '#312d4b',
},
variables: {
'code-color': '#d400ff',
'overlay-scrim-background': '#2C2942',
'overlay-scrim-opacity': 0.6,
'hover-opacity': 0.04,
'focus-opacity': 0.1,
'selected-opacity': 0.12,
'activated-opacity': 0.1,
'pressed-opacity': 0.14,
'dragged-opacity': 0.1,
'border-color': '#E7E3FC',
'table-header-background': '#3D3759',
'custom-background': '#373452',
// Shadows
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',
'shadow-key-penumbra-opacity': 'rgba(20, 18, 33, 0.12)',
'shadow-key-ambient-opacity': 'rgba(20, 18, 33, 0.04)',
},
},
transparent: {
dark: true,
colors: {
'primary': '#A370F7',
'secondary': '#8A8D93',
'on-secondary': '#FFFFFF',
'success': '#66BB6A',
'info': '#42A5F5',
'warning': '#FFA726',
'error': '#EF5350',
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#000000',
'on-background': '#E7E3FC',
'surface': 'rgba(30, 30, 30, 0.3)',
'on-surface': '#E7E3FC',
'surface-variant': 'rgba(30, 30, 30, 0.2)',
'on-surface-variant': 'rgba(255, 255, 255, 0.65)',
'grey-50': 'rgba(42, 46, 66, 0.15)',
'grey-100': 'rgba(71, 67, 96, 0.15)',
'grey-200': 'rgba(74, 80, 114, 0.15)',
'grey-300': 'rgba(94, 102, 146, 0.15)',
'grey-400': 'rgba(121, 131, 187, 0.15)',
'grey-500': 'rgba(134, 146, 208, 0.15)',
'grey-600': 'rgba(170, 179, 222, 0.15)',
'grey-700': 'rgba(182, 190, 227, 0.15)',
'grey-800': 'rgba(207, 211, 236, 0.15)',
'grey-900': 'rgba(231, 233, 246, 0.15)',
'perfect-scrollbar-thumb': 'rgba(158, 158, 190, 0.4)',
'skin-bordered-background': 'rgba(30, 30, 30, 0.3)',
'skin-bordered-surface': 'rgba(30, 30, 30, 0.3)',
'card-background': 'rgba(30, 30, 30, 0.3)',
},
variables: {
'code-color': '#6D9EEB',
'overlay-scrim-background': '0, 0, 0',
'overlay-scrim-opacity': 0.7,
'hover-opacity': 0.1,
'focus-opacity': 0.15,
'selected-opacity': 0.2,
'activated-opacity': 0.15,
'pressed-opacity': 0.2,
'dragged-opacity': 0.15,
'border-color': '#E7E3FC',
'table-header-background': 'rgba(30, 30, 30, 0.3)',
'custom-background': 'rgba(30, 30, 30, 0.3)',
'card-background': 'rgba(30, 30, 30, 0.3)',
// Shadows
'shadow-key-umbra-opacity': 'rgba(0, 0, 0, 0.07)',
'shadow-key-penumbra-opacity': 'rgba(0, 0, 0, 0.1)',
'shadow-key-ambient-opacity': 'rgba(0, 0, 0, 0.05)',
},
},
},
}
export default theme

View File

@@ -0,0 +1,79 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'MyPlugin',
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
},
shared: {
vue: {
requiredVersion: false,
generate: false,
},
vuetify: {
requiredVersion: false,
generate: false,
singleton: true,
},
'vuetify/styles': {
requiredVersion: false,
generate: false,
singleton: true,
},
},
format: 'esm'
})
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: true, // 改为true以便能分离样式文件
},
css: {
preprocessorOptions: {
scss: {
additionalData: '/* 覆盖vuetify样式 */',
}
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
if (atRule.name === 'charset') {
atRule.remove();
}
}
}
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
// 过滤掉所有vuetify相关的CSS
root.walkRules(rule => {
if (rule.selector && (
rule.selector.includes('.v-') ||
rule.selector.includes('.mdi-'))) {
rule.remove();
}
});
}
}
]
}
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
},
})

View File

@@ -0,0 +1,561 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@babel/helper-string-parser@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
"@babel/helper-validator-identifier@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8"
integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
"@babel/parser@^7.25.3":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.1.tgz#c55d5bed74449d1223701f1869b9ee345cc94cc9"
integrity sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==
dependencies:
"@babel/types" "^7.27.1"
"@babel/types@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.1.tgz#9defc53c16fc899e46941fc6901a9eea1c9d8560"
integrity sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==
dependencies:
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1"
"@esbuild/aix-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
"@esbuild/android-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
"@esbuild/android-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
"@esbuild/android-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
"@esbuild/darwin-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a"
integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
"@esbuild/darwin-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
"@esbuild/freebsd-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
"@esbuild/freebsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
"@esbuild/linux-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
"@esbuild/linux-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
"@esbuild/linux-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
"@esbuild/linux-loong64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
"@esbuild/linux-mips64el@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
"@esbuild/linux-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
"@esbuild/linux-riscv64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
"@esbuild/linux-s390x@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
"@esbuild/linux-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
"@esbuild/netbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
"@esbuild/openbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
"@esbuild/sunos-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
"@esbuild/win32-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
"@esbuild/win32-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
"@esbuild/win32-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c"
integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==
"@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a"
integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==
"@originjs/vite-plugin-federation@^1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@originjs/vite-plugin-federation/-/vite-plugin-federation-1.4.1.tgz#e6abc8f18f2cf82783eb87853f4d03e6358b43c2"
integrity sha512-Uo08jW5pj1t58OUKuZNkmzcfTN2pqeVuAWCCiKf/75/oll4Efq4cHOqSE1FXMlvwZNGDziNdDyBbQ5IANem3CQ==
dependencies:
estree-walker "^3.0.2"
magic-string "^0.27.0"
"@rollup/rollup-android-arm-eabi@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz#c228d00a41f0dbd6fb8b7ea819bbfbf1c1157a10"
integrity sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==
"@rollup/rollup-android-arm64@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz#e2b38d0c912169fd55d7e38d723aada208d37256"
integrity sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==
"@rollup/rollup-darwin-arm64@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz#1fddb3690f2ae33df16d334c613377f05abe4878"
integrity sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==
"@rollup/rollup-darwin-x64@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz#818298d11c8109e1112590165142f14be24b396d"
integrity sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==
"@rollup/rollup-freebsd-arm64@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz#91a28dc527d5bed7f9ecf0e054297b3012e19618"
integrity sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==
"@rollup/rollup-freebsd-x64@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz#28acadefa76b5c7bede1576e065b51d335c62c62"
integrity sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==
"@rollup/rollup-linux-arm-gnueabihf@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz#819691464179cbcd9a9f9d3dc7617954840c6186"
integrity sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==
"@rollup/rollup-linux-arm-musleabihf@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz#d149207039e4189e267e8724050388effc80d704"
integrity sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==
"@rollup/rollup-linux-arm64-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz#fa72ebddb729c3c6d88973242f1a2153c83e86ec"
integrity sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==
"@rollup/rollup-linux-arm64-musl@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz#2054216e34469ab8765588ebf343d531fc3c9228"
integrity sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==
"@rollup/rollup-linux-loongarch64-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz#818de242291841afbfc483a84f11e9c7a11959bc"
integrity sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==
"@rollup/rollup-linux-powerpc64le-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz#0bb4cb8fc4a2c635f68c1208c924b2145eb647cb"
integrity sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==
"@rollup/rollup-linux-riscv64-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz#4b3b8e541b7b13e447ae07774217d98c06f6926d"
integrity sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==
"@rollup/rollup-linux-riscv64-musl@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz#e065405e67d8bd64a7d0126c931bd9f03910817f"
integrity sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==
"@rollup/rollup-linux-s390x-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz#dda3265bbbfe16a5d0089168fd07f5ebb2a866fe"
integrity sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==
"@rollup/rollup-linux-x64-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz#90993269b8b995b4067b7b9d72ff1c360ef90a17"
integrity sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==
"@rollup/rollup-linux-x64-musl@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz#fdf5b09fd121eb8d977ebb0fda142c7c0167b8de"
integrity sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==
"@rollup/rollup-win32-arm64-msvc@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz#6397e1e012db64dfecfed0774cb9fcf89503d716"
integrity sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==
"@rollup/rollup-win32-ia32-msvc@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz#df0991464a52a35506103fe18d29913bf8798a0c"
integrity sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==
"@rollup/rollup-win32-x64-msvc@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz#8dae04d01a2cbd84d6297d99356674c6b993f0fc"
integrity sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==
"@types/estree@1.0.7", "@types/estree@^1.0.0":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
"@types/web-bluetooth@^0.0.21":
version "0.0.21"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63"
integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==
"@vitejs/plugin-vue@^4.4.0":
version "4.6.2"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz#057d2ded94c4e71b94e9814f92dcd9306317aa46"
integrity sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==
"@vue/compiler-core@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.13.tgz#b0ae6c4347f60c03e849a05d34e5bf747c9bda05"
integrity sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==
dependencies:
"@babel/parser" "^7.25.3"
"@vue/shared" "3.5.13"
entities "^4.5.0"
estree-walker "^2.0.2"
source-map-js "^1.2.0"
"@vue/compiler-dom@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz#bb1b8758dbc542b3658dda973b98a1c9311a8a58"
integrity sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==
dependencies:
"@vue/compiler-core" "3.5.13"
"@vue/shared" "3.5.13"
"@vue/compiler-sfc@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz#461f8bd343b5c06fac4189c4fef8af32dea82b46"
integrity sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==
dependencies:
"@babel/parser" "^7.25.3"
"@vue/compiler-core" "3.5.13"
"@vue/compiler-dom" "3.5.13"
"@vue/compiler-ssr" "3.5.13"
"@vue/shared" "3.5.13"
estree-walker "^2.0.2"
magic-string "^0.30.11"
postcss "^8.4.48"
source-map-js "^1.2.0"
"@vue/compiler-ssr@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz#e771adcca6d3d000f91a4277c972a996d07f43ba"
integrity sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==
dependencies:
"@vue/compiler-dom" "3.5.13"
"@vue/shared" "3.5.13"
"@vue/reactivity@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.13.tgz#b41ff2bb865e093899a22219f5b25f97b6fe155f"
integrity sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==
dependencies:
"@vue/shared" "3.5.13"
"@vue/runtime-core@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.13.tgz#1fafa4bf0b97af0ebdd9dbfe98cd630da363a455"
integrity sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==
dependencies:
"@vue/reactivity" "3.5.13"
"@vue/shared" "3.5.13"
"@vue/runtime-dom@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz#610fc795de9246300e8ae8865930d534e1246215"
integrity sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==
dependencies:
"@vue/reactivity" "3.5.13"
"@vue/runtime-core" "3.5.13"
"@vue/shared" "3.5.13"
csstype "^3.1.3"
"@vue/server-renderer@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.13.tgz#429ead62ee51de789646c22efe908e489aad46f7"
integrity sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==
dependencies:
"@vue/compiler-ssr" "3.5.13"
"@vue/shared" "3.5.13"
"@vue/shared@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f"
integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==
"@vueuse/core@^12.4.0":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-12.8.2.tgz#007c6dd29a7d1f6933e916e7a2f8ef3c3f968eaa"
integrity sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==
dependencies:
"@types/web-bluetooth" "^0.0.21"
"@vueuse/metadata" "12.8.2"
"@vueuse/shared" "12.8.2"
vue "^3.5.13"
"@vueuse/metadata@12.8.2":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3"
integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==
"@vueuse/shared@12.8.2":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.8.2.tgz#b9e4611d0603629c8e151f982459da394e22f930"
integrity sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==
dependencies:
vue "^3.5.13"
csstype@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
echarts@^5.4.3:
version "5.6.0"
resolved "https://registry.yarnpkg.com/echarts/-/echarts-5.6.0.tgz#2377874dca9fb50f104051c3553544752da3c9d6"
integrity sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==
dependencies:
tslib "2.3.0"
zrender "5.6.1"
entities@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
esbuild@^0.21.3:
version "0.21.5"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d"
integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==
optionalDependencies:
"@esbuild/aix-ppc64" "0.21.5"
"@esbuild/android-arm" "0.21.5"
"@esbuild/android-arm64" "0.21.5"
"@esbuild/android-x64" "0.21.5"
"@esbuild/darwin-arm64" "0.21.5"
"@esbuild/darwin-x64" "0.21.5"
"@esbuild/freebsd-arm64" "0.21.5"
"@esbuild/freebsd-x64" "0.21.5"
"@esbuild/linux-arm" "0.21.5"
"@esbuild/linux-arm64" "0.21.5"
"@esbuild/linux-ia32" "0.21.5"
"@esbuild/linux-loong64" "0.21.5"
"@esbuild/linux-mips64el" "0.21.5"
"@esbuild/linux-ppc64" "0.21.5"
"@esbuild/linux-riscv64" "0.21.5"
"@esbuild/linux-s390x" "0.21.5"
"@esbuild/linux-x64" "0.21.5"
"@esbuild/netbsd-x64" "0.21.5"
"@esbuild/openbsd-x64" "0.21.5"
"@esbuild/sunos-x64" "0.21.5"
"@esbuild/win32-arm64" "0.21.5"
"@esbuild/win32-ia32" "0.21.5"
"@esbuild/win32-x64" "0.21.5"
estree-walker@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
estree-walker@^3.0.2:
version "3.0.3"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d"
integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==
dependencies:
"@types/estree" "^1.0.0"
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
magic-string@^0.27.0:
version "0.27.0"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3"
integrity sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.4.13"
magic-string@^0.30.11:
version "0.30.17"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.0"
nanoid@^3.3.8:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
postcss@^8.4.43, postcss@^8.4.48:
version "8.5.3"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb"
integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==
dependencies:
nanoid "^3.3.8"
picocolors "^1.1.1"
source-map-js "^1.2.1"
resize-detector@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/resize-detector/-/resize-detector-0.3.0.tgz#fe495112e184695500a8f51e0389f15774cb1cfc"
integrity sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ==
rollup@^4.20.0:
version "4.40.2"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.40.2.tgz#778e88b7a197542682b3e318581f7697f55f0619"
integrity sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==
dependencies:
"@types/estree" "1.0.7"
optionalDependencies:
"@rollup/rollup-android-arm-eabi" "4.40.2"
"@rollup/rollup-android-arm64" "4.40.2"
"@rollup/rollup-darwin-arm64" "4.40.2"
"@rollup/rollup-darwin-x64" "4.40.2"
"@rollup/rollup-freebsd-arm64" "4.40.2"
"@rollup/rollup-freebsd-x64" "4.40.2"
"@rollup/rollup-linux-arm-gnueabihf" "4.40.2"
"@rollup/rollup-linux-arm-musleabihf" "4.40.2"
"@rollup/rollup-linux-arm64-gnu" "4.40.2"
"@rollup/rollup-linux-arm64-musl" "4.40.2"
"@rollup/rollup-linux-loongarch64-gnu" "4.40.2"
"@rollup/rollup-linux-powerpc64le-gnu" "4.40.2"
"@rollup/rollup-linux-riscv64-gnu" "4.40.2"
"@rollup/rollup-linux-riscv64-musl" "4.40.2"
"@rollup/rollup-linux-s390x-gnu" "4.40.2"
"@rollup/rollup-linux-x64-gnu" "4.40.2"
"@rollup/rollup-linux-x64-musl" "4.40.2"
"@rollup/rollup-win32-arm64-msvc" "4.40.2"
"@rollup/rollup-win32-ia32-msvc" "4.40.2"
"@rollup/rollup-win32-x64-msvc" "4.40.2"
fsevents "~2.3.2"
source-map-js@^1.2.0, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
vite@^5.4.11:
version "5.4.19"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.19.tgz#20efd060410044b3ed555049418a5e7d1998f959"
integrity sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==
dependencies:
esbuild "^0.21.3"
postcss "^8.4.43"
rollup "^4.20.0"
optionalDependencies:
fsevents "~2.3.3"
vue-demi@^0.13.11:
version "0.13.11"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99"
integrity sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==
vue-echarts@^6.6.1:
version "6.7.3"
resolved "https://registry.yarnpkg.com/vue-echarts/-/vue-echarts-6.7.3.tgz#30efafc51a4a9de1b8117d3b63e74b0c761ff3ba"
integrity sha512-vXLKpALFjbPphW9IfQPOVfb1KjGZ/f8qa/FZHi9lZIWzAnQC1DgnmEK3pJgEkyo6EP7UnX6Bv/V3Ke7p+qCNXA==
dependencies:
resize-detector "^0.3.0"
vue-demi "^0.13.11"
vue@^3.5.13:
version "3.5.13"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a"
integrity sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==
dependencies:
"@vue/compiler-dom" "3.5.13"
"@vue/compiler-sfc" "3.5.13"
"@vue/runtime-dom" "3.5.13"
"@vue/server-renderer" "3.5.13"
"@vue/shared" "3.5.13"
vuetify@3.7.3:
version "3.7.3"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.7.3.tgz#0e89f7f0298d452510bcbc01b0e9b53a5ce6e883"
integrity sha512-bpuvBpZl1/+nLlXDgdVXekvMNR6W/ciaoa8CYlpeAzAARbY8zUFSoBq05JlLhkIHI58AnzKVy4c09d0OtfYAPg==
zrender@5.6.1:
version "5.6.1"
resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.6.1.tgz#e08d57ecf4acac708c4fcb7481eb201df7f10a6b"
integrity sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==
dependencies:
tslib "2.3.0"

View File

@@ -47,6 +47,59 @@
<!-- Logo -->
<svg width="160px" height="160px" 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">
<style>
/* 添加SVG内部的动画样式 */
@keyframes pulse {
0%,
100% {
opacity: 0.8;
}
50% {
opacity: 1;
}
}
@keyframes glow {
0%,
100% {
filter: drop-shadow(0 0 3px rgba(141, 81, 249, 0.3));
}
50% {
filter: drop-shadow(0 0 6px rgba(141, 81, 249, 0.6));
}
}
/* 为各个元素添加动画 */
#a2-c {
filter: drop-shadow(0 0 5px rgba(141, 81, 249, 0.3));
animation: glow 3s ease-in-out infinite;
}
path {
animation: pulse 2s ease-in-out infinite;
}
/* 错开不同元素的动画开始时间 */
g:nth-child(2) path {
animation-delay: 0.3s;
}
g:nth-child(3) path {
animation-delay: 0.6s;
}
g:nth-child(4) path {
animation-delay: 0.9s;
}
g:nth-child(5) path {
animation-delay: 1.2s;
}
</style>
<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" />

14609
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{
"name": "moviepilot",
"version": "2.4.1",
"version": "2.4.5",
"private": true,
"type": "module",
"bin": "dist/service.js",
"scripts": {
"dev": "vite --host",
@@ -69,6 +70,7 @@
"@iconify/tools": "^4.0.4",
"@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@originjs/vite-plugin-federation": "^1.4.1",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},

View File

@@ -5,12 +5,28 @@
background: var(--initial-loader-bg, #fff);
block-size: 100vh;
inline-size: 100vw;
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
.loading-logo {
position: absolute;
inset-block-start: 35%;
inset-inline-start: calc(50% - 5rem);
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
/* 添加logo完成动画 - 放大虚化效果 */
.loading-complete .loading-logo {
filter: blur(10px);
opacity: 0;
transform: scale(1.5);
}
/* 添加加载背景消失动画 - 放大虚化效果 */
.loading-complete {
filter: blur(15px);
opacity: 0;
transform: scale(1.2);
}
.loading {
@@ -22,6 +38,12 @@
inline-size: 55px;
inset-block-start: 80%;
inset-inline-start: calc(50% - 27.5px);
transition: opacity 0.6s ease;
}
/* 完成时隐藏加载动画 */
.loading-complete .loading {
opacity: 0;
}
.loading .effect-1,
@@ -72,4 +94,4 @@
opacity: 1;
transform: rotate(1turn);
}
}
}

View File

@@ -1,189 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useDisplay, useTheme } from 'vuetify'
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()
const props = defineProps<{
themes: ThemeSwitcherTheme[]
}>()
const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
const currentThemeName = ref(savedTheme.value)
const getNextThemeName = () => {
const currentIndex = props.themes.findIndex(t => t.name === currentThemeName.value)
const nextIndex = (currentIndex + 1) % props.themes.length
return props.themes[nextIndex].name
}
const $toast = useToast()
// 自定义CSS弹窗
const cssDialog = ref(false)
// 自定义 CSS
const customCSS = ref('')
// 编辑器主题
const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
// 更新主题
function updateTheme() {
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
globalTheme.name.value = theme
// 保存原始主题设置,而不是计算后的值
savedTheme.value = currentThemeName.value
// 保存主题到本地
saveLocalTheme(currentThemeName.value, globalTheme)
// 刷新页面
location.reload()
}
// 切换主题
function changeTheme(theme: string) {
let nextTheme = theme
if (!theme) nextTheme = getNextThemeName()
currentThemeName.value = nextTheme
// 保存主题到服务端
try {
api.post('/user/config/Layout', {
theme: nextTheme,
})
} catch (e) {
console.error(e)
}
}
// 是否有滚动条
function hasScrollbar(el?: Element | null) {
if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
const style = window.getComputedStyle(el)
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
}
// 监听系统主题变化
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
} catch (e) {
console.error('当前设备不支持监听系统主题变化')
}
// 查询当前主题的图标
const getThemeIcon = computed(() => {
const theme = props.themes.find(t => t.name === currentThemeName.value)
return theme?.icon ?? 'mdi-circle'
})
// 监听设置主题变化
watch(
() => currentThemeName.value,
() => updateTheme(),
)
// 获取自定义 CSS
async function getCustomCSS() {
try {
const result: { [key: string]: any } = await api.get('system/setting/UserCustomCSS')
if (result && result.success && result.data?.value) {
customCSS.value = result.data?.value ?? ''
if (customCSS.value) {
const style = document.createElement('style')
style.innerHTML = result.data?.value ?? ''
document.head.appendChild(style)
}
}
} catch (error) {
console.error(error)
}
}
// 保存自定义 CSS
async function saveCustomCSS() {
cssDialog.value = false
try {
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', customCSS.value, {
headers: {
'Content-Type': 'text/plain',
},
})
if (result.success) $toast.success('自定义CSS保存成功请刷新页面生效')
} catch (e) {
console.error('保存自定义 CSS 到服务端失败')
}
}
onMounted(() => {
getCustomCSS()
})
</script>
<template>
<VMenu v-if="props.themes" class="theme-menu" scrim>
<template v-slot:activator="{ props }">
<IconBtn v-bind="props">
<VIcon :icon="getThemeIcon" />
</IconBtn>
</template>
<VList>
<div class="px-2">
<VListItem
v-for="theme in props.themes"
:key="theme.name"
@click="changeTheme(theme.name)"
:active="currentThemeName === theme.name"
class="mb-1"
>
<template #prepend>
<VIcon :icon="theme.icon" />
</template>
<VListItemTitle>{{ theme.title }}</VListItemTitle>
<template #append v-if="currentThemeName === theme.name">
<VIcon icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
<VDivider class="my-2" />
<VListItem @click="cssDialog = true">
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
<VListItemTitle>自定义主题</VListItemTitle>
</VListItem>
</div>
</VList>
</VMenu>
<!-- 自定义 CSS -- -->
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="me-2" />
自定义主题风格
</VCardTitle>
<VDialogCloseBtn @click="cssDialog = false" />
</VCardItem>
<VDivider />
<VAceEditor v-model:value="customCSS" lang="css" :theme="editorTheme" class="w-full min-h-[30rem]" />
<VDivider />
<VCardText class="text-center">
<VBtn @click="saveCustomCSS" class="w-1/2">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
保存
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,7 +1,7 @@
@use "@configured-variables" as variables;
// ————————————————————————————————————
//* ——— Perfect Scrollbar
// Perfect Scrollbar
// ————————————————————————————————————
.v-application.v-theme--dark {

View File

@@ -12,6 +12,14 @@
*/
import { promises as fs } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
// Get current directory
const __dirname = dirname(fileURLToPath(import.meta.url))
// Create require function for importing JSON files in ESM
const require = createRequire(import.meta.url)
// Installation: npm install --save-dev @iconify/tools @iconify/utils @iconify/json @iconify/iconify
import {

View File

@@ -1,17 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"module": "Node16",
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"composite": false,
"strict": true,
"moduleResolution": "node",
"moduleResolution": "node16",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
},
"exclude": [
"./*.js"
]
}
}

View File

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

View File

@@ -1,7 +1,11 @@
import type { ValidationRule } from 'vuetify/types/services/validation'
// 必输校验
export const requiredValidator: ValidationRule = (value: any) => !!value || '此项为必填项'
export const requiredValidator: ValidationRule = (value: any) => {
return !!value
}
// 数字校验
export const numberValidator: ValidationRule = (value: any) => !isNaN(value) || '请输入数字'
export const numberValidator: ValidationRule = (value: any) => {
return !isNaN(value)
}

View File

@@ -3,6 +3,13 @@ import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useI18n } from 'vue-i18n'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n'
// 国际化
const { t } = useI18n()
// 生效主题
const { global: globalTheme } = useTheme()
@@ -10,88 +17,26 @@ let themeValue = localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 生效语言
const localeValue = getBrowserLocale()
setI18nLanguage(localeValue as SupportedLocale)
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 更新data-theme属性以便CSS选择器能正确匹配
function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
// 确保body元素也有相同的主题属性以便更好地选择弹出窗口
document.body.setAttribute('data-theme', themeName)
}
// 显示状态
const show = ref(false)
// 检查是否登录
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
// 背景图片
const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
let backgroundRotationTimer: NodeJS.Timeout | null = null
// 获取背景图片
async function fetchBackgroundImages() {
try {
backgroundImages.value = await api.get('/login/wallpapers')
} catch (e) {
console.error(e)
}
}
// 开始背景图片轮换
function startBackgroundRotation() {
if (backgroundRotationTimer) clearInterval(backgroundRotationTimer)
if (backgroundImages.value.length > 1) {
backgroundRotationTimer = setInterval(() => {
activeImageIndex.value = (activeImageIndex.value + 1) % backgroundImages.value.length
}, 10000) // 每10秒切换一次
}
}
// 计算图片地址
function getImgUrl(url: string) {
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
}
// 处理页面可见性变化
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && isTransparentTheme.value) {
// 如果已有背景图片数据,直接重启轮换
if (backgroundImages.value.length > 0) {
startBackgroundRotation()
}
// 如果没有背景图片数据,重新获取
else {
fetchBackgroundImages().then(() => startBackgroundRotation())
}
}
}
// 监听主题变化
watch(
() => globalTheme.name.value,
async newTheme => {
// 更新HTML属性
updateHtmlThemeAttribute(newTheme)
if (newTheme === 'transparent' && backgroundImages.value.length === 0) {
await fetchBackgroundImages()
startBackgroundRotation()
} else if (newTheme !== 'transparent' && backgroundRotationTimer) {
clearInterval(backgroundRotationTimer)
backgroundRotationTimer = null
}
},
{ immediate: true },
)
// ApexCharts 全局配置
declare global {
interface Window {
@@ -122,10 +67,113 @@ if (window.Apex) {
}
}
// 更新data-theme属性以便CSS选择器能正确匹配
function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
// 确保body元素也有相同的主题属性以便更好地选择弹出窗口
document.body.setAttribute('data-theme', themeName)
}
// 获取背景图片
async function fetchBackgroundImages() {
try {
backgroundImages.value = await api.get(`/login/wallpapers`)
} catch (e) {
console.error(e)
}
}
// 开始背景图片轮换
function startBackgroundRotation() {
if (backgroundRotationTimer) clearInterval(backgroundRotationTimer)
if (backgroundImages.value.length > 1) {
backgroundRotationTimer = setInterval(() => {
// 计算下一个图片索引
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
// 预加载下一张图片
preloadImage(backgroundImages.value[nextIndex]).then(success => {
// 只有图片成功加载才切换
if (success) {
activeImageIndex.value = nextIndex
}
})
}, 10000) // 每10秒切换一次
}
}
// 预加载图片
function preloadImage(url: string): Promise<boolean> {
return new Promise(resolve => {
const img = new Image()
const imageUrl = getImgUrl(url)
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
// 设置超时,防止图片长时间加载
const timeout = setTimeout(() => {
img.src = ''
resolve(false)
}, 5000) // 5秒超时
img.src = imageUrl
// 如果图片已经缓存onload可能不会触发
if (img.complete) {
clearTimeout(timeout)
resolve(true)
}
})
}
// 计算图片地址
function getImgUrl(url: string) {
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && isLogin.value)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}
// 处理页面可见性变化
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
// 如果已有背景图片数据,直接重启轮换
if (backgroundImages.value.length > 0) {
startBackgroundRotation()
}
// 如果没有背景图片数据,重新获取
else {
fetchBackgroundImages().then(() => startBackgroundRotation())
}
}
}
// 添加logo动画效果并延迟移除加载界面
function animateAndRemoveLoader() {
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
if (loadingBg) {
// 先添加完成动画类
loadingBg.classList.add('loading-complete')
// 等待动画完成后再移除元素
setTimeout(() => {
removeEl('#loading-bg')
// 将background属性从html的style中移除
document.documentElement.style.removeProperty('background')
// 显示页面
show.value = true
}, 500) // 与CSS动画持续时间匹配
}
}
onMounted(() => {
// 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value)
// 加载背景图片并开始轮换
fetchBackgroundImages().then(() => startBackgroundRotation())
// 添加页面可见性变化监听
document.addEventListener('visibilitychange', handleVisibilityChange)
@@ -133,11 +181,7 @@ onMounted(() => {
nextTick(() => {
setTimeout(() => {
// 移除加载动画
removeEl('#loading-bg')
// 将background属性从html的style中移除
document.documentElement.style.removeProperty('background')
// 显示页面
show.value = true
animateAndRemoveLoader()
}, 1500)
})
})
@@ -158,7 +202,7 @@ onUnmounted(() => {
<template>
<div class="app-wrapper">
<!-- 透明主题背景 -->
<template v-if="isTransparentTheme && backgroundImages.length > 0">
<template v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)">
<div class="background-container">
<div
v-for="(imageUrl, index) in backgroundImages"
@@ -168,7 +212,7 @@ onUnmounted(() => {
:style="{ backgroundImage: `url(${getImgUrl(imageUrl)})` }"
></div>
<!-- 全局磨砂层 -->
<div class="global-blur-layer"></div>
<div v-if="isLogin" class="global-blur-layer"></div>
</div>
</template>

View File

@@ -1,85 +1,344 @@
export const storageOptions = [
import i18n from '@/plugins/i18n'
export const storageAttributes = [
{
title: '本地',
value: 'local',
type: 'local',
icon: 'mdi-folder-multiple-outline',
remote: false,
},
{
title: '阿里云盘',
value: 'alipan',
type: 'alipan',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: '115网盘',
value: 'u115',
type: 'u115',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: 'RClone',
value: 'rclone',
type: 'rclone',
icon: 'mdi-server-network-outline',
remote: true,
},
{
title: 'AList',
value: 'alist',
type: 'alist',
icon: 'mdi-server-network-outline',
remote: true,
},
]
export const innerFilterRules = [
{ title: '特效字幕', value: ' SPECSUB ' },
{ title: '中文字幕', value: ' CNSUB ' },
{ title: '国语配音', value: ' CNVOI ' },
{ title: '官种', value: ' GZ ' },
{ title: '排除: 国语配音', value: ' !CNVOI ' },
{ title: '粤语配音', value: ' HKVOI ' },
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
{ title: '促销: 免费', value: ' FREE ' },
{ title: '分辨率: 4K', value: ' 4K ' },
{ title: '分辨率: 1080P', value: ' 1080P ' },
{ title: '分辨率: 720P', value: ' 720P ' },
{ title: '排除: 720P', value: ' !720P ' },
{ title: '质量: 蓝光原盘', value: ' BLU ' },
{ title: '排除: 蓝光原盘', value: ' !BLU ' },
{ title: '质量: BLURAY', value: ' BLURAY ' },
{ title: '排除: BLURAY', value: ' !BLURAY ' },
{ title: '质量: UHD', value: ' UHD ' },
{ title: '排除: UHD', value: ' !UHD ' },
{ title: '质量: REMUX', value: ' REMUX ' },
{ title: '排除: REMUX', value: ' !REMUX ' },
{ title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
{ title: '质量: 60fps', value: ' 60FPS ' },
{ title: '排除: 60fps', value: ' !60FPS ' },
{ title: '编码: H265', value: ' H265 ' },
{ title: '排除: H265', value: ' !H265 ' },
{ title: '编码: H264', value: ' H264 ' },
{ title: '排除: H264', value: ' !H264 ' },
{ title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
{ title: '效果: HDR', value: ' HDR ' },
{ title: '排除: HDR', value: ' !HDR ' },
{ title: '效果: SDR', value: ' SDR ' },
{ title: '排除: SDR', value: ' !SDR ' },
{ title: '效果: 3D', value: ' 3D ' },
{ title: '排除: 3D', value: ' !3D ' },
export const storageIconDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.icon
return dict
}, {} as Record<string, string>)
export const storageRemoteDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.remote
return dict
}, {} as Record<string, boolean>)
export const downloaderOptions = [
{
value: 'qbittorrent',
title: i18n.global.t('setting.system.qbittorrent'),
},
{
value: 'transmission',
title: i18n.global.t('setting.system.transmission'),
},
]
export const storageDict = storageOptions.reduce((dict, item) => {
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const transferTypeOptions = [
{ title: '复制', value: 'copy' },
{ title: '移动', value: 'move' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
export const mediaServerOptions = [
{
value: 'emby',
title: i18n.global.t('setting.system.emby'),
},
{
value: 'jellyfin',
title: i18n.global.t('setting.system.jellyfin'),
},
{
value: 'plex',
title: i18n.global.t('setting.system.plex'),
},
{
value: 'trimemedia',
title: i18n.global.t('setting.system.trimeMedia'),
},
]
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const innerFilterRules = [
{ title: i18n.global.t('filterRules.specSub'), value: ' SPECSUB ' },
{ title: i18n.global.t('filterRules.cnSub'), value: ' CNSUB ' },
{ title: i18n.global.t('filterRules.cnVoi'), value: ' CNVOI ' },
{ title: i18n.global.t('filterRules.gz'), value: ' GZ ' },
{ title: i18n.global.t('filterRules.notCnVoi'), value: ' !CNVOI ' },
{ title: i18n.global.t('filterRules.hkVoi'), value: ' HKVOI ' },
{ title: i18n.global.t('filterRules.notHkVoi'), value: ' !HKVOI ' },
{ title: i18n.global.t('filterRules.free'), value: ' FREE ' },
{ title: i18n.global.t('filterRules.resolution4k'), value: ' 4K ' },
{ title: i18n.global.t('filterRules.resolution1080p'), value: ' 1080P ' },
{ title: i18n.global.t('filterRules.resolution720p'), value: ' 720P ' },
{ title: i18n.global.t('filterRules.not720p'), value: ' !720P ' },
{ title: i18n.global.t('filterRules.qualityBlu'), value: ' BLU ' },
{ title: i18n.global.t('filterRules.notBlu'), value: ' !BLU ' },
{ title: i18n.global.t('filterRules.qualityBluray'), value: ' BLURAY ' },
{ title: i18n.global.t('filterRules.notBluray'), value: ' !BLURAY ' },
{ title: i18n.global.t('filterRules.qualityUhd'), value: ' UHD ' },
{ title: i18n.global.t('filterRules.notUhd'), value: ' !UHD ' },
{ title: i18n.global.t('filterRules.qualityRemux'), value: ' REMUX ' },
{ title: i18n.global.t('filterRules.notRemux'), value: ' !REMUX ' },
{ title: i18n.global.t('filterRules.qualityWebdl'), value: ' WEBDL ' },
{ title: i18n.global.t('filterRules.notWebdl'), value: ' !WEBDL ' },
{ title: i18n.global.t('filterRules.quality60fps'), value: ' 60FPS ' },
{ title: i18n.global.t('filterRules.not60fps'), value: ' !60FPS ' },
{ title: i18n.global.t('filterRules.codecH265'), value: ' H265 ' },
{ title: i18n.global.t('filterRules.notH265'), value: ' !H265 ' },
{ title: i18n.global.t('filterRules.codecH264'), value: ' H264 ' },
{ title: i18n.global.t('filterRules.notH264'), value: ' !H264 ' },
{ title: i18n.global.t('filterRules.effectDolby'), value: ' DOLBY ' },
{ title: i18n.global.t('filterRules.notDolby'), value: ' !DOLBY ' },
{ title: i18n.global.t('filterRules.effectAtmos'), value: ' ATMOS ' },
{ title: i18n.global.t('filterRules.notAtmos'), value: ' !ATMOS ' },
{ title: i18n.global.t('filterRules.effectHdr'), value: ' HDR ' },
{ title: i18n.global.t('filterRules.notHdr'), value: ' !HDR ' },
{ title: i18n.global.t('filterRules.effectSdr'), value: ' SDR ' },
{ title: i18n.global.t('filterRules.notSdr'), value: ' !SDR ' },
{ title: i18n.global.t('filterRules.effect3d'), value: ' 3D ' },
{ title: i18n.global.t('filterRules.not3d'), value: ' !3D ' },
]
export const transferTypeOptions = [
{ title: i18n.global.t('transferType.copy'), value: 'copy' },
{ title: i18n.global.t('transferType.move'), value: 'move' },
{ title: i18n.global.t('transferType.link'), value: 'link' },
{ title: i18n.global.t('transferType.softlink'), value: 'softlink' },
]
export const qualityOptions = ref([
{
title: i18n.global.t('qualityOptions.all'),
value: '',
},
{
title: i18n.global.t('qualityOptions.blurayOriginal'),
value: 'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD',
},
{
title: i18n.global.t('qualityOptions.remux'),
value: 'Remux',
},
{
title: i18n.global.t('qualityOptions.bluray'),
value: 'Blu-?Ray',
},
{
title: i18n.global.t('qualityOptions.uhd'),
value: 'UHD|UltraHD',
},
{
title: i18n.global.t('qualityOptions.webdl'),
value: 'WEB-?DL|WEB-?RIP',
},
{
title: i18n.global.t('qualityOptions.hdtv'),
value: 'HDTV',
},
{
title: i18n.global.t('qualityOptions.h265'),
value: '[Hx].?265|HEVC',
},
{
title: i18n.global.t('qualityOptions.h264'),
value: '[Hx].?264|AVC',
},
])
// 分辨率选择框数据
export const resolutionOptions = ref([
{
title: i18n.global.t('resolutionOptions.all'),
value: '',
},
{
title: i18n.global.t('resolutionOptions.4k'),
value: '4K|2160p|x2160',
},
{
title: i18n.global.t('resolutionOptions.1080p'),
value: '1080[pi]|x1080',
},
{
title: i18n.global.t('resolutionOptions.720p'),
value: '720[pi]|x720',
},
])
// 特效选择框数据
export const effectOptions = ref([
{
title: i18n.global.t('effectOptions.all'),
value: '',
},
{
title: i18n.global.t('effectOptions.dolbyVision'),
value: 'Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+',
},
{
title: i18n.global.t('effectOptions.dolbyAtmos'),
value: 'Dolby[\\s.]*\\+?Atmos|Atmos',
},
{
title: i18n.global.t('effectOptions.hdr'),
value: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
},
{
title: i18n.global.t('effectOptions.sdr'),
value: '[\\s.]+SDR[\\s.]+',
},
])
// 媒体类型选项
export const mediaTypeOptions = [
{
title: i18n.global.t('mediaType.movie'),
value: '电影',
},
{
title: i18n.global.t('mediaType.tv'),
value: '电视剧',
},
{
title: i18n.global.t('mediaType.anime'),
value: '动漫',
},
{
title: i18n.global.t('mediaType.collection'),
value: '合集',
},
{
title: i18n.global.t('mediaType.unknown'),
value: '未知',
},
]
// 媒体类型字典
export const mediaTypeDict = mediaTypeOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
// 通知开关选项
export const notificationSwitchOptions = [
{
title: i18n.global.t('notificationSwitch.resourceDownload'),
value: '资源下载',
},
{
title: i18n.global.t('notificationSwitch.organize'),
value: '整理入库',
},
{
title: i18n.global.t('notificationSwitch.subscribe'),
value: '订阅',
},
{
title: i18n.global.t('notificationSwitch.site'),
value: '站点',
},
{
title: i18n.global.t('notificationSwitch.mediaServer'),
value: '媒体服务器',
},
{
title: i18n.global.t('notificationSwitch.manual'),
value: '手动处理',
},
{
title: i18n.global.t('notificationSwitch.plugin'),
value: '插件',
},
{
title: i18n.global.t('notificationSwitch.other'),
value: '其它',
},
]
// 通知开关字典
export const notificationSwitchDict = notificationSwitchOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
// 操作步骤选项
export const actionStepOptions = [
{
title: i18n.global.t('actionStep.addDownload'),
value: '添加下载',
},
{
title: i18n.global.t('actionStep.addSubscribe'),
value: '添加订阅',
},
{
title: i18n.global.t('actionStep.fetchDownloads'),
value: '获取下载任务',
},
{
title: i18n.global.t('actionStep.fetchMedias'),
value: '获取媒体数据',
},
{
title: i18n.global.t('actionStep.fetchRss'),
value: '获取RSS资源',
},
{
title: i18n.global.t('actionStep.fetchTorrents'),
value: '搜索站点资源',
},
{
title: i18n.global.t('actionStep.filterMedias'),
value: '过滤媒体数据',
},
{
title: i18n.global.t('actionStep.filterTorrents'),
value: '过滤资源',
},
{
title: i18n.global.t('actionStep.scanFile'),
value: '扫描目录',
},
{
title: i18n.global.t('actionStep.scrapeFile'),
value: '刮削文件',
},
{
title: i18n.global.t('actionStep.sendEvent'),
value: '发送事件',
},
{
title: i18n.global.t('actionStep.sendMessage'),
value: '发送消息',
},
{
title: i18n.global.t('actionStep.transferFile'),
value: '整理文件',
},
]
// 操作步骤字典
export const actionStepDict = actionStepOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)

View File

@@ -7,6 +7,16 @@ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
})
// 声明全局变量类型
declare global {
interface Window {
MoviePilotAPI: typeof api
}
}
// 将 API 实例暴露到全局,供插件使用
window.MoviePilotAPI = api
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store
@@ -41,13 +51,3 @@ api.interceptors.response.use(
)
export default api
export async function fetchGlobalSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global')
return result.data || {}
} catch (error) {
console.error('Failed to fetch global settings', error)
throw error
}
}

View File

@@ -631,6 +631,8 @@ export interface DashboardItem {
cols: { [key: string]: number }
// 页面元素
elements: RenderProps[]
// 渲染方式
render_mode: string
}
// 种子信息

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -3,8 +3,8 @@ import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue'
import FileNavigator from './filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageOptions } from '@/api/constants'
import { useDisplay } from 'vuetify'
import { storageIconDict } from '@/api/constants'
// 输入参数
const props = defineProps({
@@ -138,8 +138,11 @@ const showDirTree = ref(false)
// 计算属性
const storagesArray = computed(() => {
const storageCodes = props.storages?.map(item => item.type)
return storageOptions.filter(item => storageCodes?.includes(item.value))
return props.storages?.map(item => ({
title: item.name,
value: item.type,
icon: storageIconDict[item.type] ?? 'mdi-server-network-outline',
}))
})
// 方法

View File

@@ -1,4 +1,9 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
const props = defineProps<Props>()
interface Props {
@@ -28,12 +33,12 @@ interface Props {
<!-- 标题 -->
<div class="error-title">
{{ props.errorTitle || '暂无数据' }}
{{ props.errorTitle || t('common.noData') }}
</div>
<!-- 描述 -->
<div class="error-description">
{{ props.errorDescription || '没有找到相关内容' }}
{{ props.errorDescription || t('common.noContent') }}
</div>
<!-- 按钮插槽 -->

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
@@ -29,7 +28,7 @@ const getImgUrl = computed(() => {
</script>
<template>
<VHover v-bind="props">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"

View File

@@ -4,6 +4,7 @@ import { useToast } from 'vue-toast-notification'
import filter_svg from '@images/svg/filter.svg'
import { cloneDeep } from 'lodash-es'
import { innerFilterRules } from '@/api/constants'
import { useI18n } from 'vue-i18n'
// 输入参数
const props = defineProps({
@@ -21,6 +22,7 @@ const props = defineProps({
// 提示框
const $toast = useToast()
const { t } = useI18n()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
@@ -51,28 +53,28 @@ function saveRuleInfo() {
// 有空值
if (!ruleInfo.value.id || !ruleInfo.value.name) {
if (!ruleInfo.value.id && !ruleInfo.value.name) {
$toast.error('规则ID和规则名称不能为空')
$toast.error(t('customRule.error.emptyIdName'))
}
return
}
// 检查ID是否在内置的规则中
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
$toast.error('当前规则ID已被内置规则占用')
$toast.error(t('customRule.error.idOccupied'))
return
}
// 检查规则名称是否在内置的规则中
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
$toast.error('当前规则名称已被内置规则占用')
$toast.error(t('customRule.error.nameOccupied'))
return
}
// ID已存在
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
$toast.error(`规则ID【${ruleInfo.value.id}】已存在`)
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
return
}
// 规则名称已存在
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
$toast.error(`规则名称【${ruleInfo.value.name}】已存在`)
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
return
}
// 保存数据
@@ -104,8 +106,8 @@ function onClose() {
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.rule.id} - 配置`" class="rounded-t">
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem">
<VCard :title="t('customRule.title', { id: props.rule.id })">
<VDialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
@@ -114,9 +116,9 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.id"
label="规则ID"
placeholder="必填不可与其他规则ID重名"
hint="字符与数字组合,不能含空格"
:label="t('customRule.field.ruleId')"
:placeholder="t('customRule.placeholder.ruleId')"
:hint="t('customRule.hint.ruleId')"
persistent-hint
active
/>
@@ -124,9 +126,9 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.name"
label="规则名称"
placeholder="必填;不可与其他规则名称重名"
hint="使用别名便于区分规则"
:label="t('customRule.field.ruleName')"
:placeholder="t('customRule.placeholder.ruleName')"
:hint="t('customRule.hint.ruleName')"
persistent-hint
active
/>
@@ -134,9 +136,9 @@ function onClose() {
<VCol cols="12">
<VTextField
v-model="ruleInfo.include"
placeholder="关键字/正则表达式"
label="包含"
hint="必须包含的关键字或正则表达式,多个值使用|分隔"
:label="t('customRule.field.include')"
:placeholder="t('customRule.placeholder.include')"
:hint="t('customRule.hint.include')"
persistent-hint
active
/>
@@ -144,9 +146,9 @@ function onClose() {
<VCol cols="12">
<VTextField
v-model="ruleInfo.exclude"
placeholder="关键字/正则表达式"
label="排除"
hint="不能包含的关键字或正则表达式,多个值使用|分隔"
:label="t('customRule.field.exclude')"
:placeholder="t('customRule.placeholder.exclude')"
:hint="t('customRule.hint.exclude')"
persistent-hint
active
/>
@@ -154,9 +156,9 @@ function onClose() {
<VCol cols="6">
<VTextField
v-model="ruleInfo.size_range"
placeholder="0/1-10"
label="资源体积MB"
hint="最小资源文件体积或体积范围(剧集计算单集平均大小)"
:label="t('customRule.field.sizeRange')"
:placeholder="t('customRule.placeholder.sizeRange')"
:hint="t('customRule.hint.sizeRange')"
persistent-hint
active
/>
@@ -164,9 +166,9 @@ function onClose() {
<VCol cols="6">
<VTextField
v-model="ruleInfo.seeders"
placeholder="0/1-10"
label="做种人数"
hint="最小做种人数或做种人数范围"
:label="t('customRule.field.seeders')"
:placeholder="t('customRule.placeholder.seeders')"
:hint="t('customRule.hint.seeders')"
persistent-hint
active
/>
@@ -174,9 +176,9 @@ function onClose() {
<VCol cols="6">
<VTextField
v-model="ruleInfo.publish_time"
placeholder="0/1-10"
label="发布时间(分钟)"
hint="距离资源发布的最小时间间隔或时间区间"
:label="t('customRule.field.publishTime')"
:placeholder="t('customRule.placeholder.publishTime')"
:hint="t('customRule.hint.publishTime')"
persistent-hint
active
/>
@@ -185,7 +187,9 @@ function onClose() {
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
<VBtn @click="saveRuleInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">{{
t('customRule.action.confirm')
}}</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -1,8 +1,12 @@
<script lang="ts" setup>
import type { TransferDirectoryConf } from '@/api/types'
import type { StorageConf, TransferDirectoryConf } from '@/api/types'
import api from '@/api'
import { nextTick } from 'vue'
import { storageOptions } from '@/api/constants'
import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -15,6 +19,10 @@ const props = defineProps({
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
storages: {
type: Array as PropType<StorageConf[]>,
required: true,
},
width: String,
height: String,
})
@@ -23,30 +31,43 @@ const props = defineProps({
const isCollapsed = ref(true)
// 类型下拉字典
const typeItems = [
{ title: '全部', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
]
const typeItems = computed(() => [
{ title: t('common.all'), value: '' },
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
])
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
const resourceStorageOptions = computed(() => {
return storageOptions.filter(item => !item.remote || props.directory.monitor_type !== 'downloader')
return props.storages
.filter(item => !storageRemoteDict[item.type] || props.directory.monitor_type !== 'downloader')
.map(item => ({
title: item.name,
value: item.type,
}))
})
// 存储字典
const libraryStorageOptions = computed(() => {
return props.storages.map(item => ({
title: item.name,
value: item.type,
}))
})
// 自动整理方式下拉字典
const transferSourceItems = [
{ title: '不整理', value: '' },
{ title: '下载器监控', value: 'downloader' },
{ title: '目录监控', value: 'monitor' },
{ title: '手动整理', value: 'manual' },
]
const transferSourceItems = computed(() => [
{ title: t('directory.noTransfer'), value: '' },
{ title: t('directory.downloaderMonitor'), value: 'downloader' },
{ title: t('directory.directoryMonitor'), value: 'monitor' },
{ title: t('directory.manualTransfer'), value: 'manual' },
])
// 监控模式下拉字典
const MonitorModeItems = [
{ title: '性能模式', value: 'fast' },
{ title: '兼容模式', value: 'compatibility' },
]
const MonitorModeItems = computed(() => [
{ title: t('directory.performanceMode'), value: 'fast' },
{ title: t('directory.compatibilityMode'), value: 'compatibility' },
])
// 整理方式下拉字典
const transferTypeItems = ref<{ title: string; value: string }[]>([])
@@ -103,23 +124,23 @@ async function loadTransferTypeItems() {
// 整理方式无数据提示
const computedNoDataText = computed(() => {
if (!props.directory.library_storage && !props.directory.storage) {
return '请选择储存'
return t('directory.pleaseSelectStorage')
} else if (!props.directory.library_storage) {
return '请选择媒体库储存'
return t('directory.pleaseSelectLibraryStorage')
} else if (!props.directory.storage) {
return '请选择下载器储存'
return t('directory.pleaseSelectDownloadStorage')
} else {
return '选择的存储类型没有支持的整理方式'
return t('directory.noSupportedTransferType')
}
})
// 覆盖模式下拉字典
const overwriteModeItems = [
{ title: '从不', value: 'never' },
{ title: '总是', value: 'always' },
{ title: '按文件大小', value: 'size' },
{ title: '仅保留最新版本', value: 'latest' },
]
const overwriteModeItems = computed(() => [
{ title: t('directory.never'), value: 'never' },
{ title: t('directory.always'), value: 'always' },
{ title: t('directory.byFileSize'), value: 'size' },
{ title: t('directory.keepLatestOnly'), value: 'latest' },
])
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed', 'update:modelValue'])
@@ -131,7 +152,7 @@ function onClose() {
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
const default_value = [{ title: t('common.all'), value: '' }]
if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value
return default_value.concat(props.categories[props.directory.media_type ?? ''])
})
@@ -180,7 +201,7 @@ watch(
<VTextField
v-model="props.directory.name"
variant="underlined"
label="别名"
:label="t('directory.alias')"
class="me-20 text-high-emphasis font-weight-bold"
/>
<span class="absolute top-3 right-12">
@@ -197,7 +218,7 @@ watch(
v-model="props.directory.media_type"
variant="underlined"
:items="typeItems"
label="媒体类型"
:label="t('directory.mediaType')"
@update:modelValue="props.directory.media_category = ''"
/>
</VCol>
@@ -206,7 +227,7 @@ watch(
v-model="props.directory.media_category"
variant="underlined"
:items="getCategories"
label="媒体类别"
:label="t('directory.mediaCategory')"
/>
</VCol>
<VCol cols="4">
@@ -214,7 +235,7 @@ watch(
v-model="props.directory.storage"
variant="underlined"
:items="resourceStorageOptions"
label="资源存储"
:label="t('directory.resourceStorage')"
/>
</VCol>
<VCol cols="8">
@@ -222,14 +243,17 @@ watch(
v-model="props.directory.download_path"
:storage="props.directory.storage"
variant="underlined"
label="资源目录"
:label="t('directory.resourceDirectory')"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
<VSwitch v-model="props.directory.download_type_folder" label="按类型分类"></VSwitch>
<VSwitch v-model="props.directory.download_type_folder" :label="t('directory.sortByType')"></VSwitch>
</VCol>
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
<VSwitch v-model="props.directory.download_category_folder" label="按类别分类"></VSwitch>
<VSwitch
v-model="props.directory.download_category_folder"
:label="t('directory.sortByCategory')"
></VSwitch>
</VCol>
</VRow>
<VDivider v-if="$props.directory.monitor_type" class="my-3 bg-primary" />
@@ -239,7 +263,7 @@ watch(
v-model="props.directory.monitor_type"
variant="underlined"
:items="transferSourceItems"
label="自动整理"
:label="t('directory.autoTransfer')"
/>
</VCol>
</VRow>
@@ -249,15 +273,15 @@ watch(
v-model="props.directory.monitor_mode"
variant="underlined"
:items="MonitorModeItems"
label="监控模式"
:label="t('directory.monitorMode')"
/>
</VCol>
<VCol cols="4">
<VSelect
v-model="props.directory.library_storage"
variant="underlined"
:items="storageOptions"
label="媒体库存储"
:items="libraryStorageOptions"
:label="t('directory.libraryStorage')"
/>
</VCol>
<VCol cols="8">
@@ -265,7 +289,7 @@ watch(
v-model="props.directory.library_path"
:storage="props.directory.library_storage"
variant="underlined"
label="媒体库目录"
:label="t('directory.libraryDirectory')"
/>
</VCol>
<VCol cols="4">
@@ -273,7 +297,7 @@ watch(
v-model="props.directory.transfer_type"
variant="underlined"
:items="transferTypeItems"
label="整理方式"
:label="t('directory.transferType')"
:no-data-text="computedNoDataText"
/>
</VCol>
@@ -282,23 +306,23 @@ watch(
v-model="props.directory.overwrite_mode"
variant="underlined"
:items="overwriteModeItems"
label="覆盖模式"
:label="t('directory.overwriteMode')"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
<VSwitch v-model="props.directory.library_type_folder" label="按类型分类"></VSwitch>
<VSwitch v-model="props.directory.library_type_folder" :label="t('directory.sortByType')"></VSwitch>
</VCol>
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
<VSwitch v-model="props.directory.library_category_folder" label="按类别分类"></VSwitch>
<VSwitch v-model="props.directory.library_category_folder" :label="t('directory.sortByCategory')"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.renaming" label="智能重命名"></VSwitch>
<VSwitch v-model="props.directory.renaming" :label="t('directory.smartRename')"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.scraping" label="刮削元数据"></VSwitch>
<VSwitch v-model="props.directory.scraping" :label="t('directory.scrapingMetadata')"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.notify" label="发送通知"></VSwitch>
<VSwitch v-model="props.directory.notify" :label="t('directory.sendNotification')"></VSwitch>
</VCol>
</VRow>
</VForm>

View File

@@ -6,7 +6,13 @@ 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 custom_image from '@images/logos/downloader.png'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { downloaderDict } from '@/api/constants'
// 获取i18n实例
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -91,12 +97,12 @@ function openDownloaderInfoDialog() {
function saveDownloaderInfo() {
// 为空不保存,跳出警告框
if (!downloaderInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
$toast.error(t('downloader.nameRequired'))
return
}
// 重名判断
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
$toast.error(`${downloaderInfo.value.name}】已存在,请替换为其他名称`)
$toast.error(t('downloader.nameDuplicate'))
return
}
// 默认下载器去重
@@ -104,7 +110,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(t('downloader.defaultChanged'))
}
})
}
@@ -122,7 +128,7 @@ const getIcon = computed(() => {
case 'transmission':
return transmission_image
default:
return qbittorrent_image
return custom_image
}
})
@@ -168,10 +174,13 @@ onUnmounted(() => {
/>
<span class="text-h6">{{ downloader.name }}</span>
</div>
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="mt-1 flex flex-wrap text-sm">
<span class="me-2">{{ `${formatFileSize(upload_rate, 1)}/s ` }}</span>
<span>{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
<div v-else-if="!downloaderDict[downloader.type]" class="mt-1 flex flex-wrap text-sm">
<span class="me-2">自定义下载器</span>
</div>
</div>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
@@ -179,27 +188,31 @@ onUnmounted(() => {
</VCardText>
</VCard>
</VHover>
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.downloader.name} - ${t('downloader.title')}`">
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" label="启用下载器" />
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
</VCol>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.default" label="默认下载器" :disabled="!downloaderInfo.enabled" />
<VSwitch
v-model="downloaderInfo.default"
:label="t('downloader.default')"
:disabled="!downloaderInfo.enabled"
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="下载器的别名"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
/>
@@ -207,9 +220,9 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
label="地址"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
/>
@@ -217,8 +230,8 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
hint="登录使用的用户名"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
/>
@@ -227,8 +240,8 @@ onUnmounted(() => {
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
/>
@@ -236,8 +249,8 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
label="自动分类管理"
hint="由下载器自动管理分类和下载目录"
:label="t('downloader.category')"
:hint="t('downloader.category')"
persistent-hint
active
/>
@@ -245,8 +258,8 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
label="顺序下载"
hint="按顺序依次下载文件"
:label="t('downloader.sequentail')"
:hint="t('downloader.sequentail')"
persistent-hint
active
/>
@@ -254,8 +267,8 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
label="强制继续"
hint="强制继续、强制上传模式"
:label="t('downloader.force_resume')"
:hint="t('downloader.force_resume')"
persistent-hint
active
/>
@@ -263,20 +276,20 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
label="优先首尾文件"
hint="优先下载首尾文件块"
:label="t('downloader.first_last_piece')"
:hint="t('downloader.first_last_piece')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'transmission'">
<VRow v-else-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="下载器的别名"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
/>
@@ -284,9 +297,9 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
label="地址"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
/>
@@ -294,8 +307,8 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
hint="登录使用的用户名"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
/>
@@ -304,8 +317,28 @@ onUnmounted(() => {
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.type"
:label="t('downloader.type')"
:hint="t('downloader.customTypeHint')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:hint="t('downloader.nameRequired')"
persistent-hint
active
/>
@@ -315,7 +348,7 @@ onUnmounted(() => {
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -2,6 +2,10 @@
import { innerFilterRules } from '@/api/constants'
import { CustomRule } from '@/api/types'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
// 获取i18n实例
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -49,7 +53,7 @@ onMounted(() => {
</span>
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VCardTitle>优先级 {{ props.pri }}</VCardTitle>
<VCardTitle>{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
<VRow>
<VCol>
<VSelect
@@ -57,7 +61,7 @@ onMounted(() => {
variant="underlined"
:items="selectFilterOptions"
chips
label=""
:label="t('filterRule.rules')"
multiple
clearable
@update:modelValue="filtersChanged"

View File

@@ -7,6 +7,10 @@ import { useToast } from 'vue-toast-notification'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
import filter_group_svg from '@images/svg/filter-group.svg'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
// 获取i18n实例
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -56,14 +60,14 @@ const groupInfo = ref<FilterRuleGroup>({
// 媒体类型字典
const mediaTypeItems = [
{ title: '通用', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
{ title: t('common.all'), value: '' },
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
]
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
const default_value = [{ title: t('common.all'), value: '' }]
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
return default_value
}
@@ -72,11 +76,6 @@ const getCategories = computed(() => {
// 规则组规则卡片列表
const filterRuleCards = ref<FilterCard[]>([])
// 规则组类型,仅用于导入判断
const filterRuleCardsType = ref<FilterCard>({
pri: '',
rules: [],
})
// 导入代码弹窗
const importCodeDialog = ref(false)
@@ -112,10 +111,10 @@ async function shareRules() {
try {
let success
success = copyToClipboard(value)
if (await success) $toast.success('优先级规则已复制到剪贴板!')
else $toast.error('优先级规则复制失败:可能是浏览器不支持或被用户阻止!')
if (await success) $toast.success(t('filterRule.shareSuccess'))
else $toast.error(t('filterRule.shareFailed'))
} catch (error) {
$toast.error('优先级规则复制失败!')
$toast.error(t('filterRule.shareFailed'))
console.error(error)
}
}
@@ -143,7 +142,7 @@ function saveCodeString(type: string, code: any) {
}))
}
} catch (error) {
$toast.error('导入失败!')
$toast.error(t('filterRule.importFailed'))
console.error(error)
}
}
@@ -177,11 +176,11 @@ function opengroupInfoDialog() {
// 保存详情数据
function saveGroupInfo() {
if (!groupInfo.value.name.trim()) {
$toast.error('规则组名称不能为空')
$toast.error(t('filterRule.nameRequired'))
return
}
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
$toast.error(`规则组名称【${groupInfo.value.name}】已存在,请替换`)
$toast.error(t('filterRule.nameDuplicate'))
return
}
@@ -213,15 +212,15 @@ function onClose() {
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
<div class="text-body-1 mb-3">
<span v-if="!props.group.category">{{ props.group.media_type || '通用' }}</span>
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
</div>
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem">
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
<VDialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
@@ -229,9 +228,9 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
label="规则组名称"
placeholder="必填;不可与其他规则组重名"
hint="自定义规则组名称"
:label="t('filterRule.groupName')"
:placeholder="t('filterRule.nameRequired')"
:hint="t('filterRule.groupName')"
persistent-hint
active
/>
@@ -239,9 +238,9 @@ function onClose() {
<VCol cols="6" md="3">
<VSelect
v-model="groupInfo.media_type"
label="适用媒体类型"
:label="t('filterRule.mediaType')"
:items="mediaTypeItems"
hint="选择规则组适用的媒体类型"
:hint="t('filterRule.mediaType')"
persistent-hint
active
/>
@@ -250,8 +249,8 @@ function onClose() {
<VSelect
v-model="groupInfo.category"
:items="getCategories"
label="适用媒体类别"
hint="选择规则组适用的媒体类别"
:label="t('filterRule.category')"
:hint="t('filterRule.category')"
persistent-hint
active
/>
@@ -278,7 +277,7 @@ function onClose() {
/>
</template>
</draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">请添加或导入规则</div>
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" variant="tonal" @click="addFilterCard">
@@ -291,14 +290,16 @@ function onClose() {
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
<VBtn @click="saveGroupInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
title="导入规则优先级"
:title="t('filterRule.import')"
:dataType="importCodeType"
@close="importCodeDialog = false"
@save="saveCodeString"

View File

@@ -151,7 +151,7 @@ onMounted(async () => {
</script>
<template>
<VHover v-bind="props" :height="props.height" :width="props.width">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"

View File

@@ -13,6 +13,11 @@ import { useUserStore } from '@/stores'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -180,11 +185,11 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
if (season) title = `${title} ${formatSeason(season.toString())}`
let subname = '订阅'
if (best_version > 0) subname = '洗版订阅'
let subname = t('subscribe.normalSub')
if (best_version > 0) subname = t('subscribe.versionSub')
if (result) $toast.success(`${title} 添加${subname}成功!`)
else if (!result) $toast.error(`${title} 添加${subname}失败:${message}`)
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
else if (!result) $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message: message })}`)
}
// 调用API取消订阅
@@ -202,9 +207,9 @@ async function removeSubscribe() {
if (result.success) {
isSubscribed.value = false
$toast.success(`${props.media?.title} 已取消订阅!`)
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
} else {
$toast.error(`${props.media?.title} 取消订阅失败:${result.message}`)
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
}
} catch (error) {
console.error(error)
@@ -334,10 +339,14 @@ function goMediaDetail(isHovering = false) {
// 点击搜索
async function clickSearch() {
if (allSites.value?.length == 0) {
querySites()
querySelectedSites()
await querySites()
await querySelectedSites()
}
if (allSites.value?.length > 0) {
chooseSiteDialog.value = true
} else {
handleSearch()
}
chooseSiteDialog.value = true
}
// 开始搜索
@@ -393,15 +402,6 @@ function setupIntersectionObserver() {
}
}
onMounted(() => {
setupIntersectionObserver()
})
onBeforeUnmount(() => {
observer.value?.disconnect()
observer.value = null
})
// 计算图片地址
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
@@ -419,6 +419,21 @@ const getImgUrl: Ref<string> = computed(() => {
function onRemoveSubscribe() {
subscribeEditDialog.value = false
}
// 获取媒体类型文本
function getMediaTypeText(type: string | undefined) {
if (!type) return ''
return mediaTypeDict[type]
}
onMounted(() => {
setupIntersectionObserver()
})
onBeforeUnmount(() => {
observer.value?.disconnect()
observer.value = null
})
</script>
<template>
@@ -478,7 +493,7 @@ function onRemoveSubscribe() {
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 text-white font-bold"
>
{{ props.media?.type }}
{{ getMediaTypeText(props.media?.type) }}
</VChip>
<!-- 本地存在标识 -->
<ExistIcon v-if="isExists && !hover.isHovering" />

View File

@@ -5,8 +5,14 @@ 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 custom_image from '@images/logos/mediaserver.png'
import api from '@/api'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { mediaServerDict } from '@/api/constants'
// 获取i18n实例
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -32,17 +38,17 @@ const emit = defineEmits(['close', 'done', 'change'])
const infoItems = ref([
{
avatar: 'mdi-movie-roll',
title: '电影',
title: t('mediaType.movie'),
amount: '0',
},
{
avatar: 'mdi-television-box',
title: '电视剧',
title: t('mediaType.tv'),
amount: '0',
},
{
avatar: 'mdi-account',
title: '用户',
title: t('common.user'),
amount: '0',
},
])
@@ -50,7 +56,7 @@ const infoItems = ref([
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: '全部',
title: t('common.all'),
value: 'all',
},
])
@@ -81,12 +87,12 @@ function openMediaServerInfoDialog() {
function saveMediaServerInfo() {
// 为空不保存,跳出警告框
if (!mediaServerInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
$toast.error(t('common.nameRequired'))
return
}
// 重名判断
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
$toast.error(`${mediaServerInfo.value.name}】已存在,请替换为其他名称`)
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
return
}
// 执行保存
@@ -104,8 +110,10 @@ const getIcon = computed(() => {
return jellyfin_image
case 'trimemedia':
return trimemedia_image
default:
case 'plex':
return plex_image
default:
return custom_image
}
})
@@ -127,17 +135,17 @@ async function loadMediaStatistic() {
infoItems.value = [
{
avatar: 'mdi-movie-roll',
title: '电影',
title: t('mediaType.movie'),
amount: res.movie_count.toLocaleString(),
},
{
avatar: 'mdi-television-box',
title: '电视剧',
title: t('mediaType.tv'),
amount: res.tv_count.toLocaleString(),
},
{
avatar: 'mdi-account',
title: '用户',
title: t('common.user'),
amount: res.user_count.toLocaleString(),
},
]
@@ -160,7 +168,7 @@ async function loadLibrary(server: string) {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: '全部',
title: t('common.all'),
value: 'all',
})
} catch (e) {
@@ -179,33 +187,36 @@ onMounted(() => {
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
<div class="text-sm mt-5 flex flex-wrap">
<div v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled" class="text-sm mt-5 flex flex-wrap">
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
</span>
</div>
<div v-else-if="!mediaServerDict[mediaserver.type]" class="text-sm mt-5 flex flex-wrap">
<span class="me-2 mb-1">自定义媒体服务器</span>
</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.mediaserver.name} - ${t('common.config')}`">
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="mediaServerInfo.enabled" label="启用媒体服务器" />
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'emby'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
/>
@@ -213,9 +224,9 @@ onMounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
/>
@@ -223,9 +234,9 @@ onMounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
/>
@@ -233,143 +244,21 @@ onMounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Emby设置->高级->API密钥中生成的密钥"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.embyApiKeyHint')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'jellyfin'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Jellyfin设置->高级->API密钥中生成的密钥"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'trimemedia'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="mediaServerInfo.config.username" label="用户名" active />
</VCol>
<VCol cols="12" md="6">
<VTextField type="password" v-model="mediaServerInfo.config.password" label="密码" active />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.token"
label="X-Plex-Token"
hint="浏览器F12->网络从Plex请求URL中获取的X-Plex-Token"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect
v-model="mediaServerInfo.sync_libraries"
label="同步媒体库"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
hint="只有选中的媒体库才会被同步"
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
@@ -377,11 +266,209 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.jellyfinApiKeyHint')"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'trimemedia'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="mediaServerInfo.config.username" :label="t('mediaserver.username')" active />
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
active
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.token"
:label="t('mediaserver.plexToken')"
:hint="t('mediaserver.plexTokenHint')"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.type"
:label="t('mediaserver.type')"
:hint="t('mediaserver.customTypeHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField :label="t('common.name')" :hint="t('mediaserver.nameRequired')" persistent-hint />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -8,6 +8,9 @@ import slack_image from '@images/logos/slack.webp'
import chrome_image from '@images/logos/chrome.png'
import { useToast } from 'vue-toast-notification'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -42,24 +45,24 @@ const notificationInfo = ref<NotificationConf>({
// 各通知类型的名称字典
const notificationTypeNames: { [key: string]: string } = {
wechat: '企业微信',
telegram: 'Telegram',
vocechat: 'VoceChat',
synologychat: 'Synology Chat',
slack: 'Slack',
webpush: 'WebPush',
wechat: t('notification.wechat.name'),
telegram: t('notification.telegram.name'),
vocechat: t('notification.vocechat.name'),
synologychat: t('notification.synologychat.name'),
slack: t('notification.slack.name'),
webpush: t('notification.webpush.name'),
}
// 消息类型下拉字典
const notificationTypes = [
{ value: '资源下载', title: '资源下载' },
{ value: '整理入库', title: '整理入库' },
{ value: '订阅', title: '订阅' },
{ value: '站点', title: '站点' },
{ value: '媒体服务器', title: '媒体服务器' },
{ value: '手动处理', title: '手动处理' },
{ value: '插件', title: '插件' },
{ value: '其它', title: '其它' },
{ value: '资源下载', title: t('notificationSwitch.resourceDownload') },
{ value: '整理入库', title: t('notificationSwitch.organize') },
{ value: '订阅', title: t('notificationSwitch.subscribe') },
{ value: '站点', title: t('notificationSwitch.site') },
{ value: '媒体服务器', title: t('notificationSwitch.mediaServer') },
{ value: '手动处理', title: t('notificationSwitch.manual') },
{ value: '插件', title: t('notificationSwitch.plugin') },
{ value: '其它', title: t('notificationSwitch.other') },
]
// 打开详情弹窗
@@ -73,12 +76,12 @@ function openNotificationInfoDialog() {
function saveNotificationInfo() {
// 为空不保存,跳出警告框
if (!notificationInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
$toast.error(t('notification.name') + t('common.required'))
return
}
// 重名判断
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
$toast.error(`通知渠道${notificationInfo.value.name}已存在,请替换`)
$toast.error(t('notification.channel') + `${notificationInfo.value.name}` + t('common.exists'))
return
}
notificationInfoDialog.value = false
@@ -131,22 +134,22 @@ function onClose() {
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.notification.name} - 配置`" class="rounded-t">
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.notification.name} - ${t('notification.config')}`">
<VDialogCloseBtn v-model="notificationInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="notificationInfo.enabled" label="启用通知" />
<VSwitch v-model="notificationInfo.enabled" :label="t('notification.enabled')" />
</VCol>
<VCol cols="12">
<VSelect
v-model="notificationInfo.switchs"
:items="notificationTypes"
label="消息类型"
hint="开启通知的消息类型"
:label="t('notification.type')"
:hint="t('notification.typeHint')"
multiple
clearable
chips
@@ -158,66 +161,66 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_CORPID"
label="企业ID"
hint="企业微信后台企业信息中的企业ID"
:label="t('notification.wechat.corpId')"
:hint="t('notification.wechat.corpIdHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_ID"
label="应用 AgentId"
hint="企业微信自建应用的AgentId"
:label="t('notification.wechat.appId')"
:hint="t('notification.wechat.appIdHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_SECRET"
label="应用 Secret"
hint="企业微信自建应用的Secret"
:label="t('notification.wechat.appSecret')"
:hint="t('notification.wechat.appSecretHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_PROXY"
label="代理地址"
hint="微信消息的转发代理地址2022年6月20日后创建的自建应用才需要不使用代理时需要保留默认值"
:label="t('notification.wechat.proxy')"
:hint="t('notification.wechat.proxyHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_TOKEN"
label="Token"
hint="微信企业自建应用->API接收消息配置中的Token"
:label="t('notification.wechat.token')"
:hint="t('notification.wechat.tokenHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
label="EncodingAESKey"
hint="微信企业自建应用->API接收消息配置中的EncodingAESKey"
:label="t('notification.wechat.encodingAesKey')"
:hint="t('notification.wechat.encodingAesKeyHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
:label="t('notification.wechat.admins')"
:placeholder="t('notification.wechat.adminsPlaceholder')"
:hint="t('notification.wechat.adminsHint')"
persistent-hint
/>
</VCol>
@@ -226,43 +229,43 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_TOKEN"
label="Bot Token"
hint="Telegram机器人token格式123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
:label="t('notification.telegram.token')"
:hint="t('notification.telegram.tokenHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_CHAT_ID"
label="Chat ID"
hint="接受消息通知的用户、群组或频道Chat ID"
:label="t('notification.telegram.chatId')"
:hint="t('notification.telegram.chatIdHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_USERS"
label="用户白名单"
placeholder="多个用,分隔"
hint="可使用Telegram机器人的用户ID清单多个用户用,分隔,不填写则所有用户都能使用"
:label="t('notification.telegram.users')"
:placeholder="t('notification.telegram.usersPlaceholder')"
:hint="t('notification.telegram.usersHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
:label="t('notification.telegram.admins')"
:placeholder="t('notification.telegram.adminsPlaceholder')"
:hint="t('notification.telegram.adminsHint')"
persistent-hint
/>
</VCol>
@@ -271,36 +274,36 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_OAUTH_TOKEN"
label="Slack Bot User OAuth Token"
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`"
:label="t('notification.slack.oauthToken')"
:placeholder="t('notification.slack.oauthTokenPlaceholder')"
:hint="t('notification.slack.oauthTokenHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_APP_TOKEN"
label="Slack App-Level Token"
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`App-Level Token`"
:label="t('notification.slack.appToken')"
:placeholder="t('notification.slack.appTokenPlaceholder')"
:hint="t('notification.slack.appTokenHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_CHANNEL"
label="频道名称"
placeholder="全体"
hint="消息发送频道,默认`全体`"
:label="t('notification.slack.channel')"
:placeholder="t('notification.slack.channelPlaceholder')"
:hint="t('notification.slack.channelHint')"
persistent-hint
/>
</VCol>
@@ -309,25 +312,25 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_WEBHOOK"
label="机器人传入URL"
hint="Synology Chat机器人传入URL"
:label="t('notification.synologychat.webhook')"
:hint="t('notification.synologychat.webhookHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_TOKEN"
label="令牌"
hint="Synology Chat机器人令牌"
:label="t('notification.synologychat.token')"
:hint="t('notification.synologychat.tokenHint')"
persistent-hint
/>
</VCol>
@@ -336,34 +339,34 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_HOST"
label="地址"
hint="VoceChat服务端地址格式http(s)://ip:port"
:label="t('notification.vocechat.host')"
:hint="t('notification.vocechat.hostHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_API_KEY"
label="机器人密钥"
hint="VoceChat机器人密钥"
:label="t('notification.vocechat.apiKey')"
:hint="t('notification.vocechat.apiKeyHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_CHANNEL_ID"
label="频道ID"
placeholder="不包含#号"
hint="VoceChat的频道ID不包含#号"
:label="t('notification.vocechat.channelId')"
:placeholder="t('notification.vocechat.channelIdPlaceholder')"
:hint="t('notification.vocechat.channelIdHint')"
persistent-hint
/>
</VCol>
@@ -372,17 +375,17 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WEBPUSH_USERNAME"
label="登录用户名"
hint="只有对应的用户登录后才会推送消息"
:label="t('notification.webpush.username')"
:hint="t('notification.webpush.usernameHint')"
persistent-hint
/>
</VCol>
@@ -391,7 +394,7 @@ function onClose() {
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveNotificationInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -7,6 +7,7 @@ import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
import { isNullOrEmptyObject } from '@/@core/utils'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
// 输入参数
const props = defineProps({
@@ -19,6 +20,9 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['install'])
// 多语言
const { t } = useI18n()
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -59,7 +63,10 @@ async function installPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在安装 ${props.plugin?.plugin_name} v${props?.plugin?.plugin_version} ...`
progressText.value = t('plugin.installing', {
name: props.plugin?.plugin_name,
version: props?.plugin?.plugin_version,
})
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
@@ -72,12 +79,12 @@ async function installPlugin() {
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
detailDialog.value = false
// 通知父组件刷新
emit('install')
} else {
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}`)
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
@@ -125,7 +132,7 @@ function showUpdateHistory() {
// 弹出菜单
const dropdownItems = ref([
{
title: '项目主页',
title: t('plugin.projectHome'),
value: 1,
show: true,
props: {
@@ -134,7 +141,7 @@ const dropdownItems = ref([
},
},
{
title: '更新说明',
title: t('plugin.updateHistory'),
value: 2,
show: !isNullOrEmptyObject(props.plugin?.history || {}),
props: {
@@ -225,7 +232,7 @@ const dropdownItems = ref([
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
@@ -263,13 +270,13 @@ const dropdownItems = ref([
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">版本</span>
<span class="font-weight-medium">{{ t('common.version') }}</span>
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">作者</span>
<span class="font-weight-medium">{{ t('common.author') }}</span>
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</span>
@@ -277,9 +284,13 @@ const dropdownItems = ref([
</VListItem>
</VList>
<div class="text-center text-md-left">
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download"> 安装到本地 </VBtn>
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download">{{
t('plugin.installToLocal')
}}</VBtn>
<div class="text-xs mt-2" v-if="props.count">
<VIcon icon="mdi-fire" /> {{ props.count?.toLocaleString() }} 次下载
<VIcon icon="mdi-fire" />{{
t('plugin.totalDownloads', { count: props.count?.toLocaleString() })
}}
</div>
</div>
</VCardItem>

View File

@@ -10,6 +10,7 @@ import VersionHistory from '@/components/misc/VersionHistory.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
import { useI18n } from 'vue-i18n'
// 输入参数
const props = defineProps({
@@ -23,6 +24,9 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save', 'actionDone'])
// 多语言
const { t } = useI18n()
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -97,8 +101,8 @@ function showUpdateHistory() {
// 调用API卸载插件
async function uninstallPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认卸载插件 ${props.plugin?.plugin_name} ?`,
title: t('common.confirm'),
content: t('plugin.confirmUninstall', { name: props.plugin?.plugin_name }),
})
if (!isConfirmed) return
@@ -106,17 +110,17 @@ async function uninstallPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在卸载 ${props.plugin?.plugin_name} ...`
progressText.value = t('plugin.uninstalling', { name: props.plugin?.plugin_name })
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 已卸载`)
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('remove')
} else {
$toast.error(`插件 ${props.plugin?.plugin_name} 卸载失败:${result.message}}`)
$toast.error(t('plugin.uninstallFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
@@ -157,8 +161,8 @@ const authorPath: Ref<string> = computed(() => {
// 重置插件
async function resetPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `此操作将恢复插件 ${props.plugin?.plugin_name} 的默认设置,并清除所有相关数据,确定要继续吗?`,
title: t('common.confirm'),
content: t('plugin.confirmReset', { name: props.plugin?.plugin_name }),
})
if (!isConfirmed) return
@@ -166,11 +170,11 @@ async function resetPlugin() {
try {
const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 数据已重置`)
$toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('save')
} else {
$toast.error(`插件 ${props.plugin?.plugin_name} 重置失败:${result.message}}`)
$toast.error(t('plugin.resetFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
@@ -183,7 +187,7 @@ async function updatePlugin() {
releaseDialog.value = false
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在更新 ${props.plugin?.plugin_name} ...`
progressText.value = t('plugin.updating', { name: props.plugin?.plugin_name })
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
@@ -196,12 +200,12 @@ async function updatePlugin() {
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 更新成功!`)
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('save')
} else {
$toast.error(`插件 ${props.plugin?.plugin_name} 更新失败:${result.message}`)
$toast.error(t('plugin.updateFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
@@ -236,7 +240,7 @@ function configDone() {
// 弹出菜单
const dropdownItems = ref([
{
title: '查看数据',
title: t('plugin.viewData'),
value: 1,
show: props.plugin?.has_page,
props: {
@@ -245,7 +249,7 @@ const dropdownItems = ref([
},
},
{
title: '设置',
title: t('plugin.settings'),
value: 2,
show: true,
props: {
@@ -254,7 +258,7 @@ const dropdownItems = ref([
},
},
{
title: '更新',
title: t('plugin.update'),
value: 3,
show: props.plugin?.has_update,
props: {
@@ -264,7 +268,7 @@ const dropdownItems = ref([
},
},
{
title: '重置',
title: t('plugin.reset'),
value: 4,
show: true,
props: {
@@ -274,7 +278,7 @@ const dropdownItems = ref([
},
},
{
title: '卸载',
title: t('plugin.uninstall'),
value: 5,
show: true,
props: {
@@ -284,7 +288,7 @@ const dropdownItems = ref([
},
},
{
title: '查看日志',
title: t('plugin.viewLogs'),
value: 6,
show: true,
props: {
@@ -295,7 +299,7 @@ const dropdownItems = ref([
},
},
{
title: '作者主页',
title: t('plugin.authorHome'),
value: 7,
show: true,
props: {
@@ -435,8 +439,8 @@ watch(
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" max-height="80vh" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" max-height="85vh" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
@@ -446,7 +450,7 @@ watch(
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
更新到最新版本
{{ t('plugin.updateToLatest') }}
</VBtn>
</VCardItem>
</VCard>

View File

@@ -37,7 +37,7 @@ function goPlay(isHovering: boolean | null = false) {
</script>
<template>
<VHover v-bind="props">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"

View File

@@ -2,6 +2,7 @@
import type { PropType } from 'vue'
import noImage from '@images/logos/site.webp'
import { useToast } from 'vue-toast-notification'
import { useI18n } from 'vue-i18n'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue'
@@ -12,6 +13,9 @@ import { isNullOrEmptyObject } from '@/@core/utils'
import { formatFileSize } from '@/@core/utils/formatters'
import { useConfirm } from 'vuetify-use-dialog'
// 国际化
const { t } = useI18n()
// 输入参数
const cardProps = defineProps({
site: Object as PropType<Site>,
@@ -31,7 +35,7 @@ const siteIcon = ref<string>('')
const $toast = useToast()
// 测试按钮文字
const testButtonText = ref('测试连通性')
const testButtonText = ref(t('site.testConnectivity'))
// 测试按钮可用性
const testButtonDisable = ref(false)
@@ -66,14 +70,14 @@ async function getSiteIcon() {
// 测试站点连通性
async function testSite() {
try {
testButtonText.value = '测试中 ...'
testButtonText.value = t('site.testing')
testButtonDisable.value = true
const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`)
if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
if (result.success) $toast.success(t('site.testSuccess', { name: cardProps.site?.name }))
else $toast.error(t('site.testFailed', { name: cardProps.site?.name, message: result.message }))
testButtonText.value = '测试连通性'
testButtonText.value = t('site.testConnectivity')
testButtonDisable.value = false
getSiteStats()
@@ -114,8 +118,8 @@ function openSitePage() {
// 调用API删除站点信息
async function deleteSiteInfo() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认删除站点?`,
title: t('common.confirm'),
content: t('site.deleteConfirm'),
})
if (!isConfirmed) return
@@ -123,9 +127,9 @@ async function deleteSiteInfo() {
try {
const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`)
if (result.success) emit('remove')
else $toast.error(`${cardProps.site?.name} 删除失败:${result.message}`)
else $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: result.message }))
} catch (error) {
$toast.error(`${cardProps.site?.name} 删除失败!`)
$toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: error }))
console.error(error)
}
}
@@ -289,21 +293,20 @@ onMounted(() => {
</div>
<!-- 右侧操作按钮区 -->
<VSheet
class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1 transform translate-x-full transition-transform duration-200"
>
<VSheet class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
<!-- 测试按钮 -->
<VBtn
icon
variant="text"
density="comfortable"
class="mb-1 relative w-10 h-10 min-w-10 flex items-center justify-center rounded-full"
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
:disabled="testButtonDisable"
@click.stop="testSite"
size="36"
>
<div class="relative flex items-center justify-center w-full h-full">
<div
class="w-[22px] h-[22px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
:class="statColor"
></div>
</div>
@@ -318,31 +321,31 @@ onMounted(() => {
</VBtn>
<!-- 用户数据按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUserData">
<VIcon icon="mdi-chart-bell-curve" size="small" />
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
<VIcon icon="mdi-chart-bell-curve" size="20" />
</VBtn>
<!-- 更新按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUpdate">
<VIcon icon="mdi-refresh" size="small" />
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
<VIcon icon="mdi-refresh" size="20" />
</VBtn>
<!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto">
<VIcon icon="mdi-dots-vertical" size="small" />
<VBtn icon variant="text" class="mt-auto" size="36">
<VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList>
<VListItem @click="handleResourceBrowse" base-color="info">
<template #prepend>
<VIcon icon="mdi-web" size="small" />
<VIcon icon="mdi-web" size="20" />
</template>
<VListItemTitle>浏览资源</VListItemTitle>
<VListItemTitle>{{ t('site.browseResources') }}</VListItemTitle>
</VListItem>
<VListItem @click="deleteSiteInfo">
<template #prepend>
<VIcon icon="mdi-delete-outline" size="small" color="error" />
<VIcon icon="mdi-delete-outline" size="20" color="error" />
</template>
<VListItemTitle class="text-error">删除站点</VListItemTitle>
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
@@ -382,12 +385,6 @@ onMounted(() => {
</template>
<style scoped>
.site-card:hover {
.site-card-actions {
transform: translateX(0);
}
}
.site-status-indicator {
position: absolute;
z-index: 1;
@@ -426,15 +423,15 @@ onMounted(() => {
/* 上传下载条样式 */
.upload-bar {
animation: pulse-width 2s infinite;
background: linear-gradient(90deg, #4d79ff, #07f);
box-shadow: 0 0 4px rgba(0, 119, 255, 50%);
animation: pulse-width 2s infinite;
}
.download-bar {
animation: pulse-width 2s infinite;
background: linear-gradient(90deg, #42d392, #00b77e);
box-shadow: 0 0 4px rgba(0, 183, 126, 50%);
animation: pulse-width 2s infinite;
}
/* 测试状态点样式 */
@@ -442,22 +439,22 @@ onMounted(() => {
position: absolute;
z-index: 1;
border-radius: 50%;
block-size: 70%;
content: '';
height: 70%;
width: 70%;
top: 15%;
left: 15%;
inline-size: 70%;
inset-block-start: 15%;
inset-inline-start: 15%;
}
.pulse-dot::after {
position: absolute;
z-index: 2;
border-radius: 50%;
block-size: 100%;
content: '';
height: 100%;
width: 100%;
top: 0;
left: 0;
inline-size: 100%;
inset-block-start: 0;
inset-inline-start: 0;
}
.pulse-dot.error::before {
@@ -504,11 +501,11 @@ onMounted(() => {
.spinner-circle {
position: absolute;
border: 1px solid rgba(var(--v-theme-primary), 0.2);
border-top-color: rgba(var(--v-theme-primary), 1);
border-radius: 50%;
width: 100%;
height: 100%;
animation: spin 0.8s linear infinite;
block-size: 100%;
border-block-start-color: rgba(var(--v-theme-primary), 1);
inline-size: 100%;
}
/* 动画关键帧 */
@@ -518,6 +515,7 @@ onMounted(() => {
opacity: 0.85;
transform: scaleX(0.95);
}
50% {
opacity: 1;
transform: scaleX(1.05);
@@ -528,9 +526,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-error), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0);
}
@@ -540,9 +540,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-warning), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0);
}
@@ -552,9 +554,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-success), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0);
}
@@ -564,9 +568,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-secondary), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0);
}
@@ -576,6 +582,7 @@ onMounted(() => {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
@@ -585,8 +592,22 @@ onMounted(() => {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.site-card-actions {
opacity: 0;
transform: translateX(100%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
visibility: hidden;
}
.site-card:hover .site-card-actions {
opacity: 1;
transform: translateX(0);
visibility: visible;
}
</style>

View File

@@ -6,6 +6,7 @@ import alipan_png from '@images/misc/alipan.webp'
import u115_png from '@images/misc/u115.png'
import rclone_png from '@images/misc/rclone.png'
import alist_png from '@images/misc/alist.svg'
import custom_png from '@images/misc/database.png'
import api from '@/api'
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
@@ -13,6 +14,11 @@ import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
import { useToast } from 'vue-toast-notification'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { storageIconDict } from '@/api/constants'
// 国际化
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -23,7 +29,7 @@ const props = defineProps({
})
// 定义事件
const emit = defineEmits(['done'])
const emit = defineEmits(['done', 'close'])
// 提示信息
const $toast = useToast()
@@ -39,6 +45,15 @@ const used = computed(() => {
return total.value - available.value
})
// 存储
const storage_ref = ref(props.storage)
// 自定义存储名称
const customName = ref(props.storage.name)
// 自定义存储类型
const storageType = ref(props.storage.type)
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 115网盘认证对话框
@@ -47,6 +62,8 @@ const u115AuthDialog = ref(false)
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// 自定义存储配置对话框
const customConfigDialog = ref(false)
// 打开存储对话框
function openStorageDialog() {
@@ -63,8 +80,11 @@ function openStorageDialog() {
case 'alist':
aListConfigDialog.value = true
break
case 'local':
$toast.info(t('storage.noConfigNeeded'))
break
default:
$toast.info('此存储类型无需配置参数,请直接配置目录!')
customConfigDialog.value = true
break
}
}
@@ -83,7 +103,7 @@ const getIcon = computed(() => {
case 'alist':
return alist_png
default:
return storage_png
return custom_png
}
})
@@ -120,23 +140,33 @@ function handleDone() {
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
emit('done')
customConfigDialog.value = false
// 更新存储
storage_ref.value.name = customName.value
storage_ref.value.type = storageType.value
emit('done', storage_ref.value)
}
onMounted(() => {
queryStorage()
})
// 关闭
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="openStorageDialog">
<VDialogCloseBtn v-if="!storageIconDict[storage.type]" @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">未配置</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
</div>
<VImg :src="getIcon" cover class="mt-5" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-7" max-width="3rem" min-width="3rem" />
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
@@ -170,5 +200,35 @@ onMounted(() => {
@close="aListConfigDialog = false"
@done="handleDone"
/>
<VDialog v-if="customConfigDialog" v-model="customConfigDialog" scrollable max-width="30rem">
<VCard>
<VCardItem>
<VCardTitle>{{ t('storage.custom') }}</VCardTitle>
<VDialogCloseBtn v-model="customConfigDialog" />
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="storageType"
:label="t('storage.type')"
:hint="t('storage.customTypeHint')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="customName" :label="t('storage.name')" persistent-hint active />
</VCol>
</VRow>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="handleDone" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -8,6 +8,10 @@ import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import router from '@/router'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -88,22 +92,22 @@ async function searchSubscribe() {
async function toggleSubscribeStatus(state: 'R' | 'S') {
try {
// 根据传入的 state 判断对应的操作文字
const action = state === 'S' ? '暂停' : '启用'
const action = state === 'S' ? t('common.pause') : t('common.enable')
// 弹出确认框
const isConfirmed = await createConfirm({
title: `确认${action}`,
content: `是否${action}订阅 ${props.media?.name}`,
title: t('common.confirmAction', { action }),
content: t('subscribe.confirmToggle', { action, name: props.media?.name }),
})
if (!isConfirmed) return
// 调用 API 更新订阅状态
const result: { [key: string]: any } = await api.put(`subscribe/status/${props.media?.id}?state=${state}`)
// 提示
if (result.success) {
$toast.success(`${props.media?.name}${action}`)
$toast.success(t('subscribe.toggleSuccess', { name: props.media?.name, action }))
subscribeState.value = state
emit('save')
} else {
$toast.error(`${action}失败:${result.message}`)
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
}
} catch (e) {
console.log(e)
@@ -115,18 +119,18 @@ async function resetSubscribe() {
// 确认
try {
const isConfirmed = await createConfirm({
title: '确认',
content: `重置后 ${props.media?.name} 将恢复初始状态,已下载记录将被清除,未入库的内容将会重新下载,是否确认?`,
title: t('common.confirm'),
content: t('subscribe.resetConfirm', { name: props.media?.name }),
})
if (!isConfirmed) return
// 重置
const result: { [key: string]: any } = await api.get(`subscribe/reset/${props.media?.id}`)
// 提示
if (result.success) {
$toast.success(`${props.media?.name} 重置成功!`)
$toast.success(t('subscribe.resetSuccess', { name: props.media?.name }))
subscribeState.value = 'R'
emit('save')
} else $toast.error(`${props.media?.name} 重置失败:${result.message}`)
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
} catch (e) {
console.log(e)
}
@@ -171,7 +175,7 @@ async function viewSubscribeFiles() {
// 弹出菜单
const dropdownItems = computed(() => [
{
title: '编辑',
title: t('common.edit'),
value: 1,
props: {
prependIcon: 'mdi-file-edit-outline',
@@ -179,7 +183,7 @@ const dropdownItems = computed(() => [
},
},
{
title: '搜索',
title: t('common.search'),
value: 2,
props: {
prependIcon: 'mdi-magnify',
@@ -187,7 +191,7 @@ const dropdownItems = computed(() => [
},
},
{
title: '详情',
title: t('common.details'),
value: 3,
props: {
prependIcon: 'mdi-information-outline',
@@ -195,7 +199,7 @@ const dropdownItems = computed(() => [
},
},
{
title: '文件',
title: t('common.files'),
value: 4,
props: {
prependIcon: 'mdi-file-document-outline',
@@ -203,7 +207,7 @@ const dropdownItems = computed(() => [
},
},
{
title: subscribeState.value === 'S' ? '启用' : '暂停',
title: subscribeState.value === 'S' ? t('common.enable') : t('common.pause'),
value: 5,
props: {
prependIcon: subscribeState.value === 'S' ? 'mdi-play' : 'mdi-pause',
@@ -212,7 +216,7 @@ const dropdownItems = computed(() => [
},
},
{
title: '重置',
title: t('common.reset'),
value: 6,
props: {
prependIcon: 'mdi-restore-alert',
@@ -221,7 +225,7 @@ const dropdownItems = computed(() => [
},
},
{
title: '分享',
title: t('common.share'),
value: 7,
props: {
prependIcon: 'mdi-share',
@@ -231,7 +235,7 @@ const dropdownItems = computed(() => [
show: props.media?.type === '电视剧',
},
{
title: '取消订阅',
title: t('common.unsubscribe'),
value: 8,
props: {
prependIcon: 'mdi-trash-can-outline',

View File

@@ -70,7 +70,11 @@ async function handleAddDownload(item: Context | null = null) {
}
// 打开种子详情页面
function openTorrentDetail() {
function openTorrentDetail(item: Context | null = null) {
if (item && !isNullOrEmptyObject(item) && !isNullOrEmptyObject(item.torrent_info)) {
window.open(item.torrent_info.page_url, '_blank')
return
}
window.open(torrent.value?.page_url, '_blank')
}
@@ -255,7 +259,7 @@ onMounted(() => {
<VChip v-if="torrent?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm mr-2">
{{ formatFileSize(torrent.size) }}
</VChip>
<VBtn icon size="small" variant="text" color="primary" @click.stop="openTorrentDetail">
<VBtn icon size="small" variant="text" color="primary" @click.stop="openTorrentDetail()">
<VIcon icon="mdi-information-outline"></VIcon>
</VBtn>
</div>
@@ -333,7 +337,7 @@ onMounted(() => {
</span>
<span>
<VIcon
@click.stop="openTorrentDetail"
@click.stop="openTorrentDetail(item)"
size="small"
color="secondary"
icon="mdi-arrow-top-right"

View File

@@ -121,12 +121,12 @@ onMounted(() => {
</div>
<template v-slot:prepend>
<div class="d-flex align-center">
<img v-if="siteIcon" :src="siteIcon" :alt="torrent?.site_name" class="rounded mr-2" width="32" height="32" />
<VAvatar v-else size="24" class="mr-2 text-caption bg-primary-lighten-4 text-primary font-weight-bold">
<div class="d-flex flex-column align-center pr-3">
<VImg v-if="siteIcon" :src="siteIcon" :alt="torrent?.site_name" class="rounded mb-1" width="32" height="32" />
<VAvatar v-else size="24" class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold">
{{ torrent?.site_name?.substring(0, 1) }}
</VAvatar>
<div class="font-weight-bold text-body-2 d-none d-sm-block">{{ torrent?.site_name }}</div>
<div class="font-weight-bold text-body-2 text-center d-none d-sm-block">{{ torrent?.site_name }}</div>
</div>
</template>

View File

@@ -7,6 +7,10 @@ import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 扩展User类型以包含昵称字段
interface ExtendedUser extends User {
@@ -77,21 +81,21 @@ async function fetchSubscriptions() {
// 删除用户
async function removeUser() {
if (props.user.id === currentLoginUserId.value) {
$toast.error('不能删除当前登录用户!')
$toast.error(t('user.cannotDeleteCurrentUser'))
return
}
try {
const isConfirmed = await createConfirm({
title: '注意',
content: `删除用户 ${props.user?.name} 的所有数据,是否确认?`,
title: t('common.confirm'),
content: t('user.confirmDeleteUser', { username: props.user?.name }),
})
if (!isConfirmed) return
const result: { [key: string]: any } = await api.delete(`user/id/${props.user.id}`)
if (result.success) {
$toast.success('用户删除成功')
$toast.success(t('user.deleteSuccess'))
emit('remove')
} else {
$toast.error('用户删除失败!')
$toast.error(t('user.deleteFailed'))
}
} catch (error) {
console.log(error)
@@ -170,10 +174,12 @@ onMounted(() => {
</span>
</div>
<div class="d-flex flex-wrap gap-1 overflow-auto">
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>管理员</VChip>
<VChip v-else size="x-small" label>普通用户</VChip>
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>{{
t('user.admin')
}}</VChip>
<VChip v-else size="x-small" label>{{ t('user.normal') }}</VChip>
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" label>
{{ user.is_active ? '激活' : '已停用' }}
{{ user.is_active ? t('user.active') : t('user.inactive') }}
</VChip>
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
</div>
@@ -226,7 +232,7 @@ onMounted(() => {
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
<span class="text-body-2 truncate">{{ user.email || '未设置邮箱' }}</span>
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
</VCardText>
<!-- PC端显示订阅统计信息 -->
@@ -246,7 +252,7 @@ onMounted(() => {
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis font-weight-bold">{{ movieSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">电影订阅</span>
<span class="text-caption text-medium-emphasis">{{ t('user.movieSubscriptions') }}</span>
</div>
</div>
<div class="d-flex align-center gap-3">
@@ -263,7 +269,7 @@ onMounted(() => {
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis">{{ tvShowSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">剧集订阅</span>
<span class="text-caption text-medium-emphasis">{{ t('user.tvSubscriptions') }}</span>
</div>
</div>
</div>

View File

@@ -5,6 +5,9 @@ import { useConfirm } from 'vuetify-use-dialog'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
import api from '@/api'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 定义输入参数
const props = defineProps({
@@ -42,11 +45,6 @@ function handleFlow(item: Workflow) {
flowDialog.value = true
}
// 计算已完成的动作数
function resolveDoneActions(item: Workflow) {
return item.current_action?.split(',').length || 0
}
// 编辑完成
function editDone() {
editDialog.value = false
@@ -57,8 +55,8 @@ function editDone() {
// 删除任务
async function handleDelete(item: Workflow) {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认删除任务 ${item.name} ?`,
title: t('common.confirm'),
content: t('workflow.task.confirmDelete', { name: item.name }),
})
if (!isConfirmed) return
@@ -66,10 +64,10 @@ async function handleDelete(item: Workflow) {
try {
const result: { [key: string]: string } = await api.delete(`workflow/${item.id}`)
if (result.success) {
$toast.success('删除任务成功!')
$toast.success(t('workflow.task.deleteSuccess'))
emit('refresh')
} else {
$toast.error(`删除任务失败:${result.message}`)
$toast.error(t('workflow.task.deleteFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -82,10 +80,10 @@ async function handleEnable(item: Workflow) {
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/start`)
if (result.success) {
$toast.success('启用任务成功!')
$toast.success(t('workflow.task.enableSuccess'))
emit('refresh')
} else {
$toast.error(`启用任务失败:${result.message}`)
$toast.error(t('workflow.task.enableFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -99,10 +97,10 @@ async function handlePause(item: Workflow) {
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/pause`)
if (result.success) {
$toast.success('停用任务成功!')
$toast.success(t('workflow.task.pauseSuccess'))
emit('refresh')
} else {
$toast.error(`停用任务失败:${result.message}`)
$toast.error(t('workflow.task.pauseFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -121,10 +119,10 @@ async function handleRun(item: Workflow, from_begin: boolean) {
from_begin,
})
if (result.success) {
$toast.success('任务执行完成!')
$toast.success(t('workflow.task.runSuccess'))
emit('refresh')
} else {
$toast.error(`任务执行失败:${result.message}`)
$toast.error(t('workflow.task.runFailed', { message: result.message }))
emit('refresh')
}
} catch (error) {
@@ -136,8 +134,8 @@ async function handleRun(item: Workflow, from_begin: boolean) {
// 重置任务
async function handleReset(item: Workflow) {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认重置任务 ${item.name} ?`,
title: t('common.confirm'),
content: t('workflow.task.confirmReset', { name: item.name }),
})
if (!isConfirmed) return
@@ -145,10 +143,10 @@ async function handleReset(item: Workflow) {
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/reset`)
if (result.success) {
$toast.success('重置任务成功!')
$toast.success(t('workflow.task.resetSuccess'))
emit('refresh')
} else {
$toast.error(`重置任务失败:${result.message}`)
$toast.error(t('workflow.task.resetFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -157,11 +155,11 @@ async function handleReset(item: Workflow) {
// 计算状态颜色
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: '等待' }
if (status === 'S') return { color: 'success', text: t('workflow.task.status.success') }
else if (status === 'R') return { color: 'primary', text: t('workflow.task.status.running') }
else if (status === 'F') return { color: 'error', text: t('workflow.task.status.failed') }
else if (status === 'P') return { color: 'secondary', text: t('workflow.task.status.paused') }
else return { color: 'info', text: t('workflow.task.status.waiting') }
}
// 计算当前动作占比
@@ -210,37 +208,37 @@ const resolveProgress = (item: Workflow) => {
<template #prepend>
<VIcon icon="mdi-note-edit" />
</template>
<VListItemTitle>编辑任务</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.edit') }}</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, false)">
<template #prepend>
<VIcon icon="mdi-play-speed" />
</template>
<VListItemTitle>继续执行</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.continue') }}</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-replay" />
</template>
<VListItemTitle>重新执行</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.restart') }}</VListItemTitle>
</VListItem>
<VListItem v-else base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-run" />
</template>
<VListItemTitle>立即执行</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.run') }}</VListItemTitle>
</VListItem>
<VListItem base-color="warning" @click="handleReset(workflow)">
<template #prepend>
<VIcon icon="mdi-restore-alert" />
</template>
<VListItemTitle>重置任务</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.reset') }}</VListItemTitle>
</VListItem>
<VListItem base-color="error" @click="handleDelete(workflow)">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>删除任务</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.delete') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
@@ -252,11 +250,11 @@ const resolveProgress = (item: Workflow) => {
<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>
<div class="mb-1">{{ t('workflow.task.info.timer') }}</div>
<h5 class="text-h6">{{ workflow?.timer }}</h5>
</div>
<div class="flex-1">
<div class="mb-1">状态</div>
<div class="mb-1">{{ t('workflow.task.info.status') }}</div>
<h5 class="text-h6" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
{{ resolveStatusVariant(workflow?.state).text }}
</h5>
@@ -264,7 +262,7 @@ const resolveProgress = (item: Workflow) => {
</div>
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">动作数</div>
<div class="mb-1">{{ t('workflow.task.info.actionCount') }}</div>
<div>
<VAvatar size="32" color="primary" variant="tonal">
<span class="text-sm">{{ workflow?.actions?.length }}</span>
@@ -272,13 +270,13 @@ const resolveProgress = (item: Workflow) => {
</div>
</div>
<div class="flex-1">
<div class="mb-1">已执行次数</div>
<div class="mb-1">{{ t('workflow.task.info.runCount') }}</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="mb-1">{{ t('workflow.task.info.progress') }}</div>
<div class="d-flex align-center gap-5">
<div class="flex-grow-1">
<VProgressLinear color="info" rounded :model-value="resolveProgress(workflow)" />
@@ -289,7 +287,7 @@ const resolveProgress = (item: Workflow) => {
</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="mb-1">{{ t('workflow.task.info.error') }}</div>
<div class="text-error">{{ workflow?.result }}</div>
</div>
</div>

View File

@@ -5,6 +5,10 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -38,7 +42,9 @@ const loading = ref(false)
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
// 计算按钮文字
const buttonText = computed(() => (loading.value ? '下载中...' : '开始下载'))
const buttonText = computed(() =>
loading.value ? t('dialog.addDownload.downloading') : t('dialog.addDownload.startDownload'),
)
// 加载目录设置
async function loadDirectories() {
@@ -96,12 +102,20 @@ async function addDownload() {
if (result && result.success) {
// 添加下载成功
$toast.success(`${props.torrent?.site_name} ${props.torrent?.title} 下载成功!`)
$toast.success(
t('dialog.addDownload.downloadSuccess', { site: props.torrent?.site_name, title: props.torrent?.title }),
)
// 下载成功,返回链接
emit('done', props.torrent?.enclosure)
} else {
// 添加下载失败
$toast.error(`${props.torrent?.site_name} ${props.torrent?.title} 下载失败:${result?.message}`)
$toast.error(
t('dialog.addDownload.downloadFailed', {
site: props.torrent?.site_name,
title: props.torrent?.title,
message: result?.message,
}),
)
// 下载失败,返回错误原因
emit('error', result?.message)
}
@@ -123,7 +137,7 @@ onMounted(() => {
<VCardTitle class="py-4 me-12">
<VIcon icon="mdi-download" class="me-2" />
<span v-if="title">{{ torrent?.site_name }} - {{ title }}</span>
<span v-else>确认下载</span>
<span v-else>{{ t('dialog.addDownload.confirmDownload') }}</span>
</VCardTitle>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
@@ -165,9 +179,9 @@ onMounted(() => {
v-model="selectedDownloader"
:items="downloaderOptions"
size="small"
label="下载器(默认)"
:label="t('dialog.addDownload.downloader')"
variant="underlined"
placeholder="留空默认"
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
density="compact"
/>
</VCol>
@@ -175,9 +189,9 @@ onMounted(() => {
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
label="保存目录(自动)"
:label="t('dialog.addDownload.saveDirectory')"
size="small"
placeholder="留空自动匹配"
:placeholder="t('dialog.addDownload.autoPlaceholder')"
variant="underlined"
density="compact"
/>

View File

@@ -1,5 +1,9 @@
<script lang="ts" setup>
import api from '@/api'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -18,7 +22,24 @@ async function handleDone() {
emit('done')
}
// 保存rclone设
// 重置配
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/alist')
if (result.success) {
// 重置成功
alertType.value = 'success'
handleDone()
} else {
alertType.value = 'error'
text.value = result.message
}
} catch (e) {
console.error(e)
}
}
// 保存alist设置
async function savaAlistConfig() {
try {
await api.post(`storage/save/alist`, props.conf)
@@ -30,22 +51,32 @@ async function savaAlistConfig() {
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="AList配置" class="rounded-t">
<VCard :title="t('dialog.alistConfig.title')">
<VDialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="props.conf.url" hint="AList服务地址" label="地址" persistent-hint />
<VTextField
v-model="props.conf.url"
:hint="t('dialog.alistConfig.serverUrl')"
:label="t('dialog.alistConfig.serverUrl')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="props.conf.username" hint="AList登录用户名" label="用户名" persistent-hint />
<VTextField
v-model="props.conf.username"
:hint="t('dialog.alistConfig.username')"
:label="t('dialog.alistConfig.username')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="props.conf.password"
hint="AList登录密码"
label="密码"
:hint="t('dialog.alistConfig.password')"
:label="t('dialog.alistConfig.password')"
persistent-hint
/>
</VCol>
@@ -53,7 +84,12 @@ async function savaAlistConfig() {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
<VBtn variant="tonal" color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.alistConfig.reset') }}
</VBtn>
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.alistConfig.complete') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -1,5 +1,9 @@
<script lang="ts" setup>
import api from '@/api'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 定义输入
defineProps({
@@ -16,7 +20,7 @@ const emit = defineEmits(['done', 'close'])
const qrCodeUrl = ref('')
// 下方的提示信息
const text = ref('请用阿里云盘 App 扫码')
const text = ref(t('dialog.aliyunAuth.scanQrCode'))
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
@@ -74,6 +78,24 @@ async function checkQrcode() {
}
}
// 重置配置
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/alipan')
console.log(result.success)
if (result.success) {
// 重置成功
alertType.value = 'success'
handleDone()
} else {
alertType.value = 'error'
text.value = result.message
}
} catch (e) {
console.error(e)
}
}
onMounted(async () => {
await getQrcode()
})
@@ -85,7 +107,7 @@ onUnmounted(() => {
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard title="阿里云盘登录" class="rounded-t">
<VCard :title="t('dialog.aliyunAuth.loginTitle')">
<VDialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 rounded text-center p-3 border">
@@ -103,7 +125,12 @@ onUnmounted(() => {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
<VBtn variant="tonal" color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.aliyunAuth.reset') }}
</VBtn>
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.aliyunAuth.complete') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -5,6 +5,10 @@ import { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useToast } from 'vue-toast-notification'
import { VBtn } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -116,11 +120,11 @@ async function doFork() {
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
$toast.success(t('subscribe.addSuccess', { name: props.media?.share_title }))
// 完成
emit('fork', result.data.id)
} else {
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}`)
$toast.error(t('subscribe.addFailed', { name: props.media?.share_title, message: result.message }))
}
} catch (error) {
console.error(error)
@@ -144,11 +148,11 @@ async function doDelete() {
})
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 取消分享成功!`)
$toast.success(t('subscribe.cancelSuccess'))
// 完成
emit('delete', result.data.id)
} else {
$toast.error(`${props.media?.share_title} 取消分享失败:${result.message}`)
$toast.error(t('subscribe.cancelFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -200,13 +204,13 @@ onMounted(() => {
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">分享人</span>
<span class="font-weight-medium">{{ t('subscribe.sharer') }}</span>
<span class="text-body-1"> {{ media?.share_user }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0" v-if="media?.keyword">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">搜索词</span>
<span class="font-weight-medium">{{ t('subscribe.keyword') }}</span>
<span class="text-body-1"> {{ media?.keyword }}</span>
</VListItemTitle>
</VListItem>
@@ -217,7 +221,7 @@ onMounted(() => {
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
}"
>
<span class="font-weight-medium">识别词</span>
<span class="font-weight-medium">{{ t('subscribe.recognitionWords') }}</span>
<span class="text-body-1"> {{ media?.custom_words }}</span>
</VListItemTitle>
</VListItem>
@@ -232,7 +236,7 @@ onMounted(() => {
:loading="processing"
class="mb-2 me-2"
>
订阅
{{ t('subscribe.normalSub') }}
</VBtn>
<VBtn
v-if="isFollowed && props.media?.share_uid"
@@ -241,7 +245,7 @@ onMounted(() => {
prepend-icon="mdi-account-remove"
class="mb-2 me-2"
>
取消关注
{{ t('subscribe.unfollow') }}
</VBtn>
<VBtn
v-else-if="props.media?.share_uid"
@@ -250,7 +254,7 @@ onMounted(() => {
prepend-icon="mdi-account-plus"
class="mb-2 me-2"
>
关注
{{ t('subscribe.follow') }}
</VBtn>
<VBtn
v-if="
@@ -264,11 +268,13 @@ onMounted(() => {
:loading="deleting"
class="mb-2 me-2"
>
取消分享
{{ t('subscribe.cancelShare') }}
</VBtn>
</div>
<div class="text-xs mt-2" v-if="props.media?.count">
<VIcon icon="mdi-fire" /> {{ props.media?.count?.toLocaleString() }} 次复用
<VIcon icon="mdi-fire" />{{
t('subscribe.usageCount', { count: props.media?.count?.toLocaleString() })
}}
</div>
</div>
</VCardItem>

View File

@@ -1,4 +1,9 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 输入参数
const props = defineProps({
title: String,
@@ -19,15 +24,17 @@ function handleImport() {
</script>
<template>
<VDialog width="40rem" scrollable max-height="85vh" persistent>
<VCard :title="props.title" class="rounded-t">
<VDialog width="40rem" scrollable max-height="85vh">
<VCard :title="props.title">
<VDialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VTextarea v-model="codeString" />
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3"> 导入 </VBtn>
<VBtn variant="elevated" @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3">
{{ t('dialog.importCode.import') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { Context } from '@/api/types'
import MediaInfoCard from '../cards/MediaInfoCard.vue'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 输入参数
defineProps({

View File

@@ -6,6 +6,11 @@ import api from '@/api'
import { useToast } from 'vue-toast-notification'
import FormRender from '../render/FormRender.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { loadRemoteComponent } from '@/utils/federationLoader'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -38,71 +43,154 @@ 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
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
// Vue 模式:动态加载的组件
const dynamicComponent = defineAsyncComponent({
// 工厂函数
loader: async () => {
try {
if (!props.plugin?.id) {
throw new Error('插件ID不存在')
}
// 动态加载远程组件
const module = await loadRemoteComponent(props.plugin.id, 'Config')
// 直接返回加载的组件无需再获取default
return module
} catch (error) {
console.error('加载远程组件失败:', error)
}
} catch (error) {
},
// 加载中显示的组件
loadingComponent: {
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
},
// 添加错误处理
errorComponent: {
template: `
<div class="pa-4">
<VAlert type="error" title="组件加载错误">
无法加载组件,请稍后再试
</VAlert>
</div>
`,
},
// 添加超时设置
timeout: 20000,
})
//调用API读取UI和配置数据
async function loadPluginUIData() {
// 重置
isRefreshed.value = false
pluginFormItems = []
pluginConfigForm.value = {}
renderMode.value = 'vuetify'
try {
// 获取UI定义
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (!result) {
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败无效的响应`)
return
}
renderMode.value = result.render_mode
if (renderMode.value === 'vue') {
// Vue模式下初始配置在同一个API返回
if (!isNullOrEmptyObject(result.model)) {
pluginConfigForm.value = result.model
}
} else {
// Vuetify模式
pluginFormItems = result.conf || []
if (result.model) {
pluginConfigForm.value = result.model
}
}
} catch (error: any) {
console.error(error)
} finally {
isRefreshed.value = true
}
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
// 处理 Vue 组件触发的保存事件
function handleVueComponentSave(newConfig: Record<string, any>) {
pluginConfigForm.value = newConfig
savePluginConf()
}
// 调用API保存配置数据
async function savePluginConf() {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
progressText.value = t('dialog.pluginConfig.saving', { name: 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} 配置已保存`)
$toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('save')
} else {
progressDialog.value = false
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
}
progressDialog.value = false
}
onBeforeMount(async () => {
await loadPluginForm()
await loadPluginConf()
await loadPluginUIData()
})
</script>
<template>
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
<!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText v-if="isRefreshed">
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else="isRefreshed">
<div>
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info"> 查看数据 </VBtn>
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info">
{{ t('dialog.pluginConfig.viewData') }}
</VBtn>
<VSpacer />
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
<!-- 只有Vuetify模式显示默认保存按钮Vue模式由组件内部控制 -->
<VBtn
v-if="renderMode === 'vuetify'"
@click="savePluginConf"
variant="elevated"
prepend-icon="mdi-content-save"
class="px-5"
>
保存
</VBtn>
</VCardActions>
</VCard>
<!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'">
<VCardText class="pa-0">
<component
:is="dynamicComponent"
:initial-config="pluginConfigForm"
:api="api"
@save="handleVueComponentSave"
@switch="emit('switch')"
@close="emit('close')"
/>
</VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</VDialog>

View File

@@ -3,6 +3,7 @@ import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import PageRender from '@/components/render/PageRender.vue'
import api from '@/api'
import { loadRemoteComponent } from '@/utils/federationLoader'
// 输入参数
const props = defineProps({
@@ -21,32 +22,112 @@ const appMode = inject('pwaMode') && display.mdAndDown.value
// 是否刷新
const isRefreshed = ref(false)
// 组件是否已加载成功
const componentLoaded = ref(false)
// 是否正在加载数据
const isLoading = ref(false)
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
// 插件数据页面配置项
let pluginPageItems = ref([])
// 调用API读取数据页面
async function loadPluginPage() {
// Vue 模式:动态加载的组件
const dynamicComponent = defineAsyncComponent({
// 工厂函数
loader: async () => {
try {
if (!props.plugin?.id) {
throw new Error('插件ID不存在')
}
// 动态加载远程组件
const module = await loadRemoteComponent(props.plugin.id, 'Page')
componentLoaded.value = true
return module
} catch (error) {
console.error('加载远程组件失败:', error)
componentLoaded.value = false
}
},
// 加载中显示的组件
loadingComponent: {
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
},
// 添加错误处理
errorComponent: {
template: `
<div class="pa-4">
<VAlert type="error" title="组件加载错误">
无法加载组件,请稍后再试
</VAlert>
</div>
`,
},
// 添加超时设置
timeout: 20000,
})
// 调用API读取数据页面UI
async function loadPluginUIData() {
// 如果正在加载,则不重复加载
if (isLoading.value) return
isLoading.value = true
isRefreshed.value = false
pluginPageItems.value = []
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
if (result) pluginPageItems.value = result
} catch (error) {
// 如果已经是vue模式且组件已加载成功不需要再请求模式
if (renderMode.value === 'vue' && componentLoaded.value) {
isRefreshed.value = true
isLoading.value = false
return
}
const result: { [key: string]: any } = await api.get(`plugin/page/${props.plugin?.id}`)
if (!result || !result.render_mode) {
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败无效的响应`)
return
}
renderMode.value = result.render_mode
if (renderMode.value === 'vuetify') {
// Vuetify模式
pluginPageItems.value = result.page || []
}
} catch (error: any) {
console.error(error)
} finally {
isRefreshed.value = true
isLoading.value = false
}
isRefreshed.value = true
}
// 重新加载数据(可由 PageRender 或 Vue component 触发)
function handleAction(event: any) {
// 避免在组件已加载的情况下重复调用loadPluginUIData
if (renderMode.value === 'vue' && componentLoaded.value) {
return
}
loadPluginUIData()
}
onMounted(() => {
loadPluginPage()
loadPluginUIData()
})
</script>
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
<!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
<VDialogCloseBtn @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" />
<div>
<PageRender @action="handleAction" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
<div v-if="!pluginPageItems || pluginPageItems.length === 0">此插件没有详情页面</div>
</div>
</VCardText>
<VFab
icon="mdi-cog"
@@ -59,5 +140,17 @@ onMounted(() => {
:class="{ 'mb-10': appMode }"
/>
</VCard>
<!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'">
<VCardText class="pa-0">
<component
:is="dynamicComponent"
:api="api"
@action="handleAction"
@switch="emit('switch')"
@close="emit('close')"
/>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,7 +1,10 @@
<script lang="ts" setup>
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
const $toast = useToast()
// 插件仓库设置字符串
@@ -27,9 +30,9 @@ async function saveHandle() {
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoString.value)
if (result.success) {
$toast.success('插件仓库保存成功')
$toast.success(t('dialog.pluginMarketSetting.saveSuccess'))
emit('save')
} else $toast.error(`插件仓库保存失败:${result?.message}`)
} else $toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: result?.message }))
} catch (error) {
console.log(error)
}
@@ -42,26 +45,26 @@ onMounted(() => {
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard class="rounded-t">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-store-cog" class="me-2" />
插件仓库设置
{{ t('dialog.pluginMarketSetting.title') }}
</VCardTitle>
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VCardText class="pt-2">
<VTextarea
v-model="repoString"
placeholder="格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/"
hint="多个地址使用逗号分隔仅支持Github仓库"
:placeholder="t('dialog.pluginMarketSetting.repoPlaceholder')"
:hint="t('dialog.pluginMarketSetting.repoHint')"
persistent-hint
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
保存
{{ t('dialog.pluginMarketSetting.save') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -1,15 +1,19 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
value: Number,
text: String,
})
</script>
<template>
<!-- 手动整理进度框 -->
<!-- Progress Dialog -->
<VDialog :scrim="false" width="25rem">
<VCard elevation="3" color="primary">
<VCardText class="text-center">
{{ props.text }}
{{ props.text || t('dialog.progress.processing') }}
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
</VCardText>
</VCard>

View File

@@ -1,5 +1,9 @@
<script lang="ts" setup>
import api from '@/api'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -14,7 +18,7 @@ if (!props.conf.filepath) {
}
if (!props.conf.content) {
props.conf.content = '# 请在此处填写rclone配置文件内容 \n# 请参考 https://rclone.org/docs/ \n# 存储节点名必须为MP'
props.conf.content = t('dialog.rcloneConfig.defaultContent')
}
// 定义事件
@@ -34,16 +38,28 @@ async function savaRcloneConfig() {
console.error(e)
}
}
// 重置配置
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/rclone')
if (result.success) {
handleDone()
}
} catch (e) {
console.error(e)
}
}
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="RClone配置" class="rounded-t">
<VCard :title="t('dialog.rcloneConfig.title')">
<VDialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="props.conf.filepath" label="rclone配置文件路径" />
<VTextField v-model="props.conf.filepath" :label="t('dialog.rcloneConfig.filePath')" />
</VCol>
<VCol cols="12">
<VAceEditor
@@ -59,7 +75,12 @@ async function savaRcloneConfig() {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
<VBtn variant="tonal" color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.rcloneConfig.reset') }}
</VBtn>
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.rcloneConfig.complete') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -2,11 +2,15 @@
import { useToast } from 'vue-toast-notification'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import api from '@/api'
import { storageOptions, transferTypeOptions } from '@/api/constants'
import { transferTypeOptions } from '@/api/constants'
import { numberValidator } from '@/@validators'
import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { FileItem, TransferDirectoryConf, TransferForm } from '@/api/types'
import { FileItem, StorageConf, TransferDirectoryConf, TransferForm } from '@/api/types'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -31,7 +35,7 @@ const emit = defineEmits(['done', 'close'])
// 生成1到100季的下拉框选项
const seasonItems = ref(
Array.from({ length: 101 }, (_, i) => i).map(item => ({
title: `${item}`,
title: `${t('dialog.subscribeEdit.seasonFormat', { number: item })}`,
value: item,
})),
)
@@ -49,20 +53,42 @@ const progressEventSource = ref<EventSource>()
const progressDialog = ref(false)
// 整理进度文本
const progressText = ref('正在处理 ...')
const progressText = ref(t('dialog.reorganize.processing'))
// 整理进度
const progressValue = ref(0)
// 所有存储
const storages = ref<StorageConf[]>([])
// 查询存储
async function loadStorages() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Storages')
storages.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 存储字典
const storageOptions = computed(() => {
return storages.value.map(item => ({
title: item.name,
value: item.type,
}))
})
// 标题
const dialogTitle = computed(() => {
if (props.items) {
if (props.items.length > 1) return `整理 - 共 ${props.items.length}`
return `整理 - ${props.items[0].path}`
if (props.items.length > 1) return t('dialog.reorganize.multipleItemsTitle', { count: props.items.length })
return t('dialog.reorganize.singleItemTitle', { path: props.items[0].path })
} else if (props.logids) {
return `整理 - 共 ${props.logids.length}`
return t('dialog.reorganize.multipleItemsTitle', { count: props.logids.length })
}
return '手动整理'
return t('dialog.reorganize.manualTitle')
})
// 禁用指定集数
@@ -138,7 +164,7 @@ async function handleTransfer(item: FileItem, background: boolean = false) {
try {
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
if (!result.success) $toast.error(result.message)
else if (background) $toast.success(`文件 ${item.name} 已加入整理队列!`)
else if (background) $toast.success(t('dialog.reorganize.successMessage', { name: item.name }))
} catch (e) {
console.log(e)
}
@@ -159,7 +185,7 @@ async function handleTransferLog(logid: number, background: boolean = false) {
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
progressText.value = t('dialog.reorganize.processing')
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
@@ -214,6 +240,7 @@ async function transfer(background: boolean = false) {
onMounted(() => {
loadDirectories()
loadStorages()
})
onUnmounted(() => {
@@ -223,7 +250,7 @@ onUnmounted(() => {
<template>
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="dialogTitle" class="rounded-t">
<VCard :title="dialogTitle">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
@@ -233,22 +260,22 @@ onUnmounted(() => {
<VSelect
v-model="transferForm.target_storage"
:items="storageOptions"
label="目的存储"
placeholder="留空自动"
hint="整理目的存储"
:label="t('dialog.reorganize.targetStorage')"
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
:hint="t('dialog.reorganize.targetStorageHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
:label="t('dialog.reorganize.transferType')"
:items="transferTypeOptions"
hint="文件操作整理方式"
:hint="t('dialog.reorganize.transferTypeHint')"
persistent-hint
>
<template v-slot:selection="{ item }">
{{ transferForm.transfer_type === '' ? '自动' : item.title }}
{{ transferForm.transfer_type === '' ? t('dialog.reorganize.auto') : item.title }}
</template>
</VSelect>
</VCol>
@@ -256,9 +283,9 @@ onUnmounted(() => {
<VCombobox
v-model="transferForm.target_path"
:items="targetDirectories"
label="目的路径"
placeholder="留空自动"
hint="整理目的路径,留空将自动匹配"
:label="t('dialog.reorganize.targetPath')"
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
:hint="t('dialog.reorganize.targetPathHint')"
persistent-hint
/>
</VCol>
@@ -267,13 +294,13 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.type_name"
label="类型"
:label="t('dialog.reorganize.mediaType')"
:items="[
{ title: '自动', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
{ title: t('dialog.reorganize.auto'), value: '' },
{ title: t('dialog.reorganize.movie'), value: '电影' },
{ title: t('dialog.reorganize.tv'), value: '电视剧' },
]"
hint="文件的媒体类型"
:hint="t('dialog.reorganize.mediaTypeHint')"
persistent-hint
/>
</VCol>
@@ -282,11 +309,11 @@ onUnmounted(() => {
v-if="mediaSource === 'themoviedb'"
v-model="transferForm.tmdbid"
:disabled="transferForm.type_name === ''"
label="TheMovieDb编号"
placeholder="留空自动识别"
:label="t('dialog.reorganize.tmdbId')"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
hint="按名称查询媒体编号,留空自动识别"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
@click:append-inner="mediaSelectorDialog = true"
/>
@@ -294,11 +321,11 @@ onUnmounted(() => {
v-else
v-model="transferForm.doubanid"
:disabled="transferForm.type_name === ''"
label="豆瓣编号"
placeholder="留空自动识别"
:label="t('dialog.reorganize.doubanId')"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
hint="按名称查询媒体编号,留空自动识别"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
@click:append-inner="mediaSelectorDialog = true"
/>
@@ -308,18 +335,18 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_group"
label="剧集组编号"
placeholder="手动查询剧集组"
hint="指定剧集组"
:label="t('dialog.reorganize.episodeGroup')"
:placeholder="t('dialog.reorganize.episodeGroupPlaceholder')"
:hint="t('dialog.reorganize.episodeGroupHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="3">
<VSelect
v-model.number="transferForm.season"
label=""
:label="t('dialog.reorganize.season')"
:items="seasonItems"
hint="第几季"
:hint="t('dialog.reorganize.seasonHint')"
persistent-hint
/>
</VCol>
@@ -327,27 +354,27 @@ onUnmounted(() => {
<VTextField
v-model="transferForm.episode_detail"
:disabled="disableEpisodeDetail"
label=""
placeholder="起始集,终止集"
hint="集数或范围如1或1,2"
:label="t('dialog.reorganize.episodeDetail')"
:placeholder="t('dialog.reorganize.episodeDetailPlaceholder')"
:hint="t('dialog.reorganize.episodeDetailHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_format"
label="集数定位"
placeholder="使用{ep}定位集数"
hint="使用{ep}定位文件名中的集数部分以辅助识别"
:label="t('dialog.reorganize.episodeFormat')"
:placeholder="t('dialog.reorganize.episodeFormatPlaceholder')"
:hint="t('dialog.reorganize.episodeFormatHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
hint="集数偏移运算,如-10或EP*2"
:label="t('dialog.reorganize.episodeOffset')"
:placeholder="t('dialog.reorganize.episodeOffsetPlaceholder')"
:hint="t('dialog.reorganize.episodeOffsetHint')"
persistent-hint
/>
</VCol>
@@ -356,19 +383,19 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_part"
label="指定Part"
placeholder="如part1"
hint="指定Part如part1"
:label="t('dialog.reorganize.episodePart')"
:placeholder="t('dialog.reorganize.episodePartPlaceholder')"
:hint="t('dialog.reorganize.episodePartHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="transferForm.min_filesize"
label="最小文件大小MB"
:label="t('dialog.reorganize.minFileSize')"
:rules="[numberValidator]"
placeholder="0"
hint="只整理大于最小文件大小的文件"
:hint="t('dialog.reorganize.minFileSizeHint')"
persistent-hint
/>
</VCol>
@@ -377,32 +404,32 @@ onUnmounted(() => {
<VCol cols="12" md="6" v-if="transferForm.target_path">
<VSwitch
v-model="transferForm.library_type_folder"
label="按类型分类"
hint="整理时目的路径下按媒体类型添加子目录"
:label="t('dialog.reorganize.typeFolderOption')"
:hint="t('dialog.reorganize.typeFolderHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="transferForm.target_path">
<VSwitch
v-model="transferForm.library_category_folder"
label="按类别分类"
hint="整理时在目的路径下按媒体类别添加子目录"
:label="t('dialog.reorganize.categoryFolderOption')"
:hint="t('dialog.reorganize.categoryFolderHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="transferForm.scrape"
label="刮削元数据"
hint="整理完成后自动刮削元数据"
:label="t('dialog.reorganize.scrapeOption')"
:hint="t('dialog.reorganize.scrapeHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="props.logids">
<VSwitch
v-model="transferForm.from_history"
label="复用历史识别信息"
hint="使用历史整理记录中已识别的媒体信息"
:label="t('dialog.reorganize.fromHistoryOption')"
:hint="t('dialog.reorganize.fromHistoryHint')"
persistent-hint
/>
</VCol>
@@ -412,10 +439,10 @@ onUnmounted(() => {
<VCardActions class="pt-3">
<VSpacer />
<VBtn variant="elevated" color="success" @click="transfer(true)" prepend-icon="mdi-plus" class="px-5">
加入整理队列
{{ t('dialog.reorganize.addToQueue') }}
</VBtn>
<VBtn variant="elevated" @click="transfer(false)" prepend-icon="mdi-arrow-right-bold" class="px-5">
立即整理
{{ t('dialog.reorganize.reorganizeNow') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import api from '@/api'
import type { Site, Plugin, Subscribe } from '@/api/types'
import { SystemNavMenus, SettingTabs } from '@/router/menu'
import { getNavMenus, getSettingTabs } from '@/router/i18n-menu'
import { NavMenu } from '@/@layouts/types'
import { useUserStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 定义props接收modelValue
const props = defineProps<{
@@ -73,7 +77,7 @@ function loadRecentSearches() {
function getMenus(): NavMenu[] {
let menus: NavMenu[] = []
// 导航菜单
SystemNavMenus.forEach(
getNavMenus().forEach(
item =>
item &&
menus.push({
@@ -85,11 +89,11 @@ function getMenus(): NavMenu[] {
}),
)
// 设置标签页
SettingTabs.forEach(
getSettingTabs().forEach(
item =>
item &&
menus.push({
title: '设定 -> ' + item.title,
title: t('setting') + ' -> ' + item.title,
icon: item.icon,
to: `/setting?tab=${item.tab}`,
header: '',
@@ -298,7 +302,7 @@ onMounted(() => {
})
</script>
<template>
<VDialog v-model="dialog" max-width="42rem" scrollable>
<VDialog v-model="dialog" max-width="42rem" scrollable maxHeight="85vh">
<VCard class="search-dialog">
<!-- 搜索输入框 -->
<VCardItem class="pa-4 pa-sm-5 search-box-container">
@@ -311,7 +315,7 @@ onMounted(() => {
density="comfortable"
variant="outlined"
class="search-input"
placeholder="输入关键词搜索..."
:placeholder="t('dialog.searchBar.searchPlaceholder')"
@keydown.enter="searchMedia('media')"
hide-details
clearable
@@ -330,7 +334,9 @@ onMounted(() => {
<!-- 有搜索词时显示结果 -->
<VList lines="two" v-if="searchWord" class="search-list py-2">
<!-- 搜索结果分组标题 -->
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 媒体 </VListSubheader>
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">
{{ t('common.media') }}
</VListSubheader>
<!-- 媒体搜索选项 -->
<VHover>
@@ -352,9 +358,12 @@ onMounted(() => {
/>
</div>
</template>
<VListItemTitle class="font-weight-medium"> 电影电视剧 </VListItemTitle>
<VListItemTitle class="font-weight-medium"
>{{ t('recommend.categoryMovie') }}{{ t('recommend.categoryTV') }}</VListItemTitle
>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的影视作品
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('resource.title') }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
@@ -382,9 +391,10 @@ onMounted(() => {
/>
</div>
</template>
<VListItemTitle class="font-weight-medium"> 系列合集 </VListItemTitle>
<VListItemTitle class="font-weight-medium">{{ t('dialog.searchBar.collections') }}</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的系列作品
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.collectionSearch') }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
@@ -412,9 +422,10 @@ onMounted(() => {
/>
</div>
</template>
<VListItemTitle class="font-weight-medium"> 演职人员 </VListItemTitle>
<VListItemTitle class="font-weight-medium">{{ t('browse.actor') }}</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的演员导演等
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.actorSearch') }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
@@ -438,9 +449,10 @@ onMounted(() => {
<VIcon icon="mdi-history" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
</div>
</template>
<VListItemTitle class="font-weight-medium"> 整理记录 </VListItemTitle>
<VListItemTitle class="font-weight-medium">{{ t('navItems.history') }}</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的历史记录
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.historySearch') }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
@@ -452,7 +464,9 @@ onMounted(() => {
<!-- 其他搜索结果 -->
<template v-if="matchedSubscribeItems.length > 0">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 订阅 </VListSubheader>
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">{{
t('dialog.searchBar.subscriptions')
}}</VListSubheader>
<VHover v-for="subscribe in matchedSubscribeItems" :key="subscribe.id">
<template #default="hover">
@@ -475,7 +489,9 @@ onMounted(() => {
</template>
<VListItemTitle class="font-weight-medium">
{{ subscribe.name
}}<span v-if="subscribe.season" class="text-body-2"> {{ subscribe.season }} </span>
}}<span v-if="subscribe.season" class="text-body-2">
{{ t('resource.season') }} {{ subscribe.season }}</span
>
</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
{{ subscribe.type }}
@@ -490,7 +506,9 @@ onMounted(() => {
<template v-if="matchedMenuItems.length > 0">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 功能 </VListSubheader>
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">{{
t('dialog.searchBar.functions')
}}</VListSubheader>
<VHover v-for="menu in matchedMenuItems" :key="menu.title">
<template #default="hover">
@@ -527,7 +545,9 @@ onMounted(() => {
<template v-if="matchedPluginItems.length > 0">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 插件 </VListSubheader>
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">{{
t('dialog.searchBar.plugins')
}}</VListSubheader>
<VHover v-for="plugin in matchedPluginItems" :key="plugin.id">
<template #default="hover">
@@ -541,7 +561,7 @@ onMounted(() => {
>
<template #prepend>
<div class="option-icon-wrapper d-flex align-center justify-center">
<VIcon icon="mdi-puzzle" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
<VIcon icon="mdi-apps" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
</div>
</template>
<VListItemTitle class="font-weight-medium">
@@ -561,7 +581,9 @@ onMounted(() => {
<!-- 将站点资源搜索移到最底部 -->
<template v-if="searchWord">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 站点资源 </VListSubheader>
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">{{
t('dialog.searchBar.siteResources')
}}</VListSubheader>
<VCard class="mx-3 mx-sm-6 mb-4 mt-2 site-search-card">
<VCardText class="pa-3 pa-sm-4">
@@ -571,9 +593,10 @@ onMounted(() => {
<VIcon icon="mdi-file-search" color="primary" size="small" />
</div>
<div class="flex-grow-1">
<div class="font-weight-medium text-body-1">在站点中搜索种子资源</div>
<div class="font-weight-medium text-body-1">{{ t('dialog.searchBar.searchInSites') }}</div>
<div class="text-caption text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关资源
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.relatedResources') }}
</div>
</div>
<VBtn
@@ -584,7 +607,7 @@ onMounted(() => {
variant="flat"
class="search-btn"
>
搜索
{{ t('common.search') }}
</VBtn>
</div>
@@ -628,7 +651,7 @@ onMounted(() => {
class="ml-auto site-select-btn"
rounded="pill"
>
选择站点
{{ t('dialog.searchBar.selectSites') }}
<VIcon size="small" class="ml-1">mdi-cog-outline</VIcon>
</VBtn>
</div>
@@ -641,7 +664,7 @@ onMounted(() => {
<!-- 无搜索词时显示最近搜索和提示 -->
<div v-else class="recent-searches py-6 px-4 px-sm-6">
<div v-if="recentSearches.length > 0" class="mb-6">
<div class="text-h6 font-weight-medium mb-3">最近搜索</div>
<div class="text-h6 font-weight-medium mb-3">{{ t('dialog.searchBar.recentSearches') }}</div>
<div class="d-flex flex-wrap">
<VChip
v-for="(word, index) in recentSearches"
@@ -658,12 +681,12 @@ onMounted(() => {
</div>
</div>
<div class="text-center mt-6 py-6 empty-search-state">
<div v-else class="text-center mt-6 py-6 empty-search-state">
<div class="search-icon-wrapper mx-auto mb-4">
<VIcon icon="mdi-magnify" size="large" color="primary" />
</div>
<div class="text-h6 font-weight-medium mb-2">输入关键词开始搜索</div>
<div class="text-body-2 text-medium-emphasis">可搜索电影电视剧演员资源等</div>
<div class="text-h6 font-weight-medium mb-2">{{ t('dialog.searchBar.searchPlaceholder') }}</div>
<div class="text-body-2 text-medium-emphasis">{{ t('dialog.searchBar.searchTip') }}</div>
</div>
</div>
</VCardText>
@@ -790,10 +813,10 @@ onMounted(() => {
.empty-search-state,
.empty-site-state {
animation: fadeIn 0.3s ease-in-out;
animation: fade-in 0.3s ease-in-out;
}
@keyframes fadeIn {
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import type { Site, Plugin, Subscribe } from '@/api/types'
import { popScopeId, PropType } from 'vue'
import type { Site } from '@/api/types'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
const props = defineProps({
sites: {
@@ -30,7 +33,9 @@ watch(
// 全选/全不选按钮文字
const checkAllText = computed(() => {
return selectedSites.value.length < props.sites?.length ? '选择全部' : '取消全选'
return selectedSites.value.length < props.sites?.length
? t('dialog.searchSite.selectAll')
: t('dialog.searchSite.deselectAll')
})
// 全选/全不选
@@ -50,27 +55,27 @@ const filteredSites = computed(() => {
})
</script>
<template>
<!-- 手动整理进度框 -->
<!-- Site Selection Dialog -->
<VDialog max-width="40rem" fullscreen-mobile>
<VCard class="site-dialog">
<VCardTitle class="d-flex align-center pa-4">
<span class="text-h6 font-weight-medium">选择搜索站点</span>
<span class="text-h6 font-weight-medium">{{ t('dialog.searchSite.selectSites') }}</span>
<VSpacer />
<VTextField
v-model="siteFilter"
placeholder="过滤站点..."
:placeholder="t('dialog.searchSite.siteSearch')"
density="compact"
variant="outlined"
hide-details
class="ml-4"
style="max-width: 200px"
style="max-inline-size: 200px"
prepend-inner-icon="mdi-magnify"
clearable
/>
</VCardTitle>
<VDivider class="search-divider" />
<VCardText style="max-height: 420px" class="overflow-y-auto px-4 py-4">
<VCardText style="max-block-size: 420px" class="overflow-y-auto px-4 py-4">
<!-- 站点列表 -->
<div v-if="filteredSites.length > 0">
<!-- 选择操作 -->
@@ -92,7 +97,7 @@ const filteredSites = computed(() => {
class="text-body-2 font-weight-medium"
:class="selectedSites.length > 0 ? 'text-primary' : 'text-medium-emphasis'"
>
已选择 {{ selectedSites.length }}/{{ sites.length }} 个站点
{{ t('dialog.searchSite.searchAllSites', { selected: selectedSites.length, total: sites.length }) }}
</div>
</div>
@@ -138,9 +143,9 @@ const filteredSites = computed(() => {
<div class="search-icon-wrapper mb-4 mx-auto warning">
<VIcon icon="mdi-alert-circle-outline" size="large" color="warning" />
</div>
<div class="text-h6 font-weight-medium mb-2">没有找到匹配的站点</div>
<div class="text-h6 font-weight-medium mb-2">{{ t('torrent.noMatchingResults') }}</div>
<div class="text-subtitle-1 text-medium-emphasis mb-4">
{{ siteFilter ? '请尝试修改过滤条件' : '站点数据加载失败,请刷新页面重试' }}
{{ siteFilter ? t('site.noFilterData') : t('site.sitesWillBeShownHere') }}
</div>
<VBtn
v-if="siteFilter"
@@ -150,10 +155,10 @@ const filteredSites = computed(() => {
prepend-icon="mdi-refresh"
@click="siteFilter = ''"
>
重置
{{ t('torrent.clearFilters') }}
</VBtn>
<VBtn v-else color="primary" variant="flat" class="mt-3" prepend-icon="mdi-refresh" @click="emit('reload')">
重新加载站点
{{ t('common.loading') }}
</VBtn>
</div>
</VCardText>
@@ -168,7 +173,7 @@ const filteredSites = computed(() => {
@click="emit('close')"
class="mr-2 d-flex align-center justify-center"
>
取消
{{ t('dialog.searchSite.cancel') }}
</VBtn>
<VBtn
color="primary"
@@ -178,7 +183,7 @@ const filteredSites = computed(() => {
prepend-icon="mdi-magnify"
class="d-flex align-center justify-center px-5"
>
搜索
{{ t('dialog.searchSite.confirm') }}
</VBtn>
</VCardActions>
</VCard>
@@ -186,9 +191,9 @@ const filteredSites = computed(() => {
</template>
<style scoped>
.site-checkbox-wrapper {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.site-checkbox-wrapper:hover {
@@ -196,15 +201,15 @@ const filteredSites = computed(() => {
}
.site-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-selected {
border-color: rgba(var(--v-theme-primary), 0.2);
background-color: rgba(var(--v-theme-primary), 0.08);
color: rgb(var(--v-theme-primary));
border-color: rgba(var(--v-theme-primary), 0.2);
}
.site-hover {

View File

@@ -5,6 +5,10 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
import { numberValidator, requiredValidator } from '@/@validators'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -45,8 +49,8 @@ const isLimit = ref(false)
// 状态下拉项
const statusItems = [
{ title: '启用', value: true },
{ title: '停用', value: false },
{ title: t('site.status.enabled'), value: true },
{ title: t('site.status.disabled'), value: false },
]
// 生成1到50的优先级下拉框选项
@@ -64,14 +68,14 @@ async function loadDownloaderSetting() {
try {
const downloaders: DownloaderConf[] = await api.get('download/clients')
downloaderOptions.value = [
{ title: '默认', value: '' },
{ title: t('common.default'), value: '' },
...downloaders.map((item: { name: any }) => ({
title: item.name,
value: item.name,
})),
]
} catch (error) {
console.error('加载下载器设置失败:', error)
console.error(t('site.errors.loadDownloader'), error)
}
}
@@ -93,10 +97,10 @@ async function addSite() {
try {
const result: { [key: string]: string } = await api.post('site/', siteForm.value)
if (result.success) {
$toast.success('新增站点成功')
$toast.success(t('site.messages.addSuccess'))
emit('save')
} else {
$toast.error(`新增站点失败${result.message}`)
$toast.error(`${t('site.messages.addFailed')}${result.message}`)
}
} catch (error) {
console.error(error)
@@ -119,13 +123,13 @@ async function updateSiteInfo() {
}
const result: { [key: string]: any } = await api.put('site/', siteForm.value)
if (result.success) {
$toast.success(`${siteForm.value?.name} 更新成功!`)
$toast.success(`${siteForm.value?.name} ${t('site.messages.updateSuccess')}`)
emit('save')
} else {
$toast.error(`${siteForm.value?.name} 更新失败${result.message}`)
$toast.error(`${siteForm.value?.name} ${t('site.messages.updateFailed')}${result.message}`)
}
} catch (error) {
$toast.error(`${siteForm.value?.name} 更新失败`)
$toast.error(`${siteForm.value?.name} ${t('site.messages.updateFailed')}`)
console.error(error)
}
doneNProgress()
@@ -145,8 +149,9 @@ onMounted(async () => {
<template>
<VDialog scrollable :close-on-back="false" eager max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
class="rounded-t"
:title="`${props.oper === 'add' ? t('site.actions.add') : t('site.actions.edit')}${t('site.title')}${
props.oper !== 'add' ? ` - ${siteForm.name}` : ''
}`"
>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
@@ -156,19 +161,19 @@ onMounted(async () => {
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.url"
label="站点地址"
:label="t('site.fields.url')"
:rules="[requiredValidator]"
hint="格式http://www.example.com/"
:hint="t('site.hints.url')"
persistent-hint
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
v-model="siteForm.pri"
label="优先级"
:label="t('site.fields.priority')"
:items="priorityItems"
:rules="[requiredValidator]"
hint="优先级越小越优先"
:hint="t('site.hints.priority')"
persistent-hint
/>
</VCol>
@@ -176,8 +181,8 @@ onMounted(async () => {
<VSelect
v-model="siteForm.is_active"
:items="statusItems"
label="状态"
hint="站点启用/停用"
:label="t('site.fields.status')"
:hint="t('site.hints.status')"
persistent-hint
/>
</VCol>
@@ -186,25 +191,25 @@ onMounted(async () => {
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.rss"
label="RSS地址"
hint="订阅模式为`站点RSS`时使用的订阅链接,如未自动获取需手动补充"
:label="t('site.fields.rss')"
:hint="t('site.hints.rss')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="3">
<VTextField
v-model="siteForm.timeout"
label="超时时间(秒)"
hint="站点请求超时时间为0时不限制"
:label="t('site.fields.timeout')"
:hint="t('site.hints.timeout')"
persistent-hint
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
v-model="siteForm.downloader"
label="下载器"
:label="t('site.fields.downloader')"
:items="downloaderOptions"
hint="此站点使用的下载器"
:hint="t('site.hints.downloader')"
persistent-hint
/>
</VCol>
@@ -229,16 +234,16 @@ onMounted(async () => {
<VCol cols="12">
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
hint="站点请求头中的Cookie信息"
:label="t('site.fields.cookie')"
:hint="t('site.hints.cookie')"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
:label="t('site.fields.userAgent')"
:hint="t('site.hints.userAgent')"
persistent-hint
/>
</VCol>
@@ -249,16 +254,16 @@ onMounted(async () => {
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="站点请求头中的Authorization信息,特殊站点需要"
:label="t('site.fields.authorization')"
:hint="t('site.hints.authorization')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
:label="t('site.fields.apiKey')"
:hint="t('site.hints.apiKey')"
persistent-hint
/>
</VCol>
@@ -267,47 +272,52 @@ onMounted(async () => {
</VWindow>
<VRow>
<VCol cols="12" md="4">
<VSwitch v-model="isLimit" label="限制站点访问频率" />
<VSwitch v-model="isLimit" :label="t('site.fields.limitAccess')" />
</VCol>
</VRow>
<VRow v-if="isLimit">
<VCol cols="12" md="4">
<VTextField
v-model="siteForm.limit_interval"
label="单位周期(秒)"
:label="t('site.fields.limitInterval')"
:rules="[numberValidator]"
hint="限流控制的单位周期时长"
:hint="t('site.hints.limitInterval')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="siteForm.limit_count"
label="周期内访问次数"
:label="t('site.fields.limitCount')"
:rules="[numberValidator]"
hint="单位周期内允许的访问次数"
:hint="t('site.hints.limitCount')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="siteForm.limit_seconds"
label="访问间隔(秒)"
:label="t('site.fields.limitSeconds')"
:rules="[numberValidator]"
hint="每次访问需要间隔的最小时间"
:hint="t('site.hints.limitSeconds')"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.proxy" label="使用代理访问" hint="使用代理服务器访问该站点" persistent-hint />
<VSwitch
v-model="siteForm.proxy"
:label="t('site.fields.useProxy')"
:hint="t('site.hints.useProxy')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="siteForm.render"
label="浏览器仿真"
hint="使用浏览器模拟真实访问该站点"
:label="t('site.fields.browserSimulation')"
:hint="t('site.hints.browserSimulation')"
persistent-hint
/>
</VCol>
@@ -324,7 +334,7 @@ onMounted(async () => {
prepend-icon="mdi-plus"
class="px-5"
>
新增
{{ t('site.actions.add') }}
</VBtn>
<VBtn
v-else
@@ -334,7 +344,7 @@ onMounted(async () => {
prepend-icon="mdi-content-save"
class="px-5"
>
保存
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -4,6 +4,10 @@ import { Site } from '@/api/types'
import { requiredValidator } from '@/@validators'
import { useToast } from 'vue-toast-notification'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const cardProps = defineProps({
@@ -33,7 +37,7 @@ const updateButtonDisable = ref(false)
const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
const progressText = ref(t('dialog.siteCookieUpdate.processing'))
// 调用API更新站点Cookie UA
async function updateSiteCookie() {
@@ -44,7 +48,7 @@ async function updateSiteCookie() {
updateButtonDisable.value = true
progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name })
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
params: {
@@ -55,9 +59,9 @@ async function updateSiteCookie() {
})
if (result.success) {
$toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
$toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))
emit('done')
} else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
} else $toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message: result.message }))
progressDialog.value = false
updateButtonDisable.value = false
@@ -69,19 +73,19 @@ async function updateSiteCookie() {
<template>
<VDialog max-width="30rem">
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<VCard :title="t('dialog.siteCookieUpdate.title')">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
<VTextField v-model="userPwForm.username" :label="t('login.username')" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12">
<VTextField
v-model="userPwForm.password"
label="密码"
:label="t('login.password')"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@@ -90,7 +94,7 @@ async function updateSiteCookie() {
/>
</VCol>
<VCol cols="12">
<VTextField v-model="userPwForm.code" label="两步验证" />
<VTextField v-model="userPwForm.code" :label="t('login.otpCode')" />
</VCol>
</VRow>
</VForm>
@@ -105,7 +109,7 @@ async function updateSiteCookie() {
prepend-icon="mdi-refresh"
class="px-5"
>
开始更新
{{ t('dialog.siteCookieUpdate.updateButton') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -4,6 +4,10 @@ import api from '@/api'
import type { TorrentInfo, SiteCategory } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -49,11 +53,11 @@ const torrent = ref<TorrentInfo>()
// 资源浏览表头
const resourceHeaders = [
{ title: '标题', key: 'title', sortable: false },
{ title: '时间', key: 'pubdate', sortable: true },
{ title: '大小', key: 'size', sortable: true },
{ title: '做种', key: 'seeders', sortable: true },
{ title: '下载', key: 'peers', sortable: true },
{ title: t('dialog.siteResource.titleColumn'), key: 'title', sortable: false },
{ title: t('dialog.siteResource.timeColumn'), key: 'pubdate', sortable: true },
{ title: t('dialog.siteResource.sizeColumn'), key: 'size', sortable: true },
{ title: t('dialog.siteResource.seedersColumn'), key: 'seeders', sortable: true },
{ title: t('dialog.siteResource.peersColumn'), key: 'peers', sortable: true },
{ title: '', key: 'actions', sortable: false },
]
@@ -131,7 +135,7 @@ onMounted(() => {
<!-- Toolbar -->
<div>
<VToolbar color="primary">
<VToolbarTitle>{{ `浏览 - ${props.site?.name}` }}</VToolbarTitle>
<VToolbarTitle>{{ t('dialog.siteResource.browseTitle', { name: props.site?.name }) }}</VToolbarTitle>
<VSpacer />
<VToolbarItems>
<VBtn icon @click="emit('close')" class="me-3">
@@ -143,7 +147,13 @@ onMounted(() => {
<div class="p-3">
<VRow>
<VCol cols="6" md="5">
<VTextField v-model="keyword" size="small" density="compact" label="搜索关键字" clearable />
<VTextField
v-model="keyword"
size="small"
density="compact"
:label="t('dialog.siteResource.searchKeyword')"
clearable
/>
</VCol>
<VCol cols="6" md="5">
<VSelect
@@ -152,13 +162,13 @@ onMounted(() => {
size="small"
density="compact"
chips
label="资源分类"
:label="t('dialog.siteResource.resourceCategory')"
multiple
clearable
/>
</VCol>
<VCol cols="12" md="2" class="text-center">
<VBtn block prepend-icon="mdi-magnify" @click="getResourceList">搜索</VBtn>
<VBtn block prepend-icon="mdi-magnify" @click="getResourceList">{{ t('dialog.siteResource.search') }}</VBtn>
</VCol>
</VRow>
</div>
@@ -175,9 +185,9 @@ onMounted(() => {
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
:items-per-page-text="t('dialog.siteResource.itemsPerPage')"
:page-text="t('dialog.siteResource.pageText')"
:loading-text="t('dialog.siteResource.loading')"
class="h-full"
>
<template #item.title="{ item }">
@@ -242,20 +252,20 @@ onMounted(() => {
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
<VListItemTitle>{{ t('dialog.siteResource.viewDetails') }}</VListItemTitle>
</VListItem>
<VListItem v-if="item.enclosure?.startsWith('http')" @click="downloadTorrentFile(item.enclosure)">
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
<VListItemTitle>{{ t('dialog.siteResource.downloadTorrent') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
<template #no-data> 没有数据 </template>
<template #no-data>{{ t('dialog.siteResource.noData') }}</template>
</VDataTable>
</VCardText>
</VCard>

View File

@@ -4,6 +4,10 @@ import api from '@/api'
import { useDisplay, useTheme } from 'vuetify'
import { formatFileSize } from '@/@core/utils/formatters'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -36,11 +40,11 @@ const siteData = computed(() => siteDatas.value[siteDatas.value.length - 1])
const historySeries = computed(() => {
return [
{
name: '上传量',
name: t('dialog.siteUserData.uploadTitle'),
data: siteDatas.value.map(item => Math.round((item.upload ?? 0) / 1024 / 1024 / 1024)),
},
{
name: '下载量',
name: t('dialog.siteUserData.downloadTitle'),
data: siteDatas.value.map(item => Math.round((item.download ?? 0) / 1024 / 1024 / 1024)),
},
]
@@ -135,7 +139,7 @@ const historyChartOptions = computed(() => {
const seedingSeries = computed(() => {
return [
{
name: '体积',
name: t('dialog.siteUserData.volumeTitle'),
data: siteData.value?.seeding_info?.map(item => [item[0] ?? 0, Math.round((item[1] ?? 0) / 1024 / 1024 / 1024)]),
},
]
@@ -162,7 +166,7 @@ const seedingChartOptions = computed(() => {
enabled: true,
x: {
formatter: function (val: number) {
return '数量:' + val.toLocaleString()
return t('dialog.siteUserData.countTitle') + val.toLocaleString()
},
},
style: {
@@ -188,7 +192,7 @@ const seedingChartOptions = computed(() => {
},
},
title: {
text: '数量',
text: t('dialog.siteUserData.countTitle'),
},
tickAmount: 10,
},
@@ -279,10 +283,10 @@ onBeforeMount(async () => {
<template>
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard class="rounded-t">
<VCard>
<VCardItem>
<VCardTitle
>{{ `数据 - ${props.site?.name}` }}
>{{ t('dialog.siteUserData.title') }} - {{ props.site?.name }}
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh" /></IconBtn>
</VCardTitle>
<VDialogCloseBtn @click="emit('close')" />
@@ -296,9 +300,9 @@ onBeforeMount(async () => {
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1 overflow-hidden">
<span class="text-base">用户等级</span>
<span class="text-base">{{ t('dialog.siteUserData.userLevel') }}</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.user_level || '无' }}
{{ siteData?.user_level || t('dialog.siteUserData.noData') }}
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
@@ -314,7 +318,7 @@ onBeforeMount(async () => {
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1 overflow-hidden">
<span class="text-base">积分</span>
<span class="text-base">{{ t('dialog.siteUserData.bonus') }}</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.bonus?.toLocaleString() }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.bonus)">
@@ -335,7 +339,7 @@ onBeforeMount(async () => {
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">分享率</span>
<span class="text-base">{{ t('dialog.siteUserData.ratio') }}</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.ratio }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.ratio)">
@@ -356,7 +360,7 @@ onBeforeMount(async () => {
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1 overflow-hidden">
<span class="text-base">总上传量</span>
<span class="text-base">{{ t('dialog.siteUserData.uploadTotal') }}</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.upload || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.upload)">
@@ -377,7 +381,7 @@ onBeforeMount(async () => {
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1 overflow-hidden">
<span class="text-base">总下载量</span>
<span class="text-base">{{ t('dialog.siteUserData.downloadTotal') }}</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.download || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.download)">
@@ -398,7 +402,7 @@ onBeforeMount(async () => {
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1 overflow-hidden">
<span class="text-base">总做种数</span>
<span class="text-base">{{ t('dialog.siteUserData.seedingCount') }}</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.seeding?.toLocaleString() }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.seeding)">
@@ -419,7 +423,7 @@ onBeforeMount(async () => {
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1 overflow-hidden">
<span class="text-base">总做种体积</span>
<span class="text-base">{{ t('dialog.siteUserData.seedingSize') }}</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.seeding_size || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.seeding_size)">
@@ -440,7 +444,7 @@ onBeforeMount(async () => {
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1 overflow-hidden">
<span class="text-base">加入时间</span>
<span class="text-base">{{ t('dialog.siteUserData.joinTime') }}</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.join_at?.split(' ')[0] }}
</h5>
@@ -455,7 +459,7 @@ onBeforeMount(async () => {
</VRow>
<VRow>
<VCol>
<VCard title="历史流量">
<VCard :title="t('dialog.siteUserData.trafficHistory')">
<VCardText>
<VApexChart type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
</VCardText>
@@ -464,7 +468,7 @@ onBeforeMount(async () => {
</VRow>
<VRow>
<VCol>
<VCard title="做种分布">
<VCard :title="t('dialog.siteUserData.seedingDistribution')">
<VCardText>
<VApexChart type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
</VCardText>
@@ -474,6 +478,6 @@ onBeforeMount(async () => {
</VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在刷新站点数据..." />
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="t('dialog.siteUserData.refreshing')" />
</VDialog>
</template>

View File

@@ -5,6 +5,10 @@ import api from '@/api'
import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useConfirm } from 'vuetify-use-dialog'
import { useI18n } from 'vue-i18n'
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
// i18n
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -80,7 +84,7 @@ const episodeGroupOptions = computed(() => {
// 生成1到100季的下拉框选项
const seasonItems = ref(
Array.from({ length: 101 }, (_, i) => i).map(item => ({
title: `${item}`,
title: t('dialog.subscribeEdit.seasonFormat', { number: item }),
value: item,
})),
)
@@ -106,7 +110,7 @@ async function loadDownloaderSetting() {
try {
const downloaders: DownloaderConf[] = await api.get('download/clients')
downloaderOptions.value = [
{ title: '默认', value: '' },
{ title: t('common.default'), value: '' },
...downloaders.map((item: { name: any }) => ({
title: item.name,
value: item.name,
@@ -229,8 +233,8 @@ async function getSubscribeInfo() {
// 删除订阅
async function removeSubscribe() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认取消订阅?`,
title: t('common.confirm'),
content: t('dialog.subscribeEdit.cancelSubscribeConfirm'),
})
if (!isConfirmed) return
@@ -265,90 +269,6 @@ const targetDirectories = computed(() => {
return downloadDirectories.value.map(item => item.download_path)
})
// 质量选择框数据
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.]+',
},
])
onMounted(() => {
queryFilterRuleGroups()
loadDownloadDirectories()
@@ -362,22 +282,26 @@ onMounted(() => {
<template>
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`${
:title="
props.default
? `${props.type}默认订阅规则`
: `编辑订阅 - ${subscribeForm.name} ${subscribeForm.season ? `第 ${subscribeForm.season} 季` : ''}`
}`"
class="rounded-t"
? t('dialog.subscribeEdit.titleDefault')
: t('dialog.subscribeEdit.titleEditFormat', {
name: subscribeForm.name,
season: subscribeForm.season
? t('dialog.subscribeEdit.seasonFormat', { number: subscribeForm.season })
: '',
})
"
>
<VCardText>
<VDialogCloseBtn @click="emit('close')" />
<VForm @submit.prevent="() => {}">
<VTabs v-model="activeTab" show-arrows>
<VTab value="basic">
<div>基础</div>
<div>{{ t('dialog.subscribeEdit.tabs.basic') }}</div>
</VTab>
<VTab value="advance">
<div>进阶</div>
<div>{{ t('dialog.subscribeEdit.tabs.advance') }}</div>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
@@ -387,26 +311,26 @@ onMounted(() => {
<VCol cols="12" md="4">
<VTextField
v-model="subscribeForm.keyword"
label="搜索关键词"
hint="指定搜索站点时使用的关键词"
:label="t('dialog.subscribeEdit.searchKeyword')"
:hint="t('dialog.subscribeEdit.searchKeywordHint')"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="4">
<VTextField
v-model="subscribeForm.total_episode"
label="总集数"
:label="t('dialog.subscribeEdit.totalEpisode')"
:rules="[numberValidator]"
hint="剧集总集数"
:hint="t('dialog.subscribeEdit.totalEpisodeHint')"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="4">
<VTextField
v-model="subscribeForm.start_episode"
label="开始集数"
:label="t('dialog.subscribeEdit.startEpisode')"
:rules="[numberValidator]"
hint="开始订阅集数"
:hint="t('dialog.subscribeEdit.startEpisodeHint')"
persistent-hint
/>
</VCol>
@@ -415,27 +339,27 @@ onMounted(() => {
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.quality"
label="质量"
:label="t('dialog.subscribeEdit.quality')"
:items="qualityOptions"
hint="订阅资源质量"
:hint="t('dialog.subscribeEdit.qualityHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.resolution"
label="分辨率"
:label="t('dialog.subscribeEdit.resolution')"
:items="resolutionOptions"
hint="订阅资源分辨率"
:hint="t('dialog.subscribeEdit.resolutionHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.effect"
label="特效"
:label="t('dialog.subscribeEdit.effect')"
:items="effectOptions"
hint="订阅资源特效"
:hint="t('dialog.subscribeEdit.effectHint')"
persistent-hint
/>
</VCol>
@@ -446,10 +370,10 @@ onMounted(() => {
v-model="subscribeForm.sites"
:items="selectSitesOptions"
chips
label="订阅站点"
:label="t('dialog.subscribeEdit.subscribeSites')"
multiple
clearable
hint="订阅的站点范围,不选使用系统设置"
:hint="t('dialog.subscribeEdit.subscribeSitesHint')"
persistent-hint
/>
</VCol>
@@ -459,8 +383,8 @@ onMounted(() => {
<VSelect
v-model="subscribeForm.downloader"
:items="downloaderOptions"
label="下载器"
hint="指定该订阅使用的下载器"
:label="t('dialog.subscribeEdit.downloader')"
:hint="t('dialog.subscribeEdit.downloaderHint')"
persistent-hint
/>
</VCol>
@@ -468,8 +392,8 @@ onMounted(() => {
<VCombobox
v-model="subscribeForm.save_path"
:items="targetDirectories"
label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
:label="t('dialog.subscribeEdit.savePath')"
:hint="t('dialog.subscribeEdit.savePathHint')"
persistent-hint
/>
</VCol>
@@ -478,24 +402,24 @@ onMounted(() => {
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"
hint="根据洗版优先级进行洗版订阅"
:label="t('dialog.subscribeEdit.bestVersion')"
:hint="t('dialog.subscribeEdit.bestVersionHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.search_imdbid"
label="使用 ImdbID 搜索"
hint="开使用 ImdbID 精确搜索资源"
:label="t('dialog.subscribeEdit.searchImdbid')"
:hint="t('dialog.subscribeEdit.searchImdbidHint')"
persistent-hint
/>
</VCol>
<VCol v-if="props.default" cols="12" md="4">
<VSwitch
v-model="subscribeForm.show_edit_dialog"
label="订阅时编辑更多规则"
hint="添加订阅时显示此编辑订阅对话框"
:label="t('dialog.subscribeEdit.showEditDialog')"
:hint="t('dialog.subscribeEdit.showEditDialogHint')"
persistent-hint
/>
</VCol>
@@ -508,16 +432,16 @@ onMounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
hint="包含规则,支持正则表达式"
:label="t('dialog.subscribeEdit.include')"
:hint="t('dialog.subscribeEdit.includeHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
hint="排除规则,支持正则表达式"
:label="t('dialog.subscribeEdit.exclude')"
:hint="t('dialog.subscribeEdit.excludeHint')"
persistent-hint
/>
</VCol>
@@ -530,8 +454,8 @@ onMounted(() => {
chips
multiple
clearable
label="优先级规则组"
hint="按选定的过滤规则组对订阅进行过滤"
:label="t('dialog.subscribeEdit.filterGroups')"
:hint="t('dialog.subscribeEdit.filterGroupsHint')"
persistent-hint
/>
</VCol>
@@ -540,8 +464,8 @@ onMounted(() => {
v-model="subscribeForm.episode_group"
:items="episodeGroupOptions"
:item-props="episodeGroupItemProps"
label="指定剧集组"
hint="按特定剧集组识别和刮削"
:label="t('dialog.subscribeEdit.episodeGroup')"
:hint="t('dialog.subscribeEdit.episodeGroupHint')"
persistent-hint
/>
</VCol>
@@ -549,16 +473,16 @@ onMounted(() => {
<VSelect
v-model="subscribeForm.season"
:items="seasonItems"
label="指定季"
hint="指定任意季订阅"
:label="t('dialog.subscribeEdit.season')"
:hint="t('dialog.subscribeEdit.seasonHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" v-if="!props.default">
<VTextField
v-model="subscribeForm.media_category"
label="自定义类别"
hint="指定类别名称,留空自动识别"
:label="t('dialog.subscribeEdit.mediaCategory')"
:hint="t('dialog.subscribeEdit.mediaCategoryHint')"
persistent-hint
/>
</VCol>
@@ -567,14 +491,10 @@ onMounted(() => {
<VCol cols="12">
<VTextarea
v-model="subscribeForm.custom_words"
label="自定义识别词"
hint="只对该订阅使用的识别词"
:label="t('dialog.subscribeEdit.customWords')"
:hint="t('dialog.subscribeEdit.customWordsHint')"
persistent-hint
placeholder="屏蔽词
被替换词 => 替换词
前定位词 <> 后定位词 >> 集偏移量EP
被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量EP
其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别其中s、e为季数和集数可选"
:placeholder="t('dialog.subscribeEdit.customWordsPlaceholder')"
/>
</VCol>
</VRow>
@@ -585,7 +505,7 @@ onMounted(() => {
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="!props.default" color="error" @click="removeSubscribe" variant="outlined" class="me-3">
取消订阅
{{ t('dialog.subscribeEdit.cancelSubscribe') }}
</VBtn>
<VSpacer />
<VBtn
@@ -594,7 +514,7 @@ onMounted(() => {
prepend-icon="mdi-content-save"
class="px-5"
>
保存
{{ t('dialog.subscribeEdit.save') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -2,6 +2,10 @@
import api from '@/api'
import { SubscrbieInfo } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// i18n
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -21,15 +25,15 @@ const subScribeInfo = ref<SubscrbieInfo>()
// 下载文件表头
const downloadHeaders = [
{ title: '集', key: 'episode_number', sortable: true },
{ title: '种子', key: 'torrent_title', sortable: true },
{ title: '文件', key: 'file_path', sortable: true },
{ title: t('dialog.subscribeFiles.episodeColumn'), key: 'episode_number', sortable: true },
{ title: t('dialog.subscribeFiles.torrentColumn'), key: 'torrent_title', sortable: true },
{ title: t('dialog.subscribeFiles.fileColumn'), key: 'file_path', sortable: true },
]
// 媒体库文件表头
const libraryHeaders = [
{ title: '集', key: 'episode_number', sortable: true },
{ title: '文件', key: 'file_path', sortable: true },
{ title: t('dialog.subscribeFiles.episodeColumn'), key: 'episode_number', sortable: true },
{ title: t('dialog.subscribeFiles.fileColumn'), key: 'file_path', sortable: true },
]
// 调用API查询订阅文件信息
@@ -76,7 +80,7 @@ onBeforeMount(() => {
</script>
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard class="rounded-t">
<VCard>
<VCardItem class="my-2">
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
@@ -102,7 +106,7 @@ onBeforeMount(() => {
{{ subScribeInfo?.subscribe?.name }}
</div>
<div v-if="subScribeInfo?.subscribe?.season" class="text-lg align-self-center align-self-lg-end ms-3">
{{ subScribeInfo?.subscribe?.season }}
{{ t('dialog.subscribeFiles.season', { number: subScribeInfo?.subscribe?.season }) }}
</div>
</h1>
<div>{{ subScribeInfo?.subscribe?.year }}</div>
@@ -119,13 +123,13 @@ onBeforeMount(() => {
<VTab value="download" selected-class="v-slide-group-item--active v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-download" />
下载文件
{{ t('dialog.subscribeFiles.downloadTab') }}
</div>
</VTab>
<VTab value="library" selected-class="v-slide-group-item--active v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-filmstrip-box-multiple" />
媒体库文件
{{ t('dialog.subscribeFiles.libraryTab') }}
</div>
</VTab>
</VTabs>
@@ -143,9 +147,9 @@ onBeforeMount(() => {
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
:items-per-page-text="t('dialog.subscribeFiles.itemsPerPage')"
:page-text="t('dialog.subscribeFiles.pageText')"
:loading-text="t('dialog.subscribeFiles.loadingText')"
>
<template #item.episode_number="{ item }">
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
@@ -158,7 +162,7 @@ onBeforeMount(() => {
<template #item.file_path="{ item }">
<div class="text-xs" v-for="file in item.download">{{ file.file_path }}</div>
</template>
<template #no-data> 没有数据 </template>
<template #no-data> {{ t('dialog.subscribeFiles.noData') }} </template>
</VDataTable>
</div>
</transition>
@@ -176,9 +180,9 @@ onBeforeMount(() => {
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
:items-per-page-text="t('dialog.subscribeFiles.itemsPerPage')"
:page-text="t('dialog.subscribeFiles.pageText')"
:loading-text="t('dialog.subscribeFiles.loadingText')"
>
<template #item.episode_number="{ item }">
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
@@ -186,7 +190,7 @@ onBeforeMount(() => {
<template #item.file_path="{ item }">
<div class="text-xs" v-for="file in item.library">{{ file.file_path }}</div>
</template>
<template #no-data> 没有数据 </template>
<template #no-data> {{ t('dialog.subscribeFiles.noData') }} </template>
</VDataTable>
</div>
</transition>

View File

@@ -4,6 +4,11 @@ import { Subscribe } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters'
import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -38,7 +43,7 @@ const isRefreshed = ref(false)
const progressDialog = ref(false)
// 进度文字
const progressText = ref('正在重新订阅...')
const progressText = ref('')
// 调用API查询列表
async function loadHistory({ done }: { done: any }) {
@@ -82,8 +87,11 @@ async function loadHistory({ done }: { done: any }) {
// 重新订阅
async function reSubscribe(item: Subscribe) {
if (item.type === '电影') progressText.value = `正在重新订阅 ${item.name} ...`
else progressText.value = `正在重新订阅 ${item.name}${item.season} 季 ...`
if (item.type === '电影') {
progressText.value = t('dialog.subscribeHistory.resubscribeMovie', { name: item.name })
} else {
progressText.value = t('dialog.subscribeHistory.resubscribeTv', { name: item.name, season: item.season })
}
progressDialog.value = true
try {
const result: { [key: string]: any } = await api.post('subscribe/', item)
@@ -111,7 +119,7 @@ async function deleteHistory(item: Subscribe) {
// 弹出菜单
const dropdownItems = ref([
{
title: '重新订阅',
title: t('dialog.subscribeHistory.resubscribe'),
value: 1,
color: '',
props: {
@@ -120,7 +128,7 @@ const dropdownItems = ref([
},
},
{
title: '删除',
title: t('common.delete'),
value: 2,
color: 'error',
props: {
@@ -129,13 +137,19 @@ const dropdownItems = ref([
},
},
])
// 获取媒体类型文本
function getMediaTypeText(type: string | undefined) {
if (!type) return ''
return mediaTypeDict[type]
}
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%">
<VCardItem>
<VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle>
<VCardTitle>{{ t('dialog.subscribeHistory.title', { type: getMediaTypeText(props.type) }) }}</VCardTitle>
</VCardItem>
<VDivider />
<VDialogCloseBtn @click="emit('close')" />
@@ -165,7 +179,8 @@ const dropdownItems = ref([
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }} <span class="text-sm"> {{ item.season }} </span>
{{ item.name }}
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
@@ -199,7 +214,9 @@ const dropdownItems = ref([
</template>
</VInfiniteScroll>
</VList>
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center"> 没有已完成的订阅 </VCardText>
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
t('dialog.subscribeHistory.noData')
}}</VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />

View File

@@ -3,6 +3,10 @@ import api from '@/api'
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
import { PropType } from 'vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 定义事件
const emit = defineEmits(['subscribe', 'close'])
@@ -47,15 +51,18 @@ const episodeGroupOptions = computed(() => {
item => {
return {
title: item.name,
subtitle: `${item.group_count} 季 • ${item.episode_count}`,
subtitle: `${t('dialog.subscribeSeason.seasonCount', { count: item.group_count })} • ${t(
'dialog.subscribeSeason.episodeCount',
{ count: item.episode_count },
)}`,
value: item.id,
}
},
)
// 添加不使用选项
options.unshift({
title: '默认',
subtitle: `${seasonInfos.value.length}`,
title: t('dialog.subscribeSeason.defaultGroup'),
subtitle: t('dialog.subscribeSeason.seasonCount', { count: seasonInfos.value.length }),
value: '',
})
return options
@@ -142,11 +149,11 @@ function getExistColor(season: number) {
// 计算存在状态的文本
function getExistText(season: number) {
const state = seasonsNotExisted.value[season]
if (!state) return '已入库'
if (!state) return t('dialog.subscribeSeason.status.exists')
if (state === 1) return '部分缺失'
else if (state === 2) return '缺失'
else return '已入库'
if (state === 1) return t('dialog.subscribeSeason.status.partial')
else if (state === 2) return t('dialog.subscribeSeason.status.missing')
else return t('dialog.subscribeSeason.status.exists')
}
// 拼装季图片地址
@@ -191,7 +198,7 @@ onMounted(async () => {
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
<VCardTitle class="pe-10"> {{ t('dialog.subscribeSeason.title', { title: props.media?.title }) }} </VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
@@ -199,7 +206,7 @@ onMounted(async () => {
v-model="episodeGroup"
:items="episodeGroupOptions"
:item-props="episodeGroupItemProps"
label="选择剧集组"
:label="t('dialog.subscribeSeason.selectGroup')"
persistent-hint
/>
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
@@ -222,15 +229,18 @@ onMounted(async () => {
</template>
</VImg>
</template>
<VListItemTitle> {{ item.season_number }} </VListItemTitle>
<VListItemTitle>
{{ t('dialog.subscribeSeason.seasonNumber', { number: item.season_number }) }}
</VListItemTitle>
<VListItemSubtitle class="mt-1 me-2">
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
<VIcon icon="mdi-star" /> {{ item.vote_average }}
<VIcon icon="mdi-star" /> {{ t('dialog.subscribeSeason.voteAverage', { score: item.vote_average }) }}
</VChip>
{{ getYear(item.air_date || '') }} {{ item.episode_count }}
{{ getYear(item.air_date || '') }}
{{ t('dialog.subscribeSeason.episodeCount', { count: item.episode_count }) }}
</VListItemSubtitle>
<VListItemSubtitle>
{{ media?.title }} {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播
{{ t('dialog.subscribeSeason.airDate', { date: formatAirDate(item.air_date || '') }) }}
</VListItemSubtitle>
<VListItemSubtitle>
<VChip
@@ -254,7 +264,11 @@ onMounted(async () => {
</VCardText>
<div class="my-2 text-center">
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
{{
seasonsSelected.length === 0
? t('dialog.subscribeSeason.selectSeasons')
: t('dialog.subscribeSeason.submit')
}}
</VBtn>
</div>
</VCard>

View File

@@ -5,6 +5,10 @@ import api from '@/api'
import type { Subscribe, SubscribeShare } from '@/api/types'
import { useDisplay } from 'vuetify'
import { formatSeason } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -35,11 +39,11 @@ async function doShare() {
shareDoing.value = false
// 提示
if (result.success) {
$toast.success(`${props.sub?.name} 分享成功!`)
$toast.success(t('dialog.subscribeShare.shareSuccess', { name: props.sub?.name }))
// 通知父组件刷新
emit('close')
} else {
$toast.error(`${props.sub?.name} 分享失败:${result.message}`)
$toast.error(t('dialog.subscribeShare.shareFailed', { name: props.sub?.name, message: result.message }))
}
} catch (e) {
console.log(e)
@@ -51,10 +55,11 @@ const $toast = useToast()
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`分享订阅 - ${props.sub?.name} ${props.sub?.season ? `第 ${props.sub?.season} 季` : ''}`"
class="rounded-t"
:title="`${t('dialog.subscribeShare.shareSubscription')} - ${props.sub?.name} ${
props.sub?.season ? t('dialog.subscribeShare.season', { number: props.sub?.season }) : ''
}`"
>
<VCardText>
<VDialogCloseBtn @click="emit('close')" />
@@ -64,7 +69,7 @@ const $toast = useToast()
<VTextField
v-model="shareForm.share_title"
readonly
label="标题"
:label="t('dialog.subscribeShare.title')"
:rules="[requiredValidator]"
persistent-hint
/>
@@ -72,18 +77,18 @@ const $toast = useToast()
<VCol cols="12">
<VTextarea
v-model="shareForm.share_comment"
label="说明"
:label="t('dialog.subscribeShare.description')"
:rules="[requiredValidator]"
hint="填写关于该订阅的说明,订阅中的搜索词、识别词等将会默认包含在分享中"
:hint="t('dialog.subscribeShare.descriptionHint')"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="shareForm.share_user"
label="分享用户"
:label="t('dialog.subscribeShare.shareUser')"
:rules="[requiredValidator]"
hint="分享人的昵称"
:hint="t('dialog.subscribeShare.shareUserHint')"
persistent-hint
/>
</VCol>
@@ -100,7 +105,7 @@ const $toast = useToast()
class="px-5"
:loading="shareDoing"
>
确认分享
{{ t('dialog.subscribeShare.confirmShare') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -3,6 +3,10 @@ import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { FileItem, TransferQueue } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -16,7 +20,7 @@ const dataList = ref<TransferQueue[]>([])
const progressEventSource = ref<EventSource>()
// 整理进度文本
const progressText = ref('请稍候 ...')
const progressText = ref(t('dialog.transferQueue.processing'))
// 整理进度
const progressValue = ref(0)
@@ -29,10 +33,11 @@ const activeTab = ref('')
// 状态标签
const stateDict: { [key: string]: string } = {
'waiting': '等待中',
'running': '正在整理',
'completed': '完成',
'failed': '失败',
'waiting': t('dialog.transferQueue.waitingState'),
'running': t('dialog.transferQueue.runningState'),
'completed': t('dialog.transferQueue.finishedState'),
'failed': t('dialog.transferQueue.failedState'),
'cancelled': t('dialog.transferQueue.cancelledState'),
}
// 获取状态颜色
@@ -88,13 +93,13 @@ async function remove_queue_task(fileitem: FileItem) {
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
progressText.value = t('dialog.transferQueue.processing')
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
if (!progress.enable) {
progressText.value = '请稍候 ...'
progressText.value = t('dialog.transferQueue.processing')
progressValue.value = 0
if (refreshFlag.value) {
refreshFlag.value = false
@@ -138,7 +143,7 @@ onUnmounted(() => {
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%">
<VCardItem>
<VCardTitle>整理队列</VCardTitle>
<VCardTitle>{{ t('dialog.transferQueue.title') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
@@ -151,7 +156,7 @@ onUnmounted(() => {
<VCardItem v-if="dataList.length > 0 && progressValue > 0" class="text-center pt-2">
<span class="text-sm">{{ progressText }}</span>
</VCardItem>
<VCardText v-if="dataList.length === 0" class="text-center"> 没有正在整理的任务 </VCardText>
<VCardText v-if="dataList.length === 0" class="text-center"> {{ t('dialog.transferQueue.noTasks') }} </VCardText>
<VCardText>
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill" stacked>
<VTab
@@ -169,7 +174,7 @@ onUnmounted(() => {
<VListItem v-for="task in activeTasks">
<VListItemTitle>{{ task.fileitem.name }}</VListItemTitle>
<VListItemSubtitle>
大小{{ formatFileSize(task.fileitem.size || 0) }}
{{ t('dialog.transferQueue.sizeTitle') }}{{ formatFileSize(task.fileitem.size || 0) }}
<VChip size="small" :color="getStateColor(task.state)" class="ms-2">
{{ stateDict[task.state] }}
</VChip>

View File

@@ -1,6 +1,10 @@
<script lang="ts" setup>
import api from '@/api'
import QrcodeVue from 'qrcode.vue'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -17,7 +21,7 @@ const emit = defineEmits(['done', 'close'])
const qrCodeContent = ref('')
// 下方的提示信息
const text = ref('请使用微信或115客户端扫码')
const text = ref(t('dialog.u115Auth.scanQrCode'))
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
@@ -31,7 +35,23 @@ async function handleDone() {
emit('done')
}
// 调用/aliyun/qrcode api生成二维码
// 重置配置
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/u115')
if (result.success) {
// 重置成功
alertType.value = 'success'
handleDone()
} else {
alertType.value = 'error'
text.value = result.message
}
} catch (e) {
console.error(e)
}
}
// 调用/u115/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
@@ -61,7 +81,7 @@ async function checkQrcode() {
} else if (status == 1) {
// 已扫码
alertType.value = 'info'
text.value = '已扫码,请确认登录'
text.value = t('dialog.u115Auth.scanned')
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else if (status == 2) {
@@ -92,7 +112,7 @@ onUnmounted(() => {
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard title="115网盘登录" class="rounded-t">
<VCard :title="t('dialog.u115Auth.loginTitle')">
<VDialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 rounded text-center p-3 border">
@@ -104,7 +124,12 @@ onUnmounted(() => {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
<VBtn variant="tonal" color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.u115Auth.reset') }}
</VBtn>
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.u115Auth.complete') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -6,6 +6,10 @@ import api from '@/api'
import { useDisplay } from 'vuetify'
import avatar1 from '@images/avatars/avatar-1.png'
import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -52,8 +56,8 @@ const $toast = useToast()
// 状态下拉项
const statusItems = [
{ title: '激活', value: 1 },
{ title: '已停用', value: 0 },
{ title: t('dialog.userAddEdit.active'), value: 1 },
{ title: t('dialog.userAddEdit.inactive'), value: 0 },
]
// 扩展User类型以包含note字段
@@ -92,19 +96,19 @@ function changeAvatar(file: Event) {
const maxSize = 800 * 1024
// 检查文件是否为图片
if (!allowedTypes.includes(selectedFile.type)) {
$toast.error('上传的文件不符合要求,请重新选择头像')
$toast.error(t('dialog.userAddEdit.invalidFile'))
return
}
// 检查文件大小
if (selectedFile.size > maxSize) {
$toast.error('文件大小不得大于800KB')
$toast.error(t('dialog.userAddEdit.fileSizeLimit'))
return
}
fileReader.readAsDataURL(selectedFile)
fileReader.onload = () => {
if (typeof fileReader.result === 'string') {
currentAvatar.value = fileReader.result
$toast.success('新头像上传成功,待保存后生效!')
$toast.success(t('dialog.userAddEdit.avatarUploadSuccess'))
}
}
}
@@ -113,13 +117,13 @@ function changeAvatar(file: Event) {
// 重置默认头像
function resetDefaultAvatar() {
currentAvatar.value = avatar1
$toast.success('已重置为默认头像,待保存后生效!')
$toast.success(t('dialog.userAddEdit.resetAvatarSuccess'))
}
// 还原当前头像
function restoreCurrentAvatar() {
currentAvatar.value = userForm.value.avatar
$toast.success('已还原当前使用头像!')
$toast.success(t('dialog.userAddEdit.restoreAvatarSuccess'))
}
// 查询用户信息
@@ -140,22 +144,22 @@ async function fetchUserInfo() {
// 调用API 新增用户
async function addUser() {
if (isAdding.value) {
$toast.error(`正在创建【${userForm.value.name}】用户,请稍后`)
$toast.error(t('dialog.userAddEdit.creatingUser', { name: userForm.value.name }))
return
}
if (!currentUserName.value) {
$toast.error('用户名不能为空')
$toast.error(t('dialog.userAddEdit.usernameRequired'))
return
} else userForm.value.name = currentUserName.value
// 重名检查
if (props.usernames && props.usernames.includes(userForm.value.name)) {
$toast.error('用户名已存在')
$toast.error(t('dialog.userAddEdit.usernameExists'))
return
}
if (!userForm.value?.name || !newPassword.value) return
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
$toast.error('两次输入的密码不一致')
$toast.error(t('dialog.userAddEdit.passwordMismatch'))
return
}
userForm.value.password = newPassword.value
@@ -165,10 +169,10 @@ async function addUser() {
try {
const result: { [key: string]: string } = await api.post('user/', userForm.value)
if (result.success) {
$toast.success(`用户【${userForm.value.name}】创建成功`)
$toast.success(t('dialog.userAddEdit.userCreated', { name: userForm.value.name }))
emit('save')
} else {
$toast.error(`创建用户失败:${result.message}`)
$toast.error(t('dialog.userAddEdit.userCreateFailed', { message: result.message }))
// 清除用户名
userForm.value.name = ''
}
@@ -182,16 +186,16 @@ async function addUser() {
// 调用API更新用户信息
async function updateUser() {
if (isUpdating.value) {
$toast.error(`正在更新【${userForm.value.name}】用户,请稍后`)
$toast.error(t('dialog.userAddEdit.updatingUser', { name: userForm.value.name }))
return
}
if (!currentUserName.value) {
$toast.error('用户名不能为空')
$toast.error(t('dialog.userAddEdit.usernameRequired'))
return
}
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
$toast.error('两次输入的密码不一致')
$toast.error(t('dialog.userAddEdit.passwordMismatch'))
return
}
userForm.value.password = newPassword.value
@@ -219,13 +223,13 @@ async function updateUser() {
if (result.success) {
if (oldUserName !== currentUserName.value) {
$toast.success(`${oldUserName}】更名【${currentUserName.value}】, 更新成功!`)
$toast.success(t('dialog.userAddEdit.userUpdateSuccess', { name: `${oldUserName}${currentUserName.value}` }))
// 如果是当前登录用户,更新当前用户名称显示
if (isCurrentUser.value) {
userStore.setUserName(currentUserName.value)
}
} else {
$toast.success(`${userForm.value?.name}】更新成功!`)
$toast.success(t('dialog.userAddEdit.userUpdateSuccess', { name: userForm.value?.name }))
}
// 更新本地头像显示
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
@@ -234,10 +238,10 @@ async function updateUser() {
emit('save')
} else {
if (oldUserName !== currentUserName.value) {
$toast.error(`${oldUserName}】更名【${currentUserName.value}】, 更新失败:${result.message}`)
$toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: result.message }))
currentUserName.value = oldUserName
} else {
$toast.error(`${userForm.value?.name}】更新失败:${result.message}`)
$toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: result.message }))
}
}
//失败缓存值还原
@@ -247,7 +251,7 @@ async function updateUser() {
userForm.value.avatar = oldAvatar
userForm.value.password = ''
} catch (error) {
$toast.error(`${userForm.value?.name}】更新失败!`)
$toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: '' }))
console.error('更新失败:', error)
}
doneNProgress()
@@ -288,8 +292,9 @@ onMounted(() => {
<template>
<VDialog scrollable :close-on-back="false" eager max-width="40rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`${props.oper === 'add' ? '新增' : '编辑'}用户${props.oper !== 'add' ? ` - ${userName}` : ''}`"
class="rounded-t"
:title="`${props.oper === 'add' ? t('dialog.userAddEdit.add') : t('dialog.userAddEdit.edit')}${
props.oper !== 'add' ? ` - ${userName}` : ''
}`"
>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
@@ -302,7 +307,7 @@ onMounted(() => {
<div class="flex flex-wrap gap-2">
<VBtn color="primary" @click="refInputEl?.click()">
<VIcon icon="mdi-cloud-upload-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('dialog.userAddEdit.uploadAvatar') }}</span>
</VBtn>
<input
@@ -316,7 +321,7 @@ onMounted(() => {
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
<VIcon icon="mdi-refresh" />
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('common.cancel') }}</span>
</VBtn>
<VBtn
@@ -326,17 +331,17 @@ onMounted(() => {
@click="resetDefaultAvatar"
>
<VIcon icon="mdi-image-sync-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">默认</span>
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('dialog.userAddEdit.resetDefaultAvatar') }}</span>
</VBtn>
</div>
<p class="text-body-1 mb-0">允许 JPGPNGGIFWEBP 格式 最大尺寸 800KB</p>
<p class="text-body-1 mb-0">{{ t('dialog.userAddEdit.fileSizeLimit') }}</p>
</div>
</div>
</VCardItem>
<VCardText>
<VForm @submit.prevent="() => {}">
<VDivider class="my-10">
<span>用户基础设置</span>
<span>{{ t('dialog.userAddEdit.saveUserInfo') }}</span>
</VDivider>
<VRow>
<VCol md="6" cols="12">
@@ -344,11 +349,17 @@ onMounted(() => {
v-model="currentUserName"
density="comfortable"
:readonly="props.oper !== 'add'"
label="用户名"
:label="t('dialog.userAddEdit.username')"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.email" density="comfortable" clearable label="邮箱" type="email" />
<VTextField
v-model="userForm.email"
density="comfortable"
clearable
:label="t('dialog.userAddEdit.email')"
type="email"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
@@ -357,7 +368,7 @@ onMounted(() => {
:type="isNewPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
clearable
label="密码"
:label="t('dialog.userAddEdit.password')"
autocomplete=""
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
/>
@@ -370,7 +381,7 @@ onMounted(() => {
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
clearable
label="确认密码"
:label="t('dialog.userAddEdit.confirmPassword')"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
/>
</VCol>
@@ -379,7 +390,7 @@ onMounted(() => {
v-model="userForm.nickname"
density="comfortable"
clearable
label="昵称"
:label="t('dialog.userAddEdit.nickname')"
placeholder="显示昵称,优先于用户名显示"
/>
</VCol>
@@ -389,35 +400,45 @@ onMounted(() => {
:items="statusItems"
item-text="title"
item-value="value"
label="状态"
:label="t('dialog.userAddEdit.status')"
dense
/>
</VCol>
</VRow>
<VDivider class="my-10">
<span>账号绑定</span>
<span>{{ t('dialog.userAddEdit.notifications') }}</span>
</VDivider>
<VRow>
<VCol cols="12" md="6">
<VTextField v-model="userForm.settings.wechat_userid" density="comfortable" clearable label="微信用户" />
<VTextField
v-model="userForm.settings.wechat_userid"
density="comfortable"
clearable
:label="t('dialog.userAddEdit.wechat')"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.telegram_userid"
density="comfortable"
clearable
label="Telegram用户"
:label="t('dialog.userAddEdit.telegram')"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.settings.slack_userid" density="comfortable" clearable label="Slack用户" />
<VTextField
v-model="userForm.settings.slack_userid"
density="comfortable"
clearable
:label="t('dialog.userAddEdit.slack')"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.vocechat_userid"
density="comfortable"
clearable
label="VoceChat用户"
:label="t('dialog.userAddEdit.vocechat')"
/>
</VCol>
<VCol cols="12" md="6">
@@ -425,7 +446,7 @@ onMounted(() => {
v-model="userForm.settings.synologychat_userid"
density="comfortable"
clearable
label="SynologyChat用户"
:label="t('dialog.userAddEdit.synologyChat')"
/>
</VCol>
<VCol cols="12" md="6">
@@ -445,8 +466,8 @@ onMounted(() => {
prepend-icon="mdi-plus"
class="px-5"
>
<span v-if="isAdding">创建中...</span>
<span v-else>创建</span>
<span v-if="isAdding">{{ t('common.loading') }}</span>
<span v-else>{{ t('common.add') }}</span>
</VBtn>
<VBtn
v-else
@@ -457,8 +478,8 @@ onMounted(() => {
prepend-icon="mdi-content-save"
class="px-5"
>
<span v-if="isUpdating">更新中...</span>
<span v-else>更新</span>
<span v-if="isUpdating">{{ t('common.loading') }}</span>
<span v-else>{{ t('common.save') }}</span>
</VBtn>
</VCardActions>
</VCard>

View File

@@ -2,6 +2,10 @@
import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 定义事件
const emit = defineEmits(['done', 'close'])
@@ -89,17 +93,17 @@ async function handleDone() {
// 认证处理
async function checkUser() {
if (!authForm.value.site) {
$toast.error('请选择认证站点!')
$toast.error(t('dialog.userAuth.selectSiteRequired'))
return
}
if (!authSites.value[authForm.value.site]) {
$toast.error('站点配置不存在!')
$toast.error(t('dialog.userAuth.siteConfigNotExist'))
return
}
if (formFields.value.length > 0) {
for (const field of formFields.value) {
if (!authForm.value.params[field.site.toUpperCase() + '_' + field.key.toUpperCase()]) {
$toast.error(`请输入${field.name}`)
$toast.error(t('dialog.userAuth.fieldRequired', { name: field.name }))
return
}
}
@@ -108,13 +112,13 @@ async function checkUser() {
try {
const result: { [key: string]: any } = await api.post(`site/auth`, authForm.value)
if (result.success) {
$toast.success('用户认证成功,请重新登录!')
$toast.success(t('dialog.userAuth.authSuccess'))
// 1秒后刷新页面
setTimeout(() => {
emit('done')
}, 1000)
} else {
$toast.error(`认证失败:${result.message}`)
$toast.error(t('dialog.userAuth.authFailed', { message: result.message }))
}
} catch (e) {
console.error(e)
@@ -130,7 +134,7 @@ onMounted(async () => {
<template>
<VDialog width="40rem" max-height="85vh">
<VCard title="用户认证" class="rounded-t">
<VCard :title="t('dialog.userAuth.title')">
<VDialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
@@ -140,7 +144,7 @@ onMounted(async () => {
:items="dropdownItems"
item-value="key"
item-title="name"
label="选择认证站点"
:label="t('dialog.userAuth.selectSite')"
item-props
>
</VSelect>
@@ -169,7 +173,7 @@ onMounted(async () => {
size="large"
:disabled="loading"
>
开始认证
{{ t('dialog.userAuth.authBtn') }}
</VBtn>
</VCardText>
</VCard>

View File

@@ -9,6 +9,10 @@ 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'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
const { onConnect, addEdges, nodes, edges, addNodes, screenToFlowCoordinate } = useVueFlow()
@@ -18,7 +22,7 @@ const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
onConnect((connection: Connection) => {
// 双重校验
if (!isValidConnection(connection)) {
$toast.warning('非法连接:不能连接自身或同类型端口!')
$toast.warning(t('dialog.workflowActions.invalidConnection'))
return
}
addEdges(connection)
@@ -67,7 +71,7 @@ const loadComponent = async (componentName: string) => {
if (component) {
return ((await component()) as any).default
}
throw new Error(`组件 ${componentName} 未找到`)
throw new Error(t('dialog.workflowActions.componentNotFound', { component: componentName }))
}
// 将所有components中的组件加载到nodeTypes中
@@ -132,7 +136,7 @@ function handleComponentClick(action: any) {
addNodes(newNode)
// 显示提示
$toast.success('已添加组件到画布')
$toast.success(t('dialog.workflowActions.componentAdded'))
}
// 调用API 编辑任务
@@ -144,10 +148,10 @@ async function updateWorkflow() {
try {
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
if (result.success) {
$toast.success(`保存任务流程成功!`)
$toast.success(t('dialog.workflowActions.saveSuccess'))
emit('save')
} else {
$toast.error(`保存任务流程失败:${result.message}`)
$toast.error(t('dialog.workflowActions.saveFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -164,10 +168,10 @@ function saveCodeString(type: string, code: any) {
edges.value = codeObject.flows || []
}
importCodeDialog.value = false
$toast.success('导入成功!')
$toast.success(t('dialog.workflowActions.importSuccess'))
}
} catch (error) {
$toast.error('导入失败!')
$toast.error(t('dialog.workflowActions.importFailed'))
console.error(error)
}
}
@@ -176,7 +180,7 @@ function saveCodeString(type: string, code: any) {
function shareWorkflow() {
const codeString = JSON.stringify({ actions: nodes.value, flows: edges.value })
navigator.clipboard.writeText(codeString)
$toast.success('任务流程代码已复制到剪贴板!')
$toast.success(t('dialog.workflowActions.codeCopied'))
}
onMounted(() => {
@@ -202,7 +206,7 @@ const isMacOS = computed(() => {
<VIcon size="large" color="white" icon="mdi-close" />
</VBtn>
</VToolbarItems>
<VToolbarTitle> 编辑流程 - {{ workflow?.name }} </VToolbarTitle>
<VToolbarTitle> {{ t('dialog.workflowActions.title') }} - {{ workflow?.name }} </VToolbarTitle>
<VSpacer></VSpacer>
<VToolbarItems>
<VBtn icon variant="text" @click="importCodeDialog = true" class="ms-2">
@@ -248,7 +252,7 @@ const isMacOS = computed(() => {
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
title="导入任务流程"
:title="t('dialog.workflowActions.importTitle')"
dataType="workflow"
@close="importCodeDialog = false"
@save="saveCodeString"

View File

@@ -5,6 +5,10 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -13,7 +17,9 @@ const props = defineProps({
})
// 新增或修改字样
const title = computed(() => (props.workflow ? '编辑' : '创建'))
const title = computed(() =>
props.workflow ? t('dialog.workflowAddEdit.editTitle') : t('dialog.workflowAddEdit.addTitle'),
)
// 显示器宽度
const display = useDisplay()
@@ -38,17 +44,17 @@ const $toast = useToast()
// 调用API 新增任务
async function addWorkflow() {
if (!workflowForm.value.name || !workflowForm.value.timer) {
$toast.error('请填写完整信息!')
$toast.error(t('dialog.workflowAddEdit.nameRequired'))
return
}
startNProgress()
try {
const result: { [key: string]: string } = await api.post('workflow/', workflowForm.value)
if (result.success) {
$toast.success(`创建任务成功,请编辑流程!`)
$toast.success(t('dialog.workflowAddEdit.addSuccess'))
emit('save')
} else {
$toast.error(`创建任务失败:${result.message}`)
$toast.error(t('dialog.workflowAddEdit.addFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -59,17 +65,17 @@ async function addWorkflow() {
// 调用API 编辑任务
async function editWorkflow() {
if (!workflowForm.value.name || !workflowForm.value.timer) {
$toast.error('请填写完整信息!')
$toast.error(t('dialog.workflowAddEdit.nameRequired'))
return
}
startNProgress()
try {
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
if (result.success) {
$toast.success(`修改任务成功!`)
$toast.success(t('dialog.workflowAddEdit.editSuccess'))
emit('save')
} else {
$toast.error(`修改任务失败:${result.message}`)
$toast.error(t('dialog.workflowAddEdit.editFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -80,7 +86,7 @@ async function editWorkflow() {
<template>
<VDialog scrollable :close-on-back="false" eager max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${title}任务`" class="rounded-t">
<VCard :title="title">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
@@ -89,24 +95,28 @@ async function editWorkflow() {
<VCol cols="12">
<VTextField
v-model="workflowForm.name"
label="别名"
:label="t('dialog.workflowAddEdit.name')"
:rules="[requiredValidator]"
persistent-hint
hint="任务名称"
:hint="t('dialog.workflowAddEdit.namePlaceholder')"
/>
</VCol>
<VCol cols="12">
<VCronField
v-model="workflowForm.timer"
label="定时"
:label="t('dialog.workflowAddEdit.schedule')"
:rules="[requiredValidator]"
placeholder="5位cron表达式"
persistent-hint
hint="任务执行周期"
:hint="t('dialog.workflowAddEdit.cronExprDesc')"
/>
</VCol>
<VCol cols="12">
<VTextarea v-model="workflowForm.description" label="任务描述" />
<VTextarea
v-model="workflowForm.description"
:label="t('dialog.workflowAddEdit.desc')"
:placeholder="t('dialog.workflowAddEdit.descPlaceholder')"
/>
</VCol>
</VRow>
</VForm>
@@ -122,10 +132,10 @@ async function editWorkflow() {
prepend-icon="mdi-content-save"
class="px-5"
>
保存
{{ t('dialog.workflowAddEdit.confirm') }}
</VBtn>
<VBtn v-else block color="primary" variant="elevated" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
创建
{{ t('dialog.workflowAddEdit.confirm') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -10,6 +10,10 @@ import api from '@/api'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify'
import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -63,7 +67,7 @@ const renameLoading = ref(false)
const progressDialog = ref(false)
// 识别进度文本
const progressText = ref('请稍候 ...')
const progressText = ref(t('common.pleaseWait'))
// 识别进度
const progressValue = ref(0)
@@ -158,8 +162,11 @@ async function list_files() {
async function deleteItem(item: FileItem, confirm: boolean = true) {
if (confirm) {
const confirmed = await createConfirm({
title: '确认',
content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.name}`,
title: t('common.confirm'),
content: t('file.confirmFileDelete', {
type: item.type === 'dir' ? t('file.directory') : t('file.file'),
name: item.name,
}),
})
if (!confirmed) return
}
@@ -187,8 +194,8 @@ async function deleteItem(item: FileItem, confirm: boolean = true) {
// 批量删除
async function batchDelete() {
const confirmed = await createConfirm({
title: '确认',
content: `是否确认删除选中的 ${selected.value.length} 个项目?`,
title: t('common.confirm'),
content: t('file.confirmBatchDelete', { count: selected.value.length }),
})
if (!confirmed) return
@@ -199,7 +206,7 @@ async function batchDelete() {
// 删除选中的项目
selected.value.every(async item => {
progressText.value = `正在删除 ${item.name} ...`
progressText.value = t('file.deleting', { name: item.name })
await deleteItem(item, false)
})
@@ -318,9 +325,9 @@ async function rename() {
progressDialog.value = true
progressValue.value = 0
if (renameAll.value) {
progressText.value = `正在重命名 ${currentItem.value?.path} 及目录内所有文件 ...`
progressText.value = t('file.renamingAll', { path: currentItem.value?.path })
} else {
progressText.value = `正在重命名 ${currentItem.value?.name} ...`
progressText.value = t('file.renaming', { name: currentItem.value?.name })
}
if (renameAll.value) {
startLoadingProgress()
@@ -406,7 +413,7 @@ watch(
// 重置菜单
dropdownItems.value = [
{
title: '识别',
title: t('file.recognize'),
value: 1,
show: true,
props: {
@@ -417,7 +424,7 @@ watch(
},
},
{
title: '刮削',
title: t('file.scrape'),
value: 2,
show: true,
props: {
@@ -428,7 +435,7 @@ watch(
},
},
{
title: '重命名',
title: t('file.rename'),
value: 3,
show: true,
props: {
@@ -437,7 +444,7 @@ watch(
},
},
{
title: '整理',
title: t('file.reorganize'),
value: 4,
show: true,
props: {
@@ -446,7 +453,7 @@ watch(
},
},
{
title: '删除',
title: t('common.delete'),
value: 5,
show: true,
props: {
@@ -466,7 +473,7 @@ async function recognize(path: string) {
try {
// 显示进度条
progressDialog.value = true
progressText.value = `正在识别 ${path} ...`
progressText.value = t('file.recognizing', { path })
progressValue.value = 0
nameTestResult.value = await api.get('media/recognize_file', {
params: {
@@ -475,7 +482,7 @@ async function recognize(path: string) {
})
// 关闭进度条
progressDialog.value = false
if (!nameTestResult.value) $toast.error(`${path} 识别失败!`)
if (!nameTestResult.value) $toast.error(t('file.recognizeFailed', { path }))
nameTestDialog.value = !!nameTestResult.value?.meta_info?.name
} catch (error) {
console.error(error)
@@ -488,22 +495,22 @@ async function scrape(item: FileItem, confirm: boolean = true) {
if (confirm) {
// 确认
const confirmed = await createConfirm({
title: '确认',
content: `是否确认刮削 ${item.path}`,
title: t('common.confirm'),
content: t('file.confirmScrape', { path: item.path }),
})
if (!confirmed) return
}
// 显示进度条
progressDialog.value = true
progressText.value = `正在刮削 ${item.path} ...`
progressText.value = t('file.scraping', { path: item.path })
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.storage}`, item)
// 关闭进度条
progressDialog.value = false
if (!result.success) $toast.error(result.message)
else $toast.success(`${item.path} 削刮完成!`)
else $toast.success(t('file.scrapeCompleted', { path: item.path }))
} catch (error) {
console.error(error)
}
@@ -513,8 +520,8 @@ async function scrape(item: FileItem, confirm: boolean = true) {
async function batchScrape() {
// 确认
const confirmed = await createConfirm({
title: '确认',
content: `是否确认刮削选中的 ${selected.value.length} 项?`,
title: t('common.confirm'),
content: t('file.confirmBatchScrape', { count: selected.value.length }),
})
if (!confirmed) return
@@ -525,7 +532,7 @@ async function batchScrape() {
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
progressText.value = t('common.pleaseWait')
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
@@ -560,7 +567,7 @@ onMounted(() => {
flat
density="compact"
variant="plain"
placeholder="搜索 ..."
:placeholder="t('common.search')"
prepend-inner-icon="mdi-filter-outline"
class="mx-2"
rounded
@@ -606,8 +613,8 @@ onMounted(() => {
</div>
<div class="text-xl text-high-emphasis mt-3">{{ items[0]?.name }}</div>
<p class="mt-2" v-if="items[0]?.size && items[0].modify_time">
大小{{ formatBytes(items[0]?.size || 0) }}<br />
修改时间{{ formatTime(items[0]?.modify_time || 0) }}
{{ t('file.size') }}{{ formatBytes(items[0]?.size || 0) }}<br />
{{ t('file.modifyTime') }}{{ formatTime(items[0]?.modify_time || 0) }}
</p>
</VCardText>
<!-- 图片 -->
@@ -681,31 +688,33 @@ onMounted(() => {
</VList>
</VCardText>
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
没有目录或文件
{{ t('file.noFiles') }}
</VCardText>
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5">
{{ t('file.emptyDirectory') }}
</VCardText>
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5"> 空目录 </VCardText>
</VCard>
<!-- 重命名弹窗 -->
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="35rem">
<VCard title="重命名">
<VCard :title="t('file.rename')">
<VDialogCloseBtn @click="renamePopper = false" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="newName" label="新名称" :loading="renameLoading" />
<VTextField v-model="newName" :label="t('file.newName')" :loading="renameLoading" />
</VCol>
<VCol cols="12" v-if="currentItem && currentItem.type == 'dir'">
<VSwitch v-model="renameAll" label="自动重命名目录内所有媒体文件" />
<VSwitch v-model="renameAll" :label="t('file.includeSubfolders')" />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="success" variant="elevated" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
自动识别名称
{{ t('file.autoRecognizeName') }}
</VBtn>
<VBtn :disabled="!newName" variant="elevated" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
确定
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -3,6 +3,10 @@ import type { PropType } from 'vue'
import type { FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
import type { AxiosRequestConfig } from 'axios'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -276,14 +280,14 @@ function getIndentLevel(path: string, ancestorPath: string) {
>
<div class="folder-content">
<VIcon icon="mdi-home" class="me-2" color="primary" />
<span>根目录</span>
<span>{{ t('file.rootDirectory') }}</span>
</div>
</div>
<!-- 加载根目录 -->
<div v-if="loading['/']" class="tree-loading">
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />
<span>加载目录结构...</span>
<span>{{ t('file.loadingDirectoryStructure') }}</span>
</div>
<!-- 目录树结构 -->
@@ -324,7 +328,7 @@ function getIndentLevel(path: string, ancestorPath: string) {
<!-- 加载中状态 -->
<div v-if="loading[directory.path || '']" class="tree-loading pl-8">
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
<span class="text-caption">加载中...</span>
<span class="text-caption">{{ t('common.loading') }}</span>
</div>
<!-- 所有层级的子目录列表 -->

View File

@@ -2,6 +2,10 @@
import type { AxiosRequestConfig } from 'axios'
import type { EndPoints, FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -158,7 +162,7 @@ const sortIcon = computed(() => {
<IconBtn @click="changeSort">
<VIcon :icon="sortIcon" />
</IconBtn>
<IconBtn @click="goUp">
<IconBtn v-if="pathSegments.length > 0" @click="goUp">
<VIcon icon="mdi-arrow-up-bold-outline" />
</IconBtn>
<VDialog v-model="newFolderPopper" max-width="35rem">
@@ -167,16 +171,16 @@ const sortIcon = computed(() => {
<VIcon v-bind="props" icon="mdi-folder-plus-outline" />
</IconBtn>
</template>
<VCard title="新建文件夹">
<VCard :title="t('file.newFolder')">
<VDialogCloseBtn @click="newFolderPopper = false" />
<VDivider />
<VCardText>
<VTextField v-model="newFolderName" label="名称" />
<VTextField v-model="newFolderName" :label="t('common.name')" />
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn :disabled="!newFolderName" variant="elevated" @click="mkdir" prepend-icon="mdi-check" class="px-5 me-3">
新建
{{ t('common.create') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import api from '@/api'
import { DashboardItem } from '@/api/types'
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
@@ -12,6 +13,7 @@ import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
import DashboardRender from '@/components/render/DashboardRender.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { loadRemoteComponent } from '@/utils/federationLoader'
// 输入参数
const props = defineProps({
@@ -28,6 +30,43 @@ const props = defineProps({
const emit = defineEmits(['update:refreshStatus'])
// 插件UI渲染模式 ('vuetify' 或 'vue')
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
// Vue 模式:动态加载的组件
const dynamicPluginComponent = defineAsyncComponent({
// 工厂函数
loader: async () => {
try {
if (!props.config?.id) {
throw new Error('插件ID不存在')
}
// 动态加载远程组件
const module = await loadRemoteComponent(props.config.id, 'Dashboard')
// 直接返回加载的组件无需再获取default
return module
} catch (error) {
console.error('加载远程组件失败:', error)
}
},
// 加载中显示的组件
loadingComponent: {
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
},
// 添加错误处理
errorComponent: {
template: `
<div class="pa-4">
<VAlert type="error" title="组件加载错误">
无法加载组件,请稍后再试
</VAlert>
</div>
`,
},
})
onUnmounted(() => {
// 组件卸载时禁用刷新状态
emit('update:refreshStatus', false)
@@ -46,34 +85,49 @@ onUnmounted(() => {
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
<MediaServerLatest v-else-if="config?.id === 'latest'" />
<!-- 插件仪表板 -->
<VHover v-else-if="!isNullOrEmptyObject(props.config)">
<template #default="hover">
<!-- 无边框 -->
<div v-if="props.config?.attrs.border === false">
<VCard v-bind="hover.props">
<VCardText class="p-0">
<template v-else-if="!isNullOrEmptyObject(props.config)">
<!-- Vue 渲染模式 -->
<div v-if="pluginRenderMode === 'vue'">
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" :api="api" />
<!-- Vue 模式下也可以显示拖拽句柄 -->
<div class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</div>
<!-- Vuetify 渲染模式 -->
<VHover v-else-if="pluginRenderMode === 'vuetify'">
<template #default="hover">
<!-- 无边框 -->
<div v-if="props.config?.attrs.border === false">
<VCard v-bind="hover.props">
<VCardText class="p-0">
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
<div v-if="hover.isHovering" class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</VCard>
</div>
<!-- 有边框 -->
<VCard v-else v-bind="hover.props">
<VCardItem v-if="props.config?.attrs.border !== false">
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>
{{ props.config?.attrs?.title ?? props.config?.name }}
</VCardTitle>
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
</VCardItem>
<VCardText>
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
<div v-if="hover.isHovering" class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</VCard>
</div>
<!-- 有边框 -->
<VCard v-else v-bind="hover.props">
<VCardItem v-if="props.config?.attrs.border !== false">
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>
{{ props.config?.attrs?.title ?? props.config?.name }}
</VCardTitle>
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
</VCardItem>
<VCardText>
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
</VCard>
</template>
</VHover>
</template>
</VHover>
<!-- 未知模式或错误 -->
<VCard v-else>
<VCardText>无法渲染插件仪表盘部件: 未知渲染模式或配置错误</VCardText>
</VCard>
</template>
</template>

View File

@@ -1,6 +1,10 @@
<script lang="ts" setup>
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
import { ref, onMounted, onUnmounted, inject, computed } from 'vue'
import { useDisplay } from 'vuetify'
// 判断是否可以触摸
const display = useDisplay()
const isTouch = computed(() => display.mobile.value)
// 元素
const slideview_content = ref<HTMLElement | null>(null)
@@ -142,26 +146,32 @@ onActivated(() => {
</div>
<!-- 左侧导航按钮 -->
<button
<VBtn
class="nav-button nav-button-left"
@click.stop="slideNext(false)"
v-show="disabled !== 0 && disabled !== 3"
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
variant="text"
icon
color="secondary"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
</svg>
</button>
</VBtn>
<!-- 右侧导航按钮 -->
<button
<VBtn
class="nav-button nav-button-right"
@click.stop="slideNext(true)"
v-show="disabled !== 2 && disabled !== 3"
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
variant="text"
icon
color="secondary"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
</svg>
</button>
</VBtn>
</div>
</div>
</template>
@@ -189,26 +199,27 @@ onActivated(() => {
.view-all-button {
.arrow-svg {
fill: currentColor;
fill: currentcolor;
margin-inline-start: 2px;
transition: transform 0.3s ease;
margin-left: 2px;
}
display: inline-flex;
flex-shrink: 0;
align-items: center;
border-radius: 8px;
padding: 5px 12px;
background-color: transparent;
color: rgb(var(--v-theme-primary));
font-size: 0.85rem;
font-weight: 500;
padding-block: 5px;
padding-inline: 12px;
text-decoration: none;
transition: all 0.25s ease;
&:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
border-color: rgba(var(--v-theme-primary), 0.5);
background-color: rgba(var(--v-theme-primary), 0.08);
transform: translateY(-1px);
.arrow-svg {
@@ -234,40 +245,37 @@ onActivated(() => {
.nav-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border-radius: 50%;
background-color: rgba(var(--v-theme-background), 0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 50%;
backdrop-filter: blur(8px);
background-color: rgba(var(--v-theme-background), 0.3);
block-size: 36px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
cursor: pointer;
z-index: 20;
color: rgb(var(--v-theme-on-surface));
inline-size: 36px;
inset-block-start: 50%;
opacity: 0;
pointer-events: none;
transform: translateY(-50%);
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
box-shadow 0.3s ease, border-color 0.3s ease;
svg {
fill: currentColor;
block-size: 22px;
fill: currentcolor;
filter: none;
inline-size: 22px;
opacity: 0.7;
transition: all 0.3s ease;
width: 22px;
height: 22px;
filter: none;
}
&:hover {
background-color: rgba(var(--v-theme-background), 0.95);
transform: translateY(-50%) scale(1.05);
color: rgb(var(--v-theme-primary));
transform: translateY(-50%) scale(1.05);
svg {
opacity: 1;
@@ -276,11 +284,11 @@ onActivated(() => {
}
.nav-button-left {
left: 8px;
inset-inline-start: 8px;
}
.nav-button-right {
right: 8px;
inset-inline-end: 8px;
}
.slider-content {

View File

@@ -2,6 +2,9 @@
import api from '@/api'
import { DownloaderConf } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps({
id: {
@@ -22,7 +25,7 @@ async function loadDownloaderSetting() {
try {
const downloaders: DownloaderConf[] = await api.get('download/clients')
downloaderOptions.value = [
{ title: '默认', value: '' },
{ title: t('common.default'), value: '' },
...downloaders.map((item: { name: any }) => ({
title: item.name,
value: item.name,
@@ -47,23 +50,41 @@ onMounted(() => {
<VIcon icon="mdi-download" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>添加下载</VCardTitle>
<VCardSubtitle>根据资源列表添加下载任务</VCardSubtitle>
<VCardTitle>{{ t('workflow.addDownload.title') }}</VCardTitle>
<VCardSubtitle>{{ t('workflow.addDownload.subtitle') }}</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect v-model="data.downloader" :items="downloaderOptions" label="下载器" outlined dense />
<VSelect
v-model="data.downloader"
:items="downloaderOptions"
:label="t('workflow.addDownload.downloader')"
outlined
dense
/>
</VCol>
<VCol cols="12">
<VTextField v-model="data.labels" label="标签" placeholder="多个使用,分隔" outlined dense />
<VTextField
v-model="data.labels"
:label="t('workflow.addDownload.category')"
placeholder="多个使用,分隔"
outlined
dense
/>
</VCol>
<VCol cols="12">
<VPathField v-model="data.save_path" storage="local" label="保存路径" clearable placeholder="留空自动" />
<VPathField
v-model="data.save_path"
storage="local"
:label="t('workflow.addDownload.savePath')"
clearable
placeholder="留空自动"
/>
</VCol>
<VCol cols="12">
<VSwitch v-model="data.only_lack" label="仅下载缺失的资源" />
<VSwitch v-model="data.only_lack" :label="t('workflow.addDownload.onlyLack')" />
</VCol>
</VRow>
</VCardText>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps({
id: {
@@ -22,8 +25,8 @@ defineProps({
<VIcon icon="mdi-star-plus" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>添加订阅</VCardTitle>
<VCardSubtitle>根据媒体列表添加订阅</VCardSubtitle>
<VCardTitle>{{ t('workflow.addSubscribe.title') }}</VCardTitle>
<VCardSubtitle>{{ t('workflow.addSubscribe.subtitle') }}</VCardSubtitle>
</VCardItem>
<Handle id="edge_out" type="source" :position="Position.Right" />
</VCard>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps({
id: {
@@ -22,21 +25,21 @@ defineProps({
<VIcon icon="mdi-progress-download" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>获取下载任务</VCardTitle>
<VCardSubtitle>获取下载队列中的任务状态</VCardSubtitle>
<VCardTitle>{{ t('workflow.fetchDownloads.title') }}</VCardTitle>
<VCardSubtitle>{{ t('workflow.fetchDownloads.subtitle') }}</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSwitch v-model="data.loop" label="循环执行" />
<VSwitch v-model="data.loop" :label="t('workflow.fetchDownloads.loop')" />
</VCol>
<VCol cols="12">
<VTextField
v-model="data.loop_interval"
:disabled="!data.loop"
type="number"
label="循环间隔 (秒)"
:label="t('workflow.fetchDownloads.loopInterval')"
outlined
dense
clearable

View File

@@ -2,6 +2,9 @@
import { Handle, Position } from '@vue-flow/core'
import api from '@/api'
import { RecommendSource } from '@/api/types'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps({
id: {
@@ -18,55 +21,55 @@ defineProps({
const innerList = [
{
'api_path': 'recommend/tmdb_trending',
'name': '流行趋势',
'name': t('workflow.fetchMedias.tmdbTrending'),
},
{
'api_path': 'recommend/douban_showing',
'name': '正在热映',
'name': t('workflow.fetchMedias.doubanShowing'),
},
{
'api_path': 'recommend/bangumi_calendar',
'name': 'Bangumi每日放送',
'name': t('workflow.fetchMedias.bangumiCalendar'),
},
{
'api_path': 'recommend/tmdb_movies',
'name': 'TMDB热门电影',
'name': t('workflow.fetchMedias.tmdbMovies'),
},
{
'api_path': 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
'name': 'TMDB热门电视剧',
'name': t('workflow.fetchMedias.tmdbTvs'),
},
{
'api_path': 'recommend/douban_movie_hot',
'name': '豆瓣热门电影',
'name': t('workflow.fetchMedias.doubanMovieHot'),
},
{
'api_path': 'recommend/douban_tv_hot',
'name': '豆瓣热门电视剧',
'name': t('workflow.fetchMedias.doubanTvHot'),
},
{
'api_path': 'recommend/douban_tv_animation',
'name': '豆瓣热门动漫',
'name': t('workflow.fetchMedias.doubanTvAnimation'),
},
{
'api_path': 'recommend/douban_movies',
'name': '豆瓣最新电影',
'name': t('workflow.fetchMedias.doubanMovies'),
},
{
'api_path': 'recommend/douban_tvs',
'name': '豆瓣最新电视剧',
'name': t('workflow.fetchMedias.doubanTvs'),
},
{
'api_path': 'recommend/douban_movie_top250',
'name': '豆瓣电影TOP250',
'name': t('workflow.fetchMedias.doubanMovieTop250'),
},
{
'api_path': 'recommend/douban_tv_weekly_chinese',
'name': '豆瓣国产剧集榜',
'name': t('workflow.fetchMedias.doubanTvWeeklyChinese'),
},
{
'api_path': 'recommend/douban_tv_weekly_global',
'name': '豆瓣全球剧集榜',
'name': t('workflow.fetchMedias.doubanTvWeeklyGlobal'),
},
]
@@ -92,8 +95,8 @@ async function loadExtraRecommendSources() {
// 来源类型下拉框
const sourceTypeOptions = [
{ value: 'ranking', title: '推荐榜单' },
{ value: 'api', title: 'API' },
{ value: 'ranking', title: t('workflow.fetchMedias.ranking') },
{ value: 'api', title: t('workflow.fetchMedias.api') },
]
// 计算下拉框
@@ -113,14 +116,20 @@ onMounted(() => {
<VIcon icon="mdi-movie-search" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>获取媒体数据</VCardTitle>
<VCardSubtitle>获取榜单等媒体数据列表</VCardSubtitle>
<VCardTitle>{{ t('workflow.fetchMedias.title') }}</VCardTitle>
<VCardSubtitle>{{ t('workflow.fetchMedias.subtitle') }}</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect v-model="data.source_type" :items="sourceTypeOptions" label="来源" outlined dense />
<VSelect
v-model="data.source_type"
:items="sourceTypeOptions"
:label="t('workflow.fetchMedias.source')"
outlined
dense
/>
</VCol>
</VRow>
<VRow v-if="data.source_type === 'ranking'">
@@ -128,7 +137,7 @@ onMounted(() => {
<VSelect
v-model="data.sources"
:items="sourceOptions"
label="选择榜单"
:label="t('workflow.fetchMedias.selectRanking')"
chips
multiple
outlined
@@ -141,7 +150,7 @@ onMounted(() => {
<VCol cols="12">
<VTextField
v-model="data.api_path"
label="API地址"
:label="t('workflow.fetchMedias.apiPath')"
placeholder="/api/v1/plugin/xxx/xxxx"
outlined
dense

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps({
id: {
@@ -22,26 +25,33 @@ defineProps({
<VIcon icon="mdi-rss" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>获取RSS资源</VCardTitle>
<VCardSubtitle>订阅RSS地址获取资源</VCardSubtitle>
<VCardTitle>{{ t('workflow.fetchRss.title') }}</VCardTitle>
<VCardSubtitle>{{ t('workflow.fetchRss.subtitle') }}</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="data.url" label="RSS地址" outlined dense clearable />
<VTextField v-model="data.url" :label="t('workflow.fetchRss.url')" outlined dense clearable />
</VCol>
<VCol cols="12">
<VTextField v-model="data.ua" label="User-Agent" outlined dense clearable />
<VTextField v-model="data.ua" :label="t('workflow.fetchRss.userAgent')" outlined dense clearable />
</VCol>
<VCol cols="12">
<VTextField v-model="data.timeout" type="number" label="超时时间" outlined dense clearable />
<VTextField
v-model="data.timeout"
type="number"
:label="t('workflow.fetchRss.timeout')"
outlined
dense
clearable
/>
</VCol>
<VCol cols="6">
<VSwitch v-model="data.match_media" label="匹配媒体信息" />
<VSwitch v-model="data.match_media" :label="t('workflow.fetchRss.matchMedia')" />
</VCol>
<VCol cols="6">
<VSwitch v-model="data.proxy" label="使用代理" />
<VSwitch v-model="data.proxy" :label="t('workflow.fetchRss.useProxy')" />
</VCol>
</VRow>
</VCardText>

View File

@@ -2,6 +2,9 @@
import api from '@/api'
import { Site } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps({
id: {
@@ -17,11 +20,11 @@ defineProps({
// 电影/电视剧下拉框
const typeOptions = ref([
{
title: '电影',
title: t('mediaType.movie'),
value: '电影',
},
{
title: '电视剧',
title: t('mediaType.tv'),
value: '电视剧',
},
])
@@ -29,11 +32,11 @@ const typeOptions = ref([
// 搜索方式下拉框
const searchOptions = ref([
{
title: '名称',
title: t('workflow.fetchTorrents.searchOptions.name'),
value: 'keyword',
},
{
title: '媒体列表',
title: t('workflow.fetchTorrents.searchOptions.mediaList'),
value: 'media',
},
])
@@ -77,38 +80,64 @@ onMounted(() => {
<VIcon icon="mdi-search-web" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>搜索站点资源</VCardTitle>
<VCardSubtitle>搜索站点种子资源列表</VCardSubtitle>
<VCardTitle>{{ t('workflow.fetchTorrents.title') }}</VCardTitle>
<VCardSubtitle>{{ t('workflow.fetchTorrents.subtitle') }}</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect v-model="data.search_type" label="搜索方式" :items="searchOptions" outlined dense />
<VSelect
v-model="data.search_type"
:label="t('workflow.fetchTorrents.searchType')"
:items="searchOptions"
outlined
dense
/>
</VCol>
</VRow>
<VRow v-if="data.search_type === 'keyword'">
<VCol cols="6">
<VTextField v-model="data.name" label="名称" outlined dense />
<VTextField v-model="data.name" :label="t('workflow.fetchTorrents.name')" outlined dense />
</VCol>
<VCol cols="6">
<VTextField v-model="data.year" label="年份" outlined dense />
<VTextField v-model="data.year" :label="t('workflow.fetchTorrents.year')" outlined dense />
</VCol>
<VCol cols="6">
<VSelect v-model="data.type" label="类型" :items="typeOptions" outlined dense />
<VSelect
v-model="data.type"
:label="t('workflow.fetchTorrents.type')"
:items="typeOptions"
outlined
dense
/>
</VCol>
<VCol cols="6">
<VTextField v-model="data.season" type="number" label="季" outlined dense />
<VTextField
v-model="data.season"
type="number"
:label="t('workflow.fetchTorrents.season')"
outlined
dense
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect v-model="data.sites" label="站点" :items="siteOptions" chips multiple outlined dense />
<VSelect
v-model="data.sites"
:label="t('workflow.fetchTorrents.sites')"
: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="匹配媒体信息" />
<VSwitch v-model="data.match_media" :label="t('workflow.fetchTorrents.matchMedia')" />
</VCol>
</VRow>
</VCardText>

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import api from '@/api'
import { Handle, Position } from '@vue-flow/core'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
id: {
@@ -16,11 +19,11 @@ const props = defineProps({
// 电影/电视剧下拉框
const typeOptions = ref([
{
title: '电影',
title: t('mediaType.movie'),
value: '电影',
},
{
title: '电视剧',
title: t('mediaType.tv'),
value: '电视剧',
},
])
@@ -37,13 +40,6 @@ async function loadMediaCategories() {
}
}
// 根据选中的媒体类型,获取对应的媒体类别
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()
})
@@ -58,20 +54,20 @@ onMounted(() => {
<VIcon icon="mdi-filter-check" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>过滤媒体数据</VCardTitle>
<VCardSubtitle>对媒体数据列表进行过滤</VCardSubtitle>
<VCardTitle>{{ t('workflow.filterMedias.title') }}</VCardTitle>
<VCardSubtitle>{{ t('workflow.filterMedias.subtitle') }}</VCardSubtitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect v-model="data.type" label="类型" :items="typeOptions" outlined dense />
<VSelect v-model="data.type" :label="t('workflow.filterMedias.type')" :items="typeOptions" outlined dense />
</VCol>
<VCol cols="6">
<VTextField v-model="data.year" label="年份" outlined dense />
<VTextField v-model="data.year" :label="t('workflow.filterMedias.year')" outlined dense />
</VCol>
<VCol cols="6">
<VTextField v-model="data.vote" type="number" label="评分" outlined dense />
<VTextField v-model="data.vote" type="number" :label="t('workflow.filterMedias.vote')" outlined dense />
</VCol>
</VRow>
</VCardText>

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