Compare commits

...

201 Commits

Author SHA1 Message Date
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
jxxghp
204719caf8 Merge pull request #321 from madrays/v2
优化探索页UI
2025-04-08 16:31:34 +08:00
madrays
267f53942b 优化探索页UI 2025-04-08 15:47:41 +08:00
jxxghp
7ae9bbc4f0 优化 TorrentItem 列表性能 2025-04-08 15:35:11 +08:00
jxxghp
717b460246 更新设定菜单图标为 mdi-cog-outline 2025-04-08 15:04:22 +08:00
jxxghp
d52a814d77 优化 SubscribeCard 组件的图片点击事件 2025-04-08 14:52:07 +08:00
jxxghp
6e1503334e 优化主题切换器和用户通知组件样式,添加文件大小显示,调整布局和样式变量 2025-04-08 13:32:36 +08:00
jxxghp
34a33f87b2 更新 Vuetify 默认设置,修改菜单图标,调整样式变量,优化用户资料组件样式 2025-04-08 12:24:52 +08:00
jxxghp
03d885d391 Merge pull request #320 from madrays/v2 2025-04-08 06:44:08 +08:00
madrays
c50b25997d Merge branch 'jxxghp:v2' into v2 2025-04-08 01:05:39 +08:00
madrays
3adcc894b7 统一顶栏及侧边栏各项ui风格 2025-04-08 01:03:29 +08:00
jxxghp
8a73ad63ee 更新 TheMovieDbView.vue 2025-04-05 14:33:16 +08:00
jxxghp
69b5b2b900 更新 SearchSiteDialog.vue 2025-04-05 14:21:37 +08:00
jxxghp
97075fc167 更新 ProgressDialog.vue 2025-04-05 08:26:28 +08:00
jxxghp
bde70a2e26 fix:季集判断 2025-04-04 18:11:28 +08:00
jxxghp
2e05c8079b 优化剧集组管理,添加剧集组选项和查询功能,调整订阅逻辑 2025-04-04 12:17:29 +08:00
jxxghp
357191afcf 修复 SubscribeSeasonDialog 关闭事件处理,优化季信息显示逻辑 2025-04-04 10:51:55 +08:00
jxxghp
8352ba335b 添加 SubscribeSeasonDialog 组件,实现媒体季订阅功能,包含季信息获取和缺失状态检查 2025-04-04 10:47:32 +08:00
jxxghp
7ae3e402cf 优化 MediaCard 和 SearchSiteDialog 组件,移除全选功能并调整已选择站点的逻辑 2025-04-04 09:39:04 +08:00
jxxghp
c5e62cc8e4 优化多个组件,调整样式和功能以提升用户体验,添加季数选择功能 2025-04-04 09:27:06 +08:00
jxxghp
ddb0befa4d 更新 package.json 2025-04-03 20:18:37 +08:00
jxxghp
82c19f3512 优化 SiteCard 组件,修复点击事件处理以提升用户交互体验 2025-04-03 19:20:32 +08:00
jxxghp
e95552b47a 优化 SiteCard 组件,添加删除确认功能并调整样式 2025-04-03 19:18:59 +08:00
jxxghp
50ffcc6e92 优化订阅和整理对话框,添加剧集组选项和编号,调整布局以提升用户体验 2025-04-03 18:35:21 +08:00
jxxghp
43beb1df95 优化 SiteCard 组件,使用 IconBtn 替代 button 以提升用户体验 2025-04-03 12:59:30 +08:00
jxxghp
3cef928b75 优化文件浏览器组件性能,提升加载速度 2025-04-03 12:57:47 +08:00
jxxghp
6f5d62f1f9 优化文件浏览器组件样式 2025-04-03 08:29:47 +08:00
jxxghp
e3a385c989 优化文件浏览器组件,修复目录树切换逻辑 2025-04-03 08:23:07 +08:00
jxxghp
12356abf00 添加文件浏览器目录树切换功能,优化用户界面交互 2025-04-03 07:20:15 +08:00
jxxghp
50e76496a2 更新 TorrentCardListView.vue 2025-04-02 16:00:34 +08:00
jxxghp
a98bf08b2d 优化工作流操作对MacOS的删除键设置,提升跨平台用户体验 2025-04-02 14:23:49 +08:00
jxxghp
697fd57bc7 优化工作流操作调整布局和删除键设置 2025-04-02 14:21:08 +08:00
jxxghp
7a691fe4e7 优化多个页面的标签样式,提升组件一致性和用户体验 2025-04-02 13:20:22 +08:00
jxxghp
3822ab20d5 优化多个视图的卡片样式 2025-04-02 12:13:54 +08:00
jxxghp
88f261584f 优化卡片样式,移除多余的圆角设置,提升组件一致性 2025-04-02 10:44:47 +08:00
jxxghp
62db4508da 优化通知和捷径栏的卡片样式 2025-04-02 10:16:16 +08:00
jxxghp
122acc7ad3 优化用户卡片样式 2025-04-02 09:28:47 +08:00
jxxghp
c15927cca0 优化用户列表和卡片样式,提升响应式布局和用户体验 2025-04-02 09:06:55 +08:00
jxxghp
b5b2de30a2 Merge pull request #319 from madrays/v2 2025-04-02 07:02:40 +08:00
madrays
aebce53450 重构用户卡片页面 2025-04-02 01:34:30 +08:00
jxxghp
3c261a2c29 调整卡片组件的样式,优化悬停效果,提升用户体验 2025-04-01 18:52:07 +08:00
jxxghp
6a6a3bd463 优化 VCard 组件的样式,移除多余的圆角设置,提升界面一致性 2025-04-01 13:20:25 +08:00
jxxghp
ae62847ded 优化组件结构,调整 VCard 使用,提升界面一致性和可读性 2025-04-01 13:17:07 +08:00
jxxghp
c873787a89 调整 VCard 组件的阴影层级,优化资源列表容器的结构 2025-04-01 07:15:30 +08:00
jxxghp
410ff78ef5 优化 SiteCard 组件,调整样式和结构,提升可读性和用户体验 2025-04-01 00:06:31 +08:00
jxxghp
ed53fbae93 Merge pull request #318 from madrays/v2 2025-03-31 23:43:05 +08:00
madrays
a3d8aa6a33 重构站点页面 2025-03-31 23:24:14 +08:00
jxxghp
24d03431c4 Merge pull request #317 from cddjr/fix_bangumi 2025-03-31 21:13:23 +08:00
景大侠
1d40d4a329 fix Bangumi每日放送 2025-03-31 20:25:05 +08:00
jxxghp
564896d99d 更新 TransferHistoryView.vue 2025-03-31 19:54:42 +08:00
jxxghp
2b2e25202d 优化 SiteCard 组件的样式,调整内边距和布局,改善用户界面 2025-03-31 19:45:49 +08:00
jxxghp
9055b95d00 调整多个组件的样式,修正高度计算并移除 scoped 样式 2025-03-31 19:27:47 +08:00
jxxghp
5a8eb5b10e 优化多个组件的样式,添加 scoped 样式以避免样式冲突 2025-03-31 18:44:56 +08:00
jxxghp
3e36cb6e31 优化 TorrentRowListView 组件的样式,调整过滤项显示和内边距 2025-03-31 16:04:13 +08:00
jxxghp
6b4b44aec6 优化 TorrentCard 和 TorrentItem 组件的样式,调整媒体标题和站点名称的布局 2025-03-31 15:31:52 +08:00
jxxghp
91a10c9d28 优化 TorrentCard 和 TorrentItem 组件的样式,调整行数和媒体查询的响应式设计 2025-03-31 15:03:21 +08:00
jxxghp
d7fbbd2d28 调整 FileBrowser 组件的样式,修正外层 DIV 和文件列表的高度计算 2025-03-31 14:26:45 +08:00
jxxghp
7b171e2c6f 优化移动端头部和筛选菜单的样式,调整间距和对话框尺寸 2025-03-31 14:24:15 +08:00
jxxghp
90ecaa1891 更新 TorrentCard.vue 2025-03-31 13:49:59 +08:00
jxxghp
842f7401a0 更新 package.json 2025-03-31 13:40:30 +08:00
jxxghp
77a6c591ff 优化 AddDownloadDialog 组件的样式,调整下载器和保存目录选择器的显示效果 2025-03-31 13:35:36 +08:00
jxxghp
9bd3aebd73 重构 TorrentItem 组件,移除未使用的函数,优化样式和过滤器菜单 2025-03-31 13:24:14 +08:00
jxxghp
b70d03e86b 优化 TorrentCard 和 TorrentItem 组件的样式,调整过滤器相关 UI 组件的显示效果 2025-03-31 13:03:56 +08:00
jxxghp
7d1ff9876f 更新 TorrentRowListView.vue 2025-03-31 12:07:10 +08:00
jxxghp
2cd8303191 更新 TorrentCardListView.vue 2025-03-31 12:06:37 +08:00
jxxghp
21dbaf6db5 优化文件浏览器样式,增加文件列表大小限制,调整文件导航器内边距 2025-03-31 12:02:15 +08:00
jxxghp
f9f45d9e32 fix FileBrowser UI 2025-03-31 11:33:47 +08:00
jxxghp
ef5db9ee4b fix ui 2025-03-30 19:54:55 +08:00
jxxghp
a909cdc21c rollback menu layout 2025-03-30 18:02:25 +08:00
jxxghp
b8e546a584 Merge pull request #316 from madrays/v2 2025-03-30 17:35:25 +08:00
madrays
c4f54dcddc 修复搜索结果卡片视图筛选可视性,优化季集显示位置,重构nodatafound情形ui,重构文件管理器ui并增加文件树功能,优化侧边栏高度规避竖向滑动条 2025-03-30 17:25:34 +08:00
jxxghp
59b5e4a330 更新 package.json 2025-03-30 01:16:20 +08:00
jxxghp
f8f7275438 Merge pull request #315 from madrays/v2 2025-03-30 00:55:25 +08:00
madrays
6eec2e97f9 Merge branch 'v2' of https://github.com/madrays/MoviePilot-Frontend into v2 2025-03-30 00:47:22 +08:00
madrays
9020494f65 改进搜索体验:优化搜索对话框、进度条及无数据提示 2025-03-30 00:46:12 +08:00
madrays
43fbc7abd7 改进搜索体验:优化搜索对话框、进度条及无数据提示 2025-03-30 00:37:48 +08:00
jxxghp
d65a4b747d Merge pull request #314 from madrays/v2 2025-03-29 22:35:55 +08:00
madrays
849fad8a8a 优化移动端界面:修复列表视图筛选栏固定问题,优化头部布局减少空间占用 2025-03-29 22:31:46 +08:00
jxxghp
f0b2d14502 fix: 更新 TorrentCard 组件以支持多个站点图标的加载和显示 2025-03-29 20:50:55 +08:00
jxxghp
fa169fb785 fix: 优化初始加载状态的条件判断 2025-03-29 20:07:48 +08:00
jxxghp
49acf7fba3 fix: 重置加载进度值并调整搜索进度卡的内边距 2025-03-29 20:05:38 +08:00
jxxghp
80d55dae8d 更新 resource.vue 2025-03-29 19:41:55 +08:00
jxxghp
76aa5407a2 更新 TorrentRowListView.vue 2025-03-29 19:03:30 +08:00
jxxghp
d70789934f 更新 TorrentCardListView.vue 2025-03-29 19:03:03 +08:00
jxxghp
398e8b6afc feat: 优化过滤器按钮显示逻辑,支持动态显示和已选择过滤项 2025-03-29 18:28:56 +08:00
jxxghp
593fede47c feat: 更新主题颜色和背景色 2025-03-29 17:04:49 +08:00
jxxghp
40c7e9c126 chore: 更新版本号至 2.3.6 2025-03-29 15:42:20 +08:00
jxxghp
54e2f70ee0 feat: 优化 TorrentCard 组件标题显示,支持多行文本截断 2025-03-29 08:27:03 +08:00
jxxghp
81f85b9e46 feat: 优化搜索结果界面UI,感谢 @madrays 2025-03-29 08:11:13 +08:00
jxxghp
60a5476e59 Merge pull request #313 from cddjr/trimemedia
初步支持飞牛影视
2025-03-28 19:28:02 +08:00
景大侠
4271b63530 初步支持飞牛影视 2025-03-28 15:51:55 +08:00
jxxghp
8aca17f0c6 fix: 更新 AliyunAuthDialog 以使用二维码 URL 并调整状态处理逻辑 2025-03-28 13:39:36 +08:00
jxxghp
4f238dc1a3 fix: 移除 AliyunAuthDialog 和 U115AuthDialog 中的 refreshToken 相关代码 2025-03-25 12:57:42 +08:00
jxxghp
d4777fde70 fix: 移除新建文件夹对话框的 v-if 条件 2025-03-24 13:32:30 +08:00
jxxghp
b6c823c386 chore: 更新版本号至2.3.5 2025-03-23 16:39:56 +08:00
jxxghp
b7488214fc feat: 添加全选/全不选功能及按钮文本更新 2025-03-23 12:28:30 +08:00
jxxghp
06b6c3f3cb fix: 调整对话框最大宽度至45rem 2025-03-22 14:11:27 +08:00
jxxghp
abfaf926c4 fix #305 2025-03-22 10:03:11 +08:00
jxxghp
6eabeb09c9 fix #293 2025-03-22 09:53:21 +08:00
jxxghp
a15afabfa7 fix #310 2025-03-22 09:27:27 +08:00
jxxghp
30276d5022 fixhttps://github.com/jxxghp/MoviePilot/issues/4002 2025-03-22 08:08:50 +08:00
jxxghp
683ddc3fce fix: 更新二维码获取逻辑,修复定时器设置位置并优化提示信息 2025-03-21 20:32:05 +08:00
jxxghp
f00f79279b Merge pull request #309 from Aqr-K/fix/settings 2025-03-21 09:55:11 +08:00
Aqr-K
7989965b1a fix: VTextarea no longer displays all rows 2025-03-16 22:19:52 +08:00
jxxghp
5b84ce307b fix: 移除对话框的 persistent 属性 2025-03-15 11:52:16 +08:00
180 changed files with 26440 additions and 6767 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
}
}

