Compare commits

...

174 Commits

Author SHA1 Message Date
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
jxxghp
3af1013c34 优化 Vuetify 组件的默认设置:为 VMenu 和 VRangeSlider 添加 menuProps 属性以去除阴影效果,提升视觉一致性。 2025-04-22 13:15:55 +08:00
jxxghp
c2bca6fc3f 优化 SubscribeSeasonDialog 和 TransferHistoryView 组件的样式:移除 VCard 的圆角样式,调整 VBottomSheet 和 VMenu 的样式设置,提升视觉一致性。 2025-04-22 13:07:49 +08:00
jxxghp
226a12df40 重构样式文件:将多个样式文件合并,更新变量命名空间,优化导入结构,提升代码可维护性和一致性。 2025-04-22 12:43:11 +08:00
jxxghp
ab7286a87a 合并样式文件:将多个样式文件合并为单一导入,简化样式管理,提升代码可维护性。 2025-04-22 11:41:09 +08:00
jxxghp
c43fd88c7c 优化多个组件的样式:移除冗余的阴影效果,调整类名以提升视觉效果,更新样式以确保一致性。 2025-04-22 09:47:00 +08:00
jxxghp
21871626f3 优化 ShortcutBar 组件的样式:调整 VCard 的内边距和边框样式,更新 VAvatar 的变体以提升视觉效果。 2025-04-22 08:11:18 +08:00
jxxghp
8ce09ecf79 优化 Footer 组件中的动态按钮样式,调整按钮属性设置,更新图标颜色以提升视觉效果和用户体验。 2025-04-22 07:09:03 +08:00
jxxghp
ef2df85faf 更新 _misc.scss 2025-04-21 21:57:13 +08:00
jxxghp
77ec8c7a81 优化多个组件的样式和功能:调整 FileBrowser 和 TransferHistoryView 的高度计算,更新 TorrentCard 和 TorrentItem 的 VChip 颜色,修改 FileList、FileNavigator 和 FileToolbar 中 axios 的类型定义,更新主题中的 secondary 颜色,添加动态按钮到 UserListView,移除冗余的用户添加卡片样式。 2025-04-21 20:01:39 +08:00
jxxghp
06f6ab355e 更新 VSCode 设置:为 SCSS 添加保存时格式化选项,禁用自动格式化功能。 2025-04-21 17:36:12 +08:00
jxxghp
5e1761c47a 调整 VerticalNavLayout.vue 中的样式,修正导航栏宽度计算 2025-04-21 15:03:14 +08:00
jxxghp
ed63297814 修复样式文件中的注释错误,确保过渡效果和内边距设置生效;优化背景透明度设置,提升视觉效果。 2025-04-21 14:46:35 +08:00
jxxghp
3db7d6ce63 为表格组件添加边角圆度设置,确保样式一致性。 2025-04-21 14:28:31 +08:00
jxxghp
aa5f31ee70 添加Github和PIP加速代理的显示处理逻辑:使用计算属性管理代理设置 2025-04-21 14:21:34 +08:00
jxxghp
c3379e9737 优化主题切换功能:修正主题保存逻辑,确保保存原始主题设置而非计算后的值;更新错误处理,简化样式结构,提升代码可读性。 2025-04-21 13:14:21 +08:00
jxxghp
ef32172359 重构季集选项排序逻辑:将季集排序功能从计算属性移至组件方法,优化正则表达式以支持新格式,确保在选项更新时自动排序,简化相关代码。 2025-04-21 12:58:55 +08:00
jxxghp
c2381deb9f 更新多个组件的样式和功能:在 PluginCard.vue 中调整更新日志对话框的最大高度;在 TorrentCard.vue 中替换图片组件为 VImg,并添加打开详细信息的图标;在 resource.vue 中提升进度条卡片的阴影效果;在 vuetify 默认设置中为 VDialog 添加阴影和圆角;在样式文件中为选项卡激活状态添加背景色。 2025-04-21 12:47:20 +08:00
jxxghp
5753d4ff07 优化 index.html 中的样式设置,移除冗余的字体样式定义;在 _misc.scss 中调整背景渐变样式和透明度,增强视觉效果;更新 Footer.vue 中的卡片阴影效果 2025-04-21 11:51:59 +08:00
jxxghp
71437a2122 优化 FileBrowser 和 TransferHistoryView 组件的高度计算逻辑,调整 Footer 组件的动态按钮样式和动画效果 2025-04-21 08:38:21 +08:00
jxxghp
93005518d2 在 App.vue 中添加页面可见性变化处理逻辑,优化背景图片轮换功能;在 _misc.scss 中调整背景渐变透明度和样式,提升视觉效果。 2025-04-21 08:17:28 +08:00
jxxghp
da04cfc683 字体优化设置 2025-04-21 08:03:07 +08:00
jxxghp
c60eea73fc 更新 Footer.vue 2025-04-20 20:51:12 +08:00
jxxghp
bdb092bda9 更新 App.vue 2025-04-20 20:49:56 +08:00
jxxghp
84317a4217 优化 Footer 组件中的动态按钮图标样式 2025-04-20 14:56:38 +08:00
jxxghp
6dd9d94e86 优化 Footer 组件中的动态按钮样式,简化按钮属性设置;在多个视图组件中更新动态按钮图标,提升用户交互体验。 2025-04-20 14:46:34 +08:00
jxxghp
1ffcfe643c 在多个视图组件中添加动态按钮功能,支持不同操作的弹窗显示,优化按钮显示逻辑以提升用户交互体验。 2025-04-20 14:30:39 +08:00
jxxghp
87c11eda46 在 Footer 组件中添加动态按钮功能,支持注册和注销动态按钮,优化按钮显示逻辑;在 Dashboard 页面中集成动态按钮,增强用户交互体验。 2025-04-20 12:08:55 +08:00
jxxghp
9613141527 优化样式,调整背景颜色透明度以提升视觉效果;更新 MediaCardListView 组件,增加上边距以改善布局;在 PluginCardListView 组件中添加新版本过滤条件,优化过滤逻辑和用户交互体验。 2025-04-20 11:18:57 +08:00
jxxghp
a820d9129b 更新 SiteCard 组件,调整类名以增强样式一致性,并优化悬停效果以改善用户交互体验。 2025-04-20 10:30:11 +08:00
jxxghp
fd7279b528 更新 SiteCard 组件,添加条件渲染以根据站点特性动态显示图标 2025-04-20 10:21:01 +08:00
jxxghp
8e5ffa81a1 优化样式,调整背景模糊效果和颜色,更新组件内边距,增强视觉效果和用户体验。 2025-04-20 09:11:42 +08:00
jxxghp
95f6635591 更新 VerticalNavLayout.vue 2025-04-19 22:30:42 +08:00
jxxghp
7a1208a04f 更新 VerticalNavLayout.vue 2025-04-19 22:30:04 +08:00
jxxghp
06668d9415 更新 Footer.vue 2025-04-19 22:23:48 +08:00
jxxghp
708928ab26 优化 App 组件,添加全局设置注入,更新主题属性处理逻辑,增强背景图片获取功能,并实现图片地址的动态计算。重构 Footer 组件,简化菜单状态管理,提升导航体验。调整应用中心页面,增加分组标题,优化列表项的内边距,提升整体可用性和视觉效果。 2025-04-19 22:07:39 +08:00
jxxghp
78f04c4b4b 优化样式文件,注释掉冗余的样式设置,调整背景颜色和边框半径,以提升整体视觉效果和用户体验。 2025-04-19 21:43:25 +08:00
jxxghp
af20a6c821 更新 Footer 组件样式,添加边框以增强视觉效果,同时移除冗余的背景边框设置,提升整体用户体验。 2025-04-19 20:35:19 +08:00
jxxghp
3c4ee302e7 优化 ThemeSwitcher 组件,移除主题切换动画并在更新主题时刷新页面,以提升用户体验。同时,更新 Footer 组件的样式,添加指示器以增强导航效果。调整 UserProfile 组件的链接,更新为系统设定。对应用中心页面进行重构,按分组展示应用,提升可用性和视觉效果。 2025-04-19 20:29:17 +08:00
jxxghp
0987ba3575 优化 NavbarThemeSwitcher 组件,更新主题图标为水平渐变,以提升视觉一致性。同时,调整 ShortcutBar 组件中的图标颜色为主色,增强用户体验。 2025-04-19 17:40:39 +08:00
jxxghp
2b0564211d 更新 package.json 2025-04-19 15:01:39 +08:00
jxxghp
174b2b9fa3 优化 WorkflowSidebar 组件的样式,使用 VCard 替代 div 以提升视觉效果,调整背景颜色和文本颜色以支持主题切换,增强用户体验。 2025-04-19 13:20:13 +08:00
jxxghp
dc9c08ed30 优化 TorrentCard 和 TorrentItem 组件的样式,调整标签的大小为 x-small,提升视觉效果和用户体验。同时,简化了卡片底部信息的布局,确保在不同设备上的适配性。 2025-04-19 12:24:19 +08:00
jxxghp
2abbace470 优化 UserCard 组件,重构用户信息展示和操作按钮布局,提升移动端适配效果。移除冗余的状态管理,简化样式,增强用户体验。 2025-04-19 11:44:11 +08:00
jxxghp
c3511fe27e 优化 SiteCard 和 TorrentCard 组件的样式,调整卡片布局和交互效果,提升用户体验。同时,更新全局样式以支持模糊背景效果,确保在不同主题下的显示一致性。 2025-04-19 10:57:12 +08:00
jxxghp
913e1728e0 优化 FileList 组件的结构,调整搜索框样式和列表渲染方式,提升用户体验。同时,修复样式文件中卡片和列表的背景设置,确保在不同主题下的显示效果一致。 2025-04-19 07:54:44 +08:00
jxxghp
d0ea7f3fd9 优化 ShortcutBar 和 UserProfile 组件的样式,调整菜单最大宽度和按钮样式,提升用户体验和视觉效果。 2025-04-19 07:48:23 +08:00
jxxghp
c1201fbd96 优化 PluginCard 组件的样式,移除不必要的 card-backdrop 和 card-backdrop-blur 类,简化背景颜色设置,提升视觉效果和代码可维护性。 2025-04-18 17:55:36 +08:00
jxxghp
f862a3d8a1 优化 TorrentItem 组件,添加优惠标签的绝对定位样式,改进用户体验。移除旧的优惠标签组件,简化结构,提升视觉效果。 2025-04-18 17:50:01 +08:00
jxxghp
120a12edde 优化 TorrentItem 组件的样式和结构,更新优惠标签的显示方式,增强用户体验。添加新的优惠标签类以支持不同的优惠类型,并改进了媒体信息的布局和交互效果。 2025-04-18 17:46:46 +08:00
jxxghp
a484fc2d39 优化 TorrentCard 组件的样式和结构,更新优惠标签和媒体信息的显示方式,增强用户体验。添加新的优惠标签类以支持不同的优惠类型,并改进了更多来源对话框的布局和交互效果。 2025-04-18 17:41:20 +08:00
jxxghp
229264f2d0 优化多个组件的样式和结构,简化了主题切换器、快捷栏、用户通知和用户个人资料的布局,提升了用户体验。同时,移除了不必要的样式,确保组件在不同主题下的显示效果一致。 2025-04-18 16:58:37 +08:00
jxxghp
06f4898ce8 优化多个组件的 VInfiniteScroll 属性,将 overflow-hidden 修改为 overflow-visible,以改善滚动体验。同时,更新 PluginCard.vue 的样式,添加 card-backdrop 和 card-backdrop-blur 类,提升视觉效果。 2025-04-18 14:43:32 +08:00
jxxghp
476d2f7e81 feat: 添加透明主题支持及背景图片轮换功能
- 在 App.vue 中引入 API 获取背景图片,并实现背景图片的轮换功能。
- 更新主题切换逻辑,支持透明主题,并在主题变化时更新 HTML 属性。
- 在样式中添加透明主题的特定样式,确保各个组件在透明主题下的显示效果。
2025-04-18 13:47:39 +08:00
jxxghp
01c8304c8b feat: 在多个视图中添加 scrim 属性以改善对话框显示效果 2025-04-16 07:01:36 +08:00
jxxghp
e002588949 更新 PluginCardListView.vue 2025-04-15 21:56:51 +08:00
jxxghp
017656f592 fix: 更新未安装插件列表的过滤提示信息 2025-04-15 21:24:16 +08:00
jxxghp
526d2c7085 feat: 添加插件名称计算属性,优化未安装插件列表显示逻辑 2025-04-15 21:19:05 +08:00
jxxghp
86e90cfe7e chore: 更新版本号至 2.4.0 2025-04-15 13:25:50 +08:00
jxxghp
e97d815dc3 Refactor page titles and dialog close buttons across multiple views
- Replaced instances of `DialogCloseBtn` with `VDialogCloseBtn` in TransferHistoryView, AccountSettingAbout, AccountSettingSystem, and UserProfileView for consistency.
- Introduced a new component `PageContentTitle` to standardize page titles in SiteCardListView, SubscribeListView, UserListView, and WorkflowListView, improving layout and readability.
- Added keyword filtering functionality in SubscribeListView and SubscribeShareView to enhance user experience during searches.
- Added a new site logo image to the assets.
2025-04-15 13:23:56 +08:00
jxxghp
65ebdb61d0 feat:订阅分享搜索功能 2025-04-15 08:14:09 +08:00
jxxghp
cb5bccc945 fix ui warnings 2025-04-13 16:00:31 +08:00
jxxghp
8b53cd0a09 feat: 为多个组件的 VMenu 添加 scrim 属性 2025-04-13 09:42:58 +08:00
jxxghp
3d7a0d9b0d feat: 为多个组件添加边框样式 2025-04-13 09:19:20 +08:00
jxxghp
114844ad48 refactor: 移除 UserNotification 组件中多余的样式定义,简化代码 2025-04-12 09:59:41 +08:00
jxxghp
f83f709080 feat: 调整 SubscribeCard 组件的图像宽度,优化搜索框对齐和样式,移除多余的样式定义 2025-04-12 09:45:43 +08:00
jxxghp
999a6e7c6e fix 站点列表权限问题 2025-04-12 09:30:17 +08:00
jxxghp
2fd4c0b2ea feat: 优化搜索框样式,调整图标和文本,提升用户体验 2025-04-11 15:45:02 +08:00
jxxghp
16d62642f6 fix:大量tooltip对象 2025-04-11 15:20:36 +08:00
jxxghp
96a0ce8c5f feat: 为排序选择器添加 variant 属性,简化样式并移除多余 CSS 2025-04-11 08:36:24 +08:00
jxxghp
7703d8157c feat: 移除多个组件中的多余 variant 属性,简化代码 2025-04-11 08:29:00 +08:00
jxxghp
87aa4e902c feat: 优化 SlideView 组件样式,调整圆角和阴影效果 2025-04-11 07:09:50 +08:00
jxxghp
c86f32fab5 更新 styles.scss 2025-04-10 23:13:25 +08:00
jxxghp
f85ac34753 feat: 增强工作流侧边栏,支持移动端显示和组件点击事件处理 2025-04-10 21:12:48 +08:00
jxxghp
f58d4fcb7e 更新 recommend.vue 2025-04-09 23:01:13 +08:00
jxxghp
675c32cee3 feat: 使用 VCard 和 VProgressLinear 组件优化资源页面的加载进度显示 2025-04-09 22:39:41 +08:00
jxxghp
de011b35db 更新 TorrentCardListView.vue 2025-04-09 22:04:25 +08:00
jxxghp
288a7ebc20 更新 TorrentRowListView.vue 2025-04-09 22:03:47 +08:00
jxxghp
d7c3167ecd feat: 优化 TorrentRowListView 组件的数据过滤和排序逻辑 2025-04-09 21:49:15 +08:00
jxxghp
3205ae3ebe feat: 移除多个组件的背景颜色以优化样式 2025-04-09 21:31:57 +08:00
jxxghp
2ba609fb78 更新 package.json 2025-04-09 19:48:08 +08:00
jxxghp
7e70b1b7ab fix: 调整组件的内边距和边距以优化布局 2025-04-09 15:59:00 +08:00
jxxghp
561bdf4137 feat: 优化 ScrollToTopBtn 组件样式和布局,调整按钮位置和尺寸 2025-04-09 15:22:55 +08:00
jxxghp
22a2bb65c8 feat: 优化 TransferHistoryView 组件的布局和样式 2025-04-09 15:05:31 +08:00
jxxghp
c4f6db9f9f feat: 优化 ForkSubscribeDialog 组件按钮 2025-04-09 13:27:26 +08:00
jxxghp
e5d2140ea3 fix: 移除推荐内容的内边距以优化布局 2025-04-09 11:55:51 +08:00
jxxghp
83e57deec3 fix: 调整布局组件的宽度和内边距以改善响应式设计 2025-04-09 11:52:49 +08:00
jxxghp
fc357a03e5 feat: 更新 HeaderTab 组件以支持动态项和排序功能 2025-04-09 10:39:17 +08:00
jxxghp
f031077fbd feat: 更新插件市场设置对话框和订阅页面 2025-04-09 08:29:33 +08:00
jxxghp
02de63210d feat: 添加 HeaderTab 组件 2025-04-09 08:07:53 +08:00
jxxghp
98610e3e0d feat: 隐藏小屏幕设备上的回到顶部按钮 2025-04-08 21:57:35 +08:00
jxxghp
bb6cfd9d0e feat: 回到顶部按钮组件化 2025-04-08 21:52:04 +08:00
jxxghp
57c6d7e8f3 Merge pull request #322 from madrays/v2
继续优化探索页UI
2025-04-08 21:40:11 +08:00
jxxghp
6d8b850b15 Merge branch 'v2' into v2 2025-04-08 21:39:48 +08:00
madrays
db6c3ea36c 继续优化探索页UI 2025-04-08 20:43:43 +08:00
jxxghp
0ddf7ab070 feat: 优化主题切换器和用户通知组件的样式 2025-04-08 19:34:27 +08:00
jxxghp
93686bd354 feat: 添加推荐源类型字段,优化推荐页面分类逻辑和样式 2025-04-08 19:14:16 +08:00
jxxghp
89e4a68a03 feat: add ScrollToTopBtn component and integrate it into multiple pages
- Added ScrollToTopBtn component for smooth scrolling to the top of the page.
- Registered ScrollToTopBtn in main.ts.
- Integrated ScrollToTopBtn into browse.vue, discover.vue, recommend.vue, resource.vue pages.
- Updated components.d.ts to include ScrollToTopBtn type definition.
- Refactored MediaCard.vue and SlideView.vue for improved hover effects and styling.
- Cleaned up unused styles and optimized existing styles for better performance and readability.
2025-04-08 17:43:20 +08:00
232 changed files with 18686 additions and 25889 deletions

View File

@@ -11,7 +11,8 @@
},
// SCSS
"[scss]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint"
"editor.defaultFormatter": "stylelint.vscode-stylelint",
"editor.formatOnSave": false
},
// JSON
"[json]": {
@@ -106,4 +107,4 @@
]
},
"vue3snippets.enable-compile-vue-file-on-did-save-code": false
}
}

View File

@@ -1,5 +1,7 @@
# MoviePilot-Frontend
*中文 | [English](README_EN.md)*
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目NodeJS版本>= `v20.12.1`
## 推荐的IDE设置

41
README_EN.md Normal file
View File

@@ -0,0 +1,41 @@
# MoviePilot-Frontend
*[中文](README.md) | English*
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS version: >= `v20.12.1`.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (disable Vetur).
## Vite Configuration
Please refer to [Vite Configuration Reference](https://vitejs.dev/config/).
## Installation
```sh
yarn
```
### Development
```sh
yarn dev
```
### Build
```sh
yarn build
```
### Production Deployment
1. Use `nginx` or other web servers to host the `dist` static files. See `public/nginx.conf` for nginx configuration reference.
2. Use `node` command to run `service.js` directly. It listens on port `3000` by default. Set the environment variable `NGINX_PORT` 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']>

4
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 */
@@ -11,9 +12,12 @@ declare module 'vue' {
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
LoadingBanner: typeof import('./src/@core/components/LoadingBanner.vue')['default']
LocaleSwitcher: typeof import('./src/@core/components/LocaleSwitcher.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
PageContentTitle: typeof import('./src/@core/components/PageContentTitle.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScrollToTopBtn: typeof import('./src/@core/components/ScrollToTopBtn.vue')['default']
StatIcon: typeof import('./src/@core/components/StatIcon.vue')['default']
ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default']
}

View File

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

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.3.8",
"version": "2.4.3",
"private": true,
"type": "module",
"bin": "dist/service.js",
"scripts": {
"dev": "vite --host",

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,14 +1,13 @@
<template>
<div class="absolute top-0 right-0 flex items-center justify-between p-2">
<div class="pointer-events-none z-40 flex items-center">
<div class="relative inline-flex whitespace-nowrap rounded-full border-gray-700 font-semibold leading-5 ring-gray-700">
<div class="rounded-full bg-opacity-80 shadow-md w-5 border p-0 bg-green-500 border-green-400 ring-green-400 text-green-100">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<div
class="relative inline-flex whitespace-nowrap rounded-full border-gray-700 font-semibold leading-5 ring-gray-700"
>
<div
class="rounded-full bg-opacity-80 w-5 border p-0 bg-green-500 border-green-400 ring-green-400 text-green-100"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps({
// 标题
title: String,
})
</script>
<template>
<div v-if="title" class="my-3 md:flex md:items-center md:justify-between">
<div class="min-w-0 flex-1 mx-0">
<h2
class="ms-1 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-3xl sm:leading-9 md:mb-0"
data-testid="page-header"
>
<span class="text-moviepilot">{{ title }}</span>
</h2>
</div>
</div>
</template>

View File

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

View File

@@ -8,9 +8,9 @@ const props = defineProps<Props>()
</script>
<template>
<div class="absolute top-2 right-2 flex items-center justify-between p-2 shadow">
<div class="absolute top-2 right-2 flex items-center justify-between p-2">
<VBadge :color="props.color" bordered>
<template #badge>
<template #badge>
<VIcon icon="mdi-pulse"></VIcon>
</template>
</VBadge>

View File

@@ -1,328 +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 themeTransition() {
const x = performance.now()
for (let i = 0; i++ < 1e7; (i << 9) & ((9 % 9) * 9 + 9));
const cost = performance.now() - x
if (cost > 10) return
const el: HTMLElement = document.querySelector('[data-v-app]')!
const children = el.querySelectorAll('*') as NodeListOf<HTMLElement>
children.forEach(el => {
if (hasScrollbar(el)) {
el.dataset.scrollX = String(el.scrollLeft)
el.dataset.scrollY = String(el.scrollTop)
}
})
const copy = el.cloneNode(true) as HTMLElement
copy.classList.add('app-copy')
const rect = el.getBoundingClientRect()
copy.style.top = `${rect.top}px`
copy.style.left = `${rect.left}px`
copy.style.width = `${rect.width}px`
copy.style.height = `${rect.height}px`
const targetEl = document.activeElement as HTMLElement
const targetRect = targetEl.getBoundingClientRect()
const left = targetRect.left + targetRect.width / 2 + window.scrollX
const top = targetRect.top + targetRect.height / 2 + window.scrollY
el.style.setProperty('--clip-pos', `${left}px ${top}px`)
el.style.removeProperty('--clip-size')
nextTick(() => {
el.classList.add('app-transition')
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.setProperty('--clip-size', `${Math.hypot(window.innerWidth, window.innerHeight)}px`)
})
})
})
document.body.append(copy)
;(copy.querySelectorAll('[data-scroll-x], [data-scroll-y]') as NodeListOf<HTMLElement>).forEach(el => {
el.scrollLeft = +el.dataset.scrollX!
el.scrollTop = +el.dataset.scrollY!
})
function onTransitionend(e: TransitionEvent) {
if (e.target === e.currentTarget) {
copy.remove()
el.removeEventListener('transitionend', onTransitionend)
el.removeEventListener('transitioncancel', onTransitionend)
el.classList.remove('app-transition')
el.style.removeProperty('--clip-size')
el.style.removeProperty('--clip-pos')
}
}
el.addEventListener('transitionend', onTransitionend)
el.addEventListener('transitioncancel', onTransitionend)
}
// 更新主题
function updateTheme() {
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
globalTheme.name.value = theme
savedTheme.value = theme
themeTransition()
// 保存主题到本地
saveLocalTheme(theme, globalTheme)
}
// 切换主题
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('保存主题到服务端失败')
}
}
// 是否有滚动条
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">
<template v-slot:activator="{ props }">
<IconBtn v-bind="props">
<VIcon :icon="getThemeIcon" />
</IconBtn>
</template>
<VList elevation="0" class="theme-switcher-list">
<VCardItem class="theme-switcher-header">
<VCardTitle class="font-weight-medium text-primary">主题选择</VCardTitle>
</VCardItem>
<div class="theme-switcher-options px-2">
<VListItem
v-for="theme in props.themes"
:key="theme.name"
@click="changeTheme(theme.name)"
class="theme-option"
:class="{ 'theme-option-active': currentThemeName === theme.name }"
>
<template #prepend>
<div class="theme-icon-wrapper">
<VIcon :icon="theme.icon" />
</div>
</template>
<VListItemTitle>{{ theme.title }}</VListItemTitle>
<template #append v-if="currentThemeName === theme.name">
<VIcon icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
<VDivider class="my-2" />
<VListItem @click="cssDialog = true" class="theme-option custom-theme-option">
<template #prepend>
<div class="theme-icon-wrapper custom-theme-icon">
<VIcon icon="mdi-palette" />
</div>
</template>
<VListItemTitle>自定义主题</VListItemTitle>
</VListItem>
</div>
</VList>
</VMenu>
<!-- 自定义 CSS -- -->
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard title="自定义主题风格">
<DialogCloseBtn @click="cssDialog = false" />
<VDivider />
<VAceEditor
v-model:value="customCSS"
lang="css"
:theme="editorTheme"
style="block-size: 100%; min-block-size: 30rem"
/>
<VDivider />
<VCardText class="text-center">
<VBtn @click="saveCustomCSS" class="w-1/2">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
保存
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.theme-switcher-header {
background: linear-gradient(to right, rgba(var(--v-theme-primary), 0.04), rgba(var(--v-theme-primary), 0.01));
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 12px;
padding-inline: 16px;
}
.theme-switcher-options {
max-block-size: 300px;
overflow-y: auto;
}
.theme-option {
border-radius: 8px;
margin-block: 4px;
margin-inline: 0;
transition: all 0.2s ease;
&:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
transform: translateX(4px);
}
&.theme-option-active {
background-color: rgba(var(--v-theme-primary), 0.08);
}
}
.theme-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background-color: rgba(var(--v-theme-primary), 0.08);
block-size: 36px;
inline-size: 36px;
margin-inline-end: 12px;
transition: all 0.2s ease;
.v-icon {
color: rgba(var(--v-theme-primary), 0.9);
}
}
.custom-theme-icon {
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.15), rgba(var(--v-theme-info), 0.15));
.v-icon {
color: rgba(var(--v-theme-primary), 0.9);
}
}
// Theme transition
.app-copy {
position: fixed !important;
z-index: -1 !important;
overflow: clip !important;
contain: size style !important;
pointer-events: none !important;
}
.app-transition {
--clip-size: 0;
--clip-pos: 0 0;
clip-path: circle(var(--clip-size) at var(--clip-pos));
transition: clip-path 0.35s ease-out;
}
</style>

58
src/@core/scss/README.md Normal file
View File

@@ -0,0 +1,58 @@
# SCSS结构说明
## 目录整合
本项目SCSS文件已完成整合
- 主入口文件:`src/@core/scss/index.scss`
- 实际功能文件位于:`src/@core/scss/template/index.scss`
## 整合内容
- 整合了原`src/@core/scss/base``src/@core/scss/template`目录的功能
- 统一使用`template`目录作为SCSS样式的主要引用点
- 保留原有引用结构以保证向后兼容性
## 整合进度
已完成:
- ✅ 主入口文件引用更新
- ✅ mixins文件合并
- ✅ placeholders目录下文件转移
- ✅ perfect-scrollbar文件整合
- ✅ vuetify相关文件整合
- ✅ default-layout-w-vertical-nav文件整合
- ✅ 移除了template/index.scss中对base目录组件的依赖
- ✅ 修复了components.scss中对base/mixins的引用
- ✅ 修复了variables.scss中对base/variables的引用
- ✅ 修复了apex-chart.scss和full-calendar.scss的linter错误
- ✅ 整合并移除了对vuetify/variables的依赖
- ✅ 修复了SCSS变量名冲突问题
- ✅ 修复了SASS模块重复加载配置问题
- ✅ 修复了导入路径问题misc、utils等模块的引用路径
待完成:
- ⬜ 最终测试确保无样式问题
- ⬜ 清理冗余文件
## 使用方式
在项目中引用SCSS时应使用
```scss
@use "@core/scss";
```
这将自动加载所有必要的样式文件。
## 注意事项
此次整合已将所有功能文件整合到template目录不再依赖base目录的代码。现在可以安全地从外部引用template目录下的文件但需要进行最终测试以确保样式正常工作。
测试无误后可以考虑完全删除base目录以简化项目结构。
## 最近修复
在最近的更新中,我们修复了以下问题:
1. 解决了变量名冲突问题,通过使用命名空间(如`layouts-vars`)来引用外部模块变量
2. 修复了SASS模块重复配置问题将多处的`@forward...with`配置合并到了template/_variables.scss文件中
3. 统一使用命名空间引用模块,避免后续出现冲突
4. 修复了`_default-layout-w-vertical-nav.scss`中导入路径错误,将`@use "misc"`修改为`@use "../misc"`

View File

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

View File

@@ -1,8 +1,8 @@
@use "@core/scss/placeholders";
@use "@core/scss/variables";
@use "@core/scss/variables" as core-vars;
.layout-navbar {
@if variables.$navbar-high-emphasis-text {
@if core-vars.$navbar-high-emphasis-text {
@extend %layout-navbar;
}
}

View File

@@ -10,17 +10,19 @@
*/
@use "sass:map";
// @forward "@layouts/styles/variables";
// 使用模板中的变量,不再进行配置
@use "@layouts/styles/variables" as layouts-vars;
@use "utils";
// 👉 Default layout
$navbar-high-emphasis-text: true !default;
@forward "@layouts/styles/variables" with (
$layout-vertical-nav-collapsed-width: 68px !default,
);
@use "@layouts/styles/variables" as *;
// 移除@forward配置已合并到template/_variables.scss
// @forward "@layouts/styles/variables" with (
// $layout-vertical-nav-collapsed-width: 68px !default,
// );
// @use "@layouts/styles/variables" as *;
$theme-colors-name: (
"primary",
@@ -55,7 +57,7 @@ $vertical-nav-horizontal-padding: 1.375rem 1rem !default;
$vertical-nav-horizontal-padding-start: utils.get-first-value($vertical-nav-horizontal-padding);
// Vertical nav header height. Mostly we will align it with navbar height;
$vertical-nav-header-height: $layout-vertical-nav-navbar-height !default;
$vertical-nav-header-height: layouts-vars.$layout-vertical-nav-navbar-height !default;
$vertical-nav-navbar-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
// Vertical nav header padding

View File

@@ -1,157 +0,0 @@
@use "mixins";
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
@use "@layouts/styles/placeholders";
@use "@configured-variables" as variables;
// 👉 Avatar group
.v-avatar-group {
display: flex;
align-items: center;
> * {
&:not(:first-child) {
margin-inline-start: -0.8rem;
}
transition: transform 0.25s ease, box-shadow 0.15s ease;
&:hover {
z-index: 2;
transform: translateY(-5px) scale(1.05);
@include mixins.elevation(3);
}
}
> .v-avatar {
border: 2px solid rgb(var(--v-theme-surface));
transition: transform 0.15s ease;
}
}
// 👉 Button outline with default color border color
.v-alert--variant-outlined,
.v-avatar--variant-outlined,
.v-btn.v-btn--variant-outlined,
.v-card--variant-outlined,
.v-chip--variant-outlined,
.v-list-item--variant-outlined {
&:not([class*="text-"]) {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
}
&.text-default {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
}
}
// 👉 Custom Input
.v-label.custom-input {
padding: 1rem;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
opacity: 1;
white-space: normal;
&:hover {
border-color: rgba(var(--v-border-color), 0.25);
}
&.active {
border-color: rgb(var(--v-theme-primary));
.v-icon {
color: rgb(var(--v-theme-primary)) !important;
}
}
}
// Dialog responsive width
.v-dialog {
// dialog custom close btn
.v-dialog-close-btn {
position: absolute;
z-index: 1;
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity)) !important;
inset-block-start: 0.9375rem;
inset-inline-end: 0.9375rem;
.v-btn__overlay {
display: none;
}
}
.v-card {
@extend %style-scroll-bar;
}
}
@media (min-width: 600px) {
.v-dialog {
&.v-dialog-sm,
&.v-dialog-lg,
&.v-dialog-xl {
.v-overlay__content {
inline-size: 565px !important;
}
}
}
}
@media (min-width: 960px) {
.v-dialog {
&.v-dialog-lg,
&.v-dialog-xl {
.v-overlay__content {
inline-size: 865px !important;
}
}
}
}
@media (min-width: 1264px) {
.v-dialog.v-dialog-xl {
.v-overlay__content {
inline-size: 1165px !important;
}
}
}
// v-tab with pill support
.v-tabs.v-tabs-pill {
.v-tab.v-btn {
border-radius: 0.25rem !important;
transition: none;
.v-tab__slider {
visibility: hidden;
}
}
}
// loop for all colors bg
@each $color-name in variables.$theme-colors-name {
.v-tabs.v-tabs-pill {
.v-slide-group-item--active.v-tab--selected.text-#{$color-name} {
background-color: rgb(var(--v-theme-#{$color-name}));
color: rgb(var(--v-theme-on-#{$color-name})) !important;
}
}
}
// We are make even width of all v-timeline body
.v-timeline--vertical.v-timeline {
.v-timeline-item {
.v-timeline-item__body {
justify-self: stretch !important;
}
}
}
// 👉 Textarea
.v-textarea .v-field__input {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-mask-image: none !important;
mask-image: none !important;
}

View File

@@ -1,16 +0,0 @@
@use "@configured-variables" as variables;
// ————————————————————————————————————
// * ——— Perfect Scrollbar
// ————————————————————————————————————
.v-application.v-theme--dark {
.ps__rail-y,
.ps__rail-x {
background-color: transparent !important;
}
.ps__thumb-y {
background-color: variables.$plugin-ps-thumb-y-dark;
}
}

View File

@@ -1,16 +0,0 @@
@use "@core/scss/base/placeholders";
@use "@core/scss/base/variables";
.layout-vertical-nav,
.layout-horizontal-nav {
ol,
ul {
list-style: none;
}
}
.layout-navbar {
@if variables.$navbar-high-emphasis-text {
@extend %layout-navbar;
}
}

View File

@@ -1,40 +0,0 @@
@use "sass:map";
// Layout
@use "vertical-nav";
@use "default-layout";
@use "default-layout-w-vertical-nav";
// Layouts package
@use "layouts";
// Components
@use "components";
// Utilities
@use "utilities";
// Misc
@use "misc";
// Dark
@use "dark";
// libs
@use "libs/perfect-scrollbar";
a {
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
// Vuetify 3 don't provide margin bottom style like vuetify 2
p {
margin-block-end: 1rem;
}
// Iconify icon size
svg.iconify {
block-size: 1em;
inline-size: 1em;
}

View File

@@ -1,63 +0,0 @@
@use "@configured-variables" as variables;
/* This styles extends the existing layout package's styles for handling cases that aren't related to layouts package */
/*
When we use v-layout as immediate first child of `.page-content-container`, it adds display:flex and page doesn't get contained height
*/
// .layout-wrapper.layout-nav-type-vertical {
// &.layout-content-height-fixed {
// .page-content-container {
// > .v-layout:first-child > :not(.v-navigation-drawer):first-child {
// flex-grow: 1;
// block-size: 100%;
// }
// }
// }
// }
.layout-wrapper.layout-nav-type-vertical {
&.layout-content-height-fixed {
.page-content-container {
> .v-layout:first-child {
overflow: hidden;
min-block-size: 100%;
> .v-main {
// overflow-y: auto;
.v-main__wrap > :first-child {
block-size: 100%;
overflow-y: auto;
}
}
}
}
}
}
// Let div/v-layout take full height. E.g. Email App
.layout-wrapper.layout-nav-type-horizontal {
&.layout-content-height-fixed {
> .layout-page-content {
// display: flex;
}
}
}
// 👉 Floating navbar styles
@if variables.$vertical-nav-navbar-style == "floating" {
// Add spacing above navbar if navbar is floating (was in %layout-navbar-fixed placeholder)
.layout-wrapper.layout-nav-type-vertical.layout-navbar-fixed {
.layout-navbar {
inset-block-start: variables.$vertical-nav-floating-navbar-top;
}
/*
If it's floating navbar
Add `vertical-nav-floating-navbar-top` as margin top to .layout-page-content
*/
.layout-page-content {
margin-block-start: variables.$vertical-nav-floating-navbar-top;
}
}
}

View File

@@ -1,20 +0,0 @@
// scrollable-content allows creating fixed header and scrollable content for VNavigationDrawer (Used when perfect scrollbar is used)
.scrollable-content {
&.v-navigation-drawer {
.v-navigation-drawer__content {
display: flex;
overflow: hidden;
flex-direction: column;
}
}
}
// adding styling for code tag
code {
border-radius: 3px;
color: rgb(var(--v-code-color));
font-size: 90%;
font-weight: 400;
padding-block: 0.2em;
padding-inline: 0.4em;
}

View File

@@ -1,77 +0,0 @@
@use "sass:map";
@use "@styles/variables/_vuetify.scss";
@mixin elevation($z, $important: false) {
box-shadow: map.get(vuetify.$shadow-key-umbra, $z), map.get(vuetify.$shadow-key-penumbra, $z), map.get(vuetify.$shadow-key-ambient, $z) if($important, !important, null);
}
// This mixin is inspired from vuetify for adding hover styles via before pseudo element
@mixin before-pseudo() {
position: relative;
&::before {
position: absolute;
border-radius: inherit;
background: currentcolor;
block-size: 100%;
content: "";
inline-size: 100%;
inset: 0;
opacity: 0;
pointer-events: none;
}
}
@mixin bordered-skin($component, $border-property: "border", $important: false) {
#{$component} {
// background-color: rgb(var(--v-theme-background));
box-shadow: none !important;
#{$border-property}: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) if($important, !important, null);
}
}
// Inspired from vuetify's active-states mixin
// focus => 0.12 & selected => 0.08
@mixin selected-states($selector) {
// #{$selector} {
// opacity: calc(#{map.get(vuetify.$states, "selected")} * var(--v-theme-overlay-multiplier));
// }
// &:hover
// #{$selector} {
// opacity: calc(#{map.get(vuetify.$states, "selected") + map.get(vuetify.$states, "hover")} * var(--v-theme-overlay-multiplier));
// }
// &:focus-visible
// #{$selector} {
// opacity: calc(#{map.get(vuetify.$states, "selected") + map.get(vuetify.$states, "focus")} * var(--v-theme-overlay-multiplier));
// }
// @supports not selector(:focus-visible) {
// &:focus {
// #{$selector} {
// opacity: calc(#{map.get(vuetify.$states, "selected") + map.get(vuetify.$states, "focus")} * var(--v-theme-overlay-multiplier));
// }
// }
// }
#{$selector} {
opacity: calc(var(--v-selected-opacity) * var(--v-theme-overlay-multiplier));
}
&:hover
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));
}
&:focus-visible
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
}
@supports not selector(:focus-visible) {
&:focus {
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
}
}
}
}

View File

@@ -1,141 +0,0 @@
@use "@configured-variables" as variables;
@use "@layouts/styles/mixins" as layoutsMixins;
// 👉 Demo spacers
// TODO: Use vuetify SCSS variable here
$card-spacer-content: 16px;
.demo-space-x {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-block-start: -$card-spacer-content;
& > * {
margin-block-start: $card-spacer-content;
margin-inline-end: $card-spacer-content;
}
}
.demo-space-y {
& > * {
margin-block-end: $card-spacer-content;
&:last-child {
margin-block-end: 0;
}
}
}
// 👉 Card match height
.match-height.v-row {
.v-card {
block-size: 100%;
}
}
// 👉 Whitespace
.whitespace-no-wrap {
white-space: nowrap;
}
// 👉 Colors
/*
Vuetify is applying `.text-white` class to badge icon but don't provide its styles
Moreover, we also use this class in some places
In vuetify 2 with `$color-pack: false` SCSS var config this class was getting generated but this is not the case in v3
We also need !important to get correct color in badge icon
*/
.text-white {
color: #fff !important;
}
.bg-var-theme-background {
background-color: rgba(var(--v-theme-background), var(--v-hover-opacity)) !important;
}
// [/^bg-light-(\w+)$/, ([, w]) => ({ backgroundColor: `rgba(var(--v-theme-${w}), var(--v-activated-opacity))` })],
@each $color-name in variables.$theme-colors-name {
.bg-light-#{$color-name} {
background-color: rgba(var(--v-theme-#{$color-name}), var(--v-activated-opacity)) !important;
}
}
// 👉 clamp text
.clamp-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
}
.leading-normal {
line-height: normal !important;
}
// 👉 for rtl only
.flip-in-rtl {
@include layoutsMixins.rtl {
transform: scaleX(-1);
}
}
// 👉 Carousel
.carousel-delimiter-top-end {
.v-carousel__controls {
justify-content: end;
block-size: 40px;
inset-block-start: 0;
padding-inline: 1rem;
.v-btn--icon.v-btn--density-default {
block-size: calc(var(--v-btn-height) + -10px);
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
inline-size: calc(var(--v-btn-height) + -10px);
&.v-btn--active {
color: #fff;
}
.v-btn__overlay {
opacity: 0;
}
}
}
@each $color-name in variables.$theme-colors-name {
&.dots-active-#{$color-name} {
.v-carousel__controls {
.v-btn--active {
color: rgb(var(--v-theme-#{$color-name})) !important;
}
}
}
}
}
.v-timeline-item {
.app-timeline-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 16px;
font-weight: 500;
line-height: 1.3125rem;
}
.app-timeline-meta {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 12px;
line-height: 0.875rem;
}
.app-timeline-text {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 14px;
line-height: 1.25rem;
}
}

View File

@@ -1,90 +0,0 @@
@use "sass:map";
@use "sass:list";
@use "@configured-variables" as variables;
// Thanks: https://css-tricks.com/snippets/sass/deep-getset-maps/
@function map-deep-get($map, $keys...) {
@each $key in $keys {
$map: map.get($map, $key);
}
@return $map;
}
@function map-deep-set($map, $keys, $value) {
$maps: ($map,);
$result: null;
// If the last key is a map already
// Warn the user we will be overriding it with $value
@if type-of(nth($keys, -1)) == "map" {
@warn "The last key you specified is a map; it will be overrided with `#{$value}`.";
}
// If $keys is a single key
// Just merge and return
@if length($keys) == 1 {
@return map-merge($map, ($keys: $value));
}
// Loop from the first to the second to last key from $keys
// Store the associated map to this key in the $maps list
// If the key doesn't exist, throw an error
@for $i from 1 through length($keys) - 1 {
$current-key: list.nth($keys, $i);
$current-map: list.nth($maps, -1);
$current-get: map.get($current-map, $current-key);
@if not $current-get {
@error "Key `#{$key}` doesn't exist at current level in map.";
}
$maps: list.append($maps, $current-get);
}
// Loop from the last map to the first one
// Merge it with the previous one
@for $i from length($maps) through 1 {
$current-map: list.nth($maps, $i);
$current-key: list.nth($keys, $i);
$current-val: if($i == list.length($maps), $value, $result);
$result: map.map-merge($current-map, ($current-key: $current-val));
}
// Return result
@return $result;
}
// font size utility classes
@each $name, $size in variables.$font-sizes {
.text-#{$name} {
font-size: $size;
line-height: map.get(variables.$font-line-height, $name);
}
}
// truncate utility class
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// gap utility class
@each $name, $size in variables.$gap {
.gap-#{$name} {
gap: $size;
}
.gap-x-#{$name} {
column-gap: $size;
}
.gap-y-#{$name} {
row-gap: $size;
}
}
.list-none {
list-style-type: none;
}

View File