2
components.d.ts vendored
View File

@@ -12,8 +12,10 @@ declare module 'vue' {
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
LoadingBanner: typeof import('./src/@core/components/LoadingBanner.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,161 @@
<!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="#28243D" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" />
<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">
<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 Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.3.4",
"version": "2.4.1",
"private": true,
"bin": "dist/service.js",
"scripts": {
@@ -26,6 +26,7 @@
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@iconify/utils": "^2.2.1",
"@types/js-cookie": "^3.0.6",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.42.1",
@@ -43,6 +44,7 @@
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
@@ -71,6 +73,7 @@
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^20.1.4",
"@types/nprogress": "^0.2.3",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
@@ -108,4 +111,4 @@
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
}
}

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

@@ -18,10 +18,12 @@ const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
const { state: currentThemeName, next: getNextThemeName } = useCycleList(
props.themes.map(t => t.name),
{ initialValue: savedTheme.value },
)
const currentThemeName = ref(savedTheme.value)
const getNextThemeName = () => {
const currentIndex = props.themes.findIndex(t => t.name === currentThemeName.value)
const nextIndex = (currentIndex + 1) % props.themes.length
return props.themes[nextIndex].name
}
const $toast = useToast()
@@ -34,76 +36,17 @@ 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()
// 保存原始主题设置,而不是计算后的值
savedTheme.value = currentThemeName.value
// 保存主题到本地
saveLocalTheme(theme, globalTheme)
saveLocalTheme(currentThemeName.value, globalTheme)
// 刷新页面
location.reload()
}
// 切换主题
@@ -117,7 +60,7 @@ function changeTheme(theme: string) {
theme: nextTheme,
})
} catch (e) {
console.error('保存主题到服务端失败')
console.error(e)
}
}
@@ -187,38 +130,51 @@ onMounted(() => {
</script>
<template>
<VMenu v-if="props.themes">
<VMenu v-if="props.themes" class="theme-menu" scrim>
<template v-slot:activator="{ props }">
<IconBtn v-bind="props">
<VIcon :icon="getThemeIcon" />
</IconBtn>
</template>
<VList>
<VListItem v-for="theme in props.themes" :key="theme.name" @click="changeTheme(theme.name)">
<template #prepend>
<VIcon :icon="theme.icon" />
</template>
<VListItemTitle>{{ theme.title }}</VListItemTitle>
</VListItem>
<VListItem @click="cssDialog = true">
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
<VListItemTitle>自定义</VListItemTitle>
</VListItem>
<div class="px-2">
<VListItem
v-for="theme in props.themes"
:key="theme.name"
@click="changeTheme(theme.name)"
:active="currentThemeName === theme.name"
class="mb-1"
>
<template #prepend>
<VIcon :icon="theme.icon" />
</template>
<VListItemTitle>{{ theme.title }}</VListItemTitle>
<template #append v-if="currentThemeName === theme.name">
<VIcon icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
<VDivider class="my-2" />
<VListItem @click="cssDialog = true">
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
<VListItemTitle>自定义主题</VListItemTitle>
</VListItem>
</div>
</VList>
</VMenu>
<!-- 自定义 CSS -- -->
<VDialog v-if="cssDialog" v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard title="自定义主题风格">
<DialogCloseBtn @click="cssDialog = false" />
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="me-2" />
自定义主题风格
</VCardTitle>
<VDialogCloseBtn @click="cssDialog = false" />
</VCardItem>
<VDivider />
<VAceEditor
v-model:value="customCSS"
lang="css"
:theme="editorTheme"
style="block-size: 100%; min-block-size: 30rem"
/>
<VAceEditor v-model:value="customCSS" lang="css" :theme="editorTheme" class="w-full min-h-[30rem]" />
<VDivider />
<VCardText class="text-center">
<VBtn @click="saveCustomCSS" class="w-1/2">
@@ -231,19 +187,3 @@ onMounted(() => {
</VCard>
</VDialog>
</template>
<style lang="sass">
// Theme transition
.app-copy
position: fixed !important
z-index: -1 !important
pointer-events: none !important
contain: size style !important
overflow: clip !important
.app-transition
--clip-size: 0
--clip-pos: 0 0
clip-path: circle(var(--clip-size) at var(--clip-pos))
transition: clip-path .35s ease-out
</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,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,250 +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(#{variables.$vertical-nav-background-color-rgb}) 5%,
rgba(#{variables.$vertical-nav-background-color-rgb}, 75%) 45%,
rgba(#{variables.$vertical-nav-background-color-rgb}, 20%) 80%,
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
z-index: 1;
}
// 👉 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

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

View File

@@ -59,9 +59,6 @@ function handleNavScroll(evt: Event) {
</RouterLink>
</slot>
</div>
<slot name="before-nav-items">
<div class="vertical-nav-items-shadow" />
</slot>
<slot name="nav-items" :update-is-vertical-nav-scrolled="updateIsVerticalNavScrolled">
<PerfectScrollbar
tag="ul"

View File

@@ -101,6 +101,12 @@ export default defineComponent({
@use '@layouts/styles/placeholders';
@use '@layouts/styles/mixins';
.layout-page-content {
position: relative;
z-index: 1;
margin-block-start: 0;
}
.layout-wrapper.layout-nav-type-vertical {
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
block-size: 100%;
@@ -116,8 +122,8 @@ export default defineComponent({
.layout-navbar {
position: fixed;
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
inset-block-start: 0;
.navbar-content-container {
@@ -131,7 +137,7 @@ export default defineComponent({
@include mixins.boxed-content;
} @else {
.navbar-content-container {
@include mixins.boxed-content;
// @include mixins.boxed-content;
}
}
}
@@ -171,7 +177,7 @@ export default defineComponent({
}
&:not(.layout-overlay-nav) .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-width;
padding-inline-start: calc(variables.$layout-vertical-nav-width);
}
// Adjust right column pl when vertical nav is collapsed
@@ -203,7 +209,8 @@ export default defineComponent({
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
.layout-navbar {
width: 100%;
inline-size: 100%;
padding-inline: 0;
}
}
</style>

View File

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

View File

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

View File

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

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,7 @@
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api'
// 生效主题
const { global: globalTheme } = useTheme()
@@ -9,9 +10,88 @@ let themeValue = localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 更新data-theme属性以便CSS选择器能正确匹配
function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
// 确保body元素也有相同的主题属性以便更好地选择弹出窗口
document.body.setAttribute('data-theme', themeName)
}
// 显示状态
const show = ref(false)
// 背景图片
const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
let backgroundRotationTimer: NodeJS.Timeout | null = null
// 获取背景图片
async function fetchBackgroundImages() {
try {
backgroundImages.value = await api.get('/login/wallpapers')
} catch (e) {
console.error(e)
}
}
// 开始背景图片轮换
function startBackgroundRotation() {
if (backgroundRotationTimer) clearInterval(backgroundRotationTimer)
if (backgroundImages.value.length > 1) {
backgroundRotationTimer = setInterval(() => {
activeImageIndex.value = (activeImageIndex.value + 1) % backgroundImages.value.length
}, 10000) // 每10秒切换一次
}
}
// 计算图片地址
function getImgUrl(url: string) {
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
}
// 处理页面可见性变化
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && isTransparentTheme.value) {
// 如果已有背景图片数据,直接重启轮换
if (backgroundImages.value.length > 0) {
startBackgroundRotation()
}
// 如果没有背景图片数据,重新获取
else {
fetchBackgroundImages().then(() => startBackgroundRotation())
}
}
}
// 监听主题变化
watch(
() => globalTheme.name.value,
async newTheme => {
// 更新HTML属性
updateHtmlThemeAttribute(newTheme)
if (newTheme === 'transparent' && backgroundImages.value.length === 0) {
await fetchBackgroundImages()
startBackgroundRotation()
} else if (newTheme !== 'transparent' && backgroundRotationTimer) {
clearInterval(backgroundRotationTimer)
backgroundRotationTimer = null
}
},
{ immediate: true },
)
// ApexCharts 全局配置
declare global {
interface Window {
@@ -43,6 +123,12 @@ if (window.Apex) {
}
onMounted(() => {
// 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value)
// 添加页面可见性变化监听
document.addEventListener('visibilitychange', handleVisibilityChange)
ensureRenderComplete(() => {
nextTick(() => {
setTimeout(() => {
@@ -56,10 +142,96 @@ onMounted(() => {
})
})
})
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="isTransparentTheme && backgroundImages.length > 0">
<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 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

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

View File

@@ -77,7 +77,9 @@ export interface Subscribe {
// 过滤规则组
filter_groups?: string[]
// 下载器
downloader: string
downloader?: string
// 自定义剧集组
episode_group?: string
}
// 订阅分享
@@ -138,6 +140,8 @@ export interface SubscribeShare {
media_category?: string
// 复用次数
count?: number
// 自定义剧集组
episode_group?: string
}
// 历史记录
@@ -184,6 +188,8 @@ export interface TransferHistory {
errmsg?: string
// 日期
date?: string
// 源文件项
src_fileitem?: FileItem
}
// 媒体信息
@@ -286,6 +292,8 @@ export interface MediaInfo {
next_episode_to_air?: object
// 别名
names?: string[]
// 剧集组
episode_group?: string
}
// 季信息
@@ -795,6 +803,8 @@ export interface User {
permissions: { [key: string]: any }
// 用户个性化设置 json
settings: { [key: string]: string | null }
// 昵称
nickname?: string
}
// 存储空间
@@ -1212,6 +1222,8 @@ export interface TransferForm {
library_type_folder?: boolean
// 媒体库类别子目录
library_category_folder?: boolean
// 剧集组编号
episode_group?: string
}
// 整理队列
@@ -1253,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -1,9 +1,10 @@
<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'
// 输入参数
const props = defineProps({
@@ -11,7 +12,7 @@ const props = defineProps({
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Object as PropType<Axios>,
type: Function,
required: true,
},
axiosconfig: Object,
@@ -28,6 +29,12 @@ const props = defineProps({
// 对外事件
const emit = defineEmits(['pathchanged'])
// 显示器宽度
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
const fileIcons = {
// 压缩包
zip: 'mdi-folder-zip-outline',
@@ -126,6 +133,9 @@ const refreshPending = ref(false)
// 排序
const sort = ref('name')
// 是否显示目录树
const showDirTree = ref(false)
// 计算属性
const storagesArray = computed(() => {
const storageCodes = props.storages?.map(item => item.type)
@@ -154,10 +164,37 @@ function sortChanged(s: string) {
sort.value = s
refreshPending.value = true
}
// 切换目录树
function switchDirTree(state: boolean) {
showDirTree.value = state
}
// 文件列表
const fileListItems = ref<FileItem[]>([])
// 文件列表数据更新
function fileListUpdated(items: FileItem[]) {
fileListItems.value = items
}
// 外层DIV大小控制
const scrollStyle = computed(() => {
return appMode
? '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) - 7rem)'
: 'height: calc(100vh - 14rem - env(safe-area-inset-bottom)'
})
</script>
<template>
<VCard class="mx-auto" :loading="loading > 0">
<div class="mx-auto" :loading="loading > 0">
<div v-if="activeStorage && item">
<FileToolbar
:item="item"
@@ -171,20 +208,35 @@ function sortChanged(s: string) {
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<FileList
:item="item"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axios"
:refreshpending="refreshPending"
:sort="sort"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
/>
<div class="flex" :style="scrollStyle">
<FileNavigator
v-if="showDirTree"
:storage="activeStorage"
:currentPath="item.path"
:items="fileListItems"
:endpoints="endpoints"
:axios="axios"
@navigate="pathChanged"
/>
<FileList
:item="item"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axios"
:refreshpending="refreshPending"
:sort="sort"
:listStyle="fileListStyle"
:showTree="showDirTree"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
@items-updated="fileListUpdated"
@switch-tree="switchDirTree"
/>
</div>
</div>
</VCard>
</div>
</template>

View File

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

View File

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

View File

@@ -95,7 +95,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>
@@ -106,7 +106,7 @@ function onClose() {
</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" />
<VDialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
<VForm>

View File

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

View File

@@ -143,38 +143,45 @@ onUnmounted(() => {
</script>
<template>
<div>
<VCard variant="tonal" @click="openDownloaderInfoDialog">
<DialogCloseBtn @click="onClose" />
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="flex justify-space-between align-center gap-4">
<div class="align-self-start flex-1">
<div class="flex items-center">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
inline
color="success"
class="me-1"
/>
<span class="text-h6">{{ downloader.name }}</span>
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
variant="tonal"
@click="openDownloaderInfoDialog"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VDialogCloseBtn @click="onClose" />
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="flex justify-space-between align-center gap-4">
<div class="align-self-start flex-1">
<div class="flex items-center">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
inline
color="success"
class="me-1"
/>
<span class="text-h6">{{ downloader.name }}</span>
</div>
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
<span class="me-2">{{ `${formatFileSize(upload_rate, 1)}/s ` }}</span>
<span>{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
</div>
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
<span class="me-2">{{ `${formatFileSize(upload_rate, 1)}/s ` }}</span>
<span>{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</div>
</div>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</div>
</VCardText>
</VCard>
</VCardText>
</VCard>
</VHover>
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="downloaderInfoDialog" />
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm>

View File

@@ -47,7 +47,7 @@ onMounted(() => {
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VCardTitle>优先级 {{ props.pri }}</VCardTitle>
<VRow>

View File

@@ -208,7 +208,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.group.name }}</h5>
@@ -222,7 +222,7 @@ function onClose() {
</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" />
<VDialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
<VRow class="mt-1">

View File

@@ -3,6 +3,7 @@ import type { MediaServerLibrary } from '@/api/types'
import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png'
import trimemedia from '@images/logos/trimemedia.png'
// 输入参数
const props = defineProps({
@@ -38,6 +39,7 @@ function getDefaultImage() {
if (props.media?.server === 'plex') return plex
else if (props.media?.server === 'emby') return emby
else if (props.media?.server === 'jellyfin') return jellyfin
else if (props.media?.server === 'trimemedia') return trimemedia
else return plex
}
@@ -156,7 +158,7 @@ onMounted(async () => {
:height="props.height"
:width="props.width"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
@click="goPlay"
>

View File

@@ -1,17 +1,18 @@
<script lang="ts" setup>
import type { PropType, Ref } from 'vue'
import { useToast } from 'vue-toast-notification'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, NotExistMediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
import router, { registerAbortController } from '@/router'
import noImage from '@images/no-image.jpeg'
import tmdbImage from '@images/logos/tmdb.png'
import doubanImage from '@images/logos/douban-black.png'
import bangumiImage from '@images/logos/bangumi.png'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
import router, { registerAbortController } from '@/router'
import { useUserStore } from '@/stores'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
// 输入参数
const props = defineProps({
@@ -35,18 +36,12 @@ const isImageLoaded = ref(false)
// 图片加载失败
const imageLoadError = ref(false)
// TMDB识别标志
const tmdbFlag = ref(true)
// 当前订阅状态
const isSubscribed = ref(false)
// 本地存在状态
const isExists = ref(false)
// 各季缺失状态0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
const seasonsNotExisted = ref<{ [key: number]: number }>({})
// 订阅季弹窗
const subscribeSeasonDialog = ref(false)
@@ -56,9 +51,6 @@ const subscribeEditDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
// 季详情
const seasonInfos = ref<MediaSeason[]>([])
// 选中的订阅季
const seasonsSelected = ref<MediaSeason[]>([])
@@ -84,6 +76,12 @@ const selectedSites = ref<number[]>([])
// 搜索菜单显示状态
const searchMenuShow = ref(false)
// 选择站点对话框
const chooseSiteDialog = ref(false)
// 选择的剧集组
const episodeGroup = ref('')
// 查询所有站点
async function querySites() {
try {
@@ -100,7 +98,6 @@ async function querySites() {
async function querySelectedSites() {
try {
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
selectedSites.value = result.data?.value ?? []
} catch (error) {
console.log(error)
@@ -115,14 +112,6 @@ function getMediaId() {
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
}
// 订阅弹窗选择的多季
function subscribeSeasons() {
subscribeSeasonDialog.value = false
seasonsSelected.value.forEach(season => {
addSubscribe(season.season_number)
})
}
// 角标颜色
function getChipColor(type: string) {
if (type === '电影') return 'border-blue-500 bg-blue-600'
@@ -133,24 +122,9 @@ function getChipColor(type: string) {
// 添加订阅处理
async function handleAddSubscribe() {
if (props.media?.type === '电视剧') {
// 查询所有季信息
await getMediaSeasons()
if (!seasonInfos.value || seasonInfos.value.length === 0) {
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
return
}
// 检查各季的缺失状态
await checkSeasonsNotExists()
if (!tmdbFlag.value) return
if (seasonInfos.value.length === 1) {
// 添加订阅
addSubscribe(1)
} else {
// 弹出季选择列表,支持多选
seasonsSelected.value = []
subscribeSeasonDialog.value = true
}
// 弹出季选择列表,支持多选
seasonsSelected.value = []
subscribeSeasonDialog.value = true
} else {
// 电影
addSubscribe()
@@ -158,15 +132,12 @@ async function handleAddSubscribe() {
}
// 调用API添加订阅电视剧的话需要指定季
async function addSubscribe(season = 0) {
async function addSubscribe(season: number = 0, best_version: number = 0) {
// 开始处理
startNProgress()
try {
// 是否洗版
let best_version = isExists.value ? 1 : 0
if (season && props.media?.tmdb_id)
// 全部存在时洗版
best_version = !seasonsNotExisted.value[season] ? 1 : 0
if (!best_version && props.media?.type == '电影') best_version = isExists.value ? 1 : 0
// 请求API
const result: { [key: string]: any } = await api.post('subscribe/', {
name: props.media?.title,
@@ -178,6 +149,7 @@ async function addSubscribe(season = 0) {
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
season,
best_version,
episode_group: episodeGroup.value,
})
// 订阅状态
@@ -297,48 +269,6 @@ async function checkSubscribe(season = 0) {
return null
}
// 检查所有季的缺失状态(数据库)
async function checkSeasonsNotExists() {
// 开始处理
startNProgress()
try {
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
if (result) {
result.forEach(item => {
// 0-已入库 1-部分缺失 2-全部缺失
let state = 0
if (item.episodes.length === 0) state = 2
else if (item.episodes.length < item.total_episode) state = 1
seasonsNotExisted.value[item.season] = state
})
}
} catch (error) {
$toast.error(`${props.media?.title}无法识别TMDB媒体信息`)
tmdbFlag.value = false
} finally {
// 处理完成
doneNProgress()
}
}
// 查询TMDB的所有季信息
async function getMediaSeasons() {
startNProgress()
try {
seasonInfos.value = await api.get('media/seasons', {
params: {
mediaid: getMediaId(),
title: props.media?.title,
year: props.media?.year,
season: props.media?.season,
},
})
} catch (error) {
console.error(error)
}
doneNProgress()
}
// 查询订阅弹窗规则
async function queryDefaultSubscribeConfig() {
// 非管理员不显示
@@ -347,9 +277,7 @@ async function queryDefaultSubscribeConfig() {
let subscribe_config_url = ''
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.get(subscribe_config_url)
if (result.data?.value) return result.data.value.show_edit_dialog
} catch (error) {
console.log(error)
@@ -363,24 +291,18 @@ function handleSubscribe() {
else handleAddSubscribe()
}
// 计算存在状态的颜色
function getExistColor(season: number) {
const state = seasonsNotExisted.value[season]
if (!state) return 'success'
if (state === 1) return 'warning'
else if (state === 2) return 'error'
else return 'success'
}
// 计算存在状态的文本
function getExistText(season: number) {
const state = seasonsNotExisted.value[season]
if (!state) return '已入库'
if (state === 1) return '部分缺失'
else if (state === 2) return '缺失'
else return '已入库'
// 订阅多季
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
subscribeSeasonDialog.value = false
episodeGroup.value = groudId
seasonsSelected.value = seasons || []
seasonsSelected.value.forEach(season => {
let best_version = 0
if (season && props.media?.tmdb_id)
// 全部存在时洗版
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
addSubscribe(season.season_number, best_version)
})
}
// 打开详情页
@@ -411,9 +333,11 @@ function goMediaDetail(isHovering = false) {
// 点击搜索
async function clickSearch() {
if (allSites.value?.length > 0) return
querySites()
querySelectedSites()
if (allSites.value?.length == 0) {
querySites()
querySelectedSites()
}
chooseSiteDialog.value = true
}
// 开始搜索
@@ -432,6 +356,13 @@ function handleSearch() {
})
}
// 搜索多站点
function searchSites(sites: number[]) {
chooseSiteDialog.value = false
selectedSites.value = sites
handleSearch()
}
// 懒加载检查
function handleCheckLazy() {
if (props.media?.collection_id) {
@@ -484,26 +415,6 @@ const getImgUrl: Ref<string> = computed(() => {
return url
})
// 拼装季图片地址
function getSeasonPoster(posterPath: string) {
if (!posterPath) return ''
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
}
// 将yyyy-mm-dd转换为yyyy年mm月dd日
function formatAirDate(airDate: string) {
if (!airDate) return ''
const date = new Date(airDate.replaceAll(/-/g, '/'))
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
// 从yyyy-mm-dd中提取年份
function getYear(airDate: string) {
if (!airDate) return ''
const date = new Date(airDate.replaceAll(/-/g, '/'))
return date.getFullYear()
}
// 移除订阅
function onRemoveSubscribe() {
subscribeEditDialog.value = false
@@ -518,9 +429,9 @@ function onRemoveSubscribe() {
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500 rounded-lg"
class="outline-none ring-gray-500 media-card"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering ?? false)"
@@ -539,6 +450,7 @@ function onRemoveSubscribe() {
</div>
</template>
</VImg>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError || searchMenuShow"
@@ -554,31 +466,7 @@ function onRemoveSubscribe() {
</p>
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
<div v-else class="flex align-center justify-between">
<VMenu close-on-content-click v-model="searchMenuShow" max-width="450">
<template v-slot:activator="{ props }">
<IconBtn v-bind="props" icon="mdi-magnify" color="white" @click.stop="clickSearch" />
</template>
<VList>
<VListItem>
<VChipGroup v-model="selectedSites" column multiple @click.stop>
<VChip
v-for="site in allSites"
:key="site.id"
:color="selectedSites.includes(site.id) ? 'primary' : ''"
filter
variant="outlined"
:value="site.id"
size="small"
>
{{ site.name }}
</VChip>
</VChipGroup>
</VListItem>
<VListItem>
<VBtn @click="handleSearch" block>搜索</VBtn>
</VListItem>
</VList>
</VMenu>
<IconBtn icon="mdi-magnify" color="white" @click.stop="clickSearch" />
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
</div>
</VCardText>
@@ -588,7 +476,7 @@ 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 }}
</VChip>
@@ -600,7 +488,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>
@@ -619,62 +507,13 @@ function onRemoveSubscribe() {
</template>
</VHover>
<!-- 订阅季弹窗 -->
<VBottomSheet v-if="subscribeSeasonDialog" v-model="subscribeSeasonDialog" inset scrollable>
<VCard class="rounded-t">
<DialogCloseBtn @click="subscribeSeasonDialog = false" />
<VCardItem>
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
<template #prepend>
<VImg
height="90"
width="60"
:src="getSeasonPoster(item.poster_path || '')"
aspect-ratio="2/3"
class="object-cover rounded shadow ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle> {{ item.season_number }} </VListItemTitle>
<VListItemSubtitle class="mt-1 me-2">
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
<VIcon icon="mdi-star" /> {{ item.vote_average }}
</VChip>
{{ getYear(item.air_date || '') }} {{ item.episode_count }}
</VListItemSubtitle>
<VListItemSubtitle>
{{ media?.title }} {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播
</VListItemSubtitle>
<VListItemSubtitle>
<VChip v-if="seasonsNotExisted" class="mt-2" size="small" :color="getExistColor(item.season_number || 0)">
{{ getExistText(item.season_number || 0) }}
</VChip>
</VListItemSubtitle>
<template #append="{ isSelected }">
<VListItemAction start>
<VSwitch :model-value="isSelected" />
</VListItemAction>
</template>
</VListItem>
</VList>
</VCardText>
<div class="my-2 text-center">
<VBtn size="large" :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
</VBtn>
</div>
</VCard>
</VBottomSheet>
<subscribeSeasonDialog
v-if="subscribeSeasonDialog"
v-model="subscribeSeasonDialog"
:media="media"
@subscribe="subscribeSeasons"
@close="subscribeSeasonDialog = false"
/>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
@@ -684,4 +523,13 @@ function onRemoveSubscribe() {
@save="subscribeEditDialog = false"
@remove="onRemoveSubscribe"
/>
<!-- 站点选择对话框 -->
<SearchSiteDialog
v-if="chooseSiteDialog"
v-model="chooseSiteDialog"
:sites="allSites"
:selected="selectedSites"
@search="searchSites"
@close="chooseSiteDialog = false"
/>
</template>

View File

@@ -4,6 +4,7 @@ import { useToast } from 'vue-toast-notification'
import emby_image from '@images/logos/emby.png'
import jellyfin_image from '@images/logos/jellyfin.png'
import plex_image from '@images/logos/plex.png'
import trimemedia_image from '@images/logos/trimemedia.png'
import api from '@/api'
import { cloneDeep } from 'lodash-es'
@@ -101,6 +102,8 @@ const getIcon = computed(() => {
return emby_image
case 'jellyfin':
return jellyfin_image
case 'trimemedia':
return trimemedia_image
default:
return plex_image
}
@@ -172,7 +175,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>
@@ -187,7 +190,7 @@ onMounted(() => {
</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" />
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
<VForm>
@@ -278,6 +281,44 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'trimemedia'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="mediaServerInfo.config.username" label="用户名" active />
</VCol>
<VCol cols="12" md="6">
<VTextField type="password" v-model="mediaServerInfo.config.password" label="密码" active />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField

View File

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

@@ -66,7 +66,6 @@ const notificationTypes = [
function openNotificationInfoDialog() {
// 替换成深复制,避免修改时影响原数据
notificationInfo.value = cloneDeep(props.notification)
console.log(`当前卡片的通知信息:${JSON.stringify(notificationInfo.value)}`)
notificationInfoDialog.value = true
}
@@ -120,7 +119,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">
@@ -134,7 +133,7 @@ function onClose() {
</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" />
<VDialogCloseBtn v-model="notificationInfoDialog" />
<VDivider />
<VCardText>
<VForm>

View File

@@ -77,14 +77,13 @@ function goPersonDetail() {
v-bind="hover.props"
:height="personProps.height"
:width="personProps.width"
class="rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
@click.stop="goPersonDetail"
>
<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">
@@ -116,12 +115,20 @@ function goPersonDetail() {
</VHover>
</template>
<style lang="scss">
<style lang="scss" scoped>
.person-card {
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
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

@@ -147,78 +147,86 @@ const dropdownItems = ref([
<template>
<div>
<VCard :width="props.width" :height="props.height" @click="detailDialog = true" class="flex flex-col h-full">
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
></div>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ...">
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="text-white text-sm px-2 py-0 text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="detailDialog = true"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
></div>
<div class="relative flex-1 min-w-0">
<VCardTitle
class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ..."
>
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="text-white text-sm px-2 py-0 text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" class="me-1" />
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
<div class="me-n3 absolute bottom-1 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem v-for="(item, i) in dropdownItems" v-show="item.show" :key="i" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" class="me-1" />
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
<div class="me-n3 absolute bottom-1 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</VCardText>
</VCard>
</VCard>
</template>
</VHover>
<!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
</VCard>
@@ -226,7 +234,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">
@@ -237,7 +245,6 @@ const dropdownItems = ref([
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>

View File

@@ -335,6 +335,9 @@ watch(
:height="props.height"
@click="openPluginDetail"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
@@ -354,14 +357,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"
/>
@@ -390,7 +392,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"
>
@@ -404,10 +405,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>
@@ -437,9 +435,9 @@ watch(
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" max-height="80vh" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
<VDivider />

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 rounded-lg"
class="outline-none ring-gray-500"
:class="{
'transition transform-cpu duration-300 scale-105 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,5 +1,6 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import noImage from '@images/logos/site.webp'
import { useToast } from 'vue-toast-notification'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
@@ -9,6 +10,7 @@ import api from '@/api'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatFileSize } from '@/@core/utils/formatters'
import { useConfirm } from 'vuetify-use-dialog'
// 输入参数
const cardProps = defineProps({
@@ -19,6 +21,9 @@ const cardProps = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['update', 'remove'])
// 确认框
const createConfirm = useConfirm()
// 图标
const siteIcon = ref<string>('')
@@ -50,6 +55,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)
}
@@ -103,6 +111,25 @@ function openSitePage() {
window.open(cardProps.site?.url, '_blank')
}
// 调用API删除站点信息
async function deleteSiteInfo() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认删除站点?`,
})
if (!isConfirmed) return
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}`)
} catch (error) {
$toast.error(`${cardProps.site?.name} 删除失败!`)
console.error(error)
}
}
// 根据站点状态显示不同的状态图标
const statColor = computed(() => {
if (isNullOrEmptyObject(siteStats.value)) {
@@ -117,10 +144,28 @@ const statColor = computed(() => {
}
})
// 计算上传量和下载量的百分比
const getPercentage = computed(() => {
if (cardProps.data?.upload === 0) return 100
return ((cardProps.data?.download ?? 0) / ((cardProps.data?.download ?? 0) + (cardProps.data?.upload ?? 0))) * 100
// 数据百分比计算
const getMaxDataValue = computed(() => {
// 获取站点数据中的最大值作为基准
const upload = cardProps.data?.upload || 0
const download = cardProps.data?.download || 0
// 避免两者都为0的情况
if (upload === 0 && download === 0) return 1
return Math.max(upload, download)
})
// 上传百分比
const getUploadPercent = computed(() => {
const upload = cardProps.data?.upload || 0
return Math.min(100, Math.max(3, (upload / getMaxDataValue.value) * 100))
})
// 下载百分比
const getDownloadPercent = computed(() => {
const download = cardProps.data?.download || 0
return Math.min(100, Math.max(3, (download / getMaxDataValue.value) * 100))
})
// 保存站点
@@ -151,98 +196,161 @@ onMounted(() => {
<template>
<div>
<VCard
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
class="overflow-hidden h-full flex flex-col"
@click="handleResourceBrowse"
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 ? '' : 'opacity-70',
{
'border-error': statColor === 'error',
'border-warning': statColor === 'warning',
'border-success': statColor === 'success',
},
]"
:ripple="false"
variant="flat"
elevation="0"
rounded="lg"
hover
@click="siteEditDialog = true"
>
<template #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem style="padding-block-end: 0">
<VCardTitle class="font-bold">
{{ cardProps.site?.name }}
</VCardTitle>
<VCardSubtitle>
{{ cardProps.site?.url }}
</VCardSubtitle>
</VCardItem>
<VCardText class="py-1">
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
</template>
</VTooltip>
<VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
</template>
</VTooltip>
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
</template>
</VTooltip>
<VTooltip v-if="cardProps.site?.filter" text="过滤">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-filter-cog-outline" />
</template>
</VTooltip>
</VCardText>
<VCardActions>
<div class="text-sm">
{{ formatFileSize(cardProps.data?.upload || 0) }} / {{ formatFileSize(cardProps.data?.download || 0) }}
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
<!-- 主体部分 -->
<div class="relative flex-1 flex flex-col p-3 z-1">
<!-- 顶部图标和站点名称 -->
<div class="flex items-center mb-1">
<!-- 站点图标 -->
<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="text-lg font-semibold leading-tight truncate">{{ cardProps.site?.name }}</h3>
<!-- 站点特性图标 -->
<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>
<IconBtn>
<VIcon icon="mdi-chevron-down" color="primary" />
<VMenu activator="parent" close-on-content-click>
<!-- 中间部分网址 -->
<div class="my-3">
<div class="text-sm text-medium-emphasis truncate" @click.stop="openSitePage">
{{ cardProps.site?.url }}
</div>
</div>
<!-- 底部数据统计 -->
<div class="flex-1 flex flex-col justify-end">
<!-- 更直观的上传下载数据条 -->
<div class="border-t mt-1.5 pt-1.5">
<!-- 上传数据 -->
<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="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="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="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>
</div>
</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 variant="plain" @click="siteEditDialog = true" base-color="info">
<VListItem @click="handleResourceBrowse" base-color="info">
<template #prepend>
<VIcon icon="mdi-file-edit-outline" />
<VIcon icon="mdi-web" size="small" />
</template>
<VListItemTitle>编辑站点</VListItemTitle>
<VListItemTitle>浏览资源</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="handleSiteUserData">
<VListItem @click="deleteSiteInfo">
<template #prepend>
<VIcon icon="mdi-chart-bell-curve" />
<VIcon icon="mdi-delete-outline" size="small" color="error" />
</template>
<VListItemTitle>查看站点数据</VListItemTitle>
</VListItem>
<VListItem variant="plain" :disabled="testButtonDisable" @click.stop="testSite">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
<VListItemTitle>{{ testButtonText }}</VListItemTitle>
</VListItem>
<VListItem variant="plain" v-if="!cardProps.site?.public" @click="handleSiteUpdate">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
<VListItemTitle>更新 Cookie & UA</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="openSitePage">
<template #prepend>
<VIcon icon="mdi-open-in-new" />
</template>
<VListItemTitle>访问站点</VListItemTitle>
<VListItemTitle class="text-error">删除站点</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
<VSpacer />
</VCardActions>
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
<span class="absolute top-1 right-8">
<VIcon class="cursor-move">mdi-drag</VIcon>
</span>
<div class="w-full absolute bottom-0" v-if="(cardProps.data?.upload || cardProps.data?.download || 0) > 0">
<VProgressLinear :model-value="getPercentage" bg-color="success" color="warning" bg-opacity="0.5" height="3" />
</div>
</VBtn>
</VSheet>
</VCard>
<!-- 更新站点Cookie & UA弹窗 -->
<!-- 对话框组件 -->
<SiteCookieUpdateDialog
v-if="siteCookieDialog"
v-model="siteCookieDialog"
@@ -250,7 +358,6 @@ onMounted(() => {
@close="siteCookieDialog = false"
@done="onSiteCookieUpdated"
/>
<!-- 站点编辑弹窗 -->
<SiteAddEditDialog
v-if="siteEditDialog"
v-model="siteEditDialog"
@@ -259,14 +366,12 @@ onMounted(() => {
@remove="emit('remove')"
@close="siteEditDialog = false"
/>
<!-- 站点数据弹窗 -->
<SiteUserDataDialog
v-if="siteUserDataDialog"
v-model="siteUserDataDialog"
:site="cardProps.site"
@close="siteUserDataDialog = false"
/>
<!-- 站点资源弹窗 -->
<SiteResourceDialog
v-if="resourceDialog"
v-model="resourceDialog"
@@ -275,3 +380,213 @@ onMounted(() => {
/>
</div>
</template>
<style scoped>
.site-card:hover {
.site-card-actions {
transform: translateX(0);
}
}
.site-status-indicator {
position: absolute;
z-index: 1;
block-size: 2px;
inset-block-start: 0;
inset-inline: 0;
opacity: 0.5;
transition: block-size 0.3s ease, opacity 0.3s ease;
}
.site-status-indicator.error {
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-error), 0.7), transparent);
box-shadow: 0 0 8px rgba(var(--v-theme-error), 0.3);
}
.site-status-indicator.warning {
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-warning), 0.7), transparent);
box-shadow: 0 0 8px rgba(var(--v-theme-warning), 0.3);
}
.site-status-indicator.success {
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-success), 0.7), transparent);
box-shadow: 0 0 8px rgba(var(--v-theme-success), 0.3);
}
.site-status-indicator.secondary {
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-secondary), 0.7), transparent);
box-shadow: 0 0 8px rgba(var(--v-theme-secondary), 0.3);
}
/* 站点卡片悬停时状态指示器变化 */
.site-card:hover .site-status-indicator {
block-size: 2px;
opacity: 0.8;
}
/* 上传下载条样式 */
.upload-bar {
background: linear-gradient(90deg, #4d79ff, #07f);
box-shadow: 0 0 4px rgba(0, 119, 255, 50%);
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;
}
/* 测试状态点样式 */
.pulse-dot::before {
position: absolute;
z-index: 1;
border-radius: 50%;
content: '';
height: 70%;
width: 70%;
top: 15%;
left: 15%;
}
.pulse-dot::after {
position: absolute;
z-index: 2;
border-radius: 50%;
content: '';
height: 100%;
width: 100%;
top: 0;
left: 0;
}
.pulse-dot.error::before {
background-color: rgba(var(--v-theme-error), 1);
box-shadow: 0 0 10px rgba(var(--v-theme-error), 0.8);
}
.pulse-dot.error::after {
animation: pulse-animation-error 2s infinite;
box-shadow: 0 0 0 2px rgba(var(--v-theme-error), 0.3);
}
.pulse-dot.warning::before {
background-color: rgba(var(--v-theme-warning), 1);
box-shadow: 0 0 10px rgba(var(--v-theme-warning), 0.8);
}
.pulse-dot.warning::after {
animation: pulse-animation-warning 2s infinite;
box-shadow: 0 0 0 2px rgba(var(--v-theme-warning), 0.3);
}
.pulse-dot.success::before {
background-color: rgba(var(--v-theme-success), 1);
box-shadow: 0 0 10px rgba(var(--v-theme-success), 0.8);
}
.pulse-dot.success::after {
animation: pulse-animation-success 2s infinite;
box-shadow: 0 0 0 2px rgba(var(--v-theme-success), 0.3);
}
.pulse-dot.secondary::before {
background-color: rgba(var(--v-theme-secondary), 1);
box-shadow: 0 0 10px rgba(var(--v-theme-secondary), 0.8);
}
.pulse-dot.secondary::after {
animation: pulse-animation-secondary 2s infinite;
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);
}
}
@keyframes pulse-animation-warning {
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);
}
}
@keyframes pulse-animation-success {
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);
}
}
@keyframes pulse-animation-secondary {
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);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -128,45 +128,47 @@ onMounted(() => {
})
</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">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">未配置</div>
</div>
<VImg :src="getIcon" cover class="mt-5" max-width="3rem" min-width="3rem" />
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
</div>
<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

@@ -295,10 +295,10 @@ function onSubscribeEditRemove() {
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg h-full"
class="flex flex-col h-full"
:class="{
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'opacity-70': subscribeState === 'S',
}"
min-height="170"
@@ -311,12 +311,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,9 +334,9 @@ 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">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<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">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
@@ -357,7 +352,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 +383,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>
@@ -421,7 +413,7 @@ function onSubscribeEditRemove() {
/>
</div>
</template>
<style lang="scss">
<style lang="scss" scoped>
.subscribe-card-background {
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}

View File

@@ -100,9 +100,9 @@ function doDelete() {
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg h-full"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 scale-105 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">
@@ -177,7 +177,7 @@ function doDelete() {
/>
</div>
</template>
<style lang="scss">
<style lang="scss" scoped>
.subscribe-card-background {
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}

View File

@@ -30,7 +30,7 @@ const meta = ref(props.torrent?.meta_info)
const downloadItem = ref(props.torrent)
// 站点图标
const siteIcon = ref('')
const siteIcons = ref<Record<number, string>>({})
// 存储是否已经下载过的记录
const downloaded = ref<string[]>([])
@@ -51,9 +51,10 @@ function addDownloadError(error: string) {
}
// 查询站点图标
async function getSiteIcon() {
async function getSiteIcon(site: number | undefined) {
if (!site) return
try {
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
} catch (error) {
console.error(error)
}
@@ -78,141 +79,275 @@ async function downloadTorrentFile() {
window.open(torrent.value?.enclosure, '_blank')
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
// 获取优惠类型样式
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return '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 ''
}
// 打开更多来源对话框
async function openMoreTorrentsDialog() {
props.more?.forEach(t => {
return getSiteIcon(t.torrent_info?.site)
})
showMoreTorrents.value = true
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
getSiteIcon(props.torrent?.torrent_info?.site)
})
</script>
<template>
<div>
<div class="h-full">
<VCard
:width="props.width"
:height="props.height"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
:width="props.width || '100%'"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
@click="handleAddDownload(props.torrent)"
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
>
<template v-if="!showMoreTorrents" #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem class="py-1">
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VCardTitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VCardItem>
<VCardText class="text-subtitle-2">
{{ torrent?.title }}
</VCardText>
<VCardText>{{ torrent?.site_name }}{{ torrent?.description }}</VCardText>
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</VCardItem>
<VCardActions>
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
<template #append>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</template>
更多来源
</VBtn>
</VCardActions>
<VExpandTransition>
<div v-show="showMoreTorrents">
<VDivider />
<VChipGroup class="p-3" column>
<VChip v-for="(item, index) in props.more" :key="index" @click.stop="handleAddDownload(item)">
<template #append>
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
<VBadge
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
:content="item.torrent_info?.volume_factor"
inline
size="small"
/>
</template>
{{ item.torrent_info.site_name }}
</VChip>
</VChipGroup>
<!-- 优惠标签 -->
<div
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
class="discount-banner text-white px-2 py-1 text-sm font-weight-bold rounded-bl-lg"
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
>
{{ torrent?.volume_factor }}
</div>
<!-- 媒体标题 -->
<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>
<VChip
v-if="meta?.season_episode"
class="chip-season rounded-sm font-weight-bold"
variant="elevated"
size="small"
>
{{ meta?.season_episode }}
</VChip>
</div>
</VExpandTransition>
<!-- 站点信息条 -->
<div class="d-flex justify-space-between align-center flex-wrap">
<div class="d-flex align-center">
<VImg
v-if="siteIcons[torrent?.site || 0]"
:src="siteIcons[torrent?.site || 0]"
:alt="torrent?.site_name"
class="mr-2 rounded"
width="20"
height="20"
/>
<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="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="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>
</VCardItem>
<!-- 种子内容 -->
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
<!-- 种子标题 -->
<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="text-body-2 text-medium-emphasis mb-2"
:title="meta?.subtitle || torrent?.description"
>
{{ meta?.subtitle || torrent?.description }}
</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>
</VCardText>
<!-- 卡片底部信息 -->
<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="d-flex align-center">
<VChip v-if="torrent?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm mr-2">
{{ formatFileSize(torrent.size) }}
</VChip>
<VBtn icon size="small" variant="text" color="primary" @click.stop="openTorrentDetail">
<VIcon icon="mdi-information-outline"></VIcon>
</VBtn>
</div>
</VCardActions>
</VCard>
<!-- 更多来源对话框 -->
<VDialog v-model="showMoreTorrents" max-width="25rem" location="center">
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<span>其他来源</span>
<VSpacer />
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="showMoreTorrents = false"></VBtn>
</VCardTitle>
<VDivider />
<VCardText class="more-sources-content 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>
<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>
<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"
size="small"
color="secondary"
icon="mdi-arrow-top-right"
class="mr-1"
></VIcon>
</span>
</div>
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
</VDialog>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
@@ -227,3 +362,93 @@ onMounted(() => {
/>
</div>
</template>
<style scoped>
.discount-banner {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
}
.more-sources-content {
max-block-size: 60vh;
overflow-y: auto;
}
/* 卡片悬停效果 */
.torrent-card {
border: 1px solid transparent;
}
.torrent-card:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
}
/* 优惠标签样式 */
.bg-success {
background-color: #4caf50;
}
.bg-orange {
background-color: #ff5722;
}
.bg-purple {
background-color: #9c27b0;
}
.chip-season {
background-color: #3f51b5;
color: white;
}
.chip-edition {
background-color: #f44336;
color: white;
}
.chip-resolution {
background-color: #7b1fa2;
color: white;
}
.chip-codec {
background-color: #ff9800;
color: white;
}
.chip-team {
background-color: #00897b;
color: white;
}
.chip-label {
background-color: #5c6bc0;
color: white;
}
.chip-hr {
background-color: #212121;
color: white;
}
.chip-expire {
background-color: #7e57c2;
color: white;
}
.chip-free {
background-color: #4caf50;
color: white;
}
.chip-discount {
background-color: #ff5722;
color: white;
}
.chip-bonus {
background-color: #9c27b0;
color: white;
}
</style>

View File

@@ -10,9 +10,6 @@ const props = defineProps({
torrent: Object as PropType<Context>,
})
// 更多来源界面
const showMoreTorrents = ref(false)
// 种子信息
const torrent = ref(props.torrent?.torrent_info)
@@ -25,6 +22,10 @@ const meta = ref(props.torrent?.meta_info)
// 站点图标
const siteIcon = ref('')
// 站点图标加载状态
const iconLoading = ref(false)
const iconError = ref(false)
// 存储是否已经下载过的记录
const downloaded = ref<string[]>([])
@@ -33,11 +34,44 @@ const addDownloadDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
try {
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
} catch (error) {
console.error(error)
if (!torrent?.value?.site || iconLoading.value) {
return
}
iconLoading.value = true
iconError.value = false
try {
const response = await api.get(`site/icon/${torrent.value.site}`)
if (response && response.data && response.data.icon) {
siteIcon.value = response.data.icon
} else {
iconError.value = true
}
} catch (error) {
console.error('Failed to load site icon:', error)
iconError.value = true
} finally {
iconLoading.value = false
}
}
// 获取优惠类型样式
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return '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 ''
}
// 询问并添加下载
@@ -63,19 +97,6 @@ function openTorrentDetail() {
window.open(torrent.value?.page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile() {
window.open(torrent.value?.enclosure, '_blank')
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
@@ -83,96 +104,121 @@ onMounted(() => {
</script>
<template>
<div>
<div class="w-100">
<VListItem
:value="props.torrent?.torrent_info?.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"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
>
<template v-if="!showMoreTorrents" #prepend>
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
{{ torrent?.title }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
<VListItemSubtitle> {{ torrent?.site_name }}{{ torrent?.description }} </VListItemSubtitle>
<div v-if="torrent?.labels" class="pt-2">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
<!-- 优惠标签 -->
<div
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 #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
<template v-slot:prepend>
<div class="d-flex align-center">
<img v-if="siteIcon" :src="siteIcon" :alt="torrent?.site_name" class="rounded mr-2" width="32" height="32" />
<VAvatar v-else size="24" class="mr-2 text-caption bg-primary-lighten-4 text-primary font-weight-bold">
{{ 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>
<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="text-subtitle-2 font-weight-medium mb-2" :title="torrent?.title">
{{ torrent?.title }}
</div>
<div
class="text-body-2 text-medium-emphasis mb-2"
:title="meta?.subtitle || torrent?.description || '暂无描述'"
>
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
</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="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="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="d-flex align-center">
<VChip v-if="torrent?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm mr-2">
{{ formatFileSize(torrent.size) }}
</VChip>
<VBtn icon size="small" variant="text" color="primary" @click.stop="openTorrentDetail">
<VIcon icon="mdi-information-outline"></VIcon>
</VBtn>
</div>
</div>
</template>
</VListItem>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode}`"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
:media="media"
:torrent="torrent"
@done="addDownloadSuccess"
@@ -181,3 +227,88 @@ onMounted(() => {
/>
</div>
</template>
<style scoped>
.discount-banner {
position: absolute;
z-index: 3;
inset-block-start: 0;
inset-inline-end: 0;
}
.torrent-item {
border: 1px solid transparent;
}
.torrent-item:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
}
.chip-season {
background-color: #3f51b5;
color: white;
}
.chip-edition {
background-color: #f44336;
color: white;
}
.chip-resolution {
background-color: #7b1fa2;
color: white;
}
.chip-codec {
background-color: #ff9800;
color: white;
}
.chip-team {
background-color: #00897b;
color: white;
}
.chip-label {
background-color: #5c6bc0;
color: white;
}
.chip-hr {
background-color: #212121;
color: white;
}
.chip-expire {
background-color: #7e57c2;
color: white;
}
/* 优惠标签样式 */
.bg-success {
background-color: #4caf50;
}
.bg-orange {
background-color: #ff5722;
}
.bg-purple {
background-color: #9c27b0;
}
.chip-free {
background-color: #4caf50;
color: white;
}
.chip-discount {
background-color: #ff5722;
color: white;
}
.chip-bonus {
background-color: #9c27b0;
color: white;
}
</style>

View File

@@ -6,12 +6,18 @@ 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'
// 扩展User类型以包含昵称字段
interface ExtendedUser extends User {
nickname?: string
}
// 定义输入变量
const props = defineProps({
// 用户信息
user: {
type: Object as PropType<User>,
type: Object as PropType<ExtendedUser>,
required: true,
},
// 所有用户
@@ -21,6 +27,9 @@ const props = defineProps({
},
})
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
// 当前用户的ID
const currentLoginUserId = computed(() => useUserStore().userID)
@@ -45,6 +54,13 @@ const movieSubscriptions = ref(0)
// 用户电视剧订阅数量
const tvShowSubscriptions = ref(0)
// 显示名称 - 如果有昵称则优先显示昵称
const displayName = computed(() => {
const settingsNickname = props.user.settings?.nickname as string | undefined
const nickname = props.user.nickname || settingsNickname
return nickname || props.user.name
})
// 按用户查询订阅数量
async function fetchSubscriptions() {
try {
@@ -87,7 +103,7 @@ function editUser() {
userEditDialog.value = true
}
// 用户新完成时
// 用户新完成时
function onUserUpdate() {
userEditDialog.value = false
emit('save')
@@ -98,80 +114,162 @@ onMounted(() => {
})
</script>
<template>
<VCard>
<VCardText class="text-center pt-10 pb-3">
<VAvatar variant="flat" size="100" rounded>
<VImg :src="user.avatar || avatar1" alt="avatar" />
</VAvatar>
<h5 class="text-h5 mt-3">{{ user.name }}</h5>
<VChip size="small" class="mt-3" :class="{ 'text-error': user.is_superuser }">
{{ user.is_superuser ? '管理员' : '普通用户' }}
</VChip>
<VCard
:class="[
'transition-transform duration-300 hover:-translate-y-1',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
@click="userEditDialog = true"
>
<!-- 用户头像和基本信息 -->
<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>
</div>
</template>
<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="animate-pulse"
/>
</span>
</div>
<div class="d-flex flex-wrap gap-1 overflow-auto">
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>管理员</VChip>
<VChip v-else size="x-small" label>普通用户</VChip>
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" label>
{{ user.is_active ? '激活' : '已停用' }}
</VChip>
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
</div>
</div>
<!-- 移动端订阅数据信息 -->
<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="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>
</VCardTitle>
<!-- 头部操作按钮 -->
<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"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="removeUser"
>
<VIcon icon="mdi-delete" />
</VBtn>
</div>
</template>
</VCardItem>
<!-- 独立的邮箱显示 -->
<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 || '未设置邮箱' }}</span>
</VCardText>
<VCardText class="flex justify-center gap-6 pb-5">
<div class="d-flex align-center">
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
<VIcon size="24" icon="mdi-movie-open-outline"></VIcon>
</VAvatar>
<div>
<div class="text-h6">{{ movieSubscriptions }}</div>
<div class="text-sm text-no-wrap">电影订阅</div>
<!-- PC端显示订阅统计信息 -->
<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">电影订阅</span>
</div>
</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">剧集订阅</span>
</div>
</div>
</div>
<div class="d-flex align-center">
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
<VIcon size="24" icon="mdi-television"></VIcon>
</VAvatar>
<div>
<div class="text-h6">{{ tvShowSubscriptions }}</div>
<div class="text-sm text-no-wrap">电视剧订阅</div>
</div>
</div>
</VCardText>
<VCardText class="pb-6">
<VDivider class="my-2">
<h5 class="text-h6">详情</h5>
</VDivider>
<VList lines="one">
<VListItem>
<VListItemTitle class="text-sm">
<span class="font-weight-medium">邮箱</span><span class="text-body-1"> {{ user.email }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<VListItemTitle class="text-sm">
<span class="font-weight-medium">状态</span
><span class="text-body-1">
<VChip size="small" :class="{ 'text-success': user.is_active }" variant="tonal">
{{ user.is_active ? '激活' : '已停用' }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
<VListItem>
<VListItemTitle class="text-sm">
<span class="font-weight-medium">双重认证</span
><span class="text-body-1">
<VChip size="small" :class="{ 'text-success': user.is_otp }" variant="tonal">
{{ user.is_otp ? '已启用' : '未启用' }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
<VCardText class="flex flex-row justify-center">
<VBtn v-if="currentUserIsSuperuser" color="primary" class="me-4" @click="editUser"> 编辑 </VBtn>
<VBtn
v-if="currentUserIsSuperuser && props.user.id != currentLoginUserId"
color="error"
variant="outlined"
@click="removeUser"
>
删除
</VBtn>
</VCardText>
</VCard>
<!-- 用户编辑弹窗 -->
<UserAddEditDialog
v-if="userEditDialog"
@@ -183,3 +281,94 @@ onMounted(() => {
@close="userEditDialog = false"
/>
</template>
<style scoped>
.admin-decoration {
position: absolute;
z-index: 1;
display: flex;
align-items: center;
width: 100%;
top: 0;
padding: 8px 12px;
}
.admin-header {
background: linear-gradient(to bottom, rgba(var(--v-theme-warning), 0.05), transparent);
}
.admin-avatar::after {
position: absolute;
border: 1px solid rgba(var(--v-theme-warning), 0.3);
border-radius: 12px;
animation: pulse 2.5s infinite;
content: '';
inset: -5px;
pointer-events: none;
}
.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);
}
}
.admin-crown {
position: absolute;
z-index: 5;
animation: float 3s ease-in-out infinite;
top: -10px;
left: -6px;
transform: rotate(-25deg);
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);
}
}
.animate-pulse {
animation: pulse-nickname 2s ease infinite;
}
@keyframes pulse-nickname {
0%,
100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.grayscale-50 {
filter: grayscale(50%);
}
</style>

View File

@@ -172,132 +172,131 @@ const resolveProgress = (item: Workflow) => {
</script>
<template>
<div class="h-full">
<VCard class="mx-auto h-full" @click="handleFlow(workflow)" :ripple="false" :loading="loading">
<VCardItem class="py-3" :class="`bg-${resolveStatusVariant(workflow?.state).color}`">
<template #prepend>
<VAvatar variant="text" class="me-2">
<VIcon
v-if="workflow?.state === 'P'"
color="success"
size="x-large"
icon="mdi-play"
@click.stop="handleEnable(workflow)"
/>
<VIcon v-else color="warning" icon="mdi-pause" size="x-large" @click.stop="handlePause(workflow)" />
</VAvatar>
</template>
<VCardTitle class="text-white">
{{ workflow?.name }}
</VCardTitle>
<VCardSubtitle class="text-white">{{ workflow?.description }}</VCardSubtitle>
<template #append>
<IconBtn>
<VIcon icon="mdi-vector-polyline-edit" @click.stop="handleFlow(workflow)" />
</IconBtn>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" base-color="primary" @click="handleEdit(workflow)">
<template #prepend>
<VIcon icon="mdi-note-edit" />
</template>
<VListItemTitle>编辑任务</VListItemTitle>
</VListItem>
<VListItem
v-if="workflow.current_action"
variant="plain"
base-color="info"
@click="handleRun(workflow, false)"
>
<template #prepend>
<VIcon icon="mdi-play-speed" />
</template>
<VListItemTitle>继续执行</VListItemTitle>
</VListItem>
<VListItem
v-if="workflow.current_action"
variant="plain"
base-color="info"
@click="handleRun(workflow, true)"
>
<template #prepend>
<VIcon icon="mdi-replay" />
</template>
<VListItemTitle>重新执行</VListItemTitle>
</VListItem>
<VListItem v-else variant="plain" base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-run" />
</template>
<VListItemTitle>立即执行</VListItemTitle>
</VListItem>
<VListItem variant="plain" base-color="warning" @click="handleReset(workflow)">
<template #prepend>
<VIcon icon="mdi-restore-alert" />
</template>
<VListItemTitle>重置任务</VListItemTitle>
</VListItem>
<VListItem variant="plain" base-color="error" @click="handleDelete(workflow)">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>删除任务</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
</VCardItem>
<VDivider />
<VCardText>
<div class="d-flex flex-column gap-y-4">
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">定时</div>
<h5 class="text-h6">{{ workflow?.timer }}</h5>
</div>
<div class="flex-1">
<div class="mb-1">状态</div>
<h5 class="text-h6" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
{{ resolveStatusVariant(workflow?.state).text }}
</h5>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">动作数</div>
<div>
<VAvatar size="32" color="primary" variant="tonal">
<span class="text-sm">{{ workflow?.actions?.length }}</span>
</VAvatar>
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
class="mx-auto h-full"
@click="handleFlow(workflow)"
:ripple="false"
:loading="loading"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VCardItem class="py-3" :class="`bg-${resolveStatusVariant(workflow?.state).color}`">
<template #prepend>
<VAvatar variant="text" class="me-2">
<VIcon
v-if="workflow?.state === 'P'"
color="success"
size="x-large"
icon="mdi-play"
@click.stop="handleEnable(workflow)"
/>
<VIcon v-else color="warning" icon="mdi-pause" size="x-large" @click.stop="handlePause(workflow)" />
</VAvatar>
</template>
<VCardTitle class="text-white">
{{ workflow?.name }}
</VCardTitle>
<VCardSubtitle class="text-white">{{ workflow?.description }}</VCardSubtitle>
<template #append>
<IconBtn>
<VIcon icon="mdi-vector-polyline-edit" @click.stop="handleFlow(workflow)" />
</IconBtn>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem base-color="primary" @click="handleEdit(workflow)">
<template #prepend>
<VIcon icon="mdi-note-edit" />
</template>
<VListItemTitle>编辑任务</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, false)">
<template #prepend>
<VIcon icon="mdi-play-speed" />
</template>
<VListItemTitle>继续执行</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-replay" />
</template>
<VListItemTitle>重新执行</VListItemTitle>
</VListItem>
<VListItem v-else base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-run" />
</template>
<VListItemTitle>立即执行</VListItemTitle>
</VListItem>
<VListItem base-color="warning" @click="handleReset(workflow)">
<template #prepend>
<VIcon icon="mdi-restore-alert" />
</template>
<VListItemTitle>重置任务</VListItemTitle>
</VListItem>
<VListItem base-color="error" @click="handleDelete(workflow)">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>删除任务</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
</VCardItem>
<VDivider />
<VCardText>
<div class="d-flex flex-column gap-y-4">
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">定时</div>
<h5 class="text-h6">{{ workflow?.timer }}</h5>
</div>
<div class="flex-1">
<div class="mb-1">状态</div>
<h5 class="text-h6" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
{{ resolveStatusVariant(workflow?.state).text }}
</h5>
</div>
</div>
<div class="flex-1">
<div class="mb-1">已执行次数</div>
<h5 class="text-h6">{{ workflow?.run_count }}</h5>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">进度</div>
<div class="d-flex align-center gap-5">
<div class="flex-grow-1">
<VProgressLinear color="info" rounded :model-value="resolveProgress(workflow)" />
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">动作数</div>
<div>
<VAvatar size="32" color="primary" variant="tonal">
<span class="text-sm">{{ workflow?.actions?.length }}</span>
</VAvatar>
</div>
<span> {{ resolveProgress(workflow) }}% </span>
</div>
<div class="flex-1">
<div class="mb-1">已执行次数</div>
<h5 class="text-h6">{{ workflow?.run_count }}</h5>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">进度</div>
<div class="d-flex align-center gap-5">
<div class="flex-grow-1">
<VProgressLinear color="info" rounded :model-value="resolveProgress(workflow)" />
</div>
<span> {{ resolveProgress(workflow) }}% </span>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6" v-if="workflow?.result">
<div class="flex-1">
<div class="mb-1">错误信息</div>
<div class="text-error">{{ workflow?.result }}</div>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6" v-if="workflow?.result">
<div class="flex-1">
<div class="mb-1">错误信息</div>
<div class="text-error">{{ workflow?.result }}</div>
</div>
</div>
</div>
</VCardText>
</VCard>
</VCardText>
</VCard>
</VHover>
<!-- 流程对话框 -->
<WorkflowActionsDialog
v-if="flowDialog"

View File

@@ -118,77 +118,73 @@ onMounted(() => {
})
</script>
<template>
<VDialog max-width="45rem" scrollable>
<VDialog max-width="35rem" scrollable>
<VCard>
<VCardItem>
<VCardTitle v-if="title">{{ torrent?.site_name }} - {{ title }}</VCardTitle>
<VCardTitle v-else>确认下载</VCardTitle>
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<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>
</VCardTitle>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
<VList lines="one">
<VListItem>
<template #prepend>
<VIcon icon="mdi-web"></VIcon>
</template>
<VListItemTitle>
<span class="whitespace-break-spaces me-2">{{ torrent?.title }}</span>
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon icon="mdi-subtitles-outline"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-1 whitespace-break-spaces">{{ torrent?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-1">
<VChip variant="tonal" label>
{{ formatFileSize(torrent?.size || 0) }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
<VRow>
<VCol cols="12" md="4">
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
label="指定下载器"
variant="underlined"
placeholder="留空默认"
/>
</VCol>
<VCol cols="12" md="8">
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
label="指定保存目录"
placeholder="留空自动匹配"
variant="underlined"
/>
</VCol>
</VRow>
</VCardText>
<VList lines="one">
<VListItem>
<template #prepend>
<VIcon icon="mdi-web"></VIcon>
</template>
<VListItemTitle>
<span class="whitespace-break-spaces me-2">{{ torrent?.title }}</span>
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="torrent?.description">
<template #prepend>
<VIcon icon="mdi-subtitles-outline"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-2 whitespace-break-spaces">{{ torrent?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="torrent?.size">
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-2">
<VChip variant="tonal" label>
{{ formatFileSize(torrent?.size || 0) }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
<VRow class="px-7">
<VCol cols="12" md="4">
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
size="small"
label="下载器(默认)"
variant="underlined"
placeholder="留空默认"
density="compact"
/>
</VCol>
<VCol cols="12" md="8">
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
label="保存目录(自动)"
size="small"
placeholder="留空自动匹配"
variant="underlined"
density="compact"
/>
</VCol>
</VRow>
<VCardText class="text-center">
<VBtn
variant="elevated"
:disabled="loading"
@click="addDownload"
:prepend-icon="icon"
class="px-5"
size="large"
>
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
{{ buttonText }}
</VBtn>
</VCardText>

View File

@@ -31,7 +31,7 @@ async function savaAlistConfig() {
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="AList配置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VDialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">

View File

@@ -1,9 +1,8 @@
<script lang="ts" setup>
import QrcodeVue from 'qrcode.vue'
import api from '@/api'
// 定义输入
const props = defineProps({
defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
@@ -14,13 +13,7 @@ const props = defineProps({
const emit = defineEmits(['done', 'close'])
// 二维码内容
const qrCodeContent = ref('')
// ck参数
const ck = ref('')
// t参数
const t = ref('')
const qrCodeUrl = ref('')
// 下方的提示信息
const text = ref('请用阿里云盘 App 扫码')
@@ -34,9 +27,6 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
if (props.conf?.refreshToken) {
await savaAliPanConfig()
}
emit('done')
}
@@ -45,9 +35,8 @@ async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
ck.value = result.data.ck
t.value = result.data.t
qrCodeUrl.value = result.data.codeUrl
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
text.value = result.message
}
@@ -59,23 +48,21 @@ async function getQrcode() {
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/check/alipan', {
params: { ck: ck.value, t: t.value },
})
const result: { [key: string]: any } = await api.get('/storage/check/alipan')
if (result.success && result.data) {
const qrCodeStatus = result.data.qrCodeStatus
const qrCodeStatus = result.data.status
text.value = result.data.tip
if (qrCodeStatus == 'CONFIRMED') {
// 已确认完成
if (qrCodeStatus == 'LoginSuccess') {
// 登录成功
alertType.value = 'success'
handleDone()
} else if (qrCodeStatus == 'NEW' || qrCodeStatus == 'SCANED') {
} else if (qrCodeStatus == 'WaitLogin' || qrCodeStatus == 'ScanSuccess') {
// 等待登录扫码成功
alertType.value = 'info'
// 新建、待扫码
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
// 过期或者已取消
// 二维码过期
alertType.value = 'error'
}
} else {
@@ -87,18 +74,8 @@ async function checkQrcode() {
}
}
// 保存cookie设置
async function savaAliPanConfig() {
try {
await api.post(`storage/save/alipan`, props.conf)
} catch (e) {
console.error(e)
}
}
onMounted(async () => {
await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000)
})
onUnmounted(() => {
@@ -109,22 +86,21 @@ onUnmounted(() => {
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard title="阿里云盘登录" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<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">
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
<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">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
</div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<template #prepend />
</VAlert>
</VCardText>
<VCardText>
<VRow>
<VCol class="mt-2">
<VTextField label="自定义refreshToken" v-model="props.conf.refreshToken" outlined dense />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>

View File

@@ -165,7 +165,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">
@@ -230,26 +230,16 @@ onMounted(() => {
@click="doFork"
prepend-icon="mdi-heart"
:loading="processing"
class="mb-2 me-2"
>
订阅
</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"
>
取消关注
</VBtn>
@@ -258,10 +248,24 @@ onMounted(() => {
@click="followUser"
color="info"
prepend-icon="mdi-account-plus"
class="ms-2"
class="mb-2 me-2"
>
关注
</VBtn>
<VBtn
v-if="
(props.media?.share_uid && props.media?.share_uid === globalSettings.USER_UNIQUE_ID) ||
globalSettings.SUBSCRIBE_SHARE_MANAGE
"
color="error"
:disabled="deleting"
@click="doDelete"
prepend-icon="mdi-delete"
:loading="deleting"
class="mb-2 me-2"
>
取消分享
</VBtn>
</div>
<div class="text-xs mt-2" v-if="props.media?.count">
<VIcon icon="mdi-fire" /> {{ props.media?.count?.toLocaleString() }} 次复用

View File

@@ -21,7 +21,7 @@ function handleImport() {
<template>
<VDialog width="40rem" scrollable max-height="85vh" persistent>
<VCard :title="props.title" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VDialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VTextarea v-model="codeString" />
</VCardText>

View File

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

@@ -92,7 +92,7 @@ onBeforeMount(async () => {
<template>
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText v-if="isRefreshed">
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />

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" />

View File

@@ -2,11 +2,6 @@
import api from '@/api'
import { useToast } from 'vue-toast-notification'
// 输入参数
const props = defineProps({
title: String,
})
const $toast = useToast()
// 插件仓库设置字符串
@@ -47,8 +42,14 @@ onMounted(() => {
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="插件仓库设置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCard class="rounded-t">
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-store-cog" class="me-2" />
插件仓库设置
</VCardTitle>
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VCardText class="pt-2">
<VTextarea
v-model="repoString"

View File

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

View File

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

View File

@@ -87,6 +87,7 @@ const transferForm = reactive<TransferForm>({
// 所有媒体库目录
const directories = ref<TransferDirectoryConf[]>([])
// 查询目录
async function loadDirectories() {
try {
@@ -221,9 +222,9 @@ onUnmounted(() => {
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="dialogTitle" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
@@ -263,7 +264,7 @@ onUnmounted(() => {
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.type_name"
label="类型"
@@ -276,7 +277,7 @@ onUnmounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" md="6">
<VTextField
v-if="mediaSource === 'themoviedb'"
v-model="transferForm.tmdbid"
@@ -302,19 +303,37 @@ onUnmounted(() => {
@click:append-inner="mediaSelectorDialog = true"
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-show="transferForm.type_name === '电视剧'"
v-model.number="transferForm.season"
label="季"
:items="seasonItems"
hint="指定季数"
</VRow>
<VRow v-show="transferForm.type_name === '电视剧'">
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_group"
label="剧集组编号"
placeholder="手动查询剧集组"
hint="指定剧集组"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="8">
<VCol cols="12" md="3">
<VSelect
v-model.number="transferForm.season"
label="季"
:items="seasonItems"
hint="第几季"
persistent-hint
/>
</VCol>
<VCol cols="12" md="3">
<VTextField
v-model="transferForm.episode_detail"
:disabled="disableEpisodeDetail"
label="集"
placeholder="起始集,终止集"
hint="集数或范围如1或1,2"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_format"
label="集数定位"
@@ -323,26 +342,7 @@ onUnmounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_detail"
:disabled="disableEpisodeDetail"
label="指定集数"
placeholder="起始集,终止集如1或1,2"
hint="指定集数或范围如1或1,2"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_part"
label="指定Part"
placeholder="如part1"
hint="指定Part如part1"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_offset"
label="集数偏移"
@@ -351,7 +351,18 @@ onUnmounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
</VRow>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_part"
label="指定Part"
placeholder="如part1"
hint="指定Part如part1"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="transferForm.min_filesize"
label="最小文件大小MB"

View File

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

View File

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

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