@@ -1,197 +0,0 @@
@use "vuetify/lib/styles/tools/functions" as *;
/*
TODO: Add docs on when to use placeholder vs when to use SASS variable
Placeholder
- When we want to keep customization to our self between templates use it
Variables
- When we want to allow customization from both user and our side
- You can also use variable for consistency (e.g. mx 1 rem should be applied to both vertical nav items and vertical nav header)
*/
@forward "@layouts/styles/variables" with (
// Adjust z-index so vertical nav & overlay stays on top of v-layout in v-main. E.g. Email app
$layout-vertical-nav-z-index: 1004,
$layout-overlay-z-index: 1003,
);
@use "@layouts/styles/variables" as *;
// 👉 Default layout
$navbar-high-emphasis-text: true !default;
// @forward "@layouts/styles/variables" with (
// $layout-vertical-nav-width: 350px !default,
// );
$theme-colors-name: (
"primary",
"secondary",
"error",
"info",
"success",
"warning"
) !default;
// 👉 Default layout with vertical nav
$default-layout-with-vertical-nav-navbar-footer-roundness: 10px !default;
// 👉 Vertical nav
$vertical-nav-background-color-rgb: var(--v-theme-background) !default;
$vertical-nav-background-color: rgb(#{$vertical-nav-background-color-rgb}) !default;
// This is used to keep consistency between nav items and nav header left & right margin
// This is used by nav items & nav header
$vertical-nav-horizontal-spacing: 1rem !default;
$vertical-nav-horizontal-padding: 0.75rem !default;
// Vertical nav header height. Mostly we will align it with navbar height;
$vertical-nav-header-height: $layout-vertical-nav-navbar-height !default;
$vertical-nav-navbar-elevation: 3 !default;
$vertical-nav-navbar-style: "elevated" !default; // options: elevated, floating
$vertical-nav-floating-navbar-top: 1rem !default;
// Vertical nav header padding
$vertical-nav-header-padding: 1rem $vertical-nav-horizontal-padding !default;
$vertical-nav-header-inline-spacing: $vertical-nav-horizontal-spacing !default;
// Move logo when vertical nav is mini (collapsed but not hovered)
$vertical-nav-header-logo-translate-x-when-vertical-nav-mini: -4px !default;
// Space between logo and title
$vertical-nav-header-logo-title-spacing: 0.9rem !default;
// Section title margin top (when its not first child)
$vertical-nav-section-title-mt: 1.5rem !default;
// Section title margin bottom
$vertical-nav-section-title-mb: 0.5rem !default;
// Vertical nav icons
$vertical-nav-items-icon-size: 1.5rem !default;
$vertical-nav-items-nested-icon-size: 0.9rem !default;
$vertical-nav-items-icon-margin-inline-end: 0.5rem !default;
// Transition duration for nav group arrow
$vertical-nav-nav-group-arrow-transition-duration: 0.15s !default;
// Timing function for nav group arrow
$vertical-nav-nav-group-arrow-transition-timing-function: ease-in-out !default;
// 👉 Horizontal nav
/*
❗ Heads up
==================
Here we assume we will always use shorthand property which will apply same padding on four side
This is because this have been used as value of top property by `.popper-content`
*/
$horizontal-nav-padding: 0.6875rem !default;
// Gap between top level horizontal nav items
$horizontal-nav-top-level-items-gap: 4px !default;
// Horizontal nav icons
$horizontal-nav-items-icon-size: 1.5rem !default;
$horizontal-nav-third-level-icon-size: 0.9rem !default;
$horizontal-nav-items-icon-margin-inline-end: 0.625rem !default;
// We used SCSS variable because we want to allow users to update max height of popper content
// 120px is combined height of navbar & horizontal nav
$horizontal-nav-popper-content-max-height: calc((var(--vh, 1vh) * 100) - 120px - 4rem) !default;
// This variable is used for horizontal nav popper content's `margin-top` and "The bridge"'s height. We need to sync both values.
$horizontal-nav-popper-content-top: calc($horizontal-nav-padding + 0.375rem) !default;
// 👉 Plugins
$plugin-ps-thumb-y-dark: rgba(var(--v-theme-surface-variant), 0.35) !default;
// 👉 Vuetify
// Used in src/@core/scss/base/libs/vuetify/_overrides.scss
$vuetify-reduce-default-compact-button-icon-size: true !default;
// 👉 Custom variables
// for utility classes
$font-sizes: () !default;
$font-sizes: map-deep-merge(
(
"xs": 0.75rem,
"sm": 0.875rem,
"base": 1rem,
"lg": 1.125rem,
"xl": 1.25rem,
"2xl": 1.5rem,
"3xl": 1.875rem,
"4xl": 2.25rem,
"5xl": 3rem,
"6xl": 3.75rem,
"7xl": 4.5rem,
"8xl": 6rem,
"9xl": 8rem
),
$font-sizes
);
// line height
$font-line-height: () !default;
$font-line-height: map-deep-merge(
(
"xs": 1rem,
"sm": 1.25rem,
"base": 1.5rem,
"lg": 1.75rem,
"xl": 1.75rem,
"2xl": 2rem,
"3xl": 2.25rem,
"4xl": 2.5rem,
"5xl": 1,
"6xl": 1,
"7xl": 1,
"8xl": 1,
"9xl": 1
),
$font-line-height
);
// gap utility class
$gap: () !default;
$gap: map-deep-merge(
(
"0": 0,
"1": 0.25rem,
"2": 0.5rem,
"3": 0.75rem,
"4": 1rem,
"5": 1.25rem,
"6":1.5rem,
"7": 1.75rem,
"8": 2rem,
"9": 2.25rem,
"10": 2.5rem,
"11": 2.75rem,
"12": 3rem,
"14": 3.5rem,
"16": 4rem,
"20": 5rem,
"24": 6rem,
"28": 7rem,
"32": 8rem,
"36": 9rem,
"40": 10rem,
"44": 11rem,
"48": 12rem,
"52": 13rem,
"56": 14rem,
"60": 15rem,
"64": 16rem,
"72": 18rem,
"80": 20rem,
"96": 24rem
),
$gap
);

View File

@@ -1,251 +0,0 @@
@use "@core/scss/base/placeholders" as *;
@use "@core/scss/template/placeholders" as *;
@use "@layouts/styles/mixins" as layoutsMixins;
@use "@configured-variables" as variables;
@use "@core/scss/base/mixins" as mixins;
@use "vuetify/lib/styles/tools/states" as vuetifyStates;
.layout-nav-type-vertical {
// 👉 Layout Vertical nav
.layout-vertical-nav {
$sl-layout-nav-type-vertical: &;
@extend %nav;
@at-root {
// Add styles for collapsed vertical nav
.layout-vertical-nav-collapsed#{$sl-layout-nav-type-vertical}.hovered {
@include mixins.elevation(6);
}
}
background-color: variables.$vertical-nav-background-color;
// 👉 Nav header
.nav-header {
overflow: hidden;
padding: variables.$vertical-nav-header-padding;
margin-inline: variables.$vertical-nav-header-inline-spacing;
min-block-size: variables.$vertical-nav-header-height;
// TEMPLATE: Check if we need to move this to master
.app-logo {
flex-shrink: 0;
transition: transform 0.25s ease-in-out;
@at-root {
// Move logo a bit to align center with the icons in vertical nav mini variant
.layout-vertical-nav-collapsed#{$sl-layout-nav-type-vertical}:not(.hovered) .nav-header .app-logo {
transform: translateX(variables.$vertical-nav-header-logo-translate-x-when-vertical-nav-mini);
@include layoutsMixins.rtl {
transform: translateX(-(variables.$vertical-nav-header-logo-translate-x-when-vertical-nav-mini));
}
}
}
}
.app-title {
margin-inline-start: variables.$vertical-nav-header-logo-title-spacing;
}
.header-action {
@extend %nav-header-action;
}
}
// 👉 Nav items shadow
.vertical-nav-items-shadow {
position: absolute;
z-index: 1;
background:
linear-gradient(
rgb(var(--v-theme-surface)) 5%,
rgba(var(--v-theme-surface), 75%) 45%,
rgba(var(--v-theme-surface), 20%) 80%,
transparent
);
block-size: 55px;
inline-size: 100%;
inset-block-start: calc(#{variables.$vertical-nav-header-height} - 2px);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
will-change: opacity;
@include layoutsMixins.rtl {
transform: translateX(8px);
}
}
&.scrolled {
.vertical-nav-items-shadow {
opacity: 1;
}
}
.ps__rail-y {
// Setting z-index: 1 will make perfect scrollbar thumb appear on top of vertical nav items shadow;Settingz-indexSettingz-indexSettingz-indexSettingz-index
z-index: 1z-indexz-indexz-index
}
// 👉 Nav section title
.nav-section-title {
@extend %vertical-nav-item;
@extend %vertical-nav-section-title;
margin-block-end: variables.$vertical-nav-section-title-mb;
&:not(:first-child) {
margin-block-start: variables.$vertical-nav-section-title-mt;
}
.placeholder-icon {
margin-inline: auto;
}
}
// Nav item badge
.nav-item-badge {
@extend %vertical-nav-item-badge;
}
// 👉 Nav group & Link
.nav-link,
.nav-group {
overflow: hidden;
> :first-child {
@extend %vertical-nav-item;
@extend %vertical-nav-item-interactive;
}
.nav-item-icon {
@extend %vertical-nav-items-icon;
}
&.disabled {
opacity: var(--v-disabled-opacity);
pointer-events: none;
}
}
// 👉 Vertical nav link
.nav-link {
@extend %nav-link;
> .router-link-exact-active {
@extend %nav-link-active;
}
> a {
// Adds before psudo element to style hover state
@include mixins.before-pseudo;
// Adds vuetify states
@include vuetifyStates.states($active: false);
}
}
// 👉 Vertical nav group
.nav-group {
// Reduce the size of icon if link/group is inside group
.nav-group,
.nav-link {
.nav-item-icon {
@extend %vertical-nav-items-nested-icon;
}
}
// Hide icons after 2nd level
& .nav-group {
.nav-link,
.nav-group {
.nav-item-icon {
@extend %vertical-nav-items-icon-after-2nd-level;
}
}
}
.nav-group-arrow {
flex-shrink: 0;
transform-origin: center;
transition: transform variables.$vertical-nav-nav-group-arrow-transition-duration variables.$vertical-nav-nav-group-arrow-transition-timing-function;
will-change: transform;
}
// Rotate arrow icon if group is opened
&.open {
> .nav-group-label .nav-group-arrow {
transform: rotateZ(90deg);
}
}
// Nav group label
> :first-child {
// Adds before psudo element to style hover state
@include mixins.before-pseudo;
// Adds vuetify states
@include vuetifyStates.states($active: false);
}
// Active & open states for nav group label
&.active,
&.open {
> :first-child {
@extend %vertical-nav-group-open-active;
}
}
}
}
}
// SECTION: Transitions
.vertical-nav-section-title-enter-active,
.vertical-nav-section-title-leave-active {
transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out;
}
.vertical-nav-section-title-enter-from,
.vertical-nav-section-title-leave-to {
opacity: 0;
transform: translateX(15px);
@include layoutsMixins.rtl {
transform: translateX(-15px);
}
}
.transition-slide-x-enter-active,
.transition-slide-x-leave-active {
transition: opacity 0.1s ease-in-out, transform 0.12s ease-in-out;
}
.transition-slide-x-enter-from,
.transition-slide-x-leave-to {
opacity: 0;
transform: translateX(-15px);
@include layoutsMixins.rtl {
transform: translateX(15px);
}
}
.vertical-nav-app-title-enter-active,
.vertical-nav-app-title-leave-active {
transition: opacity 0.1s ease-in-out, transform 0.12s ease-in-out;
}
.vertical-nav-app-title-enter-from,
.vertical-nav-app-title-leave-to {
opacity: 0;
transform: translateX(-15px);
@include layoutsMixins.rtl {
transform: translateX(15px);
}
}
// !SECTION

View File

@@ -1 +0,0 @@
@use "overrides";

View File

@@ -1,49 +0,0 @@
// 👉 Shadow opacities
$shadow-key-umbra-opacity-custom: var(--v-shadow-key-umbra-opacity);
$shadow-key-penumbra-opacity-custom: var(--v-shadow-key-penumbra-opacity);
$shadow-key-ambient-opacity-custom: var(--v-shadow-key-ambient-opacity);
// 👉 Card transition properties
$card-transition-property-custom: box-shadow, opacity;
@forward "vuetify/settings" with (
// 👉 General settings
$color-pack: false !default,
// 👉 Shadow opacity
$shadow-key-umbra-opacity: $shadow-key-umbra-opacity-custom !default,
$shadow-key-penumbra-opacity: $shadow-key-penumbra-opacity-custom !default,
$shadow-key-ambient-opacity: $shadow-key-ambient-opacity-custom !default,
// 👉 Card
$card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
$card-elevation: 6 !default,
$card-title-line-height: 1.6 !default,
$card-actions-min-height: unset !default,
$card-text-padding: 1.25rem !default,
$card-item-padding: 1.25rem !default,
$card-actions-padding: 0 12px 12px !default,
$card-transition-property: $card-transition-property-custom !default,
$card-subtitle-opacity: 1 !default,
// 👉 Expansion Panel
$expansion-panel-active-title-min-height: 48px !default,
// 👉 List
$list-item-icon-margin-end: 16px !default,
$list-item-icon-margin-start: 16px !default,
$list-item-subtitle-opacity: 1 !default,
// 👉 Tooltip
$tooltip-background-color: rgba(59, 55, 68, 0.9) !default,
$tooltip-text-color: rgb(var(--v-theme-on-primary)) !default,
$tooltip-font-size: 0.75rem !default,
$button-icon-density: ("default": 2, "comfortable": 0, "compact": -1 ) !default,
// 👉 VTimeline
$timeline-dot-size: 34px !default,
// 👉 VOverlay
$overlay-opacity: 1 !default,
);

View File

@@ -1,5 +0,0 @@
@forward "vertical-nav";
@forward "nav";
@forward "default-layout";
@forward "default-layout-vertical-nav";
@forward "misc";

View File

@@ -1,7 +0,0 @@
%blurry-bg {
/* stylelint-disable property-no-vendor-prefix */
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
/* stylelint-enable */
background-color: rgb(var(--v-theme-surface), 0.8);
}

View File

@@ -1,34 +0,0 @@
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
@use "@core/scss/base/mixins";
// This is common style that needs to be applied to both navs
%nav {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
.nav-item-title {
letter-spacing: 0.15px;
}
.nav-section-title {
letter-spacing: 0.4px;
}
}
/*
Active nav link styles for horizontal & vertical nav
For horizontal nav it will be only applied to top level nav items
For vertical nav it will be only applied to nav links (not nav groups)
*/
%nav-link-active {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
@include mixins.elevation(3);
}
%nav-link {
a {
color: inherit;
}
}

View File

@@ -1,81 +0,0 @@
@use "@core/scss/base/mixins";
@use "@configured-variables" as variables;
@use "vuetify/lib/styles/tools/states" as vuetifyStates;
%nav-header-action {
font-size: 1.25rem;
}
// Nav items styles (including section title)
%vertical-nav-item {
margin-block: 0;
margin-inline: variables.$vertical-nav-horizontal-spacing;
padding-block: 0;
padding-inline: variables.$vertical-nav-horizontal-padding;
white-space: nowrap;
}
// This is same as `%vertical-nav-item` except section title is excluded
%vertical-nav-item-interactive {
border-radius: 0.4rem;
block-size: 2.75rem;
/*
We will use `margin-block-end` instead of `margin-block` to give more space for shadow to appear.
With `margin-block`, due to small space (space gets divided between top & bottom) shadow cuts
*/
margin-block-end: 0.375rem;
}
// Common styles for nav item icon styles
// Nav group's children icon styles are not here (Adjusts height, width & margin)
%vertical-nav-items-icon {
flex-shrink: 0;
font-size: variables.$vertical-nav-items-icon-size;
margin-inline-end: variables.$vertical-nav-items-icon-margin-inline-end;
}
// Icon styling for icon nested inside another nav item (2nd level)
%vertical-nav-items-nested-icon {
/*
`margin-inline` will be (normal icon font-size - small icon font-size) / 2
(1.5rem - 0.9rem) / 2 => 0.6rem / 2 => 0.3rem
*/
$vertical-nav-items-nested-icon-margin-inline: calc((variables.$vertical-nav-items-icon-size - variables.$vertical-nav-items-nested-icon-size) / 2);
font-size: variables.$vertical-nav-items-nested-icon-size;
margin-inline-end: $vertical-nav-items-nested-icon-margin-inline + variables.$vertical-nav-items-icon-margin-inline-end;
margin-inline-start: $vertical-nav-items-nested-icon-margin-inline;
}
%vertical-nav-items-icon-after-2nd-level {
visibility: hidden;
}
// Open & Active nav group styles
%vertical-nav-group-open-active {
@include mixins.selected-states("&::before");
}
// Section title
%vertical-nav-section-title {
// Setting height will prevent jerking when text & icon is toggled
block-size: 1.5rem;
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 0.75rem;
text-transform: uppercase;
}
// Vertical nav item badge styles
%vertical-nav-item-badge {
display: inline-block;
border-radius: 1.5rem;
font-size: 0.8em;
font-weight: 500;
line-height: 1;
padding-block: 0.25em;
padding-inline: 0.55em;
text-align: center;
vertical-align: baseline;
white-space: nowrap;
}

View File

@@ -1,33 +1,5 @@
@use "sass:map";
@use "template/index";
// Layout
@use "vertical-nav";
@use "default-layout";
// Components
@use "components";
// Utilities
@use "utilities";
// Misc
@use "misc";
// Dark
@use "dark";
a {
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
// Vuetify 3 don't provide margin bottom style like vuetify 2
p {
margin-block-end: 1rem;
}
// Iconify icon size
svg.iconify {
block-size: 1em;
inline-size: 1em;
}
// 保留这个引用以向后兼容但实际功能已经移至template/index.scss
@use "variables";

View File

@@ -1,7 +1,7 @@
$shadow-key-umbra-opacity-custom: var(--v-shadow-key-umbra-opacity);
$shadow-key-penumbra-opacity-custom: var(--v-shadow-key-penumbra-opacity);
$shadow-key-ambient-opacity-custom: var(--v-shadow-key-ambient-opacity);
$font-family-custom: inter, sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$font-family-custom: 'Inter', 'Noto Sans SC', sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
// 👉 Card transition properties
$card-transition-property-custom: box-shadow, opacity;

View File

@@ -1,7 +1,6 @@
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
@use "@configured-variables" as variables;
@use "mixins";
@use "@core/scss/base/mixins" as mixins_base;
// 👉 Alert
.v-alert {
@@ -190,5 +189,5 @@
// 👉 SnackBar
.v-snackbar--variant-elevated {
@include mixins_base.elevation(6);
@include mixins.elevation(6);
}

View File

@@ -1,9 +1,8 @@
@use "@configured-variables" as variables;
@use "@core/scss/base/placeholders" as *;
@use "@core/scss/template/placeholders" as *;
@use "placeholders" as *;
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
@use "misc";
@use "@core/scss/base/mixins";
@use "../misc";
@use "mixins";
$header: ".layout-navbar";
@@ -23,7 +22,7 @@ $header: ".layout-navbar";
// If navbar is contained => Add border radius to header
@if variables.$layout-vertical-nav-navbar-is-contained {
#{$header} {
border-radius: 0 0 variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
// border-radius: 0 0 variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
}
}
@@ -64,7 +63,7 @@ $header: ".layout-navbar";
#{$header} {
@if variables.$layout-vertical-nav-navbar-is-contained {
border-radius: variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
// border-radius: variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
}
background-color: rgb(var(--v-theme-surface));
@@ -101,4 +100,4 @@ $header: ".layout-navbar";
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
@use "sass:map";
@use "vuetify/lib/styles/settings" as vuetify_settings;
@use "@styles/variables/_vuetify.scss" as vuetify;
@mixin avatar-font-sizes($map: $avatar-sizes) {
@each $sizeName, $multiplier in vuetify_settings.$size-scales {
@@ -11,3 +12,90 @@
}
}
}
@mixin elevation($z, $important: false) {
box-shadow: map.get(vuetify.$shadow-key-umbra, $z), map.get(vuetify.$shadow-key-penumbra, $z), map.get(vuetify.$shadow-key-ambient, $z) if($important, !important, null);
}
@mixin before-pseudo() {
position: relative;
&::before {
position: absolute;
border-radius: inherit;
background: currentcolor;
block-size: 100%;
content: "";
inline-size: 100%;
inset: 0;
opacity: 0;
pointer-events: none;
}
}
@mixin bordered-skin($component, $border-property: "border", $important: false) {
#{$component} {
box-shadow: none !important;
#{$border-property}: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) if($important, !important, null);
}
}
@mixin selected-states($selector) {
#{$selector} {
opacity: calc(var(--v-selected-opacity) * var(--v-theme-overlay-multiplier));
}
&:hover
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));
}
&:focus-visible
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
}
@supports not selector(:focus-visible) {
&:focus {
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
}
}
}
}
@mixin push-anchors() {
:target {
scroll-margin-block-start: 90px;
}
}
@mixin xs {
@media (width >= 0) and (width <= 599.98px) {
@content;
}
}
@mixin sm {
@media (width >= 600px) and (width <= 959.98px) {
@content;
}
}
@mixin md {
@media (width >= 960px) and (width <= 1279.98px) {
@content;
}
}
@mixin lg {
@media (width >= 1280px) and (width <= 1919.98px) {
@content;
}
}
@mixin xl {
@media (width >= 1920px) {
@content;
}
}

View File

@@ -1,5 +1,6 @@
@use "sass:map";
@use "utils";
@use "vuetify/lib/styles/tools/functions" as *;
$vertical-nav-horizontal-padding-custom: 1.375rem 1rem;
@@ -13,15 +14,16 @@ $vertical-nav-horizontal-padding-custom: 1.375rem 1rem;
$vertical-nav-horizontal-padding-start: utils.get-first-value($vertical-nav-horizontal-padding-custom) !default;
$vertical-nav-items-icon-margin-inline-end: 0.625rem !default;
@forward "@core/scss/base/variables" with (
$layout-vertical-nav-collapsed-width: 68px !default,
// This is used to keep consistency between nav items and nav header left & right margin
// This is used by nav items & nav header
$vertical-nav-horizontal-spacing: 0 1.125rem !default,
$vertical-nav-horizontal-padding: $vertical-nav-horizontal-padding-custom !default,
// Vertical nav header padding
$vertical-nav-header-padding: 1rem 0.25rem 1rem $vertical-nav-horizontal-padding-start !default,
);
// Vertical Nav Configuration
$vertical-nav-collapsed-width: 68px !default;
// This is used to keep consistency between nav items and nav header left & right margin
// This is used by nav items & nav header
$vertical-nav-horizontal-spacing: 0 1.125rem !default;
$vertical-nav-horizontal-padding: $vertical-nav-horizontal-padding-custom !default;
// Vertical nav header padding
$vertical-nav-header-padding: 1rem 0.25rem 1rem $vertical-nav-horizontal-padding-start !default;
// 👉 Custom Variables
$avatar-font-sizes: (
@@ -31,3 +33,195 @@ $avatar-font-sizes: (
"large":20,
"x-large":24
) !default;
// 合并两个文件中的@forward配置
@forward "@layouts/styles/variables" with (
// 来自_variables.scss的配置
$layout-vertical-nav-collapsed-width: 68px !default,
// 来自template/_variables.scss的配置
$layout-vertical-nav-z-index: 1004,
$layout-overlay-z-index: 1003
);
// 使用命名空间来避免变量冲突
@use "@layouts/styles/variables" as layouts-vars;
$theme-colors-name: (
"primary",
"secondary",
"error",
"info",
"success",
"warning"
) !default;
// 👉 Default layout with vertical nav
$default-layout-with-vertical-nav-navbar-footer-roundness: 10px !default;
// 👉 Vertical nav
$vertical-nav-background-color-rgb: var(--v-theme-background) !default;
$vertical-nav-background-color: rgb(#{$vertical-nav-background-color-rgb}) !default;
// This is used to keep consistency between nav items and nav header left & right margin
// This is used by nav items & nav header
$vertical-nav-horizontal-spacing: 1rem !default;
$vertical-nav-horizontal-padding: 0.75rem !default;
// Vertical nav header height. Mostly we will align it with navbar height;
$vertical-nav-header-height: layouts-vars.$layout-vertical-nav-navbar-height !default;
$vertical-nav-navbar-elevation: 3 !default;
$vertical-nav-navbar-style: "elevated" !default; // options: elevated, floating
$vertical-nav-floating-navbar-top: 1rem !default;
// Vertical nav header padding
$vertical-nav-header-padding: 1rem $vertical-nav-horizontal-padding !default;
$vertical-nav-header-inline-spacing: $vertical-nav-horizontal-spacing !default;
// Move logo when vertical nav is mini (collapsed but not hovered)
$vertical-nav-header-logo-translate-x-when-vertical-nav-mini: -4px !default;
// Space between logo and title
$vertical-nav-header-logo-title-spacing: 0.9rem !default;
// Section title margin top (when its not first child)
$vertical-nav-section-title-mt: 1.5rem !default;
// Section title margin bottom
$vertical-nav-section-title-mb: 0.5rem !default;
// Vertical nav icons
$vertical-nav-items-icon-size: 1.5rem !default;
$vertical-nav-items-nested-icon-size: 0.9rem !default;
$vertical-nav-items-icon-margin-inline-end: 0.5rem !default;
// Transition duration for nav group arrow
$vertical-nav-nav-group-arrow-transition-duration: 0.15s !default;
// Timing function for nav group arrow
$vertical-nav-nav-group-arrow-transition-timing-function: ease-in-out !default;
// 👉 Horizontal nav
/*
❗ Heads up
==================
Here we assume we will always use shorthand property which will apply same padding on four side
This is because this have been used as value of top property by `.popper-content`
*/
$horizontal-nav-padding: 0.6875rem !default;
// Gap between top level horizontal nav items
$horizontal-nav-top-level-items-gap: 4px !default;
// Horizontal nav icons
$horizontal-nav-items-icon-size: 1.5rem !default;
$horizontal-nav-third-level-icon-size: 0.9rem !default;
$horizontal-nav-items-icon-margin-inline-end: 0.625rem !default;
// We used SCSS variable because we want to allow users to update max height of popper content
// 120px is combined height of navbar & horizontal nav
$horizontal-nav-popper-content-max-height: calc((var(--vh, 1vh) * 100) - 120px - 4rem) !default;
// This variable is used for horizontal nav popper content's `margin-top` and "The bridge"'s height. We need to sync both values.
$horizontal-nav-popper-content-top: calc($horizontal-nav-padding + 0.375rem) !default;
// 👉 Plugins
$plugin-ps-thumb-y-dark: rgba(var(--v-theme-surface-variant), 0.35) !default;
// 👉 Vuetify
// Used in src/@core/scss/base/libs/vuetify/_overrides.scss
$vuetify-reduce-default-compact-button-icon-size: true !default;
// 👉 Custom variables
// for utility classes
$font-sizes: () !default;
$font-sizes: map-deep-merge(
(
"xs": 0.75rem,
"sm": 0.875rem,
"base": 1rem,
"lg": 1.125rem,
"xl": 1.25rem,
"2xl": 1.5rem,
"3xl": 1.875rem,
"4xl": 2.25rem,
"5xl": 3rem,
"6xl": 3.75rem,
"7xl": 4.5rem,
"8xl": 6rem,
"9xl": 8rem
),
$font-sizes
);
// line height
$font-line-height: () !default;
$font-line-height: map-deep-merge(
(
"xs": 1rem,
"sm": 1.25rem,
"base": 1.5rem,
"lg": 1.75rem,
"xl": 1.75rem,
"2xl": 2rem,
"3xl": 2.25rem,
"4xl": 2.5rem,
"5xl": 1,
"6xl": 1,
"7xl": 1,
"8xl": 1,
"9xl": 1
),
$font-line-height
);
// gap utility class
$gap: () !default;
$gap: map-deep-merge(
(
"0": 0,
"1": 0.25rem,
"2": 0.5rem,
"3": 0.75rem,
"4": 1rem,
"5": 1.25rem,
"6":1.5rem,
"7": 1.75rem,
"8": 2rem,
"9": 2.25rem,
"10": 2.5rem,
"11": 2.75rem,
"12": 3rem,
"14": 3.5rem,
"16": 4rem,
"20": 5rem,
"24": 6rem,
"28": 7rem,
"32": 8rem,
"36": 9rem,
"40": 10rem,
"44": 11rem,
"48": 12rem,
"52": 13rem,
"56": 14rem,
"60": 15rem,
"64": 16rem,
"72": 18rem,
"80": 20rem,
"96": 24rem
),
$gap
);
// Avatar sizes map
$avatar-font-sizes: (
"x-small": 0.625rem,
"small": 0.75rem,
"default": 0.875rem,
"large": 1rem,
"x-large": 1.125rem,
) !default;

View File

@@ -1,8 +1,42 @@
@use "sass:map";
@use "@core/scss/base";
// Layout
@use "../vertical-nav";
@use "../default-layout";
@use "default-layout-w-vertical-nav";
// Components
@use "components";
// Utilities
@use "utilities";
@use "../utils";
// Misc
@use "../misc";
// Dark
@use "../dark";
// Variables
@use "variables";
// libs
@use "libs/perfect-scrollbar";
@use "libs/vuetify";
a {
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
// Vuetify 3 don't provide margin bottom style like vuetify 2
p {
margin-block-end: 1rem;
}
// Iconify icon size
svg.iconify {
block-size: 1em;
inline-size: 1em;
}

View File

@@ -1,76 +1,89 @@
@use "@styles/variables/_vuetify.scss" as vuetify;
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
@use "@layouts/styles/mixins" as layoutsMixins;
@use "@core/scss/base/mixins";
@use "@configureTheme" as theme;
@use "@configured-variables" as variables;
@use "../mixins";
.v-application .apexcharts-canvas {
&line[stroke="transparent"] {
display: "none";
// 👉 Apex chart
.apexcharts-canvas {
// For RTL alignment
.apexcharts-yaxis-texts-g {
text-align: start;
}
// Tooltip
.apexcharts-tooltip {
@include mixins.elevation(3);
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
background: rgb(var(--v-theme-surface));
line-height: 1.5;
.apexcharts-tooltip-title {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
background: rgb(var(--v-theme-surface));
font-weight: 500;
margin-block-end: 0.25rem;
padding-inline: 1rem;
}
.apexcharts-tooltip-text {
display: flex;
align-items: center;
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
font-size: inherit;
gap: 0.5rem;
line-height: inherit;
}
.apexcharts-tooltip-text-label,
.apexcharts-tooltip-text-value {
font-weight: 600;
line-height: 1.5;
}
.apexcharts-tooltip-series-group {
padding-block: 0 0.5rem;
padding-inline: 1rem;
&:last-child {
padding-block-end: 1rem;
}
&.active {
padding-block-start: 0;
}
}
&.apexcharts-theme-light {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
}
&.apexcharts-theme-dark {
color: white;
}
.apexcharts-tooltip-series-group:first-of-type {
padding-block-end: 0;
border-color: rgb(var(--v-border-color));
background: rgb(var(--v-theme-surface));
box-shadow: none;
.apexcharts-tooltip-text-label,
.apexcharts-tooltip-text-value {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
}
}
}
.apexcharts-xaxistooltip {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
background: rgb(var(--v-theme-grey-50));
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
&::after {
border-block-end-color: rgb(var(--v-theme-grey-50));
}
&::before {
border-block-end-color: rgba(var(--v-border-color), var(--v-border-opacity));
}
.apexcharts-marker {
transition: none;
}
// 👉 stroke-dasharray
.apexcharts-radialbar,
.apexcharts-radialbar-slice-current {
stroke-linecap: round;
}
.apexcharts-xaxistooltip,
.apexcharts-yaxistooltip {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
background: rgb(var(--v-theme-grey-50));
&::after {
border-inline-start-color: rgb(var(--v-theme-grey-50));
}
border-color: rgb(var(--v-border-color));
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
&::after,
&::before {
border-inline-start-color: rgba(var(--v-border-color), var(--v-border-opacity));
}
}
.apexcharts-xaxistooltip-text,
.apexcharts-yaxistooltip-text {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
}
.apexcharts-yaxis .apexcharts-yaxis-texts-g .apexcharts-yaxis-label {
@include layoutsMixins.rtl {
text-anchor: start;
border-block-end-color: rgb(var(--v-border-color));
}
}
// 👉 Text color
.apexcharts-text,
.apexcharts-tooltip-text,
.apexcharts-datalabel-label,
@@ -78,23 +91,16 @@
.apexcharts-xaxistooltip-text,
.apexcharts-yaxistooltip-text,
.apexcharts-legend-text {
font-family: vuetify.$body-font-family !important;
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity)) !important;
font-family: inherit !important;
}
.apexcharts-pie-label {
fill: white;
filter: none;
}
.apexcharts-marker {
box-shadow: none;
}
.apexcharts-legend-marker {
margin-inline-end: 0.3875rem !important;
@include layoutsMixins.rtl {
margin-inline-end: 0.75rem !important;
// 👉 Annotation Label
.apexcharts-annotation-rect {
&.apexcharts-xaxis-annotation-rect,
&.apexcharts-yaxis-annotation-rect {
fill-opacity: 0.05;
stroke-opacity: 0;
}
}
}

View File

@@ -1,4 +1,5 @@
@use "@core/scss/base/mixins";
@use "../mixins";
@use "@configured-variables" as variables;
.v-application .fc {
--fc-today-bg-color: rgba(var(--v-theme-on-surface), 0.04);
@@ -16,16 +17,20 @@
padding: 0;
}
.fc-toolbar-title {
display: inline-block;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.25rem;
font-weight: 500;
margin-inline-start: 0.25rem;
}
.fc-col-header-cell-cushion {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.875rem;
font-weight: 600;
}
.fc-toolbar .fc-toolbar-title {
margin-inline-start: 0.25rem;
}
.fc-event-time {
font-size: 0.75rem;
}
@@ -95,6 +100,7 @@
gap: 1rem 0.5rem;
}
// 👉 Toolbar Chunk and Button Group
.fc-toolbar-chunk {
display: flex;
align-items: center;
@@ -102,19 +108,38 @@
.fc-button-group {
.fc-button-primary {
&,
&:focus,
&:hover,
&:not(.disabled):active {
border-color: transparent;
background-color: transparent;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
&:focus {
box-shadow: none !important;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
}
}
// 👉 sidebar toggler
.fc-drawerToggler-button {
display: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='rgba(94,86,105,0.68)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M3 12h18M3 6h18M3 18h18'/%3E%3C/svg%3E");
background-position: 50%;
background-repeat: no-repeat;
block-size: 1.5625rem;
font-size: 0;
inline-size: 1.5625rem;
margin-inline-end: 0.25rem;
@media (width <= 1264px) {
display: block !important;
}
.v-theme--dark & {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='rgba(232,232,241,0.68)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M3 12h18M3 6h18M3 18h18'/%3E%3C/svg%3E");
}
}
// Special styling for the last toolbar chunk
&:last-child {
.fc-button-group {
border: 0.0625rem solid rgba(var(--v-border-color), var(--v-border-opacity));
@@ -139,13 +164,6 @@
}
}
.fc-toolbar-title {
display: inline-block;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.25rem;
font-weight: 500;
}
.fc-scrollgrid-section {
th {
border-inline: 0;
@@ -217,37 +235,6 @@
}
}
// 👉 sidebar toggler
.fc-toolbar-chunk {
.fc-button-group {
align-items: center;
.fc-button .fc-icon {
vertical-align: bottom;
}
// Below two `background-image` styles contains static color due to browser limitation of not parsing the css var inside CSS url()
.fc-drawerToggler-button {
display: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='rgba(94,86,105,0.68)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M3 12h18M3 6h18M3 18h18'/%3E%3C/svg%3E");
background-position: 50%;
background-repeat: no-repeat;
block-size: 1.5625rem;
font-size: 0;
inline-size: 1.5625rem;
margin-inline-end: 0.25rem;
@media (width <= 1264px) {
display: block !important;
}
.v-theme--dark & {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='rgba(232,232,241,0.68)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M3 12h18M3 6h18M3 18h18'/%3E%3C/svg%3E");
}
}
}
}
// Workaround of https://github.com/fullcalendar/fullcalendar/issues/6407
.fc-col-header,
.fc-daygrid-body,

View File

@@ -2,6 +2,11 @@ $ps-size: 0.25rem;
$ps-hover-size: 0.375rem;
$ps-track-size: 0.5rem;
.ps__thumb-x,
.ps__thumb-y {
background-color: rgb(var(--v-theme-perfect-scrollbar-thumb)) !important;
}
.ps__thumb-y {
inline-size: $ps-size;
inset-inline-end: 0.0625rem;
@@ -29,15 +34,10 @@ $ps-track-size: 0.5rem;
inline-size: $ps-hover-size;
}
.ps__thumb-x,
.ps__thumb-y {
background-color: rgb(var(--v-theme-perfect-scrollbar-thumb)) !important;
}
// fix bug
@media(hover: none) {
.ps > .ps__rail-x,
.ps > .ps__rail-y {
opacity: 0.6;
}
}
}

View File

@@ -1,5 +1,5 @@
@use "@core/scss/base/utils";
@use "@configured-variables" as variables;
@use "../../../utils";
// 👉 Application
// We need accurate vh in mobile devices as well
@@ -195,7 +195,6 @@ h6,
color: rgb(var(--v-border-color));
}
// 👉 DataTable
// 👉 DataTable
.v-data-table {
/* stylelint-disable-next-line no-descending-specificity */
@@ -250,34 +249,53 @@ h6,
.v-badge__badge {
display: flex;
align-items: center;
justify-content: center;
}
// 👉 Btn focus outline style removed
.v-btn:focus-visible::after {
opacity: 0 !important;
// 👉 Dialog
.v-dialog--fullscreen {
background-color: rgb(var(--v-theme-surface));
}
// .v-select chip spacing for slot
.v-input:not(.v-select--chips) .v-select__selection {
.v-chip {
margin-block: 2px var(--select-chips-margin-bottom);
}
// For dialog card title
.v-card-item + .v-card-text {
padding-block-start: 0 !important;
}
// 👉 VCard and VList subtitle color
.v-card-subtitle,
.v-list-item-subtitle {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
}
// 👉 v-slide-group (List of chips)
.v-slide-group {
.v-slide-group__container {
display: flex;
flex-wrap: wrap;
// 👉 placeholders
.v-field__input {
@at-root {
& input::placeholder,
input#{&}::placeholder,
textarea#{&}::placeholder {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity)) !important;
opacity: 1 !important;
// Spacing between buttons in v-slide-group
.v-slide-group-item:not(:last-child) {
margin-inline-end: 0.5rem;
}
}
}
// 👉 Expansion Panel
.v-expansion-panels {
.v-expansion-panel-title {
min-block-size: unset !important;
padding-block: 1rem !important;
}
}
// 👉 v-textarea
.v-textarea {
textarea {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
&:hover,
&:focus {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
}
}
// 👉 Cursor
.cursor-pointer {
cursor: pointer;
}

View File

@@ -2,9 +2,20 @@ $shadow-key-umbra-opacity-custom: var(--v-shadow-key-umbra-opacity);
$shadow-key-penumbra-opacity-custom: var(--v-shadow-key-penumbra-opacity);
$shadow-key-ambient-opacity-custom: var(--v-shadow-key-ambient-opacity);
/* stylelint-disable-next-line max-line-length */
$font-family-custom: inter, sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$font-family-custom: 'Inter', 'Noto Sans SC', sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
// 👉 Card transition properties
$card-transition-property-custom: box-shadow, opacity;
@forward "vuetify/settings" with (
// 👉 General settings
$color-pack: false !default,
// 👉 Shadow opacity
$shadow-key-umbra-opacity: $shadow-key-umbra-opacity-custom !default,
$shadow-key-penumbra-opacity: $shadow-key-penumbra-opacity-custom !default,
$shadow-key-ambient-opacity: $shadow-key-ambient-opacity-custom !default,
@forward "../../../base/libs/vuetify/variables" with (
$body-font-family: $font-family-custom !default,
$border-radius-root: 6px !default,
@@ -110,6 +121,18 @@ $font-family-custom: inter, sans-serif, -apple-system, blinkmacsystemfont, "Sego
24: (0 9px 46px 8px $shadow-key-ambient-opacity-custom)
) !default,
// 👉 Card
$card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
$card-elevation: 6 !default,
$card-title-line-height: 2rem !default,
$card-actions-min-height: unset !default,
$card-text-padding: 1.25rem !default,
$card-item-padding: 1.25rem !default,
$card-actions-padding: 0 12px 12px !default,
$card-transition-property: $card-transition-property-custom !default,
$card-subtitle-opacity: 1 !default,
$card-title-letter-spacing: 0.0094rem !default,
// 👉 Typography
$typography: (
"h1": (
@@ -161,13 +184,16 @@ $font-family-custom: inter, sans-serif, -apple-system, blinkmacsystemfont, "Sego
)
) !default,
// 👉 Card
$card-title-letter-spacing: 0.0094rem !default,
$card-title-line-height: 2rem !default,
$card-subtitle-opacity: 1 !default,
// 👉 List
$list-item-icon-margin-end: 16px !default,
$list-item-icon-margin-start: 16px !default,
$list-item-subtitle-opacity: 1 !default,
$list-subheader-text-opacity: 1 !default,
// 👉 Tooltip
$tooltip-background-color:#212121 !default,
$tooltip-background-color: #212121 !default,
$tooltip-text-color: rgb(var(--v-theme-on-primary)) !default,
$tooltip-font-size: 0.75rem !default,
$tooltip-border-radius: 4px !default,
$tooltip-padding: 4px 8px !default,
@@ -209,9 +235,6 @@ $font-family-custom: inter, sans-serif, -apple-system, blinkmacsystemfont, "Sego
// 👉 Menu
$menu-content-border-radius: 5px !default,
// 👉 List
$list-subheader-text-opacity: 1 !default,
// 👉 Snackbar
$snackbar-background:#212121 !default,
$snackbar-border-radius: 4px !default,

View File

@@ -1 +1,2 @@
@use "@core/scss/base/libs/vuetify";
@use "variables";
@use "overrides";

View File

@@ -1,13 +1,13 @@
@use "@configured-variables" as variables;
@use "misc";
@use "@core/scss/base/mixins";
@use "../mixins";
%default-layout-vertical-nav-scrolled-sticky-elevated-nav {
background-color: rgb(var(--v-theme-surface));
}
%default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled {
@include mixins.elevation(variables.$vertical-nav-navbar-elevation);
// @include mixins.elevation(variables.$vertical-nav-navbar-elevation);
// If navbar is contained => Squeeze navbar content on scroll
@if variables.$layout-vertical-nav-navbar-is-contained {
@@ -36,11 +36,10 @@
block-size: calc(variables.$layout-vertical-nav-navbar-height + variables.$vertical-nav-floating-navbar-top + 0.5rem);
content: "";
inset-block-start: -(variables.$vertical-nav-floating-navbar-top);
inset-inline-end: 0;
inset-inline-start: 0;
inset-inline: 0;
/* stylelint-disable property-no-vendor-prefix */
-webkit-mask: linear-gradient(black, black 18%, transparent 100%);
mask: linear-gradient(black, black 18%, transparent 100%);
/* stylelint-enable */
}
}
}

View File

@@ -1,3 +1,3 @@
%layout-navbar {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
}

View File

@@ -1,2 +1,5 @@
@forward "nav";
@forward "vertical-nav";
@forward "default-layout";
@forward "default-layout-vertical-nav";
@forward "misc";

View File

@@ -0,0 +1,120 @@
%blurry-bg {
position: relative;
background: transparent;
box-shadow: none;
&::before {
position: absolute;
z-index: -1;
block-size: calc(env(safe-area-inset-top, 0px) + 5rem);
content: "";
inset-block-start: 0;
inset-inline: 0;
pointer-events: none;
transition: opacity 0.2s ease-in-out, background 0.2s ease-in-out;
// PC端样式 (默认)
.v-theme--light & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-surface), 0.9) 0%,
rgba(var(--v-theme-surface), 0.7) 20%,
rgba(var(--v-theme-surface), 0.5) 40%,
rgba(var(--v-theme-surface), 0.3) 60%,
rgba(var(--v-theme-surface), 0.1) 80%,
rgba(var(--v-theme-surface), 0.0) 100%
);
}
.v-theme--dark & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0.8) 0%,
rgba(var(--v-theme-background), 0.6) 20%,
rgba(var(--v-theme-background), 0.4) 40%,
rgba(var(--v-theme-background), 0.25) 60%,
rgba(var(--v-theme-background), 0.1) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
}
.v-theme--purple & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0.8) 0%,
rgba(var(--v-theme-background), 0.6) 20%,
rgba(var(--v-theme-background), 0.4) 40%,
rgba(var(--v-theme-background), 0.25) 60%,
rgba(var(--v-theme-background), 0.1) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
}
.v-theme--transparent & {
background: linear-gradient(
to bottom,
rgba(11, 11, 11, 60%) 0%,
rgba(11, 11, 11, 50%) 20%,
rgba(11, 11, 11, 40%) 40%,
rgba(11, 11, 11, 25%) 60%,
rgba(11, 11, 11, 10%) 80%,
rgba(11, 11, 11, 0%) 100%
);
}
}
}
// 移动端样式
@media (pointer: coarse) {
%blurry-bg {
&::before {
.v-theme--light & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-surface), 1) 0%,
rgba(var(--v-theme-surface), 0.9) 20%,
rgba(var(--v-theme-surface), 0.7) 40%,
rgba(var(--v-theme-surface), 0.5) 60%,
rgba(var(--v-theme-surface), 0.2) 80%,
rgba(var(--v-theme-surface), 0.0) 100%
);
}
.v-theme--dark & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 1) 0%,
rgba(var(--v-theme-background), 0.85) 20%,
rgba(var(--v-theme-background), 0.7) 40%,
rgba(var(--v-theme-background), 0.5) 60%,
rgba(var(--v-theme-background), 0.3) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
}
.v-theme--purple & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 1) 0%,
rgba(var(--v-theme-background), 0.85) 20%,
rgba(var(--v-theme-background), 0.7) 40%,
rgba(var(--v-theme-background), 0.5) 60%,
rgba(var(--v-theme-background), 0.3) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
}
.v-theme--transparent & {
background: linear-gradient(
to bottom,
rgba(11, 11, 11, 90%) 0%,
rgba(11, 11, 11, 80%) 20%,
rgba(11, 11, 11, 60%) 40%,
rgba(11, 11, 11, 40%) 60%,
rgba(11, 11, 11, 15%) 80%,
rgba(11, 11, 11, 0%) 100%
);
}
}
}
}

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

@@ -116,7 +116,7 @@ export default defineComponent({
.layout-navbar {
position: fixed;
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
width: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
inset-block-start: 0;
@@ -131,7 +131,7 @@ export default defineComponent({
@include mixins.boxed-content;
} @else {
.navbar-content-container {
@include mixins.boxed-content;
// @include mixins.boxed-content;
}
}
}

View File

@@ -105,7 +105,6 @@ export default defineComponent({
position: relative;
z-index: 1;
margin-block-start: 0;
padding-block-start: 0;
}
.layout-wrapper.layout-nav-type-vertical {
@@ -124,7 +123,7 @@ export default defineComponent({
.layout-navbar {
position: fixed;
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
inset-block-start: 0;
.navbar-content-container {
@@ -138,7 +137,7 @@ export default defineComponent({
@include mixins.boxed-content;
} @else {
.navbar-content-container {
@include mixins.boxed-content;
// @include mixins.boxed-content;
}
}
}
@@ -178,7 +177,7 @@ export default defineComponent({
}
&:not(.layout-overlay-nav) .layout-content-wrapper {
padding-inline-start: calc(variables.$layout-vertical-nav-width + 8px);
padding-inline-start: calc(variables.$layout-vertical-nav-width);
}
// Adjust right column pl when vertical nav is collapsed
@@ -210,9 +209,8 @@ export default defineComponent({
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
.layout-navbar {
inline-size: calc(100% - 0px);
margin-inline-start: 8px;
padding-inline-start: 0;
inline-size: 100%;
padding-inline: 0;
}
}
</style>

View File

@@ -29,6 +29,7 @@ body,
.navbar-content-container {
padding-block-start: env(safe-area-inset-top);
padding-inline: 0.5rem;
}
.layout-page-content {
@@ -39,7 +40,8 @@ body,
// TODO: Use grid gutter variable here;
padding-block: 1.5rem;
padding-block-start: calc(env(safe-area-inset-top) + 4.25rem);
padding-inline: 0.5rem;
padding-block-start: calc(env(safe-area-inset-top) + 4.5rem);
// display: flex;display
@@ -51,7 +53,7 @@ body,
& > div:first-child {
position: relative;
flex: auto;
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
}
}
}

View File

@@ -4,7 +4,7 @@
%boxed-content {
@at-root #{&}-spacing {
// TODO: Use grid gutter variable here
padding-inline: 0.5rem;
// padding-inline: 0.5rem;
}
inline-size: 100%;

View File

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

View File

@@ -114,6 +114,7 @@ export interface NavLinkProps {
export interface NavLink extends NavLinkProps, Partial<AclProperties> {
title: string
full_title?: string
icon?: unknown
badgeContent?: string
badgeClass?: string

View File

@@ -2,6 +2,14 @@
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()
@@ -9,9 +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')
// 显示状态
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
// ApexCharts 全局配置
declare global {
interface Window {
@@ -42,24 +67,215 @@ 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)
ensureRenderComplete(() => {
nextTick(() => {
setTimeout(() => {
// 移除加载动画
removeEl('#loading-bg')
// 将background属性从html的style中移除
document.documentElement.style.removeProperty('background')
// 显示页面
show.value = true
animateAndRemoveLoader()
}, 1500)
})
})
})
onUnmounted(() => {
// 移除页面可见性监听
document.removeEventListener('visibilitychange', handleVisibilityChange)
// 清除轮换定时器
if (backgroundRotationTimer) {
clearInterval(backgroundRotationTimer)
backgroundRotationTimer = null
}
})
</script>
<template>
<VApp v-show="show">
<RouterView />
</VApp>
<div class="app-wrapper">
<!-- 透明主题背景 -->
<template v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)">
<div class="background-container">
<div
v-for="(imageUrl, index) in backgroundImages"
:key="index"
class="background-image"
:class="{ 'active': index === activeImageIndex }"
:style="{ backgroundImage: `url(${getImgUrl(imageUrl)})` }"
></div>
<!-- 全局磨砂层 -->
<div v-if="isLogin" class="global-blur-layer"></div>
</div>
</template>
<VApp v-show="show" :class="{ 'transparent-app': isTransparentTheme }">
<RouterView />
</VApp>
</div>
</template>
<style lang="scss">
/* 全局样式 */
.app-wrapper {
position: relative;
inline-size: 100%;
min-block-size: 100vh;
}
.background-container {
position: fixed;
z-index: 0;
overflow: hidden;
block-size: 100%;
inline-size: 100%;
inset-block-start: 0;
inset-inline-start: 0;
}
.background-image {
position: absolute;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
block-size: 100%;
inline-size: 100%;
inset-block-start: 0;
inset-inline-start: 0;
opacity: 0;
transition: opacity 1.5s ease;
&::after {
position: absolute;
background: linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%);
block-size: 100%;
content: '';
inline-size: 100%;
inset-block-start: 0;
inset-inline-start: 0;
}
&.active {
opacity: 1;
}
}
/* 全局磨砂层 */
.global-blur-layer {
position: absolute;
z-index: 1;
backdrop-filter: blur(16px);
background-color: rgba(128, 128, 128, 30%);
block-size: 100%;
inline-size: 100%;
inset-block-start: 0;
inset-inline-start: 0;
}
</style>

View File

@@ -1,85 +1,304 @@
import i18n from '@/plugins/i18n'
export const storageOptions = [
{
title: '本地',
title: i18n.global.t('storage.local'),
value: 'local',
icon: 'mdi-folder-multiple-outline',
remote: false,
},
{
title: '阿里云盘',
title: i18n.global.t('storage.alipan'),
value: 'alipan',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: '115网盘',
title: i18n.global.t('storage.u115'),
value: 'u115',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: 'RClone',
title: i18n.global.t('storage.rclone'),
value: 'rclone',
icon: 'mdi-cloud-outline',
icon: 'mdi-server-network-outline',
remote: true,
},
{
title: 'AList',
title: i18n.global.t('storage.alist'),
value: 'alist',
icon: 'mdi-cloud-outline',
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 storageDict = storageOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const transferTypeOptions = [
{ title: '复制', value: 'copy' },
{ title: '移动', value: 'move' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
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

@@ -44,7 +44,11 @@ export default api
export async function fetchGlobalSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global')
const result: { [key: string]: any } = await api.get('system/global', {
params: {
token: 'moviepilot',
},
})
return result.data || {}
} catch (error) {
console.error('Failed to fetch global settings', error)

View File

@@ -1265,6 +1265,8 @@ export interface RecommendSource {
name: string
// 媒体数据源API地址
api_path: string
// 类型
type: string
}
// 站点资源分类

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -1,11 +1,14 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
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 { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -13,7 +16,7 @@ const props = defineProps({
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Object as PropType<Axios>,
type: Function,
required: true,
},
axiosconfig: Object,
@@ -182,14 +185,14 @@ function fileListUpdated(items: FileItem[]) {
// 外层DIV大小控制
const scrollStyle = computed(() => {
return appMode
? 'height: calc(100vh - 10rem - env(safe-area-inset-bottom) - 6rem)'
: 'height: calc(100vh - 10rem - env(safe-area-inset-bottom)'
? 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom) - 6.5rem)'
: 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom)'
})
// 文件列表大小限制
const fileListStyle = computed(() => {
return appMode
? 'height: calc(100vh - 14rem - env(safe-area-inset-bottom) - 6rem)'
? 'height: calc(100vh - 14rem - env(safe-area-inset-bottom) - 7rem)'
: 'height: calc(100vh - 14rem - env(safe-area-inset-bottom)'
})
</script>
@@ -220,7 +223,6 @@ const fileListStyle = computed(() => {
@navigate="pathChanged"
/>
<FileList
class="flex-grow"
:item="item"
:storage="activeStorage"
:icons="fileIcons"

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>,
@@ -37,7 +36,7 @@ const getImgUrl = computed(() => {
:width="props.width"
class="ring-gray-500"
:class="{
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': imageLoaded,
}"
@click="goPlay"

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
}
// 保存数据
@@ -95,7 +97,7 @@ function onClose() {
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.rule.name }}</h5>
@@ -104,9 +106,9 @@ 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">
<DialogCloseBtn v-model="ruleInfoDialog" />
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem">
<VCard :title="t('customRule.title', { id: props.rule.id })" class="rounded-t">
<VDialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
<VForm>
@@ -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

@@ -3,6 +3,10 @@ import type { TransferDirectoryConf } from '@/api/types'
import api from '@/api'
import { nextTick } from 'vue'
import { storageOptions } from '@/api/constants'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -23,11 +27,11 @@ 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('media.movie'), value: '电影' },
{ title: t('media.tv'), value: '电视剧' },
])
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
const resourceStorageOptions = computed(() => {
@@ -35,18 +39,18 @@ const resourceStorageOptions = computed(() => {
})
// 自动整理方式下拉字典
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 +107,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 +135,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 ?? ''])
})
@@ -175,12 +179,12 @@ watch(
<template>
<VCard variant="tonal" :width="props.width" :height="props.height">
<DialogCloseBtn @click="onClose" />
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<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 +201,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 +210,7 @@ watch(
v-model="props.directory.media_category"
variant="underlined"
:items="getCategories"
label="媒体类别"
:label="t('directory.mediaCategory')"
/>
</VCol>
<VCol cols="4">
@@ -214,7 +218,7 @@ watch(
v-model="props.directory.storage"
variant="underlined"
:items="resourceStorageOptions"
label="资源存储"
:label="t('directory.resourceStorage')"
/>
</VCol>
<VCol cols="8">
@@ -222,14 +226,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 +246,7 @@ watch(
v-model="props.directory.monitor_type"
variant="underlined"
:items="transferSourceItems"
label="自动整理"
:label="t('directory.autoTransfer')"
/>
</VCol>
</VRow>
@@ -249,7 +256,7 @@ watch(
v-model="props.directory.monitor_mode"
variant="underlined"
:items="MonitorModeItems"
label="监控模式"
:label="t('directory.monitorMode')"
/>
</VCol>
<VCol cols="4">
@@ -257,7 +264,7 @@ watch(
v-model="props.directory.library_storage"
variant="underlined"
:items="storageOptions"
label="媒体库存储"
:label="t('directory.libraryStorage')"
/>
</VCol>
<VCol cols="8">
@@ -265,7 +272,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 +280,7 @@ watch(
v-model="props.directory.transfer_type"
variant="underlined"
:items="transferTypeItems"
label="整理方式"
:label="t('directory.transferType')"
:no-data-text="computedNoDataText"
/>
</VCol>
@@ -282,23 +289,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

@@ -7,6 +7,10 @@ import type { DownloaderInfo } from '@/api/types'
import qbittorrent_image from '@images/logos/qbittorrent.png'
import transmission_image from '@images/logos/transmission.png'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
// 获取i18n实例
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -91,12 +95,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 +108,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'))
}
})
}
@@ -150,7 +154,7 @@ onUnmounted(() => {
@click="openDownloaderInfoDialog"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<DialogCloseBtn @click="onClose" />
<VDialogCloseBtn @click="onClose" />
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -180,26 +184,30 @@ onUnmounted(() => {
</VCard>
</VHover>
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="downloaderInfoDialog" />
<VCard :title="`${props.downloader.name} - ${t('downloader.title')}`" class="rounded-t">
<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 +215,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 +225,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 +235,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 +244,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 +253,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 +262,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,8 +271,8 @@ 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
/>
@@ -274,9 +282,9 @@ onUnmounted(() => {
<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 +292,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 +302,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 +312,8 @@ onUnmounted(() => {
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
/>
@@ -315,7 +323,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({
@@ -47,9 +51,9 @@ onMounted(() => {
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<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
}
@@ -208,30 +207,30 @@ function onClose() {
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
<div class="text-body-1 mb-3">
<span v-if="!props.group.category">{{ props.group.media_type || '通用' }}</span>
<span v-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">
<DialogCloseBtn v-model="groupInfoDialog" />
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem">
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`" class="rounded-t">
<VDialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
<VRow class="mt-1">
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
label="规则组名称"
placeholder="必填;不可与其他规则组重名"
hint="自定义规则组名称"
: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

@@ -158,7 +158,7 @@ onMounted(async () => {
:height="props.height"
:width="props.width"
:class="{
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
@click="goPlay"
>

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>
@@ -429,9 +444,9 @@ function onRemoveSubscribe() {
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500 media-card"
class="outline-none ring-gray-500 media-card"
:class="{
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering ?? false)"
@@ -450,7 +465,7 @@ function onRemoveSubscribe() {
</div>
</template>
</VImg>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError || searchMenuShow"
@@ -476,9 +491,9 @@ function onRemoveSubscribe() {
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
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" />
@@ -488,7 +503,7 @@ function onRemoveSubscribe() {
variant="elevated"
size="small"
:class="getChipColor('rating')"
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
class="absolute right-2 top-2 bg-opacity-80 text-white font-bold"
>
{{ formatRating(props.media?.vote_average) }}
</VChip>
@@ -533,15 +548,3 @@ function onRemoveSubscribe() {
@close="chooseSiteDialog = false"
/>
</template>
<style lang="scss" scoped>
.media-card {
position: relative;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.03);
z-index: 2;
}
}
</style>

View File

@@ -7,6 +7,10 @@ import plex_image from '@images/logos/plex.png'
import trimemedia_image from '@images/logos/trimemedia.png'
import api from '@/api'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
// 获取i18n实例
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -32,17 +36,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 +54,7 @@ const infoItems = ref([
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: '全部',
title: t('common.all'),
value: 'all',
},
])
@@ -81,12 +85,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
}
// 执行保存
@@ -127,17 +131,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 +164,7 @@ async function loadLibrary(server: string) {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: '全部',
title: t('common.all'),
value: 'all',
})
} catch (e) {
@@ -175,7 +179,7 @@ onMounted(() => {
<template>
<div>
<VCard variant="tonal" @click="openMediaServerInfoDialog">
<DialogCloseBtn @click="onClose" />
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
@@ -188,24 +192,24 @@ onMounted(() => {
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="mediaServerInfoDialog" />
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.mediaserver.name} - ${t('common.config')}`" class="rounded-t">
<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 +217,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 +227,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,8 +237,8 @@ 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
/>
@@ -244,9 +248,9 @@ onMounted(() => {
<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
/>
@@ -254,9 +258,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
/>
@@ -264,9 +268,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
/>
@@ -274,8 +278,8 @@ onMounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Jellyfin设置->高级->API密钥中生成的密钥"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.jellyfinApiKeyHint')"
persistent-hint
active
/>
@@ -285,9 +289,9 @@ onMounted(() => {
<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
/>
@@ -295,9 +299,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
/>
@@ -305,25 +309,21 @@ onMounted(() => {
<VCol cols="12">
<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
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
label="用户名"
active
/>
<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="密码"
:label="t('mediaserver.password')"
active
/>
</VCol>
@@ -332,9 +332,9 @@ onMounted(() => {
<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
/>
@@ -342,9 +342,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
/>
@@ -352,9 +352,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
/>
@@ -362,8 +362,8 @@ onMounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.token"
label="X-Plex-Token"
hint="浏览器F12->网络从Plex请求URL中获取的X-Plex-Token"
:label="t('mediaserver.plexToken')"
:hint="t('mediaserver.plexTokenHint')"
persistent-hint
active
/>
@@ -373,12 +373,12 @@ onMounted(() => {
<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"
@@ -390,7 +390,7 @@ onMounted(() => {
</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

@@ -53,7 +53,6 @@ function replaceNewLine(value: string) {
aspect-ratio="3/2"
cover
position="top"
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>

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,31 +45,30 @@ 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') },
]
// 打开详情弹窗
function openNotificationInfoDialog() {
// 替换成深复制,避免修改时影响原数据
notificationInfo.value = cloneDeep(props.notification)
console.log(`当前卡片的通知信息:${JSON.stringify(notificationInfo.value)}`)
notificationInfoDialog.value = true
}
@@ -74,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
@@ -120,7 +122,7 @@ function onClose() {
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<div class="flex items-center">
@@ -132,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">
<DialogCloseBtn v-model="notificationInfoDialog" />
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.notification.name} - ${t('notification.config')}`" class="rounded-t">
<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
@@ -159,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>
@@ -227,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>
@@ -272,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>
@@ -310,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>
@@ -337,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>
@@ -373,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>
@@ -392,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

@@ -83,7 +83,7 @@ function goPersonDetail() {
@click.stop="goPersonDetail"
>
<div
class="person-card relative transform-gpu cursor-pointer rounded shadow transition duration-150 ease-in-out scale-100 ring-gray-700"
class="person-card relative transform-gpu cursor-pointer rounded transition duration-150 ease-in-out scale-100 ring-gray-700"
>
<div style="padding-block-end: 150%">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
@@ -117,10 +117,18 @@ function goPersonDetail() {
<style lang="scss" scoped>
.person-card {
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
background-image: linear-gradient(
45deg,
rgb工(var(--v-theme-background), 0.3),
rgba(var(--v-theme-surface), 0.3) 60%
);
}
.person-card:hover {
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-custom-background)) 60%);
background-image: linear-gradient(
45deg,
rgba(var(--v-theme-background), 0.3),
rgba(var(--v-custom-background), 0.3) 60%
);
}
</style>

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: {
@@ -156,7 +163,7 @@ const dropdownItems = ref([
@click="detailDialog = true"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
>
<div
@@ -185,7 +192,6 @@ const dropdownItems = ref([
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
@@ -208,13 +214,7 @@ const dropdownItems = ref([
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
@click="item.props.click"
>
<VListItem v-for="(item, i) in dropdownItems" v-show="item.show" :key="i" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
@@ -232,8 +232,8 @@ 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} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
</VCard>
@@ -241,7 +241,7 @@ const dropdownItems = ref([
<!-- 插件详情-->
<VDialog v-if="detailDialog" v-model="detailDialog" max-width="30rem">
<VCard>
<DialogCloseBtn @click="detailDialog = false" />
<VDialogCloseBtn @click="detailDialog = false" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
@@ -252,7 +252,6 @@ const dropdownItems = ref([
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
@@ -271,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>
@@ -285,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: {
@@ -336,7 +340,7 @@ watch(
@click="openPluginDetail"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
>
<div
@@ -357,14 +361,13 @@ watch(
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<div class="relative flex-shrink-0 self-center cursor-move">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
@@ -393,7 +396,6 @@ watch(
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
@@ -407,10 +409,7 @@ watch(
</IconBtn>
</div>
</VCardText>
<div v-if="hover.isHovering" class="me-n3 absolute top-0 right-5">
<VIcon class="cursor-move text-white">mdi-drag</VIcon>
</div>
<div v-else-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
</VCard>
@@ -440,9 +439,9 @@ watch(
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<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" />
<VDivider />
@@ -451,7 +450,7 @@ watch(
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
更新到最新版本
{{ t('plugin.updateToLatest') }}
</VBtn>
</VCardItem>
</VCard>

View File

@@ -43,9 +43,9 @@ function goPlay(isHovering: boolean | null = false) {
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500"
class="outline-none ring-gray-500"
:class="{
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': isImageLoaded,
}"
>
@@ -69,7 +69,7 @@ function goPlay(isHovering: boolean | null = false) {
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
class="absolute left-2 top-2 bg-opacity-80 text-white font-bold"
>
{{ props.media?.type }}
</VChip>

View File

@@ -1,6 +1,8 @@
<script lang="ts" setup>
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'
@@ -11,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>,
@@ -30,7 +35,7 @@ const siteIcon = ref<string>('')
const $toast = useToast()
// 测试按钮文字
const testButtonText = ref('测试连通性')
const testButtonText = ref(t('site.testConnectivity'))
// 测试按钮可用性
const testButtonDisable = ref(false)
@@ -54,6 +59,9 @@ const siteStats = ref<SiteStatistic>({})
async function getSiteIcon() {
try {
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
if (!siteIcon.value) {
siteIcon.value = noImage
}
} catch (error) {
console.error(error)
}
@@ -62,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()
@@ -110,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
@@ -119,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)
}
}
@@ -192,115 +200,92 @@ onMounted(() => {
<template>
<div>
<VCard
class="site-card relative h-full flex flex-col overflow-hidden group"
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300 cursor-pointer hover:-translate-y-1"
:class="[
cardProps.site?.is_active ? '' : 'inactive',
cardProps.site?.is_active ? '' : 'opacity-70',
{
'status-error': statColor === 'error',
'status-warning': statColor === 'warning',
'status-success': statColor === 'success',
'border-error': statColor === 'error',
'border-warning': statColor === 'warning',
'border-success': statColor === 'success',
},
]"
:ripple="false"
@click="handleResourceBrowse"
variant="flat"
elevation="0"
rounded="lg"
hover
@click="siteEditDialog = true"
>
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
<!-- 主体部分 -->
<div class="site-card-content relative flex-1 flex flex-col">
<div class="relative flex-1 flex flex-col p-3 z-1">
<!-- 顶部图标和站点名称 -->
<div class="flex items-center mb-1">
<!-- 站点图标 -->
<div class="site-icon-container mr-2.5" @click.stop="siteEditDialog = true">
<img :src="siteIcon" class="site-icon" :alt="cardProps.site?.name" />
<div class="site-icon-edit-overlay">
<VIcon icon="mdi-pencil" color="white" size="16" />
</div>
</div>
<!-- 拖动图标 -->
<VIcon icon="mdi-drag" size="20" class="drag-handle cursor-move opacity-40 mr-1.5 z-10" />
<VAvatar tile rounded="lg" size="32" class="me-2 cursor-move">
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-square" />
</div>
</template>
</VImg>
</VAvatar>
<!-- 站点名称和特性图标 -->
<div class="flex-1 min-w-0 flex items-center">
<h3 class="site-title truncate">{{ cardProps.site?.name }}</h3>
<h3 class="text-lg font-semibold leading-tight truncate">{{ cardProps.site?.name }}</h3>
<!-- 站点特性图标 -->
<div class="site-features flex items-center gap-1 ml-auto">
<VTooltip>
<template #activator="{ props }">
<div v-if="cardProps.site?.limit_interval" v-bind="props" class="feature-icon-wrapper">
<VIcon icon="mdi-speedometer" size="16" class="site-feature-icon" />
</div>
</template>
<span>流控</span>
</VTooltip>
<VTooltip>
<template #activator="{ props }">
<div v-if="cardProps.site?.proxy === 1" v-bind="props" class="feature-icon-wrapper">
<VIcon icon="mdi-network-outline" size="16" class="site-feature-icon" />
</div>
</template>
<span>代理</span>
</VTooltip>
<VTooltip>
<template #activator="{ props }">
<div v-if="cardProps.site?.render === 1" v-bind="props" class="feature-icon-wrapper">
<VIcon icon="mdi-apple-safari" size="16" class="site-feature-icon" />
</div>
</template>
<span>仿真</span>
</VTooltip>
<VTooltip>
<template #activator="{ props }">
<div v-if="cardProps.site?.filter" v-bind="props" class="feature-icon-wrapper">
<VIcon icon="mdi-filter-cog-outline" size="16" class="site-feature-icon" />
</div>
</template>
<span>过滤</span>
</VTooltip>
<div class="flex items-center gap-2 ml-auto mr-10">
<div v-if="cardProps.site?.limit_interval" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-speedometer" size="16" color="primary" class="opacity-85 hover:opacity-100" />
</div>
<div v-if="cardProps.site?.proxy" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-network-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
</div>
<div v-if="cardProps.site?.render" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-apple-safari" size="16" color="primary" class="opacity-85 hover:opacity-100" />
</div>
<div v-if="cardProps.site?.filter" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-filter-cog-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
</div>
</div>
</div>
</div>
<!-- 中间部分网址 -->
<div class="site-meta mb-1.5">
<div class="site-url truncate" @click.stop="openSitePage">
<div class="my-3">
<div class="text-sm text-medium-emphasis truncate" @click.stop="openSitePage">
{{ cardProps.site?.url }}
</div>
</div>
<!-- 底部数据统计 -->
<div class="site-stats flex-1 flex flex-col justify-end">
<div class="flex-1 flex flex-col justify-end">
<!-- 更直观的上传下载数据条 -->
<div class="data-transfer-stats">
<div class="border-t mt-1.5 pt-1.5">
<!-- 上传数据 -->
<div class="data-row upload-row">
<div class="data-label">
<div class="flex items-center justify-between gap-3 mb-1.5">
<div class="text-sm text-medium-emphasis min-w-[70px]">
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
</div>
<div class="data-progress-bar">
<div class="progress-filled upload-filled" :style="`width: ${getUploadPercent}%`">
<div class="progress-glow"></div>
</div>
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
<VProgressLinear :model-value="getUploadPercent" color="info" height="4" rounded="lg" />
</div>
</div>
<!-- 下载数据 -->
<div class="data-row download-row">
<div class="data-label">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center text-[0.8rem] text-medium-emphasis min-w-[70px]">
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
</div>
<div class="data-progress-bar">
<div class="progress-filled download-filled" :style="`width: ${getDownloadPercent}%`">
<div class="progress-glow"></div>
</div>
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
<VProgressLinear :model-value="getDownloadPercent" color="warning" height="4" rounded="lg" />
</div>
</div>
</div>
@@ -308,71 +293,65 @@ onMounted(() => {
</div>
<!-- 右侧操作按钮区 -->
<div class="site-card-actions">
<VTooltip>
<template #activator="{ props }">
<IconBtn
v-bind="props"
elevation="0"
class="site-action-btn test-btn"
@click.stop="testSite"
:class="{ 'testing': testButtonDisable }"
>
<div class="test-btn-content">
<div class="pulse-dot" :class="statColor"></div>
</div>
<div v-if="testButtonDisable" class="loading-overlay">
<div class="loading-spinner">
<div class="spinner-circle"></div>
<div class="spinner-circle-dot"></div>
</div>
<span class="loading-text">测试中</span>
</div>
</IconBtn>
</template>
<span>测试站点连通性</span>
</VTooltip>
<VTooltip v-if="!cardProps.site?.public">
<template #activator="{ props }">
<IconBtn v-bind="props" elevation="0" class="site-action-btn" @click.stop="handleSiteUserData">
<VIcon icon="mdi-chart-bell-curve" size="18" />
</IconBtn>
</template>
<span>查看站点数据</span>
</VTooltip>
<VTooltip v-if="!cardProps.site?.public">
<template #activator="{ props }">
<IconBtn v-bind="props" elevation="0" class="site-action-btn" @click.stop="handleSiteUpdate">
<VIcon icon="mdi-refresh" size="18" />
</IconBtn>
</template>
<span>更新Cookie/UA</span>
</VTooltip>
<VTooltip>
<template #activator="{ props }">
<IconBtn v-bind="props" elevation="0" class="site-action-btn more-btn">
<VIcon icon="mdi-dots-vertical" size="18" />
<VMenu activator="parent" close-on-content-click location="left">
<VList density="compact" nav class="dropdown-menu">
<VListItem variant="plain" @click="siteEditDialog = true" base-color="info">
<template #prepend>
<VIcon icon="mdi-file-edit-outline" size="small" />
</template>
<VListItemTitle>编辑站点</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="deleteSiteInfo">
<template #prepend>
<VIcon icon="mdi-delete-outline" size="small" color="error" />
</template>
<VListItemTitle class="text-error">删除站点</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<span>更多操作</span>
</VTooltip>
</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"
>
<!-- 测试按钮 -->
<VBtn
icon
variant="text"
density="comfortable"
class="mb-1 relative w-10 h-10 min-w-10 flex items-center justify-center rounded-full"
:disabled="testButtonDisable"
@click.stop="testSite"
>
<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="statColor"
></div>
</div>
<div
v-if="testButtonDisable"
class="absolute inset-0 flex flex-col items-center justify-center bg-surface/95 rounded-full shadow-md animate-fade-in"
>
<div class="relative w-6 h-6">
<div class="spinner-circle"></div>
</div>
</div>
</VBtn>
<!-- 用户数据按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUserData">
<VIcon icon="mdi-chart-bell-curve" size="small" />
</VBtn>
<!-- 更新按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUpdate">
<VIcon icon="mdi-refresh" size="small" />
</VBtn>
<!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto">
<VIcon icon="mdi-dots-vertical" size="small" />
<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" />
</template>
<VListItemTitle>{{ t('site.browseResources') }}</VListItemTitle>
</VListItem>
<VListItem @click="deleteSiteInfo">
<template #prepend>
<VIcon icon="mdi-delete-outline" size="small" color="error" />
</template>
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</VSheet>
</VCard>
<!-- 对话框组件 -->
@@ -407,33 +386,12 @@ onMounted(() => {
</template>
<style scoped>
.site-card {
position: relative;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.09);
border-radius: 10px;
background: rgba(var(--v-theme-surface), 0.95);
cursor: pointer;
transition: all 0.3s ease;
}
.site-card:hover {
border-color: rgba(var(--v-theme-primary), 0.2);
box-shadow: 0 3px 12px -6px rgba(0, 0, 0, 10%);
transform: translateY(-4px);
.site-card-actions {
transform: translateX(0);
}
}
.inactive {
opacity: 0.7;
}
.site-card-content {
z-index: 1;
padding-block: 10px;
padding-inline: 12px;
}
/* 站点状态指示器 - 更精致的渐变指示 */
.site-status-indicator {
position: absolute;
z-index: 1;
@@ -470,404 +428,40 @@ onMounted(() => {
opacity: 0.8;
}
/* 拖动手柄 */
.drag-handle {
position: relative;
z-index: 10;
}
/* 数据显示相关样式 */
.data-transfer-stats {
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.05);
margin-block-start: 6px;
padding-block-start: 6px;
}
.data-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-block-end: 6px;
}
.data-row:last-child {
margin-block-end: 0;
}
.data-label {
display: flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.8);
font-size: 0.8rem;
min-inline-size: 70px;
}
.data-progress-bar {
position: relative;
overflow: hidden;
flex-grow: 1;
border-radius: 4px;
background: rgba(var(--v-theme-on-surface), 0.08);
block-size: 4px;
}
.progress-filled {
position: absolute;
overflow: hidden;
border-radius: 4px;
block-size: 100%;
inset-block-start: 0;
inset-inline-start: 0;
min-inline-size: 3px;
transition: inline-size 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.upload-filled {
animation: pulse-width 2s infinite;
/* 上传下载条样式 */
.upload-bar {
background: linear-gradient(90deg, #4d79ff, #07f);
box-shadow: 0 0 4px rgba(0, 119, 255, 50%);
animation: pulse-width 2s infinite;
}
.download-filled {
animation: pulse-width 2s infinite;
.download-bar {
background: linear-gradient(90deg, #42d392, #00b77e);
box-shadow: 0 0 4px rgba(0, 183, 126, 50%);
animation: pulse-width 2s infinite;
}
.progress-glow {
position: absolute;
animation: shimmer 1.5s linear infinite;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 50%), transparent);
background-size: 200% 100%;
inset: 0;
}
@keyframes pulse-width {
0%,
100% {
opacity: 0.85;
}
50% {
opacity: 1;
}
}
@keyframes shimmer {
0% {
background-position: -100% 0;
}
100% {
background-position: 100% 0;
}
}
/* 速度等级样式 */
.speed-idle {
animation: none !important;
inline-size: 5% !important;
opacity: 0.5;
}
.speed-low {
animation-duration: 6s !important;
inline-size: 30% !important;
}
.speed-medium {
animation-duration: 4s !important;
inline-size: 50% !important;
}
.speed-high {
animation-duration: 2s !important;
inline-size: 70% !important;
}
@keyframes pulse-width {
0%,
100% {
transform: scaleX(0.95);
}
50% {
transform: scaleX(1.05);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* 站点图标 */
.site-icon-container {
position: relative;
overflow: hidden;
border-radius: 8px;
block-size: 38px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 6%);
cursor: pointer;
inline-size: 38px;
transition: transform 0.2s ease;
}
.site-icon-container:hover {
transform: scale(1.05);
}
.site-icon {
block-size: 100%;
inline-size: 100%;
object-fit: cover;
}
.site-icon-edit-overlay {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 50%);
inset: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.site-icon-container:hover .site-icon-edit-overlay {
opacity: 1;
}
/* 站点标题 */
.site-title {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.2;
}
/* 站点网址 */
.site-url {
color: rgba(var(--v-theme-on-surface), 0.6);
cursor: pointer;
font-size: 0.9rem;
transition: color 0.2s ease;
}
.site-url:hover {
color: rgba(var(--v-theme-primary), 0.9);
}
/* 站点特性图标 */
.site-feature-icon {
color: rgba(var(--v-theme-primary), 0.95);
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 5%));
margin-block: 0;
margin-inline: 1px;
opacity: 0.85;
transition: all 0.2s ease;
}
.site-feature-icon:hover {
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 10%));
opacity: 1;
transform: translateY(-1px);
}
/* 特性标签 */
.site-features {
margin-block-start: 0;
}
/* 数据统计 */
.site-stats {
margin-block-start: auto;
padding-block-start: 1rem;
}
.site-data-values {
color: rgba(var(--v-theme-on-surface), 0.8);
font-size: 12px;
}
.site-data-bar {
overflow: hidden;
border-radius: 1.5px;
block-size: 3px;
}
.site-data-bar-bg {
position: absolute;
background-color: rgba(var(--v-theme-on-surface), 0.05);
inset: 0;
}
.site-data-bar-upload {
background-color: rgba(var(--v-theme-info), 0.4);
}
.site-data-bar-download {
background-color: rgba(var(--v-theme-success), 0.4);
}
/* 状态样式 */
.status-error {
border-color: rgba(var(--v-theme-error), 0.2);
}
.status-warning {
border-color: rgba(var(--v-theme-warning), 0.2);
}
.status-success {
border-color: rgba(var(--v-theme-success), 0.2);
}
/* 操作按钮 */
.site-card-actions {
position: absolute;
z-index: 20;
display: flex;
flex-direction: column;
background: rgba(var(--v-theme-surface), 0.97);
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.06);
inset-block: 0;
inset-inline-end: 0;
padding-block: 8px;
padding-inline: 4px;
transform: translateX(100%);
transition: transform 0.2s ease;
}
/* 测试按钮特殊样式 */
.test-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 50% !important;
block-size: 40px !important;
inline-size: 40px !important;
margin-block-end: 12px;
min-inline-size: 40px;
}
.test-btn-content {
display: flex;
align-items: center;
justify-content: center;
block-size: 100%;
inline-size: 100%;
}
.loading-overlay {
position: absolute;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 50%;
animation: fade-in 0.2s ease;
background: rgba(var(--v-theme-surface), 0.95);
box-shadow: 0 2px 10px rgba(0, 0, 0, 10%);
inset: 0;
}
.loading-spinner {
position: relative;
block-size: 24px;
inline-size: 24px;
}
.spinner-circle {
position: absolute;
border: 2px solid rgba(var(--v-theme-primary), 0.2);
border-radius: 50%;
animation: spin 0.8s linear infinite;
block-size: 100%;
border-block-start-color: rgba(var(--v-theme-primary), 1);
inline-size: 100%;
}
.spinner-circle-dot {
position: absolute;
border-radius: 50%;
animation: spin 0.8s linear infinite reverse;
background-color: rgba(var(--v-theme-primary), 1);
block-size: 4px;
inline-size: 4px;
inset-block-start: 0;
inset-inline-start: 50%;
margin-block-start: -2px;
margin-inline-start: -2px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-text {
position: absolute;
color: rgba(var(--v-theme-primary), 1);
font-size: 12px;
font-weight: 500;
inset-block-end: -20px;
margin-block-start: 4px;
white-space: nowrap;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.pulse-dot {
position: relative;
border-radius: 50%;
background-color: transparent;
block-size: 22px;
box-shadow: inset 0 0 0 2px rgba(var(--v-theme-on-surface), 0.1);
inline-size: 22px;
}
/* 测试状态点样式 */
.pulse-dot::before {
position: absolute;
z-index: 1;
border-radius: 50%;
block-size: 70%;
content: '';
inline-size: 70%;
inset-block-start: 15%;
inset-inline-start: 15%;
height: 70%;
width: 70%;
top: 15%;
left: 15%;
}
.pulse-dot::after {
position: absolute;
z-index: 2;
border-radius: 50%;
block-size: 100%;
content: '';
inline-size: 100%;
inset-block-start: 0;
inset-inline-start: 0;
height: 100%;
width: 100%;
top: 0;
left: 0;
}
.pulse-dot.error::before {
@@ -910,15 +504,37 @@ onMounted(() => {
box-shadow: 0 0 0 2px rgba(var(--v-theme-secondary), 0.3);
}
/* 加载动画 */
.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;
}
/* 动画关键帧 */
@keyframes pulse-width {
0%,
100% {
opacity: 0.85;
transform: scaleX(0.95);
}
50% {
opacity: 1;
transform: scaleX(1.05);
}
}
@keyframes pulse-animation-error {
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);
}
@@ -928,11 +544,9 @@ 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);
}
@@ -942,11 +556,9 @@ 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);
}
@@ -956,96 +568,29 @@ 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);
}
}
.site-card:hover .site-card-actions {
transform: translateX(0);
}
.site-action-btn {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
border: none;
border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 1);
block-size: 32px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 5%);
color: rgba(var(--v-theme-on-surface), 0.8);
cursor: pointer;
inline-size: 36px;
margin-block-end: 4px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.site-action-btn::before {
position: absolute;
background: radial-gradient(circle at center, rgba(var(--v-theme-primary), 0.1), transparent 70%);
content: '';
inset: 0;
opacity: 0;
transition: opacity 0.3s ease;
}
.site-action-btn:hover {
background-color: white;
box-shadow: 0 3px 6px rgba(0, 0, 0, 10%);
color: rgba(var(--v-theme-primary), 1);
transform: translateY(-2px);
}
.site-action-btn:hover::before {
opacity: 1;
}
.site-action-btn.animate-pulse {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
@keyframes spin {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-primary), 0.4);
transform: rotate(0deg);
}
70% {
box-shadow: 0 0 0 6px rgba(var(--v-theme-primary), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-primary), 0);
transform: rotate(360deg);
}
}
.site-action-btn.more-btn {
margin-block: auto 0;
}
.dropdown-menu {
overflow: hidden;
border-radius: 8px;
}
.feature-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
block-size: 24px;
inline-size: 24px;
transition: background-color 0.2s ease;
}
.feature-icon-wrapper:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -13,6 +13,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 { storageOptions } from '@/api/constants'
// 国际化
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -64,7 +69,7 @@ function openStorageDialog() {
aListConfigDialog.value = true
break
default:
$toast.info('此存储类型无需配置参数,请直接配置目录!')
$toast.info(t('storage.noConfigNeeded'))
break
}
}
@@ -123,50 +128,57 @@ function handleDone() {
emit('done')
}
// 根据存储类型获取文本
function getStorageTypeText(type: string) {
return storageOptions.find((option) => option.value === type)?.title
}
onMounted(() => {
queryStorage()
})
</script>
<template>
<VCard variant="tonal" @click="openStorageDialog">
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">未配置</div>
<div>
<VCard variant="tonal" @click="openStorageDialog">
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ getStorageTypeText(storage.type) }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</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" />
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
</div>
<VImg :src="getIcon" cover class="mt-5" max-width="3rem" min-width="3rem" />
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
</div>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
:conf="props.storage.config || {}"
@close="aliyunAuthDialog = false"
@done="handleDone"
/>
<U115AuthDialog
v-if="u115AuthDialog"
v-model="u115AuthDialog"
:conf="props.storage.config || {}"
@close="u115AuthDialog = false"
@done="handleDone"
/>
<RcloneConfigDialog
v-if="rcloneConfigDialog"
v-model="rcloneConfigDialog"
:conf="props.storage.config || {}"
@close="rcloneConfigDialog = false"
@done="handleDone"
/>
<AlistConfigDialog
v-if="aListConfigDialog"
v-model="aListConfigDialog"
:conf="props.storage.config || {}"
@close="aListConfigDialog = false"
@done="handleDone"
/>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
:conf="props.storage.config || {}"
@close="aliyunAuthDialog = false"
@done="handleDone"
/>
<U115AuthDialog
v-if="u115AuthDialog"
v-model="u115AuthDialog"
:conf="props.storage.config || {}"
@close="u115AuthDialog = false"
@done="handleDone"
/>
<RcloneConfigDialog
v-if="rcloneConfigDialog"
v-model="rcloneConfigDialog"
:conf="props.storage.config || {}"
@close="rcloneConfigDialog = false"
@done="handleDone"
/>
<AlistConfigDialog
v-if="aListConfigDialog"
v-model="aListConfigDialog"
:conf="props.storage.config || {}"
@close="aListConfigDialog = false"
@done="handleDone"
/>
</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',
@@ -298,7 +302,7 @@ function onSubscribeEditRemove() {
class="flex flex-col h-full"
:class="{
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'opacity-70': subscribeState === 'S',
}"
min-height="170"
@@ -311,12 +315,7 @@ function onSubscribeEditRemove() {
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem
v-if="item.show !== false"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
@@ -339,8 +338,8 @@ function onSubscribeEditRemove() {
<div v-if="subscribeState === 'P'" class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none" />
</template>
<div>
<VCardText class="flex items-center">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VCardText class="flex items-center py-3">
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md cursor-move" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
<template #placeholder>
<div class="w-full h-full">
@@ -357,7 +356,7 @@ function onSubscribeEditRemove() {
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<VCardText class="flex justify-space-between align-center flex-wrap py-3">
<div class="flex align-center">
<IconBtn
v-if="props.media?.total_episode"
@@ -388,9 +387,6 @@ function onSubscribeEditRemove() {
color="success"
/>
</div>
<div v-if="hover.isHovering" class="me-n3 absolute top-1 right-10">
<IconBtn><VIcon class="cursor-move text-white">mdi-drag</VIcon></IconBtn>
</div>
</div>
</VCard>
</template>

View File

@@ -102,7 +102,7 @@ function doDelete() {
:key="props.media?.id"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
min-height="170"
@click="showForkSubscribe"
@@ -119,7 +119,7 @@ function doDelete() {
</template>
<div class="h-full flex flex-col">
<VCardText class="flex items-center pb-1 grow">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">

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')
}
@@ -81,10 +85,19 @@ async function downloadTorrentFile() {
// 获取优惠类型样式
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return 'free-discount'
if (downloadVolumeFactor === 0) return 'free-discount'
else if (downloadVolumeFactor < 1) return 'percent-discount'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
if (!downloadVolumeFactor) return 'bg-success'
if (downloadVolumeFactor === 0) return 'bg-success'
else if (downloadVolumeFactor < 1) return 'bg-orange'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'bg-purple'
else return ''
}
// 获取优惠标签类
function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return 'chip-free'
if (downloadVolumeFactor === 0) return 'chip-free'
else if (downloadVolumeFactor < 1) return 'chip-discount'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'
else return ''
}
@@ -108,112 +121,155 @@ onMounted(() => {
:width="props.width || '100%'"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
@click="handleAddDownload(props.torrent)"
class="torrent-card h-full"
:class="{ 'downloaded-card': downloaded.includes(torrent?.enclosure || '') }"
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
:class="{ 'border-success border-2 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
hover
>
<!-- 优惠标签 -->
<div
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
class="discount-banner"
class="discount-banner text-white px-2 py-1 text-sm font-weight-bold rounded-bl-lg"
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
>
{{ torrent?.volume_factor }}
</div>
<!-- 媒体标题 -->
<div class="card-header">
<div class="media-title-wrapper flex flex-row flex-wrap justify-start">
<span class="media-title me-2">
<VCardItem class="pt-3 pb-0">
<div class="d-flex flex-row flex-wrap justify-start mb-2 pr-8">
<span class="text-h6 font-weight-bold text-truncate me-2">
{{ media?.title ?? meta?.name }}
</span>
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
<VChip
v-if="meta?.season_episode"
class="chip-season rounded-sm font-weight-bold"
variant="elevated"
size="small"
>
{{ meta?.season_episode }}
</VChip>
</div>
<!-- 站点信息条 -->
<div class="site-info">
<div class="d-flex justify-space-between align-center flex-wrap">
<div class="d-flex align-center">
<img
:alt="torrent?.site_name"
<VImg
v-if="siteIcons[torrent?.site || 0]"
:src="siteIcons[torrent?.site || 0]"
class="site-icon"
:alt="torrent?.site_name"
class="mr-2 rounded"
width="20"
height="20"
/>
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
<span class="site-name">{{ torrent?.site_name }}</span>
<VAvatar v-else size="20" class="mr-2 text-caption bg-surface-variant" color="surface-variant">
{{ torrent?.site_name?.substring(0, 1) }}
</VAvatar>
<span class="font-weight-bold text-body-2">{{ torrent?.site_name }}</span>
</div>
<div class="seeder-peers">
<span v-if="torrent?.seeders" class="seed-info">
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
<div class="d-flex align-center gap-3">
<span v-if="torrent?.seeders" class="d-flex align-center font-weight-bold">
<VIcon size="small" color="success" icon="mdi-arrow-up" class="mr-1"></VIcon>
{{ torrent?.seeders }}
</span>
<span v-if="torrent?.peers" class="peer-info">
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
<span v-if="torrent?.peers" class="d-flex align-center font-weight-bold">
<VIcon size="small" color="warning" icon="mdi-arrow-down" class="mr-1"></VIcon>
{{ torrent?.peers }}
</span>
</div>
</div>
</div>
</VCardItem>
<!-- 种子内容 -->
<div class="card-content">
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
<!-- 种子标题 -->
<div class="torrent-title" :title="torrent?.title">
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1" :title="torrent?.title">
{{ torrent?.title }}
</div>
<!-- 种子描述 -->
<div
v-if="meta?.subtitle || torrent?.description"
class="torrent-desc grow"
class="text-body-2 text-medium-emphasis mb-2"
:title="meta?.subtitle || torrent?.description"
>
{{ meta?.subtitle || torrent?.description }}
</div>
<!-- 资源标签区 -->
<div class="tags-container">
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
<div class="d-flex flex-wrap gap-1 mb-2">
<!-- 版本标签 -->
<VChip v-if="meta?.edition" class="chip-edition rounded-sm" size="x-small" variant="elevated">
{{ meta?.edition }}
</VChip>
<!-- 分辨率标签 -->
<VChip v-if="meta?.resource_pix" class="chip-resolution rounded-sm" size="x-small" variant="elevated">
{{ meta?.resource_pix }}
</VChip>
<!-- 编码标签 -->
<VChip v-if="meta?.video_encode" class="chip-codec rounded-sm" size="x-small" variant="elevated">
{{ meta?.video_encode }}
</VChip>
<!-- 制作组标签 -->
<VChip v-if="meta?.resource_team" class="chip-team rounded-sm" size="x-small" variant="elevated">
{{ meta?.resource_team }}
</VChip>
<!-- 其他标签 -->
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
class="chip-label rounded-sm"
size="x-small"
variant="elevated"
>
{{ label }}
</VChip>
<!-- 特殊标签 -->
<VChip v-if="torrent?.hit_and_run" class="chip-hr rounded-sm" size="x-small" variant="elevated">H&R</VChip>
<VChip v-if="torrent?.freedate_diff" class="chip-expire rounded-sm" size="x-small" variant="elevated">
{{ torrent?.freedate_diff }}
</VChip>
</div>
</div>
</VCardText>
<!-- 卡片底部信息 -->
<div class="card-footer">
<div class="more-sources-wrapper" v-if="props.more && props.more.length > 0">
<div class="more-sources-toggle" @click.stop="openMoreTorrentsDialog">
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" size="small" class="me-1"></VIcon>
<span>更多来源 ({{ props.more.length }})</span>
</div>
<VCardActions class="border-t border-opacity-10 mt-auto pa-2">
<div v-if="props.more && props.more.length > 0">
<VBtn
variant="text"
color="primary"
size="small"
class="pa-1 d-flex align-center"
@click.stop="openMoreTorrentsDialog"
>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" size="small" class="mr-1"></VIcon>
更多来源 ({{ props.more.length }})
</VBtn>
</div>
<VSpacer />
<!-- 体积和详情按钮并排 -->
<div class="card-actions">
<div v-if="torrent?.size" class="size-badge">
<div class="d-flex align-center">
<VChip v-if="torrent?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm mr-2">
{{ formatFileSize(torrent.size) }}
</div>
<VBtn
size="small"
icon="mdi-information-outline"
variant="text"
color="primary"
class="detail-btn"
@click.stop="openTorrentDetail"
></VBtn>
</VChip>
<VBtn icon size="small" variant="text" color="primary" @click.stop="openTorrentDetail()">
<VIcon icon="mdi-information-outline"></VIcon>
</VBtn>
</div>
</div>
</VCardActions>
</VCard>
<!-- 更多来源对话框 - 改为独立对话框 -->
<VDialog v-model="showMoreTorrents" max-width="380px" location="center">
<!-- 更多来源对话框 -->
<VDialog v-model="showMoreTorrents" max-width="25rem" location="center">
<VCard>
<VCardTitle class="py-2 d-flex align-center">
<VCardTitle class="py-3 d-flex align-center">
<span>其他来源</span>
<VSpacer />
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="showMoreTorrents = false"></VBtn>
@@ -221,45 +277,77 @@ onMounted(() => {
<VDivider />
<VCardText class="more-sources-content">
<div
v-for="(item, index) in props.more"
:key="index"
@click.stop="handleAddDownload(item)"
class="more-source-item cursor-pointer"
>
<div class="source-site-info">
<img
:alt="item.torrent_info?.site_name"
v-if="siteIcons[item.torrent_info?.site || 0]"
:src="siteIcons[item.torrent_info?.site || 0]"
class="source-site-icon"
/>
<span v-else class="source-site-fallback">{{ item.torrent_info?.site_name?.substring(0, 1) }}</span>
<span class="source-site-name">{{ item.torrent_info.site_name }}</span>
<span v-if="item.meta_info?.season_episode" class="season-tag source-season-tag">
{{ item.meta_info.season_episode }}
</span>
<VCardText class="more-sources-content pa-0">
<VList lines="one" density="compact">
<VListItem
v-for="(item, index) in props.more"
:key="index"
@click.stop="handleAddDownload(item)"
class="border-b border-opacity-5 hover:bg-primary-lighten-5"
>
<template v-slot:prepend>
<div class="d-flex align-center gap-1">
<VImg
v-if="siteIcons[item.torrent_info?.site || 0]"
:src="siteIcons[item.torrent_info?.site || 0]"
:alt="item.torrent_info?.site_name"
width="16"
height="16"
class="rounded"
/>
<VAvatar v-else size="16" class="text-caption bg-surface-variant">
{{ item.torrent_info?.site_name?.substring(0, 1) }}
</VAvatar>
<span class="text-body-2 font-weight-bold">{{ item.torrent_info.site_name }}</span>
<span
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
class="source-discount"
:class="
getPromotionClass(item.torrent_info?.downloadvolumefactor, item.torrent_info?.uploadvolumefactor)
"
>
{{ item.torrent_info?.volume_factor }}
</span>
</div>
<VChip
v-if="item.meta_info?.season_episode"
class="chip-season rounded-sm ml-1"
size="x-small"
variant="elevated"
>
{{ item.meta_info.season_episode }}
</VChip>
<div class="source-stats">
<span class="source-size">{{ formatFileSize(item.torrent_info?.size) }}</span>
<span class="source-seeders">
<VIcon size="x-small" color="success" icon="mdi-arrow-up"></VIcon>
{{ item.torrent_info?.seeders }}
</span>
</div>
</div>
<VChip
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
:class="
getPromotionChipClass(
item.torrent_info?.downloadvolumefactor,
item.torrent_info?.uploadvolumefactor,
)
"
size="x-small"
variant="elevated"
class="rounded-sm ml-1"
>
{{ item.torrent_info?.volume_factor }}
</VChip>
</div>
</template>
<template v-slot:append>
<div class="d-flex align-center gap-2">
<span class="text-caption font-weight-bold text-primary">
{{ formatFileSize(item.torrent_info?.size) }}
</span>
<span class="d-flex align-center text-caption font-weight-bold">
<VIcon size="small" color="success" icon="mdi-arrow-up" class="mr-1"></VIcon>
{{ item.torrent_info?.seeders }}
</span>
<span>
<VIcon
@click.stop="openTorrentDetail(item)"
size="small"
color="secondary"
icon="mdi-arrow-top-right"
class="mr-1"
></VIcon>
</span>
</div>
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
</VDialog>
@@ -280,381 +368,91 @@ onMounted(() => {
</template>
<style scoped>
.torrent-card {
overflow: hidden;
border-radius: 12px;
box-shadow: none;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
cursor: pointer;
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
position: relative;
}
.torrent-card:hover {
transform: translateY(-4px);
border-color: rgba(var(--v-theme-primary), 0.3);
}
.discount-banner {
position: absolute;
top: 0;
right: 0;
color: white;
padding: 4px 10px;
font-weight: 600;
font-size: 0.9rem;
border-radius: 0 0 0 12px;
z-index: 2;
}
.free-discount {
background-color: #4caf50;
font-weight: 700;
}
.percent-discount {
background-color: #ff5722;
}
.upload-bonus {
background-color: #9c27b0;
}
.size-badge {
background-color: rgba(var(--v-theme-primary), 0.9);
color: white;
padding: 2px 8px;
font-weight: 600;
font-size: 0.8rem;
border-radius: 4px;
margin-right: 6px;
display: flex;
align-items: center;
}
.card-header {
padding: 12px 16px 0;
}
.media-title-wrapper {
margin-bottom: 8px;
padding-right: 2rem;
}
.media-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), 0.87);
}
.season-tag {
font-size: 0.875rem;
background-color: #5c6bc0;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.site-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.site-icon {
width: 20px;
height: 20px;
margin-right: 8px;
border-radius: 2px;
}
.site-fallback {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 8px;
font-weight: 700;
color: rgba(var(--v-theme-on-surface), 0.8);
background-color: rgba(var(--v-theme-on-surface), 0.1);
border-radius: 2px;
}
.site-name {
font-size: 0.875rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), 0.85);
}
.seeder-peers {
display: flex;
align-items: center;
gap: 12px;
}
.seed-info {
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 4px;
font-weight: 600;
}
.peer-info {
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 4px;
font-weight: 600;
}
.card-content {
padding: 0 16px;
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
}
.torrent-title {
font-size: 0.9rem;
font-weight: 500;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
color: rgba(var(--v-theme-on-surface), 0.87);
margin-bottom: 8px;
}
.torrent-desc {
font-size: 0.85rem;
color: rgba(var(--v-theme-on-surface), 0.6);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.resource-tag {
font-size: 0.8rem;
padding: 3px 8px;
border-radius: 4px;
color: white;
font-weight: 700;
}
.edition {
background-color: #f44336;
}
.resolution {
background-color: #e91e63;
}
.codec {
background-color: #ff9800;
}
.team {
background-color: #03a9f4;
}
.expire {
background-color: #9c27b0;
}
.label {
background-color: #3f51b5;
}
.hr {
background-color: #000000;
}
.card-footer {
padding: 8px 16px;
display: flex;
align-items: center;
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.08);
margin-top: auto;
}
.more-sources-wrapper {
position: relative;
}
.more-sources-toggle {
font-size: 0.875rem;
color: rgb(var(--v-theme-primary));
display: flex;
align-items: center;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.more-sources-toggle:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
inset-block-start: 0;
inset-inline-end: 0;
}
.more-sources-content {
max-height: 60vh;
max-block-size: 60vh;
overflow-y: auto;
}
.more-source-item {
padding: 8px 0;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.05);
/* 卡片悬停效果 */
.torrent-card {
border: 1px solid transparent;
}
.more-source-item:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
.torrent-card:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
}
.source-site-info {
display: flex;
align-items: center;
gap: 6px;
/* 优惠标签样式 */
.bg-success {
background-color: #4caf50;
}
.source-site-icon {
width: 16px;
height: 16px;
border-radius: 2px;
.bg-orange {
background-color: #ff5722;
}
.source-site-fallback {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.7rem;
color: rgba(var(--v-theme-on-surface), 0.8);
background-color: rgba(var(--v-theme-on-surface), 0.1);
border-radius: 2px;
.bg-purple {
background-color: #9c27b0;
}
.source-site-name {
font-size: 0.875rem;
font-weight: 600;
}
.source-season-tag {
font-size: 0.75rem;
padding: 1px 4px;
margin-left: 4px;
background-color: #5c6bc0;
}
.source-discount {
font-weight: 700;
font-size: 0.8rem;
margin-left: 6px;
padding: 1px 5px;
border-radius: 3px;
.chip-season {
background-color: #3f51b5;
color: white;
}
.source-stats {
display: flex;
align-items: center;
gap: 10px;
.chip-edition {
background-color: #f44336;
color: white;
}
.source-size {
font-size: 0.8rem;
font-weight: 600;
color: rgb(var(--v-theme-primary));
.chip-resolution {
background-color: #7b1fa2;
color: white;
}
.source-seeders {
display: flex;
align-items: center;
gap: 2px;
font-weight: 600;
font-size: 0.8rem;
.chip-codec {
background-color: #ff9800;
color: white;
}
.card-actions {
display: flex;
align-items: center;
.chip-team {
background-color: #00897b;
color: white;
}
.detail-btn {
border-radius: 50%;
min-width: 36px;
height: 36px;
.chip-label {
background-color: #5c6bc0;
color: white;
}
.downloaded-card {
border: 2px solid #4caf50 !important;
opacity: 0.85;
.chip-hr {
background-color: #212121;
color: white;
}
@media (max-width: 640px) {
.resource-tag {
font-size: 0.75rem;
padding: 2px 6px;
}
.chip-expire {
background-color: #7e57c2;
color: white;
}
.full-text {
white-space: normal;
word-break: break-word;
font-size: 14px;
line-height: 1.5;
.chip-free {
background-color: #4caf50;
color: white;
}
.menu-activator {
width: 100%;
cursor: pointer;
.chip-discount {
background-color: #ff5722;
color: white;
}
.break-words {
word-wrap: break-word;
word-break: break-word;
}
.overflow-visible {
overflow: visible !important;
}
.whitespace-break-spaces {
white-space: normal !important;
.chip-bonus {
background-color: #9c27b0;
color: white;
}
</style>

View File

@@ -58,10 +58,19 @@ async function getSiteIcon() {
// 获取优惠类型样式
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return 'free-discount'
if (downloadVolumeFactor === 0) return 'free-discount'
else if (downloadVolumeFactor < 1) return 'percent-discount'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
if (!downloadVolumeFactor) return 'bg-success'
if (downloadVolumeFactor === 0) return 'bg-success'
else if (downloadVolumeFactor < 1) return 'bg-orange'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'bg-purple'
else return ''
}
// 获取优惠标签类
function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return 'chip-free'
if (downloadVolumeFactor === 0) return 'chip-free'
else if (downloadVolumeFactor < 1) return 'chip-discount'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'
else return ''
}
@@ -95,80 +104,112 @@ onMounted(() => {
</script>
<template>
<div class="list-item-wrapper">
<div class="w-100">
<VListItem
:value="props.torrent?.torrent_info?.enclosure"
class="torrent-item rounded"
:class="{ 'downloaded-item': downloaded.includes(torrent?.enclosure || '') }"
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
:class="{ 'border-start border-success border-3 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
@click="handleAddDownload"
>
<!-- 优惠标签 -->
<div
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
class="discount-banner text-white px-2 py-1 text-sm font-weight-bold rounded-bl-lg"
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
>
{{ torrent?.volume_factor }}
</div>
<template v-slot:prepend>
<div class="site-wrapper">
<img :alt="torrent?.site_name" v-if="siteIcon" :src="siteIcon" class="site-icon" />
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
<div class="site-name d-none d-sm-block">{{ torrent?.site_name }}</div>
<span
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
class="free-tag"
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
>
{{ torrent?.volume_factor }}
</span>
<div 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">
{{ 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>
</template>
<VListItemTitle class="item-content">
<div class="item-header">
<div class="media-info flex flex-row flex-wrap justify-start">
<span class="media-title me-2">{{ media?.title ?? meta?.name }}</span>
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
</div>
<VListItemTitle>
<div class="d-flex flex-row flex-wrap align-center mb-2">
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
<VChip v-if="meta?.season_episode" class="chip-season rounded-sm font-weight-bold" variant="elevated">
{{ meta?.season_episode }}
</VChip>
</div>
<div class="torrent-title" :title="torrent?.title">
<div class="text-subtitle-2 font-weight-medium mb-2" :title="torrent?.title">
{{ torrent?.title }}
</div>
<div class="torrent-description" :title="meta?.subtitle || torrent?.description || '暂无描述'">
<div
class="text-body-2 text-medium-emphasis mb-2"
:title="meta?.subtitle || torrent?.description || '暂无描述'"
>
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
</div>
<div class="tags-container">
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
<div class="d-flex flex-wrap gap-1 mb-2">
<!-- 版本标签 -->
<VChip v-if="meta?.edition" class="chip-edition rounded-sm" size="x-small" variant="elevated">
{{ meta?.edition }}
</VChip>
<!-- 分辨率标签 -->
<VChip v-if="meta?.resource_pix" class="chip-resolution rounded-sm" size="x-small" variant="elevated">
{{ meta?.resource_pix }}
</VChip>
<!-- 编码标签 -->
<VChip v-if="meta?.video_encode" class="chip-codec rounded-sm" size="x-small" variant="elevated">
{{ meta?.video_encode }}
</VChip>
<!-- 制作组标签 -->
<VChip v-if="meta?.resource_team" class="chip-team rounded-sm" size="x-small" variant="elevated">
{{ meta?.resource_team }}
</VChip>
<!-- 其他标签 -->
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
class="chip-label rounded-sm"
size="x-small"
variant="elevated"
>
{{ label }}
</VChip>
<!-- 特殊标签 -->
<VChip v-if="torrent?.hit_and_run" class="chip-hr rounded-sm" size="x-small" variant="elevated"> H&R </VChip>
<VChip v-if="torrent?.freedate_diff" class="chip-expire rounded-sm" size="x-small" variant="elevated">
{{ torrent?.freedate_diff }}
</VChip>
</div>
</VListItemTitle>
<template v-slot:append>
<div class="item-actions">
<div class="torrent-stats">
<span v-if="torrent?.seeders" class="seed-info">
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
<div class="d-flex flex-column align-end gap-2">
<div class="d-flex align-center gap-3">
<span v-if="torrent?.seeders" class="d-flex align-center font-weight-bold">
<VIcon size="small" color="success" icon="mdi-arrow-up" class="mr-1"></VIcon>
{{ torrent?.seeders }}
</span>
<span v-if="torrent?.peers" class="peer-info">
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
<span v-if="torrent?.peers" class="d-flex align-center font-weight-bold">
<VIcon size="small" color="warning" icon="mdi-arrow-down" class="mr-1"></VIcon>
{{ torrent?.peers }}
</span>
</div>
<div class="action-buttons">
<div v-if="torrent?.size" class="size-badge">
<div class="d-flex align-center">
<VChip v-if="torrent?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm mr-2">
{{ formatFileSize(torrent.size) }}
</div>
</VChip>
<VBtn
density="comfortable"
variant="text"
color="primary"
icon="mdi-information-outline"
size="small"
class="detail-btn"
@click.stop="openTorrentDetail"
></VBtn>
<VBtn icon size="small" variant="text" color="primary" @click.stop="openTorrentDetail">
<VIcon icon="mdi-information-outline"></VIcon>
</VBtn>
</div>
</div>
</template>
@@ -188,294 +229,86 @@ onMounted(() => {
</template>
<style scoped>
.list-item-wrapper {
inline-size: 100%;
.discount-banner {
position: absolute;
z-index: 3;
inset-block-start: 0;
inset-inline-end: 0;
}
.torrent-item {
padding: 12px;
background-color: rgb(var(--v-theme-surface));
box-shadow: none;
margin-block-end: 8px;
transition: background-color 0.2s ease, transform 0.2s ease;
border: 1px solid transparent;
}
.torrent-item:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
background-color: rgba(var(--v-theme-primary), 0.04);
transform: translateY(-2px);
}
.site-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
min-inline-size: 100px;
.chip-season {
background-color: #3f51b5;
color: white;
}
.site-icon {
border-radius: 4px;
block-size: 32px;
inline-size: 32px;
margin-inline-end: 8px;
.chip-edition {
background-color: #f44336;
color: white;
}
.site-fallback {
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background-color: rgba(var(--v-theme-primary), 0.1);
block-size: 24px;
color: rgb(var(--v-theme-primary));
font-size: 0.8rem;
font-weight: 700;
inline-size: 24px;
margin-inline-end: 8px;
.chip-resolution {
background-color: #7b1fa2;
color: white;
}
.site-name {
color: rgba(var(--v-theme-on-surface), 0.8);
font-size: 0.9rem;
font-weight: 600;
margin-inline-end: 8px;
.chip-codec {
background-color: #ff9800;
color: white;
}
.season-tag {
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
.chip-team {
background-color: #00897b;
color: white;
}
.chip-label {
background-color: #5c6bc0;
color: white;
font-size: 0.875rem;
font-weight: 600;
margin-inline-end: 8px;
padding-block: 2px;
padding-inline: 6px;
}
.free-tag {
position: absolute;
z-index: 1;
border-radius: 4px;
.chip-hr {
background-color: #212121;
color: white;
font-size: 0.7rem;
font-weight: 700;
inset-block-start: 0;
inset-inline-end: 0;
padding-block: 2px;
padding-inline: 6px;
}
.free-discount {
.chip-expire {
background-color: #7e57c2;
color: white;
}
/* 优惠标签样式 */
.bg-success {
background-color: #4caf50;
font-weight: 700;
}
.percent-discount {
.bg-orange {
background-color: #ff5722;
}
.upload-bonus {
.bg-purple {
background-color: #9c27b0;
}
.item-content {
display: flex;
flex-direction: column;
gap: 8px;
inline-size: 100%;
}
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
inline-size: 100%;
}
.media-info {
align-items: center;
}
.media-title {
color: rgba(var(--v-theme-on-surface), 0.87);
font-size: 1.125rem;
font-weight: 600;
}
.item-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.torrent-stats {
display: flex;
align-items: center;
gap: 12px;
}
.action-buttons {
display: flex;
align-items: center;
}
.seed-info {
display: flex;
align-items: center;
font-size: 0.95rem;
font-weight: 600;
gap: 4px;
}
.peer-info {
display: flex;
align-items: center;
font-size: 0.95rem;
font-weight: 600;
gap: 4px;
}
.size-badge {
border-radius: 4px;
background-color: rgba(var(--v-theme-primary), 0.1);
color: rgb(var(--v-theme-primary));
font-size: 0.9rem;
font-weight: 600;
margin-inline-end: 6px;
padding-block: 2px;
padding-inline: 8px;
}
.torrent-title {
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.87);
font-size: 0.9rem;
margin-block-end: 6px;
max-inline-size: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.torrent-description {
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.65);
font-size: 0.8rem;
inline-size: 100%;
margin-block-end: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.resource-tag {
border-radius: 4px;
.chip-free {
background-color: #4caf50;
color: white;
font-size: 0.8rem;
font-weight: 700;
padding-block: 3px;
padding-inline: 8px;
}
.edition {
background-color: #f44336;
.chip-discount {
background-color: #ff5722;
color: white;
}
.resolution {
background-color: #e91e63;
}
.codec {
background-color: #ff9800;
}
.team {
background-color: #03a9f4;
}
.expire {
.chip-bonus {
background-color: #9c27b0;
}
.label {
background-color: #3f51b5;
}
.hr {
background-color: #000;
}
.detail-btn {
border-radius: 50%;
}
.downloaded-item {
border-inline-start: 4px solid #4caf50;
opacity: 0.85;
}
.break-words {
word-break: break-word;
word-wrap: break-word;
}
.overflow-visible {
overflow: visible !important;
}
.whitespace-break-spaces {
white-space: normal !important;
}
@media (width <= 600px) {
.torrent-item {
padding: 8px;
}
.site-icon,
.site-fallback {
block-size: 24px;
inline-size: 24px;
}
.site-wrapper {
flex-wrap: wrap;
margin-inline-end: 10px;
min-inline-size: 24px;
}
.site-name {
font-size: 0.8rem;
margin-inline-end: 4px;
}
.size-badge {
font-size: 0.7rem;
}
.resource-tag {
font-size: 0.75rem;
padding-block: 2px;
padding-inline: 6px;
}
.action-buttons {
display: flex;
flex-direction: row;
align-items: center;
}
.torrent-description {
max-inline-size: calc(100vw - 150px);
}
color: white;
}
</style>

View File

@@ -6,6 +6,11 @@ import avatar1 from '@images/avatars/avatar-1.png'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 扩展User类型以包含昵称字段
interface ExtendedUser extends User {
@@ -26,6 +31,9 @@ const props = defineProps({
},
})
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
// 当前用户的ID
const currentLoginUserId = computed(() => useUserStore().userID)
@@ -50,15 +58,6 @@ const movieSubscriptions = ref(0)
// 用户电视剧订阅数量
const tvShowSubscriptions = ref(0)
// 是否显示更多操作菜单
const showMenu = ref(false)
// 鼠标悬停状态
const isHovered = ref(false)
// 是否为移动设备
const isMobile = ref(window.innerWidth < 600)
// 显示名称 - 如果有昵称则优先显示昵称
const displayName = computed(() => {
const settingsNickname = props.user.settings?.nickname as string | undefined
@@ -66,13 +65,6 @@ const displayName = computed(() => {
return nickname || props.user.name
})
// 计算用户卡片状态类
const cardStatusClass = computed(() => {
if (!props.user.is_active) return 'user-card-inactive'
if (props.user.is_superuser) return 'user-card-admin'
return ''
})
// 按用户查询订阅数量
async function fetchSubscriptions() {
try {
@@ -89,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)
@@ -121,149 +113,167 @@ function onUserUpdate() {
emit('save')
}
// 更新窗口大小监听
function handleResize() {
isMobile.value = window.innerWidth < 600
}
onMounted(() => {
fetchSubscriptions()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<VCard
class="user-card"
:class="[{ 'user-card-hover': isHovered }, cardStatusClass, { 'mobile-card': isMobile }]"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
:class="[
'transition-transform duration-300 hover:-translate-y-1',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
@click="userEditDialog = true"
>
<!-- 管理员卡片装饰 -->
<div v-if="user.is_superuser" class="admin-decoration">
<div class="decoration-line"></div>
<div class="decoration-circle"><VIcon icon="mdi-shield-star" size="x-small" color="warning" /></div>
<div class="decoration-line"></div>
</div>
<!-- 用户头像和基本信息 -->
<div class="user-card-header" :class="{ 'admin-header': user.is_superuser }">
<div class="user-avatar-container">
<VAvatar
:size="isMobile ? 50 : 74"
rounded="lg"
class="user-avatar"
:class="{ 'admin-avatar': user.is_superuser, 'inactive-avatar': !user.is_active }"
>
<VImg :src="user.avatar || avatar1" :alt="user.name" />
<div v-if="!user.is_active" class="avatar-overlay">
<VIcon icon="mdi-account-lock" color="white" size="small" />
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
<template v-slot:prepend>
<div class="position-relative mr-4">
<VAvatar
size="72"
rounded="lg"
:class="[
user.is_superuser ? 'admin-avatar' : 'border-4 bg-surface',
!user.is_active ? 'grayscale-50 opacity-90' : '',
]"
:style="user.is_superuser ? 'border: 4px solid rgba(var(--v-theme-warning), 0.3);' : ''"
>
<VImg :src="user.avatar || avatar1" :alt="user.name" />
<div
v-if="!user.is_active"
class="position-absolute d-flex align-center justify-center rounded-lg bg-surface-variant opacity-20"
style="inset: 0"
>
<VIcon icon="mdi-account-lock" color="white" />
</div>
</VAvatar>
<div v-if="user.is_superuser" class="admin-crown">
<VIcon icon="mdi-crown" color="warning" />
</div>
</VAvatar>
<div v-if="user.is_superuser" class="admin-crown">
<VIcon icon="mdi-crown" color="warning" size="small" />
</div>
</div>
</template>
<div class="user-info">
<div class="user-name-section">
<div class="name-and-badges">
<h3 class="user-name" :class="{ 'admin-name': user.is_superuser, 'inactive-name': !user.is_active }">
<VCardTitle class="pa-0 d-flex flex-column">
<div class="d-flex flex-column mb-1">
<div class="d-flex align-center">
<span
:class="[
'text-h6 font-weight-bold truncate',
user.is_superuser ? 'text-warning' : '',
!user.is_active ? 'text-medium-emphasis' : '',
]"
>
{{ displayName }}
<VIcon
v-if="user.nickname || user.settings?.nickname"
icon="mdi-format-quote-close"
size="x-small"
color="info"
class="nickname-icon"
class="animate-pulse"
/>
</h3>
<div class="user-badges">
<VChip v-if="user.is_superuser" size="x-small" color="error" class="user-badge admin-badge">管理员</VChip>
<VChip v-else size="x-small" color="default" class="user-badge">普通用户</VChip>
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" class="user-badge">
{{ user.is_active ? '激活' : '已停用' }}
</VChip>
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" class="user-badge"> 2FA </VChip>
</div>
</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>{{
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 ? t('user.active') : t('user.inactive') }}
</VChip>
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
</div>
</div>
<!-- 移动端订阅数据信息 -->
<div v-if="isMobile" class="mobile-stats">
<div class="mobile-stat-item">
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" />
<span>{{ movieSubscriptions }}</span>
<div v-if="isMobile" class="d-flex gap-5 mt-2">
<div class="d-flex align-center">
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" class="mr-1" />
<span class="text-body-2">{{ movieSubscriptions }}</span>
</div>
<div class="mobile-stat-item">
<VIcon size="x-small" icon="mdi-television-classic" color="primary" />
<span>{{ tvShowSubscriptions }}</span>
<div class="d-flex align-center">
<VIcon size="x-small" icon="mdi-television-classic" color="primary" class="mr-1" />
<span class="text-body-2">{{ tvShowSubscriptions }}</span>
</div>
</div>
</div>
</VCardTitle>
<!-- 头部操作按钮 -->
<div class="user-actions" :class="{ 'mobile-actions': isMobile }">
<VBtn
icon
size="small"
:color="user.is_superuser ? 'warning' : 'primary'"
variant="text"
@click="editUser"
class="action-btn"
>
<VIcon icon="mdi-pencil" />
<VTooltip v-if="!isMobile" activator="parent" location="bottom">编辑用户</VTooltip>
</VBtn>
<template v-slot:append>
<div :class="['d-flex', isMobile ? 'position-absolute top-2 right-2' : '']">
<VBtn
icon
size="small"
:color="user.is_superuser ? 'warning' : 'primary'"
variant="text"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="editUser"
>
<VIcon icon="mdi-pencil" />
</VBtn>
<VBtn
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
icon
size="small"
color="error"
variant="text"
@click="removeUser"
class="action-btn"
>
<VIcon icon="mdi-delete" />
<VTooltip v-if="!isMobile" activator="parent" location="bottom">删除用户</VTooltip>
</VBtn>
</div>
</div>
<VBtn
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
icon
size="small"
color="error"
variant="text"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="removeUser"
>
<VIcon icon="mdi-delete" />
</VBtn>
</div>
</template>
</VCardItem>
<!-- 独立的邮箱显示 -->
<div class="email-container" :class="{ 'admin-email': user.is_superuser, 'inactive-email': !user.is_active }">
<VIcon icon="mdi-email-outline" size="small" color="primary" class="email-icon" />
<span class="email-text">{{ user.email || '未设置邮箱' }}</span>
</div>
<VDivider class="mx-4" />
<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 || t('user.noEmail') }}</span>
</VCardText>
<!-- PC端显示订阅统计信息 -->
<div v-if="!isMobile" class="user-card-body">
<div class="user-stats-container">
<div class="stat-item">
<div class="stat-icon-container" :class="{ 'admin-stat': user.is_superuser }">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
</div>
<div class="stat-content">
<div class="stat-value">{{ movieSubscriptions }}</div>
<div class="stat-label">电影订阅</div>
<VCardText v-if="!isMobile" class="px-4 pt-0 pb-4">
<div rounded="lg" class="d-flex justify-space-around pa-3">
<div class="d-flex align-center gap-3">
<VAvatar
tile
rounded="lg"
size="large"
class="mr-1"
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
>
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
</div>
</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">{{ t('user.movieSubscriptions') }}</span>
</div>
</div>
<div class="stat-item">
<div class="stat-icon-container" :class="{ 'admin-stat': user.is_superuser }">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" size="20" />
</div>
<div class="stat-content">
<div class="stat-value">{{ tvShowSubscriptions }}</div>
<div class="stat-label">剧集订阅</div>
<div class="d-flex align-center gap-3">
<VAvatar
tile
rounded="lg"
size="large"
class="mr-1"
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
>
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" />
</div>
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis">{{ tvShowSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">{{ t('user.tvSubscriptions') }}</span>
</div>
</div>
</div>
</div>
</VCardText>
</VCard>
<!-- 用户编辑弹窗 -->
@@ -279,103 +289,20 @@ onUnmounted(() => {
</template>
<style scoped>
.user-card {
position: relative;
overflow: hidden;
background: rgb(var(--v-theme-surface));
transition: all 0.3s ease;
}
.user-card-hover {
transform: translateY(-5px);
}
.user-card-admin {
border: 2px solid transparent;
background-clip: content-box, border-box;
background-image: linear-gradient(rgb(var(--v-theme-surface)), rgb(var(--v-theme-surface))),
linear-gradient(120deg, rgba(var(--v-theme-warning), 0.5), rgba(var(--v-theme-error), 0.5));
background-origin: border-box;
}
.user-card-inactive {
position: relative;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
background-color: rgba(var(--v-theme-surface), 0.95);
opacity: 0.85;
}
.user-card-inactive::before {
position: absolute;
z-index: 1;
backdrop-filter: grayscale(30%);
content: '';
inset: 0;
pointer-events: none;
}
.admin-decoration {
position: absolute;
z-index: 1;
display: flex;
align-items: center;
inset-block-start: 0;
inset-inline: 0;
padding-block: 4px;
padding-inline: 12px;
}
.decoration-line {
flex: 1;
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.1), rgba(var(--v-theme-warning), 0.7));
block-size: 1px;
}
.decoration-line:last-child {
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.7), rgba(var(--v-theme-warning), 0.1));
}
.decoration-circle {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-warning), 0.5);
border-radius: 50%;
background: rgb(var(--v-theme-surface));
block-size: 18px;
inline-size: 18px;
margin-block: 0;
margin-inline: 8px;
}
.user-card-header {
position: relative;
z-index: 2;
display: flex;
padding-block: 20px 12px;
padding-inline: 16px;
width: 100%;
top: 0;
padding: 8px 12px;
}
.admin-header {
background: linear-gradient(to bottom, rgba(var(--v-theme-warning), 0.05), transparent);
}
.user-avatar-container {
position: relative;
margin-inline-end: 16px;
}
.user-avatar {
border: 4px solid rgb(var(--v-theme-surface));
box-shadow: 0 4px 8px rgba(var(--v-theme-on-surface), 0.1);
transition: all 0.3s ease;
}
.admin-avatar {
border: 4px solid rgba(var(--v-theme-warning), 0.1);
box-shadow: 0 5px 15px rgba(var(--v-theme-warning), 0.2);
}
.admin-avatar::after {
position: absolute;
border: 1px solid rgba(var(--v-theme-warning), 0.3);
@@ -386,114 +313,53 @@ onUnmounted(() => {
pointer-events: none;
}
.admin-stats-container {
background-color: rgba(var(--v-theme-warning), 0.1);
}
.user-stats-container {
background-color: rgba(var(--v-theme-primary), 0.1);
}
@keyframes pulse {
0% {
opacity: 0.6;
transform: scale(0.95);
}
70% {
opacity: 0.2;
transform: scale(1.05);
}
100% {
opacity: 0.6;
transform: scale(0.95);
}
}
.inactive-avatar {
border-color: rgba(var(--v-theme-on-surface), 0.1);
filter: grayscale(50%);
opacity: 0.9;
}
.avatar-overlay {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
backdrop-filter: blur(1px);
background: rgba(var(--v-theme-on-surface), 0.2);
inset: 0;
}
.otp-badge {
position: absolute;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
animation: glow 2s infinite alternate;
inset-block-end: 0;
inset-inline-end: 0;
}
.otp-badge .v-icon {
color: #4caf50 !important;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 40%));
font-size: 18px;
}
@keyframes glow {
from {
opacity: 0.9;
transform: scale(1);
}
to {
opacity: 1;
transform: scale(1.15);
}
}
.mobile-otp {
inset-block-end: 0 !important;
inset-inline-end: 0 !important;
}
.mobile-otp .v-icon {
font-size: 16px;
}
.admin-crown {
position: absolute;
z-index: 5;
animation: float 3s ease-in-out infinite;
background: transparent;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 40%));
inset-block-start: -10px;
inset-inline-start: -6px;
top: -10px;
left: -6px;
transform: rotate(-25deg);
}
.admin-crown .v-icon {
color: #ffc107 !important;
font-size: 24px;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 40%));
}
@keyframes float {
0% {
transform: rotate(-25deg) translateY(0);
}
50% {
transform: rotate(-25deg) translateY(-3px);
}
100% {
transform: rotate(-25deg) translateY(0);
}
}
.nickname-icon {
.animate-pulse {
animation: pulse-nickname 2s ease infinite;
filter: brightness(1.1);
margin-inline-start: 4px;
opacity: 0.9;
vertical-align: middle;
}
@keyframes pulse-nickname {
@@ -502,287 +368,13 @@ onUnmounted(() => {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.drag-handle {
cursor: move;
margin-inline-end: 6px;
opacity: 0.3;
transition: opacity 0.2s ease;
}
.user-card:hover .drag-handle {
opacity: 0.8;
}
.user-info {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
min-inline-size: 0;
}
.user-name-section {
margin-block-end: 8px;
}
.name-and-badges {
display: flex;
flex-direction: column;
margin-block-end: 4px;
}
.user-name {
display: flex;
overflow: hidden;
align-items: center;
font-size: 1.2rem;
font-weight: 600;
margin-block: 0 4px;
margin-inline: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-name {
color: rgb(var(--v-theme-warning));
font-weight: 700;
text-shadow: 0 1px 2px rgba(var(--v-theme-warning), 0.1);
}
.inactive-name {
color: rgba(var(--v-theme-on-surface), 0.6);
}
.user-badges {
display: flex;
flex-wrap: nowrap;
gap: 4px;
margin-block-end: 4px;
-ms-overflow-style: none;
overflow-x: auto;
scrollbar-width: none;
}
.user-badges::-webkit-scrollbar {
display: none;
}
.user-badge {
flex-shrink: 0;
font-size: 0.7rem;
white-space: nowrap;
}
.admin-badge {
border: 1px solid rgba(var(--v-theme-error), 0.3);
}
.user-account,
.user-email {
position: absolute;
display: flex;
overflow: hidden;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.8rem;
inline-size: 100%;
inset-block-start: 100%;
inset-inline-start: 0;
margin-block-start: 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-label {
color: rgba(var(--v-theme-on-surface), 0.5);
margin-inline-end: 4px;
}
.account-value {
font-weight: 500;
}
.info-icon {
margin-inline-end: 4px;
opacity: 0.6;
}
.email-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-actions {
display: flex;
align-items: flex-start;
}
.mobile-actions {
position: absolute;
display: flex;
gap: 4px;
inset-block-start: 10px;
inset-inline-end: 10px;
}
.action-btn {
opacity: 0.7;
transition: all 0.3s ease;
}
.action-btn:hover {
opacity: 1;
transform: scale(1.1);
}
.mobile-card {
border-radius: 12px;
}
.mobile-stats {
position: relative;
z-index: 5;
display: flex;
justify-content: flex-start;
gap: 20px;
margin-block-start: 8px;
padding-block: 4px;
padding-inline: 0;
}
.mobile-stat-item {
display: flex;
align-items: center;
font-size: 0.95rem;
gap: 6px;
}
.mobile-stat-item .v-icon {
font-size: 18px !important;
}
.mobile-stat-item span {
font-weight: 500;
}
.user-card-body {
padding-block: 0 16px;
padding-inline: 16px;
}
.user-stats-container {
display: flex;
justify-content: space-around;
padding: 12px;
border-radius: 10px;
background-color: rgba(var(--v-theme-on-surface), 0.02);
margin-block-start: 8px;
}
.stat-item {
display: flex;
align-items: center;
gap: 10px;
}
.stat-icon-container {
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background-color: rgba(var(--v-theme-primary), 0.1);
block-size: 40px;
box-shadow: 0 2px 6px rgba(var(--v-theme-on-surface), 0.05);
inline-size: 40px;
}
.admin-stat {
background-color: rgba(var(--v-theme-warning), 0.1);
box-shadow: 0 2px 6px rgba(var(--v-theme-warning), 0.2);
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.1rem;
font-weight: 600;
}
.stat-label {
color: rgba(var(--v-theme-on-surface), 0.6);
font-size: 0.75rem;
}
.menu-item {
font-size: 0.9rem;
}
.text-error {
color: rgb(var(--v-theme-error));
}
.email-container {
display: flex;
overflow: hidden;
align-items: center;
background-color: transparent;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.05);
padding-block: 8px;
padding-inline: 16px;
white-space: nowrap;
}
.admin-email {
background-color: transparent;
}
.inactive-email {
background-color: transparent;
opacity: 0.9;
}
.email-container .email-icon {
flex-shrink: 0;
margin-inline-end: 8px;
opacity: 0.7;
}
.email-container .email-text {
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.8);
font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-card .email-container {
padding-block: 6px;
padding-inline: 12px;
}
.mobile-card .email-container .email-text {
font-size: 0.8rem;
}
.mobile-card .user-avatar-container {
position: relative;
}
.mobile-card .otp-badge {
position: absolute;
z-index: 10;
inset-block-end: 0 !important;
inset-inline-end: 0 !important;
.grayscale-50 {
filter: grayscale(50%);
}
</style>

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') }
}
// 计算当前动作占比
@@ -206,51 +204,41 @@ const resolveProgress = (item: Workflow) => {
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" base-color="primary" @click="handleEdit(workflow)">
<VListItem base-color="primary" @click="handleEdit(workflow)">
<template #prepend>
<VIcon icon="mdi-note-edit" />
</template>
<VListItemTitle>编辑任务</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.edit') }}</VListItemTitle>
</VListItem>
<VListItem
v-if="workflow.current_action"
variant="plain"
base-color="info"
@click="handleRun(workflow, false)"
>
<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"
variant="plain"
base-color="info"
@click="handleRun(workflow, true)"
>
<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 variant="plain" base-color="info" @click="handleRun(workflow, true)">
<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 variant="plain" base-color="warning" @click="handleReset(workflow)">
<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 variant="plain" base-color="error" @click="handleDelete(workflow)">
<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>
@@ -262,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>
@@ -274,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>
@@ -282,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)" />
@@ -299,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)
}
@@ -120,12 +134,12 @@ onMounted(() => {
<template>
<VDialog max-width="35rem" scrollable>
<VCard>
<VCardTitle class="py-3 me-12">
<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>
<DialogCloseBtn @click="emit('close')" />
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VList lines="one">
<VListItem>
@@ -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({
@@ -30,22 +34,32 @@ async function savaAlistConfig() {
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="AList配置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCard :title="t('dialog.alistConfig.title')" class="rounded-t">
<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 +67,9 @@ async function savaAlistConfig() {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </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')
@@ -85,10 +89,10 @@ onUnmounted(() => {
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard title="阿里云盘登录" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCard :title="t('dialog.aliyunAuth.loginTitle')" class="rounded-t">
<VDialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 shadow-lg rounded text-center p-3 border">
<div class="my-6 rounded text-center p-3 border">
<VImg class="mx-auto" :src="qrCodeUrl" width="200" height="200">
<template #placeholder>
<div class="w-full h-full">
@@ -103,7 +107,9 @@ onUnmounted(() => {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </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)
@@ -165,7 +169,7 @@ onMounted(() => {
<template>
<VDialog max-width="40rem" scrollable>
<VCard>
<DialogCloseBtn @click="emit('close')" />
<VDialogCloseBtn @click="emit('close')" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
@@ -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>
@@ -230,41 +234,47 @@ onMounted(() => {
@click="doFork"
prepend-icon="mdi-heart"
:loading="processing"
class="mb-2 me-2"
>
订阅
{{ t('subscribe.normalSub') }}
</VBtn>
<VBtn
v-if="props.media?.share_uid && props.media?.share_uid === globalSettings.USER_UNIQUE_ID"
color="error"
:disabled="deleting"
@click="doDelete"
prepend-icon="mdi-delete"
:loading="deleting"
class="ms-2"
>
取消分享
</VBtn>
<VBtn
v-else-if="isFollowed && props.media?.share_uid"
v-if="isFollowed && props.media?.share_uid"
color="warning"
@click="unfollowUser"
prepend-icon="mdi-account-remove"
class="ms-2"
class="mb-2 me-2"
>
取消关注
{{ t('subscribe.unfollow') }}
</VBtn>
<VBtn
v-else-if="props.media?.share_uid"
@click="followUser"
color="info"
prepend-icon="mdi-account-plus"
class="ms-2"
class="mb-2 me-2"
>
关注
{{ t('subscribe.follow') }}
</VBtn>
<VBtn
v-if="
(props.media?.share_uid && props.media?.share_uid === globalSettings.USER_UNIQUE_ID) ||
globalSettings.SUBSCRIBE_SHARE_MANAGE
"
color="error"
:disabled="deleting"
@click="doDelete"
prepend-icon="mdi-delete"
:loading="deleting"
class="mb-2 me-2"
>
{{ 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>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard :title="props.title" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<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({
@@ -13,7 +17,7 @@ const emit = defineEmits(['close'])
<template>
<VDialog max-width="50rem">
<VCard>
<DialogCloseBtn @click="emit('close')" />
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<MediaInfoCard :context="context" />
</VCardItem>

View File

@@ -6,6 +6,10 @@ 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'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -67,17 +71,17 @@ async function loadPluginConf() {
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)
@@ -91,16 +95,20 @@ onBeforeMount(async () => {
</script>
<template>
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCard :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`" class="rounded-t">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText v-if="isRefreshed">
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info"> 查看数据 </VBtn>
<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>
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
{{ t('dialog.pluginConfig.save') }}
</VBtn>
</VCardActions>
</VCard>
<!-- 进度框 -->

View File

@@ -43,7 +43,7 @@ onMounted(() => {
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<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" />

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