Compare commits

...

536 Commits

Author SHA1 Message Date
jxxghp
0cbdf24315 fix https://github.com/jxxghp/MoviePilot/issues/4849 2025-08-27 12:32:19 +08:00
jxxghp
164ea79bd1 Use body-lock for quick access background scroll (#382)
* Checkpoint before follow-up message

Co-authored-by: jxxghp <jxxghp@live.cn>

* Replace custom BodyLock with body-scroll-lock library

Co-authored-by: jxxghp <jxxghp@live.cn>

* Fix scroll behavior in QuickAccess panel with targeted scroll disabling

Co-authored-by: jxxghp <jxxghp@live.cn>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-25 23:10:15 +08:00
jxxghp
97f3435bb3 Prevent scroll when QuickAccess overlay is open (#381)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-25 22:46:28 +08:00
jxxghp
63b108ff6b 更新 package.json 2025-08-25 22:19:02 +08:00
jxxghp
b0880cb369 更新 types.ts 2025-08-25 21:48:55 +08:00
jxxghp
5f70ee8e18 更新缓存版本至 v1.1.0 2025-08-25 13:18:38 +08:00
jxxghp
4c64f7a2c3 优化 HTML 结构,调整 CSS 样式 2025-08-25 13:03:45 +08:00
jxxghp
262927e459 fix:整理中的所以取消 2025-08-24 18:50:07 +08:00
jxxghp
b16c566004 fix ui 2025-08-24 18:32:29 +08:00
jxxghp
1af82dbee6 优化 TransferQueueDialog 组件,合并相同 title_year 的媒体记录和任务 2025-08-24 18:25:59 +08:00
jxxghp
2e9a5a4e13 更新 TransferQueueDialog 组件 2025-08-24 18:18:53 +08:00
jxxghp
b455f603dc 调整 TransferQueueDialog 组件 2025-08-24 18:11:36 +08:00
jxxghp
37c0c3e339 优化 TransferQueueDialog 组件的 SSE 连接管理 2025-08-24 18:02:15 +08:00
jxxghp
b6cb341082 fix 整理进度显示 2025-08-24 17:50:03 +08:00
jxxghp
1af1a06700 优化 SSE 管理器 2025-08-24 17:34:43 +08:00
jxxghp
79e4ecfdbe 引入 crypto-js 库以计算文件路径的 MD5 值 2025-08-24 17:05:36 +08:00
jxxghp
1585271e37 为 TransferQueueDialog 组件优化 SSE 监听管理 2025-08-24 16:16:33 +08:00
jxxghp
c240b171e4 更新 package.json 版本号至 2.7.6 2025-08-24 13:03:25 +08:00
jxxghp
9c405e90ac 重构 TransferQueueDialog 组件,添加整体和当前文件进度管理 2025-08-24 13:02:40 +08:00
jxxghp
3ec3212ca5 更新 service-worker.ts 2025-08-24 08:16:14 +08:00
jxxghp
b1289f6177 实现订阅批量管理功能 2025-08-23 21:20:09 +08:00
jxxghp
64b7ba48c8 fix ios 2025-08-23 20:39:40 +08:00
jxxghp
f093053ea4 优化对话框状态管理 2025-08-23 19:32:23 +08:00
jxxghp
9faa0ded59 为对话框组件添加防止滚动穿透的样式 2025-08-23 19:14:12 +08:00
jxxghp
0f7dafeb23 控制合集搜索项的显示 2025-08-23 19:03:50 +08:00
jxxghp
472d1960d9 重构对话框组件,将所有 DialogWrapper 替换为 VDialog,并更新缓存版本至 v1.1.0 2025-08-23 18:55:34 +08:00
jxxghp
6e50acf106 更新 service-worker.ts 2025-08-23 10:22:31 +08:00
jxxghp
a3fb4b1534 更新 package.json 版本号至 2.7.5 2025-08-23 08:56:14 +08:00
jxxghp
382cae32a2 fix site import dialog 2025-08-23 08:47:16 +08:00
jxxghp
0aa4851f8e Merge pull request #380 from jxxghp/cursor/implement-site-batch-import-and-export-2694 2025-08-23 07:32:40 +08:00
Cursor Agent
65271e6d13 Remove package-lock.json from version control
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-22 23:13:47 +00:00
Cursor Agent
671cf8d588 Refactor site import/export feature with improved toast notifications
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-22 23:12:01 +00:00
Cursor Agent
afc7c81028 Add site batch import/export functionality with preview and validation
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-22 23:09:03 +00:00
jxxghp
c330aee560 增强消息视图的SSE连接管理 2025-08-21 09:22:57 +08:00
jxxghp
eafe63c886 更新 package.json 2025-08-20 10:34:29 +08:00
jxxghp
53206d05b8 更新 service-worker.ts 2025-08-20 10:34:10 +08:00
jxxghp
af085d457e 更新 PluginCardListView.vue 2025-08-20 10:33:52 +08:00
jxxghp
fb36033939 修复数据库类型判断 2025-08-19 13:18:02 +08:00
jxxghp
584e7672df 更新版本号至2.7.3 2025-08-19 13:08:23 +08:00
jxxghp
d4f7a5a1c0 fix https://github.com/jxxghp/MoviePilot/issues/4769 2025-08-17 11:38:17 +08:00
jxxghp
2a9ea81ad4 feat: 优化SSE连接延迟,添加初始化状态提示 2025-08-17 08:39:02 +08:00
jxxghp
276948dd68 feat: 修复成功率计算和统计总览功能 2025-08-12 15:28:58 +08:00
jxxghp
990c5583f2 移除不必要的 TMDB 图片域名选项 2025-08-12 14:27:01 +08:00
jxxghp
644f1b5640 Merge pull request #377 from Sowevo/v2 2025-08-12 06:52:26 +08:00
sowevo
5261fbe870 🚨 0 2025-08-12 05:44:27 +08:00
sowevo
e4f2d85e2b 🚨 多余参数 2025-08-12 05:18:23 +08:00
sowevo
8e3ccdc24a feat: 透明倒影 2025-08-12 05:09:58 +08:00
sowevo
cd6d93affd feat: 透明背景 2025-08-12 04:32:51 +08:00
sowevo
6096ab0c9b feat: 调整间距 2025-08-12 04:31:19 +08:00
sowevo
0a87bb1db1 canvas固定宽和高 2025-08-12 04:14:13 +08:00
jxxghp
a19042c655 在设置中添加浏览器仿真选项 2025-08-11 21:35:20 +08:00
jxxghp
a889687a6a 更新 package.json 2025-08-10 18:16:09 +08:00
jxxghp
e1cdc715aa 更新 GitHub Actions 配置,启用最新版本标记功能 2025-08-06 16:37:09 +08:00
jxxghp
a82b3a0a29 优化消息处理逻辑 2025-08-05 15:47:46 +08:00
jxxghp
d93a71f0be 更新 TorrentRowListView.vue 2025-08-03 11:56:32 +08:00
jxxghp
899dc765bc 更新 TorrentCardListView.vue 2025-08-03 11:55:53 +08:00
jxxghp
449490e52d 更新 SiteStatisticsDialog.vue 2025-08-02 14:53:49 +08:00
jxxghp
5541d7974e 更新 SiteStatisticsDialog.vue 2025-08-02 14:34:59 +08:00
jxxghp
ae3eb36183 添加站点耗时统计信息展示 2025-08-02 14:20:17 +08:00
jxxghp
d57e9a397c 优化样式以支持动态颜色显示。 2025-08-02 11:12:27 +08:00
jxxghp
9d4fd16d81 优化透明主题下的模糊度和透明度设置 2025-07-29 11:49:59 +08:00
jxxghp
3b16e7a123 优化透明主题的模糊度和透明度设置 2025-07-29 09:46:23 +08:00
jxxghp
1c4a2176e9 实现透明主题的透明度和模糊度设置功能 2025-07-29 08:20:16 +08:00
jxxghp
62f9243714 更新 service-worker.ts 2025-07-29 07:05:17 +08:00
jxxghp
03bd23d314 更新文件系统资源检查的相关提示信息 2025-07-26 23:11:29 +08:00
jxxghp
27497d1812 更新 SiteAddEditDialog.vue 2025-07-26 08:34:22 +08:00
jxxghp
f36c1bd2b5 整合主题管理器,优化主题切换逻辑 2025-07-25 13:39:47 +08:00
jxxghp
cf72b2cdb9 更新加载动画的样式和逻辑。 2025-07-23 20:33:02 +08:00
jxxghp
44f6950fea Merge pull request #376 from wumode/fix_recommend
fix: 修复推荐页面外部推荐源URL参数拼接问题
2025-07-23 20:24:32 +08:00
wumode
308ddfedea fix: 修复推荐页面外部推荐源URL参数拼接问题 2025-07-23 20:12:36 +08:00
jxxghp
ac7c330e2f 优化工作流卡片和对话框中的事件类型显示逻辑 2025-07-23 15:33:43 +08:00
jxxghp
1bde3492da 更新 package.json 2025-07-23 12:01:03 +08:00
jxxghp
f884518df3 优化工作流任务卡片的状态显示 2025-07-23 11:52:54 +08:00
jxxghp
1f7f9ce9db 新增工作流触发类型和事件类型支持 2025-07-22 20:58:55 +08:00
jxxghp
58acde2292 优化支持站点的显示逻辑 2025-07-21 12:49:55 +08:00
jxxghp
4e0fe2f449 更新 AccountSettingAbout.vue 2025-07-21 12:38:37 +08:00
jxxghp
536793ab25 新增支持站点折叠功能,并更新相关国际化文本 2025-07-21 11:53:29 +08:00
jxxghp
23a48e07a2 优化订阅列表视图的状态筛选逻辑 2025-07-21 09:57:10 +08:00
jxxghp
1e55557154 优化订阅列表视图的状态筛选逻辑 2025-07-21 09:53:38 +08:00
jxxghp
752231086d 新增订阅功能的状态筛选选项 2025-07-21 09:38:59 +08:00
jxxghp
6f315a408a 移除站点链接的 href 属性 2025-07-20 15:40:58 +08:00
jxxghp
6fa4caa85e fix https://github.com/jxxghp/MoviePilot/issues/4635 2025-07-20 12:34:22 +08:00
jxxghp
1b36c1752f 优化消息弹窗的滚动逻辑 2025-07-20 08:39:33 +08:00
jxxghp
cd58498971 加载消息时按时间排序以确保最新消息在最后 2025-07-20 08:32:10 +08:00
jxxghp
1586137a5d 优化离线状态管理逻辑 2025-07-20 08:25:20 +08:00
jxxghp
6cb8bf74df 在滚动锁定功能中添加事件传播停止,以增强用户体验 2025-07-19 17:45:43 +08:00
jxxghp
787802d0db 优化模块测试视图 2025-07-19 08:55:08 +08:00
jxxghp
b4ad39db12 优化全局滚动锁定功能 2025-07-18 16:39:25 +08:00
jxxghp
c13edbe017 更新 package.json 2025-07-18 11:07:29 +08:00
jxxghp
7546da4f90 新增订阅分享页面及相关搜索功能 2025-07-18 11:05:05 +08:00
jxxghp
76b9a8d9e7 新增支持站点查看功能 2025-07-17 20:46:46 +08:00
jxxghp
d6d52338e9 优化排名展示效果 2025-07-16 12:59:09 +08:00
jxxghp
caa67a0f49 新增订阅分享统计功能 2025-07-16 09:37:34 +08:00
jxxghp
6ddc3ea996 fix #375 2025-07-15 20:25:42 +08:00
jxxghp
7edbf7c724 更新用户资料和账户设置中的链接 2025-07-15 17:31:15 +08:00
jxxghp
4f233ca886 更新 package.json 版本号至 2.6.6 2025-07-15 14:54:49 +08:00
jxxghp
457831536a 移除AccountSettingSite.vue中的USER_AGENT字段 2025-07-14 12:30:54 +08:00
jxxghp
ccef0d87db 更新缓存版本至v1.0.3 2025-07-13 13:52:16 +08:00
jxxghp
584d290283 增强全局滚动锁定功能 2025-07-13 13:46:28 +08:00
jxxghp
2ab14fa33b fix 2025-07-13 13:35:25 +08:00
jxxghp
f0317e1d74 为明亮主题优化Footer组件的背景色透明度 2025-07-13 13:32:05 +08:00
jxxghp
17a206e0f4 更新 DownloadingCard.vue 2025-07-13 11:40:23 +08:00
jxxghp
8ea352cc2f 优化DownloadingCard组件 2025-07-13 11:31:26 +08:00
jxxghp
0f10920898 fix #374 2025-07-13 11:22:27 +08:00
jxxghp
eb098ca775 增强滚动锁定功能 2025-07-13 09:46:38 +08:00
jxxghp
e25caddfef 更新 package.json 2025-07-12 15:15:48 +08:00
jxxghp
c74cf6cf6e 移除构建Plex深度链接时的警告弹窗 2025-07-12 15:13:18 +08:00
jxxghp
ce2d04fa64 更新Plex深度链接构建逻辑 2025-07-12 15:04:36 +08:00
jxxghp
40a4e29c7e 重构深度链接功能 2025-07-12 14:57:03 +08:00
jxxghp
60385715e6 新增媒体服务器深度链接功能 2025-07-12 13:47:00 +08:00
jxxghp
3cce92e83d 优化媒体查询条件,增强响应式样式支持 2025-07-12 13:16:30 +08:00
jxxghp
602b0067d2 Merge pull request #373 from jtcymc/v2 2025-07-12 07:17:57 +08:00
shaw
51d07db99b refactor(dialog): 将日志输出级别从 log 改为 warn
- 在 SubscribeEditDialog.vue 和 SubscribeSeasonDialog.vue 组件中- 当 tmdbid 未设置或为空时,使用 console.warn替代 console.log
- 此修改提高了日志的可见性和严重性级别,以便更好地提醒开发者注意潜在问题
2025-07-12 00:01:55 +08:00
shaw
33d121fd64 fix(dialog): 修复剧集分组查询时 TMDBID 未设置或为空的问题
- 在 SubscribeEditDialog 和 SubscribeSeasonDialog 组件中添加了对 TMDBID 的空值检查
- 如果 TMDBID 未设置或为空,将不会执行剧集分组查询,避免出现错误
2025-07-11 23:57:01 +08:00
jxxghp
e409dbd5b8 优化可用高度计算 2025-07-11 15:14:16 +08:00
jxxghp
79d203470a 更新 service-worker.ts 2025-07-11 07:26:01 +08:00
jxxghp
0f1341615b fix size 2025-07-11 07:06:46 +08:00
jxxghp
97f5410b1c Add files via upload 2025-07-10 23:31:48 +08:00
jxxghp
195f6b7e50 优化卡片组件样式 2025-07-10 22:45:59 +08:00
jxxghp
6691f40c49 优化卡片组件样式 2025-07-10 22:00:06 +08:00
jxxghp
bc1849f0a0 fix 全屏弹窗背景 2025-07-10 21:06:54 +08:00
jxxghp
0f64ea1403 为垂直导航布局的固定导航栏添加内边距,以改善滚动时的视觉效果 2025-07-10 17:29:48 +08:00
jxxghp
320fc1604c 更新 SubscribeListView.vue 中的 Teleport 组件条件,以支持根据订阅类型动态渲染 2025-07-10 16:54:00 +08:00
jxxghp
a8eaf3b995 移除 vite.config.ts 中的缓存键处理逻辑以提高代码简洁性 2025-07-10 16:50:35 +08:00
jxxghp
308a951f78 修正 Vuetify 变量路径为相对路径 2025-07-10 16:40:49 +08:00
jxxghp
9f98b549e9 重构scss文件结构 2025-07-10 16:39:22 +08:00
jxxghp
0e2a259999 优化 WorkflowShareCard 组件 2025-07-10 15:08:10 +08:00
jxxghp
b3d3561111 将 useDialogScrollLock 替换为 useScrollLock 2025-07-10 12:56:51 +08:00
jxxghp
ad857b0810 删除 useScrollLock 组合式 API 2025-07-10 12:52:57 +08:00
jxxghp
0918fa1685 将所有 VDialog 组件替换为 DialogWrapper 组件 2025-07-10 12:44:37 +08:00
jxxghp
273d1f8ef2 更新 _misc.scss 文件,调整媒体查询条件 2025-07-10 11:25:59 +08:00
jxxghp
af1e0a2a60 在 PWAInstallPrompt.vue 中添加 HTTPS 环境检查 2025-07-10 10:59:02 +08:00
jxxghp
79ae772367 优化垂直导航栏样式 2025-07-09 19:53:14 +08:00
jxxghp
d57c8aa305 更新 FetchTorrentsAction.vue 2025-07-09 14:39:20 +08:00
jxxghp
bbd8c1b6d4 更新 yarn.lock 2025-07-09 13:17:30 +08:00
jxxghp
ced9288ed7 优化浏览器警告 2025-07-09 13:16:56 +08:00
jxxghp
cf87e2d5ac 添加工作流备注功能 2025-07-09 12:22:08 +08:00
jxxghp
153d4c1d01 更新工作流分享卡片和对话框 2025-07-09 11:44:52 +08:00
jxxghp
1c50fa228e 更新 ForkWorkflowDialog.vue 2025-07-09 11:28:02 +08:00
jxxghp
0067dc6be3 更新 package.json 2025-07-09 11:17:15 +08:00
jxxghp
36389a5b8c 优化 PWA 状态恢复逻辑 2025-07-09 11:07:24 +08:00
jxxghp
c7443d993e 更新工作流分享功能 2025-07-09 10:46:33 +08:00
jxxghp
9f8dbf3c75 fix workflow 2025-07-09 00:11:19 +08:00
jxxghp
35332544e4 Add workflow sharing functionality (#371)
* Add workflow sharing feature with share, fork, and browse functionality

Co-authored-by: jxxghp <jxxghp@163.com>

* Refactor workflow page with dynamic tabs and internationalization support

Co-authored-by: jxxghp <jxxghp@163.com>

* Remove workflow share implementation documentation

Co-authored-by: jxxghp <jxxghp@163.com>

* Fix indentation and structure in Chinese locale files

Co-authored-by: jxxghp <jxxghp@163.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-08 23:31:22 +08:00
jxxghp
f2bc832aca rollback service.js 2025-07-08 22:17:41 +08:00
jxxghp
a6847f7f53 Merge pull request #369 from jxxghp/cursor/fix-pwa-install-prompt-text-display-14c8
Fix PWA install prompt text display
2025-07-08 14:36:18 +08:00
Cursor Agent
396ab64874 Refactor PWA install steps to use dynamic translation keys
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-08 06:19:07 +00:00
jxxghp
59ee3d8ceb fix 订阅历史按钮 2025-07-08 13:48:45 +08:00
jxxghp
3e152bd389 优化 PWAInstallPrompt 组件中的文本提取逻辑 2025-07-08 13:37:18 +08:00
jxxghp
56e8f61bbf 将 PWAInstallPrompt 组件中的 div 替换为 VCard 2025-07-08 13:26:12 +08:00
jxxghp
83c00b0544 fix PWAInstallPrompt 2025-07-08 13:03:11 +08:00
jxxghp
5f82cc715e 优化 PWA 安装提示组件 2025-07-07 23:18:32 +08:00
jxxghp
3ce7fc34f0 fix PWAInstallPrompt 2025-07-07 23:11:27 +08:00
jxxghp
9fc5291fec 优化服务工作者 2025-07-07 22:56:36 +08:00
jxxghp
27c7a842db Merge pull request #367 from jxxghp/cursor/fix-sync-queue-data-corruption-issues-ee60
Cursor/fix sync queue data corruption issues ee60
2025-07-07 22:49:17 +08:00
jxxghp
ffe1992df1 Merge pull request #368 from jxxghp/cursor/fix-ios-detection-inconsistency-in-pwa-2971
Fix iOS detection inconsistency in PWA
2025-07-07 22:48:57 +08:00
Cursor Agent
a80877bab7 Fix PWA install detection on iOS with additional check for MSStream
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-07 14:48:00 +00:00
jxxghp
c787a3c786 Merge pull request #366 from jxxghp/cursor/fix-pwa-support-detection-bug-0f65 2025-07-07 22:43:47 +08:00
Cursor Agent
abda382b96 Refactor service worker types and extract type definitions
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-07 14:35:25 +00:00
Cursor Agent
c5ab0a2cc6 Refactor IndexedDB sync mechanism with dedicated store and improved handling
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-07 14:32:55 +00:00
Cursor Agent
15340dd550 Improve PWA install support detection for various platforms
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-07 14:25:12 +00:00
jxxghp
deaf444864 Merge pull request #364 from jxxghp/cursor/evaluate-app-shell-model-compliance-1502
Evaluate app shell model compliance
2025-07-07 22:09:11 +08:00
Cursor Agent
a5413d1116 Remove PWA optimization documentation files
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-07 14:05:12 +00:00
Cursor Agent
6cb6a5822b Implement PWA optimizations with advanced caching and install features
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-07 13:46:09 +00:00
Cursor Agent
2ffd6f7430 Enhance PWA caching strategy with offline support and optimization docs
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-07 13:35:58 +00:00
jxxghp
cd9eaf4fd7 fix Teleport 2025-07-07 17:24:58 +08:00
jxxghp
3cfe27b7b3 fix 2025-07-07 15:17:49 +08:00
jxxghp
44d78fd2ea Merge pull request #363 from cddjr/fix_top_level_await 2025-07-07 14:07:20 +08:00
jxxghp
0cf3342449 重构PWA状态管理 2025-07-07 14:05:11 +08:00
景大侠
7e4c6516c5 修复 Safari14兼容Top-level await特性
v2.4.4引入的问题
2025-07-07 13:57:35 +08:00
jxxghp
73d7eb65b8 移除可见性状态管理器 2025-07-07 11:32:38 +08:00
jxxghp
fca4afb606 优化PWA状态管理 2025-07-07 11:28:57 +08:00
jxxghp
b15672d593 fix 2025-07-07 11:19:45 +08:00
jxxghp
7a37a18f23 修复下拉快速访问 2025-07-07 11:17:01 +08:00
jxxghp
a14806e840 Merge pull request #361 from jxxghp/cursor/enhance-pwa-state-restoration-features-24b1 2025-07-07 07:50:41 +08:00
Cursor Agent
bbd2851f36 Improve element selector generation for scroll position tracking
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 23:34:58 +00:00
Cursor Agent
48418771d4 Remove PWA state management documentation files
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 23:27:03 +00:00
Cursor Agent
a81071a50a Refactor PWA state management to simplify and streamline implementation
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 23:24:31 +00:00
Cursor Agent
304b990994 Implement lightweight PWA state management with zero-overhead approach
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 23:14:50 +00:00
Cursor Agent
8824869cd1 Enhance PWA state management with advanced scroll, form, and modal tracking
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 23:03:42 +00:00
jxxghp
325cce5f82 Merge pull request #360 from jxxghp/cursor/analyze-factors-causing-ios-to-kill-pwa-ac82
Analyze factors causing iOS to kill PWA
2025-07-07 06:40:43 +08:00
jxxghp
85db26a704 Merge branch 'v2' into cursor/analyze-factors-causing-ios-to-kill-pwa-ac82 2025-07-06 23:41:42 +08:00
Cursor Agent
65b0acdcb4 Refactor data refresh mechanism with conditional timer support
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 15:29:44 +00:00
Cursor Agent
9a27af8c5a Bump version to 2.6.3 and remove optimization documentation files
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 15:13:08 +00:00
jxxghp
93ad0859e8 重构PWA状态管理,统一检测方法并优化状态恢复逻辑 2025-07-06 23:04:34 +08:00
Cursor Agent
5e62bac245 Implement background optimization composable for data refresh and SSE
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 15:01:17 +00:00
Cursor Agent
bea6c1e326 Optimize PWA background performance with SSE and timer management
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 14:52:58 +00:00
Cursor Agent
df76b01826 Add background and SSE managers for improved app lifecycle management
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 14:36:31 +00:00
jxxghp
5d22cb84bf 更新 package.json 2025-07-06 20:03:25 +08:00
jxxghp
f01c61e09f 更新 App.vue 2025-07-06 19:52:37 +08:00
jxxghp
d50e67f3bc Merge pull request #359 from jxxghp/cursor/pwa-5007
分析PWA状态切换体验问题
2025-07-06 18:35:53 +08:00
Cursor Agent
3726c472fc Remove console logs for silent PWA state restoration optimization
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 10:31:35 +00:00
Cursor Agent
dc174e81cf Optimize PWA state restoration for seamless, silent background switching
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 10:14:40 +00:00
Cursor Agent
c9867bc453 Optimize PWA state restoration and loading experience
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 08:30:12 +00:00
Cursor Agent
8e282fb216 Add PWA performance analysis report for background-to-foreground experience
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 07:22:01 +00:00
jxxghp
e9c0792cb3 Merge pull request #358 from jxxghp/cursor/prevent-ios-from-killing-pwa-background-245b
fix: PWA状态管理器初始化在DOM已加载时失败的问题
2025-07-06 15:04:45 +08:00
Cursor Agent
e7e1b4c43f fix: PWA状态管理器初始化在DOM已加载时失败的问题
- 修复DOMContentLoaded事件监听器可能不触发的问题
- 检查document.readyState状态,如果DOM已就绪则立即初始化
- 确保PWA状态管理器在所有情况下都能正确初始化
- 解决main.ts作为模块加载时的时序问题
2025-07-06 06:58:15 +00:00
jxxghp
dc56c177b7 Merge pull request #356 from jxxghp/cursor/prevent-ios-from-killing-pwa-background-245b 2025-07-06 14:54:36 +08:00
Cursor Agent
c0ee998874 feat: 实现PWA状态管理防止iOS后台被杀
- 添加多层存储策略(localStorage + sessionStorage + IndexedDB + Service Worker缓存)
- 实现智能状态恢复决策机制
- 自动监听页面生命周期事件进行状态保存和恢复
- 支持表单数据、滚动位置、UI状态的自动保存
- 专为iOS设备PWA优化,解决后台被杀导致状态丢失的问题
- 版本号更新至 2.6.3
2025-07-06 06:52:18 +00:00
Cursor Agent
e1ff50e1e3 Refactor usePWAState and useGlobalPWAState composables
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 06:50:30 +00:00
Cursor Agent
0e440955c8 Implement PWA state management for improved iOS background persistence
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 06:44:06 +00:00
Cursor Agent
a16dd497c4 Add comprehensive PWA state management solution for iOS background handling
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 06:34:25 +00:00
jxxghp
5aa4e9339d Merge pull request #355 from jxxghp/cursor/fix-sse-issues-in-public-service-js-b303 2025-07-06 08:10:30 +08:00
Cursor Agent
723fa96519 Refactor SSE proxy middleware for improved event streaming support
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-05 23:59:58 +00:00
Cursor Agent
75252fded6 Refactor proxy middleware for improved SSE and API request handling
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-05 23:56:51 +00:00
jxxghp
51fbcdfa56 更新 Footer.vue 2025-07-06 00:51:44 +08:00
jxxghp
61c9b97d70 优化垂直导航布局和动态标签页的样式 2025-07-06 00:43:06 +08:00
jxxghp
23b09d09ce 优化移动端Header UI 2025-07-06 00:43:06 +08:00
jxxghp
a00f6ab8ff 更新 HeaderTab.vue 2025-07-05 21:56:28 +08:00
jxxghp
bb59095bad 优化 HeaderTab 组件的模糊背景样式 2025-07-05 20:47:18 +08:00
jxxghp
da57124d5e Merge pull request #354 from jxxghp/cursor/add-blurred-background-to-headertab-d515 2025-07-05 17:31:17 +08:00
Cursor Agent
a00800a128 Remove demo.html file with header tab scroll effect example
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-05 09:24:44 +00:00
Cursor Agent
a98db1699d Create demo page for HeaderTab scroll transparency effect
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-05 09:21:25 +00:00
Cursor Agent
e3d9e736ad Add blur background effect on header tab when scrolling
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-05 09:12:18 +00:00
jxxghp
28f38d8b80 Fix tab selection on discovery page (#353)
* Fix: Ensure first tab is selected when no valid tab is active

Co-authored-by: jxxghp <jxxghp@163.com>

* Fix discover page tab selection logic when no tab is active

Co-authored-by: jxxghp <jxxghp@163.com>

* Fix tab selection logic in discover page lifecycle hooks

Co-authored-by: jxxghp <jxxghp@163.com>

* Remove unnecessary onMounted hook in discover page

Co-authored-by: jxxghp <jxxghp@163.com>

* Move dynamic header tab registration after tabs initialization

Co-authored-by: jxxghp <jxxghp@163.com>

* Refactor dynamic header tab registration in discover page

Co-authored-by: jxxghp <jxxghp@163.com>

* Fix tab selection logic in discover page to ensure first tab is selected

Co-authored-by: jxxghp <jxxghp@163.com>

* Update useDynamicHeaderTab to sync modelValue in tab configuration

Co-authored-by: jxxghp <jxxghp@163.com>

* Remove auto-tab selection logic from discover page

Co-authored-by: jxxghp <jxxghp@163.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-05 16:41:24 +08:00
jxxghp
3b7c34258f 更新 _misc.scss 2025-07-05 14:46:54 +08:00
jxxghp
9dde646695 修改 tab-header 的圆角样式 2025-07-05 14:34:30 +08:00
jxxghp
4bdee63f28 优化动态标签页注册逻辑 2025-07-05 14:26:57 +08:00
jxxghp
20dced021d 优化动态标签页注册逻辑 2025-07-05 14:06:00 +08:00
jxxghp
17cf640e23 优化 PageContentTitle 组件的样式,调整了边距;在 discover 页面中引入 useDynamicHeaderTab 组合函数;移除 UserListView 中未使用的 useDisplay 导入。 2025-07-05 12:22:08 +08:00
jxxghp
24369daea0 v2.6.2 2025-07-05 12:13:53 +08:00
jxxghp
873bf905ab 优化动态标签页注册逻辑 2025-07-05 12:13:53 +08:00
jxxghp
da0756adf0 动态Tab组件 2025-07-05 12:13:53 +08:00
jxxghp
09942ec946 更新 SubscribeEditDialog.vue 2025-07-05 09:21:25 +08:00
jxxghp
2650bc6068 添加离线状态管理和网络请求处理 2025-07-05 08:23:06 +08:00
jxxghp
6bd7274c9c Update index.html 2025-07-05 06:49:58 +08:00
jxxghp
129ccf9e39 更新 index.html 2025-07-05 06:43:38 +08:00
jxxghp
e2b789cfbc 优化加载动画逻辑 2025-07-04 21:26:44 +08:00
jxxghp
bb70e91277 重构服务工作者逻辑优 2025-07-04 18:32:04 +08:00
jxxghp
f6c07a29ce 更新服务工作者逻辑 2025-07-04 17:30:01 +08:00
jxxghp
4347983fc7 更新vite.config.ts,扩展缓存策略以支持更多文件类型和API请求 2025-07-04 16:57:51 +08:00
jxxghp
12b463d9e8 更新vite.config.ts,增加页面缓存配置 2025-07-04 16:39:31 +08:00
jxxghp
edc0949bed 移除全局设置store并更新引用路径 2025-07-04 16:21:05 +08:00
jxxghp
85780917c2 整合全局设置store,优化PWA模式检测 2025-07-04 16:19:50 +08:00
jxxghp
e45919cac1 优化PWA支持 2025-07-04 13:33:06 +08:00
jxxghp
c61821ef4e 在App.vue中优化加载动画逻辑,移除不必要的延迟 2025-07-04 12:12:13 +08:00
jxxghp
011902598b 在App.vue中添加主题支持以配置ApexCharts 2025-07-04 08:12:10 +08:00
jxxghp
3186c6ca0e 更新 AnalyticsNetwork.vue 2025-07-03 22:12:28 +08:00
jxxghp
3a680a132f 添加可拖拽排序功能 2025-07-03 20:05:08 +08:00
jxxghp
455dda54e8 添加存储后自动保存 2025-07-03 19:57:29 +08:00
jxxghp
5ea5ab07d9 移除WorkflowActionsDialog组件中的VSpacer元素 2025-07-03 19:50:04 +08:00
jxxghp
35c8025b00 在仪表板中添加网络流量组件 2025-07-03 19:14:31 +08:00
jxxghp
615c162663 插件图标使用缓存 2025-07-03 17:09:56 +08:00
jxxghp
c4bd15e5a0 fix storage save 2025-07-03 15:41:44 +08:00
jxxghp
edc92905f7 在MediaInfoCard组件中添加web_source信息的显示 2025-07-03 14:02:53 +08:00
jxxghp
bf5bbd3689 添加SMB网络共享支持 2025-07-03 12:43:42 +08:00
jxxghp
eb70ca233b 重构DefaultLayout.vue组件 2025-07-03 08:48:44 +08:00
jxxghp
8718816fce 将多个组件中的VFab按钮包裹在Teleport中,以确保在移动设备上正确显示 2025-07-03 07:18:31 +08:00
jxxghp
7d36330b4b 在PluginDataDialog组件中添加show_switch属性的绑定 2025-07-02 21:55:02 +08:00
jxxghp
1fa0474fef 调整DownloaderCard、MediaServerCard和StorageCard组件中图标的上边距 2025-07-02 21:49:42 +08:00
jxxghp
4070b27148 调整QuickAccess.vue组件的过渡时间为0.6秒 2025-07-02 21:39:41 +08:00
jxxghp
3892b0ed05 添加PluginDataDialog组件的show_switch属性 2025-07-02 21:30:44 +08:00
jxxghp
a06cf69d7a 优化QuickAccess.vue组件样式 2025-07-02 20:43:33 +08:00
jxxghp
61dc2568e8 优化快速访问组件 2025-07-02 20:28:58 +08:00
jxxghp
ac6362e698 更新 QuickAccess.vue 2025-07-02 17:55:19 +08:00
jxxghp
94afdf5495 更新样式和布局 2025-07-02 17:41:58 +08:00
jxxghp
d96f8acdbc 优化默认布局和快速访问组件 2025-07-02 17:12:14 +08:00
jxxghp
d39c795f92 更新快速访问组件的导入方式 2025-07-02 16:11:12 +08:00
jxxghp
8e12e0562b 更改快速访问组件的导入路径 2025-07-02 16:08:27 +08:00
jxxghp
7a1babb418 重构插件快速访问组件 2025-07-02 16:07:18 +08:00
jxxghp
8d65f0c2a8 优化快速访问插件的下拉手势逻辑 2025-07-02 15:59:11 +08:00
jxxghp
b8dff560f0 添加插件快速访问功能,支持下拉手势触发 2025-07-02 14:18:58 +08:00
jxxghp
b48c26ee73 调整日历视图的背景颜色 2025-07-02 12:31:30 +08:00
jxxghp
8328e51ae0 调整存储添加逻辑 2025-07-02 08:58:16 +08:00
jxxghp
7070eb8a7d 更改流媒体平台的源芯片背景颜色 2025-07-01 17:32:24 +08:00
jxxghp
d0aa26441c 单独显示流媒体平台 2025-07-01 17:14:03 +08:00
jxxghp
1bba7103c8 调整主题背景颜色为深灰色以提升视觉效果 2025-07-01 12:54:01 +08:00
jxxghp
7f8dd744f2 调整表格和输入框的背景颜色以适应透明主题 2025-07-01 12:39:44 +08:00
jxxghp
2f4a707498 为筛选菜单添加内边距样式 2025-07-01 11:58:57 +08:00
jxxghp
569bc3c8ec 站点添加筛选功能 2025-07-01 11:38:00 +08:00
jxxghp
b01421aa94 优化组件加载逻辑 2025-06-30 20:38:50 +08:00
jxxghp
30d933bd85 更新 package.json 2025-06-30 20:16:14 +08:00
jxxghp
377998335b 简化导航状态管理 2025-06-30 20:14:31 +08:00
jxxghp
21d21aa438 优化图片加载逻辑,添加导航状态管理 2025-06-30 19:55:27 +08:00
jxxghp
18cf1ea3d7 更新 FileList.vue、FileNavigator.vue 和 FileToolbar.vue 中 axios 属性的类型定义为 Function 2025-06-30 19:39:02 +08:00
jxxghp
60ea884fe2 添加全局请求和图片优化器 2025-06-30 17:37:30 +08:00
jxxghp
999fa9d9a6 自定义存储类型添加索引以区分不同的自定义存储 2025-06-29 11:21:40 +08:00
jxxghp
e80034e7f8 更新 package.json 2025-06-29 07:54:18 +08:00
jxxghp
b16f99941a Merge pull request #350 from tbc0309/v2 2025-06-29 07:52:27 +08:00
ERROR204
3503e7d5b1 fix service.js 2025-06-29 03:06:31 +08:00
ERROR204
d1d80acef8 fix service.js 2025-06-29 03:00:25 +08:00
jxxghp
16fe916b07 将 AList 更名为 OpenList 2025-06-28 08:32:36 +08:00
jxxghp
d754c3dae3 更新 NoDataFound 组件 2025-06-27 23:26:43 +08:00
jxxghp
1b32a3e8cd 在消息视图中添加倒序功能 2025-06-27 20:39:15 +08:00
jxxghp
15a6f215b4 更新 TorrentCard.vue 2025-06-27 18:09:55 +08:00
jxxghp
38014ba342 添加发布时间显示功能,并在排序中支持按发布时间排序 2025-06-27 17:43:43 +08:00
jxxghp
7dcc293a09 fix mobile toast 2025-06-27 10:03:18 +08:00
jxxghp
35ce244490 Merge pull request #348 from Aqr-K/fix-progress 2025-06-26 15:47:08 +08:00
Aqr-K
3bade2060a fix(progress): 修复重复点击时,progressEventSource 被覆盖会产生孤儿事件的情况。 2025-06-26 14:31:51 +08:00
jxxghp
f8307f25c9 fix service.js 2025-06-26 12:32:16 +08:00
jxxghp
5c9ebb9aae 为Toast组件添加隐藏进度条选项以优化用户体验 2025-06-25 19:47:24 +08:00
jxxghp
ebc2a764c2 将vue-toast-notification替换为vue-toastification,并更新相关样式和依赖项 2025-06-25 17:42:36 +08:00
jxxghp
bed21856ab 调整背景透明度 2025-06-23 19:57:35 +08:00
jxxghp
61805d13ab 为通知列表添加细 scrollbar 样式以改善用户体验 2025-06-23 11:23:57 +08:00
jxxghp
e47d8d5d2b 修复通知列表的溢出问题 2025-06-23 11:18:55 +08:00
jxxghp
0bd81499f6 更新版本号至2.5.7-1 2025-06-17 20:00:07 +08:00
jxxghp
201ae2c237 fix https://github.com/jxxghp/MoviePilot/issues/4456 2025-06-17 19:59:23 +08:00
jxxghp
df4c3c7676 fix https://github.com/jxxghp/MoviePilot/issues/4455 2025-06-16 14:07:45 +08:00
jxxghp
667693902f 为MessageCard组件添加最小高度属性 2025-06-16 12:29:24 +08:00
jxxghp
9e261d30f8 v2.5.7 2025-06-16 11:52:21 +08:00
jxxghp
5f6bade809 为MessageCard组件添加图片加载占位符 2025-06-16 11:51:34 +08:00
jxxghp
273168ae5c 优化消息滚动逻辑 2025-06-15 13:40:01 +08:00
jxxghp
a55269e9e6 优化消息滚动逻辑 2025-06-15 08:05:05 +08:00
jxxghp
9c386f8533 为MessageCard组件添加图片加载事件,更新MessageView以处理图片加载完成后的滚动事件 2025-06-15 07:59:35 +08:00
jxxghp
17ee5f456a 实现未读消息的全局事件处理 2025-06-14 14:17:26 +08:00
jxxghp
6cefdb5d37 调整应用启动时的延迟时间和重试机制 2025-06-14 13:16:51 +08:00
jxxghp
74fc8bd131 优化插件和订阅的加载顺序配置,移除本地存储逻辑,增加错误处理 2025-06-14 11:08:42 +08:00
jxxghp
aa9dab5d96 优化未读消息弹窗的打开逻辑 2025-06-14 10:11:41 +08:00
jxxghp
5b461f8e1f 更新网络测试视图 2025-06-14 08:13:44 +08:00
jxxghp
bde06be3df Merge pull request #347 from cddjr/feat_nettest
feat 网络测试支持GitHub加速代理、新增pip测试
2025-06-14 07:57:19 +08:00
jxxghp
fe17986b2a 更新桌面图标徽章的逻辑 2025-06-14 07:52:55 +08:00
景大侠
e9160ecefd feat 网络测试支持GitHub加速代理、新增pip测试 2025-06-13 18:38:59 +08:00
jxxghp
05ebd48f09 Merge pull request #346 from wumode/fix_download_api 2025-06-13 14:25:18 +08:00
wumode
6dbc3f4bab fix: 无法设置非默认下载器状态 2025-06-13 08:50:54 +08:00
jxxghp
bc7166789b 更新 package.json 2025-06-12 23:05:11 +08:00
jxxghp
750b91db66 新增未读消息计数和桌面图标徽章更新功能 2025-06-12 22:58:16 +08:00
jxxghp
b69a338e13 fix https://github.com/jxxghp/MoviePilot/pull/4434 2025-06-12 18:42:42 +08:00
jxxghp
036fe65b12 Merge pull request #345 from alfchao/v2 2025-06-12 16:16:09 +08:00
xuchao3
732017ac77 fix:修改清华pip源地址 2025-06-12 11:25:04 +08:00
jxxghp
5bd71b4688 fix https://github.com/jxxghp/MoviePilot/issues/4424#issuecomment-2964853532 2025-06-12 11:13:37 +08:00
jxxghp
44ba2dff78 调整用户编辑对话框的样式 2025-06-11 20:38:20 +08:00
jxxghp
0954e4bde2 新增季NFO相关翻译及设置选项 2025-06-11 19:50:56 +08:00
jxxghp
5b183d31e2 更新 Footer.vue 2025-06-11 19:29:59 +08:00
jxxghp
b2017764eb 更新 Footer.vue 2025-06-11 19:22:07 +08:00
jxxghp
f27cd796b6 调整多个组件的高度计算逻辑 2025-06-11 13:21:42 +08:00
jxxghp
3c051b8698 优化用户信息展示和权限显示逻辑 2025-06-11 12:56:10 +08:00
jxxghp
052d6edd13 更新版本号至 2.5.5 2025-06-11 00:02:26 +08:00
jxxghp
e7dc61e3d9 移除 TOKENIZED_SEARCH 设定 2025-06-11 00:01:34 +08:00
jxxghp
f0aefdfdf8 更新管理描述,明确下载管理和站点管理功能 2025-06-10 23:55:43 +08:00
jxxghp
0beec368b8 重构用户卡片和用户编辑对话框中的权限显示逻辑 2025-06-10 23:52:45 +08:00
jxxghp
3f1d03a127 在多个组件中实现权限管理功能 2025-06-10 23:44:06 +08:00
jxxghp
eb143c28e3 新增用户权限管理功能 2025-06-10 23:25:59 +08:00
jxxghp
1631951a24 优化Footer组件中的按钮图标大小和样式 2025-06-10 22:55:14 +08:00
jxxghp
31bdd89373 更新刮削开关设置界面 2025-06-10 21:21:38 +08:00
jxxghp
ad5ae12d44 新增刮削开关设置功能 2025-06-10 19:56:12 +08:00
jxxghp
c838db262c 优化重启流程,增加重启状态管理和轮询清理逻辑 2025-06-09 21:09:12 +08:00
jxxghp
623b807a11 在插件安装成功后,清空过滤条件以确保数据刷新时的准确性 2025-06-09 20:54:16 +08:00
jxxghp
ce9335a842 优化插件卡片列表视图中的按钮颜色逻辑 2025-06-09 20:47:55 +08:00
jxxghp
1c62465c3e 新增插件市场手动刷新功能 2025-06-09 20:35:05 +08:00
jxxghp
a2c176bdee 新增服务状态检测与轮询功能,优化重启流程,增加超时提示信息 2025-06-09 16:21:31 +08:00
jxxghp
bff8c0f86b 优化注销流程,增加10秒延迟后再执行注销操作 2025-06-09 15:56:03 +08:00
jxxghp
1065973e07 feat:插件筛选运行中插件 2025-06-09 12:49:41 +08:00
jxxghp
8e042d5691 移除内存监控相关设置及其翻译,简化系统设置界面 2025-06-08 18:37:02 +08:00
jxxghp
d9a6b32e5f add apple-touch-icon-precomposed 2025-06-06 21:33:37 +08:00
jxxghp
eed3f97fbf 更新 package.json 2025-06-06 14:04:46 +08:00
jxxghp
6b9a8ed108 feat:内存监控开关 2025-06-06 13:50:09 +08:00
jxxghp
adc718b751 实现文件浏览器的拖动分隔条功能 2025-06-06 08:44:06 +08:00
jxxghp
df9981d0c9 重构 LoadingBanner 组件 2025-06-06 08:32:07 +08:00
jxxghp
f58b661b1b Merge pull request #344 from cddjr/fix_search_progress 2025-06-05 20:46:50 +08:00
景大侠
ec1926ba60 fix: 优化搜索进度条,避免卡”正在搜索,请稍候...“
1、通过进度有无变化来判定超时,避免误判
2、避免搜索期间误判完成,导致SSE被提前终止
2025-06-05 20:29:51 +08:00
jxxghp
e853851933 修改点击事件和工具栏密度设置 2025-06-05 19:31:34 +08:00
jxxghp
3705ce3b90 更新 UserAuthDialog.vue 2025-06-04 22:45:05 +08:00
jxxghp
7ad73ff251 移除保存设置时的重载系统调用,进一步简化设置保存逻辑 2025-06-04 08:19:16 +08:00
jxxghp
6c23e8892a 移除多个组件中的重载系统生效配置函数,简化保存设置逻辑 2025-06-03 19:53:31 +08:00
jxxghp
58efafac71 Merge pull request #343 from wkeylin/v2 2025-06-03 15:19:53 +08:00
wkeylin
abf2364bf6 fix: 日志日期优化 2025-06-03 14:30:15 +08:00
jxxghp
0650f35dbb Update module-federation-guide.md 2025-06-03 10:39:34 +08:00
jxxghp
cc593634d2 更新模块联邦指南,添加关于上传dist文件夹的注意事项,明确不需要上传的目录和文件类型 2025-06-03 10:37:12 +08:00
jxxghp
79a3b9de8a 更新版本号至 2.5.3 2025-06-03 10:17:19 +08:00
jxxghp
ceb46ec974 Merge pull request #342 from jtcymc/v2 2025-06-03 06:43:07 +08:00
shaw
a7e2893a57 refactor(components): 将 VSelect 组件替换为 VAutocomplete组件
- 在 DirectoryCard.vue 中将 VSelect 替换为VAutocomplete,用于 library_storage 字段
- 在 FilterRuleGroupCard.vue 中将两个 VSelect 组件替换为 VAutocomplete,用于 media_type 和 category 字段
2025-06-02 22:37:36 +08:00
shaw
2efe8efde0 refactor(components): 将 VSelect 组件替换为 VAutocomplete组件,以支持搜索待选项
- 在多个组件中将 VSelect 组件替换为 VAutocomplete 组件,以支持搜索待选项
- 此更改可以提供更丰富的用户交互体验和更好的性能
2025-06-02 21:48:07 +08:00
jxxghp
31047b0d44 优化账户设置缓存页面的筛选条件 2025-05-30 17:01:47 +08:00
jxxghp
7c2b724d10 fix ui 2025-05-30 09:04:15 +08:00
jxxghp
ca5670f06b v2.5.2 2025-05-30 08:48:39 +08:00
jxxghp
427e05871d 调整SubscribeCard组件的样式 2025-05-30 08:32:16 +08:00
jxxghp
bef56bdb56 优化账户设置缓存页面中的输入字段,添加持久提示和图标,提升用户体验 2025-05-30 08:27:10 +08:00
jxxghp
d450d02e18 在账户设置缓存页面中添加固定表头 2025-05-30 08:25:03 +08:00
jxxghp
85a766cc7b 调整多个组件的样式和结构,优化用户界面体验 2025-05-30 08:15:48 +08:00
jxxghp
a473f356c9 优化缓存管理页面 2025-05-29 22:56:40 +08:00
jxxghp
52b5fdf383 添加清空缓存确认提示,优化缓存管理页面的用户体验 2025-05-29 22:37:03 +08:00
jxxghp
b886f02043 缓存管理页面 2025-05-29 20:49:19 +08:00
jxxghp
61963ea497 reset 2025-05-29 20:12:14 +08:00
jxxghp
2f9b27ad9e reset 2025-05-29 20:11:34 +08:00
jxxghp
9334109767 Merge pull request #341 from madrays/v2
增加缓存管理页面
2025-05-29 12:32:51 +08:00
jxxghp
2bc52576d9 更新package.json中的版本号 2025-05-29 08:23:18 +08:00
jxxghp
700d2c4a51 刷新数据时重新加载文件夹配置,以确保插件正确显示。 2025-05-29 08:21:17 +08:00
madrays
103bdb32c8 增加缓存管理页面 2025-05-29 00:45:12 +08:00
jxxghp
92b745e180 优化搜索站点对话框 2025-05-28 21:25:37 +08:00
jxxghp
a2007083b8 更新MoviePilot自动更新设置逻辑,支持'release'和'dev'选项 2025-05-28 21:15:52 +08:00
jxxghp
36a5f7ff29 添加自动更新MoviePilot和站点资源的设置选项 2025-05-28 21:05:46 +08:00
jxxghp
f727aea51d 为多个设置组件的保存按钮添加图标,以提升用户体验和一致性。 2025-05-28 10:09:05 +08:00
jxxghp
936ca24328 优化对话框组件,添加图标以提升用户体验 2025-05-28 08:59:31 +08:00
jxxghp
62f49b6087 优化插件文件夹内插件的筛选逻辑 2025-05-28 08:49:53 +08:00
jxxghp
e9ddbf9962 添加代理服务器设置 2025-05-28 08:24:42 +08:00
jxxghp
196cf522e6 fix 2025-05-27 21:41:06 +08:00
jxxghp
3fce3bf4a7 优化多个组件的输入框,添加图标以提升用户体验,确保提示信息的一致性和可读性。 2025-05-27 21:38:25 +08:00
jxxghp
1cfee25695 优化多个组件的输入框,添加图标以提升用户体验,确保提示信息的一致性和可读性。 2025-05-27 21:23:08 +08:00
jxxghp
5711285a77 更新多个卡片组件,统一标题文本为“配置”,添加图标以提升用户体验,优化输入框提示信息,确保一致性和可读性。 2025-05-27 17:46:51 +08:00
jxxghp
e6f537ca3a 优化多个对话框组件的布局,添加图标以提升用户体验,调整部分文本提示,确保一致性和可读性。 2025-05-27 17:40:20 +08:00
jxxghp
3b5220af57 fix plugin list loading 2025-05-27 14:00:15 +08:00
jxxghp
fa6b4b1d2d 调整插件列表显示行数,从三行改为两行,以优化界面布局。 2025-05-27 13:49:55 +08:00
jxxghp
7968e5374b 优化文件夹内插件的显示顺序,确保按照保存顺序排列插件,提升用户体验。 2025-05-27 13:48:13 +08:00
jxxghp
64997ebe45 重构插件混合排序逻辑,优化全局排序配置,兼容旧格式,提升插件和文件夹的排序体验。 2025-05-27 13:40:55 +08:00
jxxghp
f8592b01e2 优化错误日志输出 2025-05-27 13:29:53 +08:00
jxxghp
087474f514 fix 2025-05-27 13:26:09 +08:00
jxxghp
1725088f05 fix 插件混合排序问题 2025-05-27 13:12:09 +08:00
jxxghp
ec1b756a3d 添加混合排序功能,重构插件列表显示逻辑,移除冗余代码并优化拖拽排序体验。 2025-05-27 13:01:08 +08:00
jxxghp
76a06e0817 移除 AddDownloadDialog 组件中的显示器宽度逻辑,简化对话框全屏显示设置 2025-05-27 07:54:34 +08:00
jxxghp
02fb608d7b 更新 PluginCard.vue 2025-05-26 22:40:48 +08:00
jxxghp
e17fc2fc12 更新 package.json 2025-05-26 21:38:10 +08:00
jxxghp
4f6c317652 修复 PersonDetailView 组件中的 VImg 标签,移除多余的 v-img 指令以简化代码。 2025-05-26 21:30:23 +08:00
jxxghp
46c198be26 重构 credits.vue 和 media.vue 组件,简化 API 路径处理,移除不必要的路由参数,同时优化 PersonCardListView 组件的样式。 2025-05-26 21:28:52 +08:00
jxxghp
8552203d43 PluginCard 组件中的实时日志弹窗代码 2025-05-26 13:26:13 +08:00
jxxghp
139eaa7016 优化 PluginCard 组件 2025-05-26 12:44:08 +08:00
jxxghp
d81120ab8f 为 PluginCard 组件添加实时日志弹窗功能 2025-05-26 12:37:49 +08:00
jxxghp
6353d56beb Merge pull request #339 from madrays/v2 2025-05-26 11:26:26 +08:00
madrays
aa05496b42 插件分身多语言支持 2025-05-26 11:20:10 +08:00
madrays
dc15e537d8 增加插件分身功能 2025-05-26 10:55:55 +08:00
jxxghp
6fbd41f40a 优化 PluginAppCard 和 PluginCard 组件的样式 2025-05-25 20:57:42 +08:00
jxxghp
0181f614e1 为 SiteCard 和 SubscribeCard 组件添加显示器宽度逻辑,优化图标的鼠标移动样式 2025-05-25 19:50:57 +08:00
jxxghp
fded7b0b28 为多个组件的对话框添加全屏显示逻辑 2025-05-25 19:44:04 +08:00
jxxghp
7e637f835a 优化 TorrentCardListView 和 TorrentRowListView 组件的确认按钮样式 2025-05-25 15:51:24 +08:00
jxxghp
deaaf1834d 为 v-table 组件的表头添加背景模糊效果和背景色,提升视觉效果 2025-05-25 15:01:28 +08:00
jxxghp
139c870f99 更新 MediaServerCard.vue 2025-05-25 11:01:26 +08:00
jxxghp
4cc2350bc6 移除 SiteResourceDialog 组件中的分页文本绑定 2025-05-25 09:17:00 +08:00
jxxghp
8b31a118da 为英文和中文语言文件添加分页文本格式,提升用户界面信息展示 2025-05-24 22:28:58 +08:00
jxxghp
cca26acb78 更新 PluginFolderCard 和 PluginCardListView 组件的默认渐变背景颜色,提升视觉效果 2025-05-24 20:09:43 +08:00
jxxghp
245edbd2f6 优化 PluginAppCard 组件的文本显示方式 2025-05-24 20:06:11 +08:00
jxxghp
903d22c622 优化多个组件的样式和结构,调整文本显示方式,提升用户界面体验 2025-05-24 20:01:20 +08:00
jxxghp
8b1805628e 为 PluginFolderCard 组件添加背景图片计算逻辑和背景遮罩样式,优化背景显示效果 2025-05-24 17:36:32 +08:00
jxxghp
11c8c488da 调整 ConfirmDialog 组件的宽度属性 2025-05-24 17:22:49 +08:00
jxxghp
4dd4e0e148 自实现 UseConfirm 组件 2025-05-24 17:19:43 +08:00
jxxghp
21f352aa64 优化 PluginAppCard 组件,添加插件标签显示功能;调整 PluginFolderCard 组件的菜单位置和图标样式;更新 PluginCardListView 组件的文件夹显示逻辑。 2025-05-24 16:38:34 +08:00
jxxghp
6c4beffdb7 优化多个组件的按钮样式 2025-05-24 15:37:40 +08:00
jxxghp
43d3efa838 优化 PluginFolderCard 组件 2025-05-24 14:47:47 +08:00
jxxghp
1c99839ab4 更新版本号至 2.5.0 2025-05-24 14:20:36 +08:00
jxxghp
c9e05ce5b1 调整 PluginFolderCard 组件的最小高度属性,从 9rem 修改为 8.5rem 2025-05-24 14:11:39 +08:00
jxxghp
3fe7ed0e1d 优化多个组件中的按钮样式 2025-05-24 14:06:10 +08:00
jxxghp
b3bff5c6f5 移除 PluginCardListView 组件中的调试日志,优化错误处理逻辑 2025-05-24 14:06:10 +08:00
jxxghp
e357bac70f 为文件夹功能添加国际化支持 2025-05-24 14:06:10 +08:00
jxxghp
ad51d4e4f3 调整 PluginCardListView 组件的样式 2025-05-24 14:06:10 +08:00
jxxghp
912d8ced93 更新 PluginFolderCard 组件,添加国际化支持 2025-05-24 14:06:10 +08:00
jxxghp
8334999e98 优化 PluginAppCard、PluginCard 和 PluginFolderCard 组件的样式,调整布局和响应式设计 2025-05-24 14:06:10 +08:00
jxxghp
5e23ea7809 更新 NotificationChannelCard.vue 2025-05-24 09:43:47 +08:00
jxxghp
b62d291aab Merge pull request #338 from madrays/v2 2025-05-24 06:34:30 +08:00
madrays
a34dd8148f 重构插件页面,增加文件夹功能 2025-05-24 03:58:14 +08:00
jxxghp
ba13e6ac35 fix #337 2025-05-23 22:29:19 +08:00
jxxghp
8efa5f7a28 调整 SubscribeCard 组件中 VCardText 的下边距,从 1 修改为 2,以改善布局效果 2025-05-23 08:04:26 +08:00
jxxghp
f0ef9565e2 更新 SubscribeCard.vue 2025-05-23 07:24:23 +08:00
jxxghp
78688ab63c 优化 SubscribeCard 组件的样式,调整文本和图标的大小,增强可读性 2025-05-23 07:15:30 +08:00
jxxghp
e90b30bf63 调整 SubscribeCard 组件中图像容器的宽度,从 16px 修改为 14px,以优化布局 2025-05-22 15:22:51 +08:00
jxxghp
5312b82ba7 优化 PluginAppCard、PluginCard 和 SubscribeCard 组件的样式,调整布局和间距,增强响应式设计 2025-05-22 15:21:25 +08:00
jxxghp
bc705f2560 更新 SubscribeCard.vue 2025-05-22 06:59:39 +08:00
jxxghp
6477f43de1 更新 SubscribeCard.vue 2025-05-22 06:50:32 +08:00
jxxghp
bdc0fdd076 优化 PluginAppCard 和 PluginCard 组件的样式 2025-05-21 21:29:26 +08:00
jxxghp
1f09e1ff93 优化垂直导航样式,修复边框半径设置,删除不必要的代码,移除 TransitionExpand 组件 2025-05-21 21:06:40 +08:00
jxxghp
4bcc89d9da 优化 PluginAppCard 和 PluginCard 组件的样式 2025-05-21 20:49:52 +08:00
jxxghp
8f93b49dde 优化多个组件的样式,调整卡片布局和间距,更新网格列数以适应不同屏幕尺寸 2025-05-21 20:26:48 +08:00
jxxghp
74eeae900e 调整背景透明度 2025-05-21 19:32:42 +08:00
jxxghp
63424bb134 Merge pull request #336 from Aqr-K/fix/i18n 2025-05-20 19:48:58 +08:00
Aqr-K
1c5e410881 fix(i18n): 修复非支持地区输出null,导致的显示问题 2025-05-20 19:14:31 +08:00
jxxghp
f79cc41f3c 更新 FetchMediasAction 组件,调整下拉框选项格式为包含值和标题的对象 2025-05-19 12:27:07 +08:00
jxxghp
49cccbe69e 更新 package.json 版本号至 2.4.9 2025-05-18 15:36:46 +08:00
jxxghp
c4a02f7497 新增自定义通知类型支持,更新相关提示信息和样式 2025-05-18 13:39:44 +08:00
jxxghp
59e12c5e96 优化 TorrentCard 组件的样式,更新替换词支持格式的描述信息 2025-05-18 12:55:38 +08:00
jxxghp
a347bdc412 将 package.json 版本号降级至 2.4.8 2025-05-16 12:38:34 +08:00
jxxghp
3f3c1ecd02 更新 package.json 版本号至 2.4.9 2025-05-16 12:37:34 +08:00
jxxghp
d5d9c78c91 重构 InvokePluginAction 组件,优化插件和动作选项的加载逻辑 2025-05-15 22:12:57 +08:00
jxxghp
5b0d8d902b 工作流新增调用插件功能组件 2025-05-15 20:53:41 +08:00
jxxghp
2978e46d02 fix ui 2025-05-15 13:03:09 +08:00
jxxghp
54e0633d77 更新 package.json 2025-05-15 12:09:53 +08:00
jxxghp
ab3db66195 增加安全图片域名功能,优化自定义壁纸API相关提示信息 2025-05-15 09:59:51 +08:00
jxxghp
17e19da3d8 Merge pull request #334 from Seed680/v2
背景壁纸增加自定义API
2025-05-15 09:22:36 +08:00
qiaoyun680
f22aca0c5d 背景壁纸增加自定义API,优化输入提示 2025-05-14 20:52:10 +08:00
qiaoyun680
c257e11ee3 背景壁纸增加自定义API 2025-05-14 20:28:01 +08:00
jxxghp
8b23f0bb2e 在登录页面中优化背景模糊效果 2025-05-14 14:35:39 +08:00
jxxghp
a82a89afd3 优化样式,调整背景模糊效果和颜色透明度,以提升用户界面视觉效果。 2025-05-14 14:22:44 +08:00
jxxghp
5c0d0d5a95 更新 DashboardItem 接口,将 render_mode 属性改为可选,并调整 dashboard.vue 中 VDialog 组件的最大高度以改善用户界面体验。 2025-05-14 13:22:31 +08:00
jxxghp
9dbd090482 优化 TorrentItem.vue 中的 VChip 组件,调整属性格式以提升代码可读性。 2025-05-14 11:26:53 +08:00
jxxghp
e25583dff9 v2.4.7 2025-05-14 09:11:44 +08:00
jxxghp
d997dc0394 优化登录页面,添加登录按钮的加载状态管理,确保用户体验流畅。 2025-05-13 23:28:03 +08:00
jxxghp
6b6353ed41 优化 App.vue 中的背景图片加载逻辑,调整异步加载方式并简化图片地址获取逻辑。 2025-05-13 19:25:41 +08:00
jxxghp
e73d906564 fix #333 2025-05-13 19:14:13 +08:00
jxxghp
7e3e850e21 Merge pull request #333 from Seed680/v2 2025-05-13 18:46:23 +08:00
qiaoyun680
56b2dc4ebf 资源搜索结果页面增加排序切换 2025-05-13 16:25:22 +08:00
jxxghp
9444b0e518 优化 App.vue 中的背景图片加载逻辑,添加登录状态变化时清空背景图片数组的处理,并更新图片地址获取逻辑以支持缓存和原始地址的选择。 2025-05-13 13:37:37 +08:00
jxxghp
bcb72118f5 在背景图片加载失败时添加重试机制,3秒后自动重试加载背景图片 2025-05-13 08:18:44 +08:00
jxxghp
c59be8d981 更新 module-federation-guide.md 2025-05-12 18:03:09 +08:00
jxxghp
8466a40455 重构 App.vue 中的背景图片加载逻辑 2025-05-12 13:51:45 +08:00
jxxghp
f435b4fc52 在 fetchBackgroundImages 函数中初始化 activeImageIndex 为 0,以确保背景图片加载时的索引正确。 2025-05-12 11:23:48 +08:00
jxxghp
5686c6fe65 在构建工作流中移除了删除标签的选项,以简化发布流程。 2025-05-12 11:09:49 +08:00
jxxghp
6810112eda 在 App.vue 中添加登录状态变化的监听,确保登录后重新加载背景图片;同时更新 .vscode/settings.json,增加 i18n-ally.localesPaths 配置。 2025-05-12 10:44:01 +08:00
jxxghp
11a2d07935 优化 App.vue 中的国际化代码,调整 LoadingBanner 组件的样式,增加 SubscribeFilesDialog 组件的加载状态管理。 2025-05-12 07:56:52 +08:00
jxxghp
02cd2f1570 在构建工作流中添加了删除标签和发布的选项,并设置在出错时继续执行。 2025-05-11 08:44:30 +08:00
jxxghp
924c1d72ea 优化自定义滚动条样式 2025-05-11 08:41:48 +08:00
jxxghp
5d9b2e1919 更新 AlistConfigDialog.vue 2025-05-11 07:56:50 +08:00
jxxghp
f7fa440f9a 更新 AlistConfigDialog.vue 2025-05-10 23:31:49 +08:00
jxxghp
d4aaa46968 优化 SubscribeCard 和 SubscribeShareCard 组件的结构 2025-05-10 23:18:00 +08:00
jxxghp
93ac5e1b3b 优化 PluginAppCard 和 PluginCard 组件的背景样式,更新渐变效果以增强视觉层次感。 2025-05-10 22:38:19 +08:00
jxxghp
c7a8c68e14 调整 PluginCard 组件的背景样式,优化渐变效果以提升视觉效果。 2025-05-10 22:25:53 +08:00
jxxghp
77afb4d736 优化 PluginAppCard 和 PluginCard 组件的背景样式 2025-05-10 22:10:02 +08:00
jxxghp
141796ab24 更新 AccountSettingSystem.vue 中的 v-model,修改为 TMDB_SCRAP_ORIGINAL_IMAGE,以更准确地反映设置项。 2025-05-10 21:58:13 +08:00
jxxghp
30d733f55d v2.4.6 2025-05-10 21:54:32 +08:00
jxxghp
6a39e65b6b 添加 TMDB 刮削原语种选项。 2025-05-10 21:45:45 +08:00
jxxghp
c27013b7ad Merge pull request #332 from Seed680/v2 2025-05-10 21:24:45 +08:00
jxxghp
582ce496fa 添加 TMDB 刮削图片语言相关 2025-05-10 20:44:06 +08:00
jxxghp
5b4dbb82d5 调整 ShortcutBar 组件中的对话框最大宽度 2025-05-10 19:33:57 +08:00
jxxghp
011a0d16ab 加载远程组件时如未注册则重新注册 2025-05-10 08:40:14 +08:00
jxxghp
ac5539194d 优化 PersonCard 组件,移除多余的样式类以简化结构 2025-05-09 20:29:54 +08:00
Seed680
6b7e1b3c4e Merge branch 'jxxghp:v2' into v2 2025-05-09 09:21:10 +08:00
jxxghp
30c3d00139 移除 Vite 配置中的手动分块选项,简化配置以提升可读性和维护性。 2025-05-09 00:06:58 +08:00
jxxghp
36d460cd74 更新 Vite 配置,启用压缩选项以移除控制台日志和调试器, 2025-05-09 00:04:14 +08:00
jxxghp
af287f50bb 更新 Vite 配置,添加 dummy 远程模块,简化共享库列表,提升代码可读性和维护性。 2025-05-08 23:54:34 +08:00
jxxghp
3199392637 优化 Vite 配置,更新共享库列表,移除不必要的 Rollup 选项,并简化远程模块加载逻辑,提升代码可读性和维护性。 2025-05-08 23:32:13 +08:00
Seed680
11cb2eb0f8 Merge branch 'jxxghp:v2' into v2 2025-05-08 23:28:18 +08:00
qiaoyun680
4dce1c94a3 feat(storge): 添加alist存储的登录方式(令牌、访客) 2025-05-08 23:26:54 +08:00
jxxghp
4e3a61b8a8 增强共享作用域管理,添加初始化和dummy远程模块功能,防止生产环境中的共享作用域问题,同时为远程模块URL添加版本标记以防止缓存。 2025-05-08 23:08:48 +08:00
jxxghp
3b1e65fc75 修复 Vite 配置中的压缩选项,将 drop_console 设置为 false,以保留控制台日志。 2025-05-08 22:53:56 +08:00
jxxghp
32b4b944cc fix remoteEntry url 2025-05-08 22:42:20 +08:00
jxxghp
22a51a524e fix ui 2025-05-08 19:48:35 +08:00
jxxghp
ac0cbbdb95 Merge pull request #331 from Seed680/v2 2025-05-08 17:50:06 +08:00
qiaoyun680
2260f23d3c feat(storge): 添加存储重置选项 2025-05-08 17:39:36 +08:00
jxxghp
d43952c0bf 优化 Vuetify 实例配置,添加默认主题和主题设置,以提升主题管理的灵活性和可维护性。 2025-05-08 17:32:28 +08:00
jxxghp
bd368123d2 更新 package.json 中的版本号,从 2.4.4-1 升级至 2.4.5 2025-05-08 17:26:31 +08:00
jxxghp
cbdd70427e fix 联邦组件样式冲突问题 2025-05-08 17:24:04 +08:00
jxxghp
d7526f5283 移除 Vite 配置中的 PostCSS 插件设置,并在 SiteCard 组件中为按钮添加统一的大小属性,以提升界面一致性。 2025-05-08 16:09:32 +08:00
jxxghp
08e914a968 优化 SiteCard 组件,调整按钮样式和图标大小,提升界面一致性和可读性。 2025-05-08 15:53:32 +08:00
jxxghp
53a8835b6d 更新 Tailwind CSS 配置,添加重要性设置;在 Vite 配置中添加 PostCSS 插件;优化多个组件的 VCard 结构,移除多余的 class 属性以提升代码整洁性。 2025-05-08 15:31:57 +08:00
jxxghp
e3bff71a91 优化 Vue 渲染模式下的组件结构,将动态组件包裹在 VCard 和 VCardText 中,以提升布局一致性和可读性,同时在 DashboardElement.vue 中为动态插件组件添加 API 属性。 2025-05-08 14:48:33 +08:00
jxxghp
6276009e88 修复文件浏览器工具栏中的“向上”按钮,仅在路径段存在时显示 2025-05-08 13:13:49 +08:00
jxxghp
ddc5320f71 fix https://github.com/jxxghp/MoviePilot/issues/4211#issuecomment-2858674237 订阅排序相互干扰的问题 2025-05-08 10:35:07 +08:00
jxxghp
15af66aaaf fix 资源筛选 2025-05-08 07:11:29 +08:00
jxxghp
fe7a080553 更新插件组件的依赖版本 2025-05-07 17:25:59 +08:00
jxxghp
66bfc3e868 更新 Vite 配置 2025-05-07 17:16:39 +08:00
jxxghp
93aa3fb95d 更新示例 2025-05-07 16:20:28 +08:00
jxxghp
4f5caf1712 更新 module-federation-guide.md 2025-05-07 15:45:42 +08:00
jxxghp
9d27e967cd 优化示例UI 2025-05-07 14:49:24 +08:00
jxxghp
eb3e035a7c 更新 Vite 配置以支持 vuetify/styles 的版本要求,优化共享依赖配置 2025-05-07 14:34:10 +08:00
jxxghp
04200e94ff 移除 Config.vue 和 Dashboard.vue 中的样式,更新 Page.vue 以使用 API 获取最近记录,增强组件功能和代码整洁性。 2025-05-07 13:28:42 +08:00
jxxghp
ae9a13e0fa 添加“综合筛选”和“清除全部”功能 2025-05-07 12:22:54 +08:00
jxxghp
df8857fb52 fix example 2025-05-07 10:56:28 +08:00
jxxghp
9642fed1f1 移除 DashboardItem 接口中的 component_url 属性,简化类型定义。 2025-05-07 10:56:28 +08:00
jxxghp
1a273ea2d6 更新 module-federation-guide.md 2025-05-07 09:32:41 +08:00
jxxghp
c0ba921a7e 更新 Config.vue 2025-05-07 09:31:58 +08:00
jxxghp
8bbad227eb 更新 Page.vue 2025-05-07 09:31:29 +08:00
261 changed files with 24556 additions and 7005 deletions

View File

@@ -44,6 +44,7 @@ jobs:
- name: Delete Release
uses: dev-drprasad/delete-tag-and-release@v1.1
continue-on-error: true
with:
tag_name: ${{ env.frontend_version }}
delete_release: true
@@ -56,7 +57,7 @@ jobs:
name: ${{ env.frontend_version }}
draft: false
prerelease: false
make_latest: false
make_latest: true
files: |
dist.zip
env:

View File

@@ -106,5 +106,8 @@
}
]
},
"vue3snippets.enable-compile-vue-file-on-did-save-code": false
"vue3snippets.enable-compile-vue-file-on-did-save-code": false,
"i18n-ally.localesPaths": [
"src/locales"
]
}

1
components.d.ts vendored
View File

@@ -8,6 +8,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ConfirmDialog: typeof import('./src/@core/components/ConfirmDialog.vue')['default']
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']

View File

@@ -9,7 +9,7 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
## 2. 技术要求
- Node.js 16+
- Node.js 20+
- Vue 3
- Vite 4+
- TypeScript 5+
@@ -50,28 +50,78 @@ export default defineConfig({
plugins: [
vue(),
federation({
name: 'LogsClean',
name: 'MyPlugin',
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
},
shared: ['vue', 'vuetify'],
shared: {
vue: {
requiredVersion: false,
generate: false,
},
vuetify: {
requiredVersion: false,
generate: false,
singleton: true,
},
'vuetify/styles': {
requiredVersion: false,
generate: false,
singleton: true,
},
},
format: 'esm'
})
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: false,
cssCodeSplit: true, // 改为true以便能分离样式文件
},
css: {
preprocessorOptions: {
scss: {
additionalData: '/* 覆盖vuetify样式 */',
}
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
if (atRule.name === 'charset') {
atRule.remove();
}
}
}
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
// 过滤掉所有vuetify相关的CSS
root.walkRules(rule => {
if (rule.selector && (
rule.selector.includes('.v-') ||
rule.selector.includes('.mdi-'))) {
rule.remove();
}
});
}
}
]
}
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
},
})
})
```
## 5. 组件开发规范
@@ -86,7 +136,7 @@ const emit = defineEmits(['action', 'switch', 'close'])
// 接收API对象
const props = defineProps({
api: {
type: any,
type: Object,
default: () => {}
}
})
@@ -111,7 +161,7 @@ function notifyClose() {
<template>
<div class="plugin-page">
<!-- 插件详情页面操作按钮 -->
<!-- 插件详情页面操作按钮示例 -->
<v-btn @click="notifyRefresh">刷新数据</v-btn>
<v-btn @click="notifySwitch">配置插件</v-btn>
<v-btn @click="notifyClose">关闭页面</v-btn>
@@ -130,7 +180,7 @@ const props = defineProps({
default: () => ({})
},
api: {
type: any,
type: Object,
default: () => {}
}
})
@@ -159,16 +209,16 @@ function notifyClose() {
<template>
<div class="plugin-config">
<!-- 配置表单 -->
<!-- 配置表单示例 -->
<v-text-field v-model="config.someField" label="配置项"></v-text-field>
<!-- 保存按钮 -->
<!-- 保存按钮示例 -->
<v-btn color="primary" @click="saveConfig">保存配置</v-btn>
<!-- 关闭按钮 -->
<!-- 关闭按钮示例 -->
<v-btn color="primary" @click="notifyClose">关闭页面</v-btn>
<!-- 切换按钮 -->
<!-- 切换按钮示例 -->
<v-btn color="primary" @click="notifySwitch">切换到详情页面</v-btn>
</div>
</template>
@@ -214,7 +264,12 @@ const props = defineProps({
yarn build
```
将生成的dist文件夹上传到插件后端目录下默认为`dist/assets`,在插件的后端代码中,实现以下方法来集成远程组件:
- 将生成的dist文件夹上传到插件后端目录下默认为`dist/assets`
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
- 在插件的后端python代码中实现以下方法来集成远程组件
```python
def get_render_mode() -> Tuple[str, str]:
@@ -226,6 +281,28 @@ def get_render_mode() -> Tuple[str, str]:
return "vue", "dist/assets"
```
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
```typescript
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
```
```python
def get_api(self) -> List[Dict[str, Any]]:
"""
注册插件API
"""
return [
{
"path": "/history",
"endpoint": self.get_history,
"methods": ["GET"],
"auth": "bear", # 认证类型设为bear
"summary": "查询历史记录"
}
]
```
## 7. 调试与排错

View File

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

View File

@@ -1,74 +1,88 @@
<template>
<div class="plugin-config">
<v-card>
<v-card-title>插件配置</v-card-title>
<v-card-text>
<v-card-item>
<v-card-title>插件配置</v-card-title>
<template #append>
<v-btn icon color="primary" variant="text" @click="notifyClose">
<v-icon left>mdi-close</v-icon>
</v-btn>
</template>
</v-card-item>
<v-card-text class="overflow-y-auto">
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
<v-form ref="form" v-model="isFormValid" @submit.prevent="saveConfig">
<!-- 基本设置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">基本设置</div>
<v-text-field
v-model="config.name"
label="插件名称"
variant="outlined"
:rules="[v => !!v || '名称不能为空']"
hint="显示在插件列表中的名称"
></v-text-field>
<v-textarea
v-model="config.description"
label="插件描述"
variant="outlined"
rows="3"
hint="简要说明插件的功能和用途"
></v-textarea>
<v-row>
<v-col cols="12">
<v-switch
v-model="config.enable"
label="启用插件"
color="primary"
inset
hint="启用插件后,插件将开始工作"
persistent-hint
></v-switch>
</v-col>
<v-col cols="12">
<v-text-field
v-model="config.name"
label="插件名称"
variant="outlined"
:rules="[v => !!v || '名称不能为空']"
hint="显示在插件列表中的名称"
></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea
v-model="config.description"
label="插件描述"
variant="outlined"
rows="3"
hint="简要说明插件的功能和用途"
></v-textarea>
</v-col>
</v-row>
<!-- 功能配置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">功能配置</div>
<v-switch
v-model="config.enable_notifications"
label="启用通知"
color="primary"
inset
hint="接收插件状态变更通知"
persistent-hint
></v-switch>
<v-select
v-model="config.update_interval"
label="更新频率"
:items="updateIntervalOptions"
variant="outlined"
item-title="text"
item-value="value"
></v-select>
<v-row>
<v-col cols="12">
<v-select
v-model="config.update_interval"
label="更新频率"
:items="updateIntervalOptions"
variant="outlined"
item-title="text"
item-value="value"
></v-select>
</v-col>
</v-row>
<!-- API配置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">API设置</div>
<v-text-field
v-model="config.api_url"
label="API地址"
variant="outlined"
hint="外部服务API地址"
:rules="[v => !v || v.startsWith('http') || '请输入有效的URL']"
></v-text-field>
<v-text-field
v-model="config.api_key"
label="API密钥"
variant="outlined"
:append-inner-icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'"
:type="showApiKey ? 'text' : 'password'"
@click:append-inner="showApiKey = !showApiKey"
></v-text-field>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="config.api_url"
label="API地址"
variant="outlined"
hint="外部服务API地址"
:rules="[v => !v || v.startsWith('http') || '请输入有效的URL']"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="config.api_key"
label="API密钥"
variant="outlined"
:append-inner-icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'"
:type="showApiKey ? 'text' : 'password'"
@click:append-inner="showApiKey = !showApiKey"
></v-text-field>
</v-col>
</v-row>
<!-- 高级选项区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">高级选项</div>
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>高级选项</v-expansion-panel-title>
@@ -96,10 +110,9 @@
</v-form>
</v-card-text>
<v-card-actions>
<v-btn color="secondary" @click="resetForm">重置</v-btn>
<v-spacer></v-spacer>
<v-btn color="secondary" variant="outlined" @click="resetForm">重置</v-btn>
<v-btn color="primary" :disabled="!isFormValid" @click="saveConfig" :loading="saving">保存配置</v-btn>
<v-btn color="primary" @click="notifyClose">关闭</v-btn>
</v-card-actions>
</v-card>
</div>
@@ -115,7 +128,7 @@ const props = defineProps({
default: () => ({}),
},
api: {
type: any,
type: Object,
default: () => {},
},
})
@@ -143,7 +156,7 @@ const updateIntervalOptions = [
const defaultConfig = {
name: '我的插件',
description: '',
enable_notifications: true,
enable: true,
update_interval: 60,
api_url: '',
api_key: '',
@@ -209,10 +222,3 @@ function notifyClose() {
emit('close')
}
</script>
<style scoped>
.plugin-config {
padding: 16px;
inline-size: 100%;
}
</style>

View File

@@ -295,20 +295,4 @@ onUnmounted(() => {
clearInterval(refreshTimer)
}
})
</script>
<style scoped>
.dashboard-widget {
inline-size: 100%;
}
.chart-container {
block-size: 200px;
margin-block-end: 16px;
}
.chart {
block-size: 100%;
inline-size: 100%;
}
</style>
</script>

View File

@@ -1,7 +1,14 @@
<template>
<div class="plugin-page">
<v-card>
<v-card-title>{{ title }}</v-card-title>
<v-card-item>
<v-card-title>{{ title }}</v-card-title>
<template #append>
<v-btn icon color="primary" variant="text" @click="notifyClose">
<v-icon left>mdi-close</v-icon>
</v-btn>
</template>
</v-card-item>
<v-card-text>
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
<v-skeleton-loader v-if="loading" type="card"></v-skeleton-loader>
@@ -50,19 +57,15 @@
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="refreshData" :loading="loading">
<v-icon left>mdi-refresh</v-icon>
刷新数据
</v-btn>
<v-spacer></v-spacer>
<v-btn color="primary" @click="notifySwitch">
<v-icon left>mdi-cog</v-icon>
配置
</v-btn>
<v-btn color="primary" @click="notifyClose">
<v-icon left>mdi-close</v-icon>
关闭
</v-btn>
</v-card-actions>
</v-card>
</div>
@@ -73,8 +76,12 @@ import { ref, onMounted } from 'vue'
// 接收初始配置
const props = defineProps({
model: {
type: Object,
default: () => {},
},
api: {
type: any,
type: Object,
default: () => {},
},
})
@@ -121,9 +128,6 @@ async function refreshData() {
error.value = null
try {
// 模拟API调用 - 实际开发中应使用 fetch 调用真实API
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟数据
stats.value = {
'电影': Math.floor(Math.random() * 100) + 50,
@@ -133,14 +137,8 @@ async function refreshData() {
'综艺': Math.floor(Math.random() * 100) + 5,
}
// 模拟最近记录
recentItems.value = [
{ type: 'movie', title: '肖申克的救赎 (1994)', time: '今天 12:30' },
{ type: 'tv', title: '绝命毒师 S01E01', time: '昨天 18:45' },
{ type: 'download', title: '开始下载:星际穿越', time: '2天前' },
{ type: 'success', title: '下载完成:黑客帝国', time: '3天前' },
{ type: 'error', title: '下载失败:泰坦尼克号', time: '一周前' },
]
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
status.value = Math.random() > 0.2 ? 'running' : 'paused'
lastUpdated.value = new Date().toLocaleString()
@@ -169,10 +167,3 @@ onMounted(() => {
refreshData()
})
</script>
<style scoped>
.plugin-page {
padding: 16px;
inline-size: 100%;
}
</style>

View File

@@ -3,15 +3,16 @@ import App from './App.vue'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import defaults from './vuetify/defaults'
import theme from './vuetify/theme'
import 'vuetify/styles'
// 创建Vuetify实例
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'dark'
}
theme,
defaults
})
// 创建应用

View File

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

View File

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

View File

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

View File

@@ -147,7 +147,7 @@
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a"
integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==
"@originjs/vite-plugin-federation@^1.3.5":
"@originjs/vite-plugin-federation@^1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@originjs/vite-plugin-federation/-/vite-plugin-federation-1.4.1.tgz#e6abc8f18f2cf82783eb87853f4d03e6358b43c2"
integrity sha512-Uo08jW5pj1t58OUKuZNkmzcfTN2pqeVuAWCCiKf/75/oll4Efq4cHOqSE1FXMlvwZNGDziNdDyBbQ5IANem3CQ==
@@ -260,10 +260,10 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
"@types/web-bluetooth@^0.0.20":
version "0.0.20"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==
"@types/web-bluetooth@^0.0.21":
version "0.0.21"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63"
integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==
"@vitejs/plugin-vue@^4.4.0":
version "4.6.2"
@@ -350,27 +350,27 @@
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f"
integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==
"@vueuse/core@^10.6.0":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.11.1.tgz#15d2c0b6448d2212235b23a7ba29c27173e0c2c6"
integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==
"@vueuse/core@^12.4.0":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-12.8.2.tgz#007c6dd29a7d1f6933e916e7a2f8ef3c3f968eaa"
integrity sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==
dependencies:
"@types/web-bluetooth" "^0.0.20"
"@vueuse/metadata" "10.11.1"
"@vueuse/shared" "10.11.1"
vue-demi ">=0.14.8"
"@types/web-bluetooth" "^0.0.21"
"@vueuse/metadata" "12.8.2"
"@vueuse/shared" "12.8.2"
vue "^3.5.13"
"@vueuse/metadata@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7"
integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==
"@vueuse/metadata@12.8.2":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3"
integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==
"@vueuse/shared@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.11.1.tgz#62b84e3118ae6e1f3ff38f4fbe71b0c5d0f10938"
integrity sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==
"@vueuse/shared@12.8.2":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.8.2.tgz#b9e4611d0603629c8e151f982459da394e22f930"
integrity sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==
dependencies:
vue-demi ">=0.14.8"
vue "^3.5.13"
csstype@^3.1.3:
version "3.1.3"
@@ -513,7 +513,7 @@ tslib@2.3.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
vite@^5.0.0:
vite@^5.4.11:
version "5.4.19"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.19.tgz#20efd060410044b3ed555049418a5e7d1998f959"
integrity sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==
@@ -524,11 +524,6 @@ vite@^5.0.0:
optionalDependencies:
fsevents "~2.3.3"
vue-demi@>=0.14.8:
version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
vue-demi@^0.13.11:
version "0.13.11"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99"
@@ -542,7 +537,7 @@ vue-echarts@^6.6.1:
resize-detector "^0.3.0"
vue-demi "^0.13.11"
vue@^3.3.4:
vue@^3.5.13:
version "3.5.13"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a"
integrity sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==
@@ -553,10 +548,10 @@ vue@^3.3.4:
"@vue/server-renderer" "3.5.13"
"@vue/shared" "3.5.13"
vuetify@^3.4.0:
version "3.8.3"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.8.3.tgz#6bee55aefdf0b3789f6a2e53058c88c63b54db44"
integrity sha512-hzlYRGY6O+GB2R2Ujg1HBtHjVAgJc3NkeymZl1IahZ+0vEyV0qLCFvTqYhXziB04O5roidBe6sca9TED7VYA/A==
vuetify@3.7.3:
version "3.7.3"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.7.3.tgz#0e89f7f0298d452510bcbc01b0e9b53a5ce6e883"
integrity sha512-bpuvBpZl1/+nLlXDgdVXekvMNR6W/ciaoa8CYlpeAzAARbY8zUFSoBq05JlLhkIHI58AnzKVy4c09d0OtfYAPg==
zrender@5.6.1:
version "5.6.1"

View File

@@ -1,105 +1,260 @@
<!DOCTYPE html>
<html lang="en" style="
<html lang="zh-CN" style="
overflow: hidden auto;
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
min-block-size: 100vh;
min-block-size: 100dvh;
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-top: env(safe-area-inset-top);
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 charset="UTF-8" />
<!-- 核心viewport设置 - 针对PWA优化 -->
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
<!-- 防止缩放和选择,提供原生应用体验 -->
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
<!-- 基础信息 -->
<meta name="description" content="MoviePilot - 智能影视媒体库管理工具" />
<meta name="author" content="MoviePilot" />
<meta name="keywords" content="MoviePilot,影视,媒体库,管理" />
<!-- 安全和隐私 -->
<meta name="Robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="origin" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="referrer" content="no-referrer" />
<!-- PWA - 基础图标 -->
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/logo.png" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<!-- iOS Safari PWA 优化 -->
<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" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
<!-- iOS Safari 全屏模式 -->
<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" />
<!-- iOS Safari 防止自动识别 -->
<meta name="apple-mobile-web-app-orientations" content="portrait" />
<!-- Android Chrome PWA 优化 -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-title" content="MoviePilot" />
<!-- Microsoft Windows PWA -->
<meta name="msapplication-TileColor" content="#0E1116" />
<meta name="msapplication-TileImage" content="/android-chrome-192x192.png" />
<meta name="msapplication-config" content="none" />
<meta name="msapplication-tap-highlight" content="no" />
<meta name="msapplication-navbutton-color" content="#0E1116" />
<!-- 主题色彩 - 适配深色和浅色模式 -->
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
<meta name="color-scheme" content="dark light" />
<!-- 屏幕方向锁定 -->
<meta name="screen-orientation" content="portrait" />
<meta name="x5-orientation" content="portrait" />
<meta name="x5-fullscreen" content="true" />
<meta name="x5-page-mode" content="app" />
<!-- UC浏览器优化 -->
<meta name="browsermode" content="application" />
<meta name="wap-font-scale" content="no" />
<!-- 360浏览器优化 -->
<meta name="renderer" content="webkit" />
<!-- 触摸优化 -->
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" />
<!-- 缓存控制 -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- DNS预解析和预连接 -->
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
<link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
<link rel="dns-prefetch" href="//image.tmdb.org" />
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<!-- 预加载关键资源 -->
<link rel="preload" href="/logo.png" as="image" />
<link rel="modulepreload" href="/src/main.ts" />
<!-- 内联关键CSS -->
<style>
/* 关键路径CSS - 从loader.css内联 */
#loading-bg {
position: fixed;
z-index: 99999;
display: block;
background: var(--initial-loader-bg, #fff);
block-size: 100vh;
inline-size: 100vw;
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
.loading-logo {
position: absolute;
inset-block-start: 35%;
inset-inline-start: calc(50% - 5rem);
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
/* 添加logo完成动画 - 放大虚化效果 */
.loading-complete .loading-logo {
filter: blur(10px);
opacity: 0;
transform: scale(1.5);
}
/* 添加加载背景消失动画 - 放大虚化效果 */
.loading-complete {
filter: blur(15px);
opacity: 0;
transform: scale(1.2);
}
.loading {
position: absolute;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 55px;
inline-size: 55px;
inset-block-start: 80%;
inset-inline-start: calc(50% - 27.5px);
transition: opacity 0.6s ease;
}
/* 完成时隐藏加载动画 */
.loading-complete .loading {
opacity: 0;
}
.loading .effect-1,
.loading .effect-2,
.loading .effect-3 {
position: absolute;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 100%;
border-inline-start: 3px solid var(--initial-loader-color, #eee);
inline-size: 100%;
}
.loading .effect-1 {
animation: rotate 1s ease infinite;
}
.loading .effect-2 {
animation: rotate-opacity 1s ease infinite 0.1s;
}
.loading .effect-3 {
animation: rotate-opacity 1s ease infinite 0.2s;
}
.loading .effects {
transition: all 0.3s ease;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(1turn);
}
}
@keyframes rotate-opacity {
0% {
opacity: 0.1;
transform: rotate(0deg);
}
100% {
opacity: 1;
transform: rotate(1turn);
}
}
</style>
<!-- 初始化脚本 -->
<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)
// 检测系统主题是否为深色模式
function checkPrefersColorSchemeIsDark() {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches
} catch (e) {
return false
}
}
// 主题色彩初始化
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
let primaryColor = localStorage.getItem('materio-initial-loader-color')
// 检查主题设置
const savedTheme = localStorage.getItem('theme')
const isAutoTheme = savedTheme === 'auto'
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
if (isAutoTheme || !loaderColor) {
loaderColor = checkPrefersColorSchemeIsDark() ? '#0E1116' : '#FFFFFF'
}
if (!primaryColor) {
primaryColor = '#9155FD'
}
// 应用主题色彩
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
// 状态栏适配
if (window.navigator.standalone) {
document.documentElement.style.setProperty('--status-bar-height', '20px')
}
// 安全区域适配
function updateSafeArea() {
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)',
)
if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)
if (safeAreaBottom) document.documentElement.style.setProperty('--safe-area-bottom', safeAreaBottom)
}
updateSafeArea()
window.addEventListener('resize', updateSafeArea)
window.addEventListener('orientationchange', updateSafeArea)
</script>
</head>
<body style="margin: 0">
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
<svg width="160px" height="160px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
<style>
/* 添加SVG内部的动画样式 */
@keyframes pulse {
0%,
100% {
opacity: 0.8;
}
50% {
opacity: 1;
}
}
@keyframes glow {
0%,
100% {
filter: drop-shadow(0 0 3px rgba(141, 81, 249, 0.3));
}
50% {
filter: drop-shadow(0 0 6px rgba(141, 81, 249, 0.6));
}
}
/* 为各个元素添加动画 */
#a2-c {
filter: drop-shadow(0 0 5px rgba(141, 81, 249, 0.3));
animation: glow 3s ease-in-out infinite;
}
path {
animation: pulse 2s ease-in-out infinite;
}
/* 错开不同元素的动画开始时间 */
g:nth-child(2) path {
animation-delay: 0.3s;
}
g:nth-child(3) path {
animation-delay: 0.6s;
}
g:nth-child(4) path {
animation-delay: 0.9s;
}
g:nth-child(5) path {
animation-delay: 1.2s;
}
</style>
<g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
<rect x="0" y="0" width="192" height="192" style="fill: none" />
@@ -211,4 +366,4 @@
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.4.4",
"version": "2.7.7",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -27,6 +27,7 @@
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@iconify/utils": "^2.2.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
@@ -40,11 +41,14 @@
"ace-builds": "^1.37.4",
"apexcharts": "^4.0.0",
"axios": "^1.7.9",
"body-scroll-lock": "^3.1.5",
"colorthief": "^2.6.0",
"copy-to-clipboard": "^3.3.3",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
"mousetrap": "^1.6.5",
@@ -56,13 +60,12 @@
"tailwindcss": "^ 3.4.17",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-toast-notification": "^3.1.3",
"vue-toastification": "^2.0.0-rc.5",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.8.0",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "3.7.3",
"vuetify-use-dialog": "^0.6.11",
"webfontloader": "^1.6.28"
},
"devDependencies": {
@@ -72,6 +75,7 @@
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@originjs/vite-plugin-federation": "^1.4.1",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@types/body-scroll-lock": "^3.1.2",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^20.1.4",
@@ -105,6 +109,7 @@
"vite": "^5.4.11",
"vite-plugin-pages": "^0.32.1",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.4",
"vue-shepherd": "^4.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,97 +0,0 @@
#loading-bg {
position: fixed;
z-index: 9999;
display: block;
background: var(--initial-loader-bg, #fff);
block-size: 100vh;
inline-size: 100vw;
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
.loading-logo {
position: absolute;
inset-block-start: 35%;
inset-inline-start: calc(50% - 5rem);
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
/* 添加logo完成动画 - 放大虚化效果 */
.loading-complete .loading-logo {
filter: blur(10px);
opacity: 0;
transform: scale(1.5);
}
/* 添加加载背景消失动画 - 放大虚化效果 */
.loading-complete {
filter: blur(15px);
opacity: 0;
transform: scale(1.2);
}
.loading {
position: absolute;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 55px;
inline-size: 55px;
inset-block-start: 80%;
inset-inline-start: calc(50% - 27.5px);
transition: opacity 0.6s ease;
}
/* 完成时隐藏加载动画 */
.loading-complete .loading {
opacity: 0;
}
.loading .effect-1,
.loading .effect-2,
.loading .effect-3 {
position: absolute;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 100%;
border-inline-start: 3px solid var(--initial-loader-color, #eee);
inline-size: 100%;
}
.loading .effect-1 {
animation: rotate 1s ease infinite;
}
.loading .effect-2 {
animation: rotate-opacity 1s ease infinite 0.1s;
}
.loading .effect-3 {
animation: rotate-opacity 1s ease infinite 0.2s;
}
.loading .effects {
transition: all 0.3s ease;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(1turn);
}
}
@keyframes rotate-opacity {
0% {
opacity: 0.1;
transform: rotate(0deg);
}
100% {
opacity: 1;
transform: rotate(1turn);
}
}

53
public/logo.svg Normal file
View File

@@ -0,0 +1,53 @@
<svg width="3em" height="3em" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" 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="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"/>
</clipPath>
<g clip-path="url(#_clip5)">
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
<path d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z" style="fill:url(#_Linear6);"/>
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z" style="fill:rgb(141,81,249);"/>
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z" style="fill:url(#_Radial7);"/>
</g>
</g>
</g>
</g>
</g>
<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>

After

Width:  |  Height:  |  Size: 10 KiB

160
public/offline.html Normal file
View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>MoviePilot - 离线</title>
<link rel="icon" href="/favicon.ico">
<style>
:root {
--primary-color: #9155FD;
--surface-color: #FFFFFF;
--text-color: #333333;
--border-color: rgba(0, 0, 0, 0.12);
}
@media (prefers-color-scheme: dark) {
:root {
--surface-color: #0E1116;
--text-color: #FFFFFF;
--border-color: rgba(255, 255, 255, 0.12);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--surface-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.offline-container {
text-align: center;
max-width: 400px;
width: 100%;
padding: 40px;
background: var(--surface-color);
border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1), 0 0 0 1px var(--border-color);
}
.icon-wrapper {
width: 120px;
height: 120px;
margin: 0 auto 32px;
background: rgba(145, 85, 253, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.icon {
width: 64px;
height: 64px;
fill: var(--primary-color);
}
h1 {
font-size: 2rem;
margin-bottom: 16px;
font-weight: 600;
}
p {
font-size: 1.1rem;
line-height: 1.6;
opacity: 0.7;
margin-bottom: 32px;
}
.retry-button {
background: var(--primary-color);
color: white;
border: none;
padding: 12px 32px;
font-size: 1rem;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: opacity 0.2s;
}
.retry-button:hover {
opacity: 0.9;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 24px;
padding: 8px 16px;
background: rgba(145, 85, 253, 0.1);
border-radius: 20px;
font-size: 0.875rem;
}
.status-dot {
width: 8px;
height: 8px;
background: #EF5350;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
</style>
</head>
<body>
<div class="offline-container">
<div class="icon-wrapper">
<svg class="icon" viewBox="0 0 24 24">
<path d="M12,2.03C17.73,2.5 22,7.08 22,12.75C22,13.84 21.79,14.89 21.4,15.86L19.53,14C19.5,13.83 19.5,13.67 19.5,13.5A2.5,2.5 0 0,0 17,11A2.5,2.5 0 0,0 14.5,13.5A2.5,2.5 0 0,0 17,16A2.5,2.5 0 0,0 19.5,13.5C19.5,13.67 19.5,13.83 19.53,14L21.4,15.86C20.04,19.09 16.9,21.47 13.19,21.97L11.75,20.53C11.83,20.5 11.92,20.5 12,20.5A2.5,2.5 0 0,0 14.5,18A2.5,2.5 0 0,0 12,15.5A2.5,2.5 0 0,0 9.5,18C9.5,18.08 9.5,18.17 9.53,18.25L7.66,16.38C7.25,15.96 6.86,15.5 6.5,15H8.17C8.06,14.7 8,14.35 8,14A3,3 0 0,1 11,11A3,3 0 0,1 14,14C14,14.35 13.94,14.7 13.83,15H15.5C15.14,15.5 14.75,15.96 14.34,16.38L12.47,14.5C12.5,14.42 12.5,14.33 12.47,14.25L10.6,12.38C10.18,11.97 9.72,11.59 9.23,11.25L7.36,9.38C6.94,8.96 6.5,8.61 6,8.31V6.64L4.14,4.78C3.6,5.55 3.17,6.4 2.86,7.31L1,5.45V4.46L2.05,3.41C2.5,2.86 3.05,2.41 3.66,2.06L20,18.4L18.73,19.67L12.47,13.41L11.75,20.53C11.83,20.5 11.92,20.5 12,20.5A2.5,2.5 0 0,0 14.5,18A2.5,2.5 0 0,0 12,15.5A2.5,2.5 0 0,0 9.5,18C9.5,18.08 9.5,18.17 9.53,18.25L7.66,16.38C7.25,15.96 6.86,15.5 6.5,15H8.17C8.06,14.7 8,14.35 8,14A3,3 0 0,1 11,11A3,3 0 0,1 14,14C14,14.35 13.94,14.7 13.83,15H15.5C15.14,15.5 14.75,15.96 14.34,16.38L2.46,4.5C3.5,3.17 4.9,2.15 6.5,1.58V3.25C5.43,3.7 4.47,4.33 3.66,5.11L2.61,6.16V8.03C3.16,7.33 3.82,6.73 4.57,6.25V8.31C3.57,9.14 2.75,10.19 2.21,11.39L1,10.18V8.65C1.5,6.16 3.03,4.03 5.11,2.71L6.39,4C8.97,2.73 12.03,2.24 14.97,3.03L16.84,4.9C18.17,5.86 19.25,7.16 19.94,8.68L18.07,6.81C17.07,5.5 15.66,4.5 14,4.04V5.71C15.93,6.17 17.5,7.53 18.33,9.3L16.46,7.43C15.46,6.61 14.2,6.08 12.82,6V7.67C13.69,7.79 14.47,8.11 15.14,8.58L13.27,6.71C12.94,6.66 12.6,6.63 12.25,6.63L10.38,4.76C10.87,4.66 11.37,4.59 11.88,4.56L10,2.68C10.66,2.56 11.33,2.5 12,2.5V2.03Z" />
</svg>
</div>
<h1>您当前处于离线状态</h1>
<p>无法连接到 MoviePilot 服务器。请检查您的网络连接后重试。</p>
<button class="retry-button" onclick="window.location.reload()">
重新加载
</button>
<div class="status-badge">
<span class="status-dot"></span>
<span>离线状态</span>
</div>
</div>
<script>
// 监听网络状态变化
window.addEventListener('online', function() {
window.location.reload();
});
// Service Worker 消息处理
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', function(event) {
if (event.data && event.data.type === 'OFFLINE_STATUS' && !event.data.offline) {
window.location.reload();
}
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
modelValue: boolean
type?: 'info' | 'warn' | 'error'
title?: string
content?: string
confirmText?: string
cancelText?: string
width?: string | number
}
const props = withDefaults(defineProps<Props>(), {
type: 'info',
title: '',
content: '',
confirmText: '',
cancelText: '',
width: '28rem',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}>()
// 对话框类型对应的图标和颜色
const typeConfig = {
info: {
icon: 'mdi-information',
color: 'info',
},
warn: {
icon: 'mdi-alert',
color: 'warning',
},
error: {
icon: 'mdi-alert-circle',
color: 'error',
},
}
// 获取当前类型的配置
const currentType = computed(() => typeConfig[props.type])
// 确认按钮点击
function handleConfirm() {
emit('confirm')
emit('update:modelValue', false)
}
// 取消按钮点击
function handleCancel() {
emit('cancel')
emit('update:modelValue', false)
}
</script>
<template>
<VDialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" :max-width="width">
<VCard>
<VCardItem>
<div class="d-flex align-center justify-start mt-3">
<VAvatar :color="currentType.color" variant="text" size="x-large">
<VIcon size="x-large" :icon="currentType.icon" />
</VAvatar>
<div class="mx-3">
<p class="font-weight-bold text-xl text-high-emphasis">{{ title }}</p>
<p>{{ content }}</p>
</div>
</div>
</VCardItem>
<VCardActions class="mx-auto">
<VBtn variant="tonal" color="secondary" class="px-5" @click="handleCancel">
{{ cancelText }}
</VBtn>
<VBtn variant="elevated" :color="currentType.color" @click="handleConfirm" class="px-5">
{{ confirmText }}
</VBtn>
</VCardActions>
<VDialogCloseBtn @click="handleCancel" />
</VCard>
</VDialog>
</template>

View File

@@ -1,15 +1,88 @@
<script lang="ts" setup>
// 定义输入参数
const props = defineProps({
progress: Number,
text: String,
})
</script>
<template>
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center">
<VProgressCircular v-if="!props.text || !props.progress" class="mb-3" size="64" indeterminate color="primary" />
<VProgressCircular v-if="props.progress" class="mb-3" color="primary" :model-value="props.progress" size="64" />
<span>{{ props.text }}</span>
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center my-5">
<div class="initial-loading-container">
<div class="initial-loading-content">
<div class="wave-loader">
<div class="wave-dot"></div>
<div class="wave-dot"></div>
<div class="wave-dot"></div>
<div class="wave-dot"></div>
</div>
<div class="initial-loading-text" v-if="props.text">{{ props.text }}</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 初始的加载状态 */
.initial-loading-container {
display: flex;
align-items: center;
justify-content: center;
min-block-size: 20vh;
}
.initial-loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.wave-loader {
display: flex;
align-items: center;
block-size: 40px;
gap: 6px;
}
.wave-dot {
border-radius: 50%;
animation: wave 1.5s ease-in-out infinite;
background-color: rgb(var(--v-theme-primary));
block-size: 8px;
inline-size: 8px;
}
.wave-dot:nth-child(1) {
animation-delay: 0s;
}
.wave-dot:nth-child(2) {
animation-delay: 0.2s;
}
.wave-dot:nth-child(3) {
animation-delay: 0.4s;
}
.wave-dot:nth-child(4) {
animation-delay: 0.6s;
}
@keyframes wave {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-15px);
}
}
.initial-loading-text {
color: rgb(var(--v-theme-primary));
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 1px;
}
</style>

View File

@@ -5,7 +5,7 @@ defineProps({
})
</script>
<template>
<div v-if="title" class="my-3 md:flex md:items-center md:justify-between">
<div v-if="title" class="my-3 mx-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"

View File

@@ -51,8 +51,8 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 16px;
inset-block-end: 30px;
inset-inline-end: 30px;
inset-block-end: 2rem;
inset-inline-end: 2rem;
}
.global-action-button {

View File

@@ -3,6 +3,30 @@
@use "@layouts/styles/_placeholders";
@use "@configured-variables" as variables;
// 👉 Alert
.v-alert {
.v-alert__close {
.v-icon {
block-size: 20px !important;
font-size: 20px !important;
inline-size: 20px !important;
}
}
&:not(.v-alert--prominent) .v-alert__prepend {
.v-icon {
block-size: 1.375rem !important;
font-size: 1.375rem !important;
inline-size: 1.375rem !important;
}
}
.v-alert-title {
line-height: 1.5rem;
margin-block-end: 0.25rem;
}
}
// 👉 Avatar font-size
.v-avatar {
@include mixins.avatar-font-sizes($map: variables.$avatar-font-sizes);
@@ -33,6 +57,23 @@
}
}
// 👉 Button
.v-btn {
/* stylelint-disable-next-line no-descending-specificity */
&:not(.v-btn--icon) .v-icon {
--v-icon-size-multiplier: 0.9525 !important;
}
}
// 👉 Chip
.v-chip.v-chip--size-default .v-avatar {
--v-avatar-height: 24px;
}
.v-chip.v-chip--density-comfortable {
line-height: 1;
}
// Dialog responsive width
.v-dialog {
.v-card {
@@ -40,7 +81,7 @@
}
}
@media (min-width: 576px) {
@media (width >= 576px) {
.v-dialog {
&.v-dialog-sm,
&.v-dialog-lg,
@@ -50,7 +91,7 @@
}
}
@media (min-width: 992px) {
@media (width >= 992px) {
.v-dialog {
&.v-dialog-lg,
&.v-dialog-xl {
@@ -59,18 +100,32 @@
}
}
@media (min-width: 1200px) {
@media (width >= 1200px) {
.v-dialog.v-dialog-xl,
.v-dialog.v-dialog-xl .v-overlay__content > .v-card {
inline-size: 1165px !important;
}
}
// v-tab with pill support
// 👉 Expansion Panel
.v-expansion-panel {
.v-expansion-panel-text {
font-size: 1rem;
}
}
// 👉 Tooltip
.v-tooltip > .v-overlay__content {
font-weight: 500;
line-height: 0.875rem;
}
// 👉 List
// 👉 Tab with pill support
.v-tabs.v-tabs-pill {
.v-tab.v-btn {
border-radius: 0.375rem !important;
border-radius: 6px !important;
min-inline-size: 8.125rem;
transition: none;
@@ -94,7 +149,7 @@
}
}
// 👉 added box shadow
// 👉 Timeline added box shadow
.v-timeline-item {
.v-timeline-divider__dot {
.v-timeline-divider__inner-dot {
@@ -160,7 +215,6 @@
}
// 👉 Slider
.v-slider.v-input--horizontal .v-slider-track__fill {
block-size: var(--v-slider-track-size);
}
@@ -171,7 +225,19 @@
.v-slider-thumb {
.v-slider-thumb__label {
background: rgb(117, 117, 117);
color: rgb(var(--v-theme-on-primary));
&::before {
color: rgb(117, 117, 117);
}
}
}
// 👉 Switch
.v-switch {
.v-selection-control:not(.v-selection-control--dirty) .v-switch__thumb {
color: #fff;
}
}
@@ -179,5 +245,45 @@
.v-table--density-default > .v-table__wrapper > table > tbody > tr > td,
.v-table--density-default > .v-table__wrapper > table > thead > tr > td,
.v-table--density-default > .v-table__wrapper > table > tfoot > tr > td {
block-size: 50px;
block-size: 50px !important;
}
.v-table {
--v-table-header-height: 54px !important;
th {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
font-size: 0.75rem;
.v-data-table-header__content {
display: flex;
justify-content: space-between;
}
}
.v-selection-control {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
font-size: 1rem;
}
}
.v-data-table {
th {
background: rgb(var(--v-table-header-background)) !important;
}
}
// 👉 Pagination
.v-pagination {
.v-btn {
border-radius: 4px;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 14px;
font-weight: 400;
}
}
// 👉 SnackBar
.v-snackbar--variant-elevated {
@include mixins.elevation(6);
}

View File

@@ -1,7 +1,7 @@
@use "@configured-variables" as variables;
@use "placeholders" as *;
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
@use "../misc";
@use "misc";
@use "mixins";
$header: ".layout-navbar";
@@ -16,25 +16,43 @@ $header: ".layout-navbar";
@if variables.$vertical-nav-navbar-style == "elevated" {
// Add transition
#{$header} {
transition: padding 0.2s ease, background-color 0.18s ease;
transition: padding 0.2s ease;
}
// If navbar is contained => Add border radius to header
@if variables.$layout-vertical-nav-navbar-is-contained {
#{$header} {
// #{$header} {
// border-radius: 0 0 variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
}
// }
}
// Scrolled styles for sticky navbar
@at-root {
/* This html selector with not selector is required when:
dialog is opened and window don't have any scroll. This removes window-scrolled class from layout and out style broke
/* Only apply scrolled styles when window is actually scrolled,
not when dialog is opened without scroll
*/
html.v-overlay-scroll-blocked:not([style*="--v-body-scroll-y: 0px;"]) .layout-navbar-fixed,
&.window-scrolled.layout-navbar-fixed {
#{$header} {
padding-inline: 1rem;
@extend %default-layout-vertical-nav-scrolled-sticky-elevated-nav;
@extend %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled;
}
.navbar-blur#{$header} {
@extend %blurry-bg;
}
}
/* Ensure header styles are preserved when dialog is opened,
but only if window was scrolled before dialog opened
*/
html.v-overlay-scroll-blocked &.window-scrolled.layout-navbar-fixed {
#{$header} {
padding-inline: 1rem;
@extend %default-layout-vertical-nav-scrolled-sticky-elevated-nav;
@extend %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled;
}
@@ -63,7 +81,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));

View File

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

View File

@@ -11,11 +11,58 @@
// adding styling for code tag
code {
background: rgba(var(--v-code-background-color), var(--v-focus-opacity));
border-radius: 3px;
background: rgba(var(--v-code-background-color), var(--v-focus-opacity));
color: currentcolor;
font-size: 85%;
font-weight: 400;
padding-block: 0.2em;
padding-inline: 0.4em;
}
%blurry-bg {
position: relative;
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
@media (width >= 1280px) and (hover: hover) {
background: rgba(var(--v-theme-background), 1);
.v-theme--transparent & {
backdrop-filter: blur(var(--transparent-blur-light, 5px));
background: rgba(var(--v-theme-background), var(--transparent-opacity-light, 0.1)) !important;
}
}
@media (width < 1280px), (hover: none) {
background: transparent;
&::before {
position: absolute;
z-index: -1;
backdrop-filter: blur(24px);
block-size: calc(env(safe-area-inset-top, 0px) + var(--navbar-tab-height) + 4rem);
content: "";
inset-block-start: 0;
inset-inline: 0;
pointer-events: none;
transition: padding 0.3s ease-in-out;
.v-theme--light & {
background: rgba(var(--v-theme-surface), 0.6);
}
.v-theme--dark & {
background: rgba(var(--v-theme-background), 0.5);
}
.v-theme--purple & {
background: rgba(var(--v-theme-background), 0.5);
}
.v-theme--transparent & {
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
background: rgba(var(--v-theme-background), var(--transparent-opacity-heavy, 0.5));
}
}
}
}

View File

@@ -1,4 +1,6 @@
@use "sass:map";
@use "vuetify/lib/styles/settings" as vuetify_settings;
@use "@styles/variables/_vuetify.scss" as vuetify;
@mixin themed($property, $light-value, $dark-value) {
@at-root {
@@ -17,11 +19,12 @@
// 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%;
border-radius: inherit;
content: "";
inline-size: 100%;
inset: 0;
@@ -43,8 +46,8 @@
&::before {
position: absolute;
background-color: currentcolor;
border-radius: inherit;
background-color: currentcolor;
content: "";
inset: 0;
opacity: $opacity;
@@ -56,10 +59,81 @@
@mixin avatar-font-sizes($map: $avatar-sizes) {
@each $sizeName, $multiplier in vuetify_settings.$size-scales {
/* stylelint-disable-next-line scss/no-global-function-names */
$size: map-get($map, $sizeName);
$size: map.get($map, $sizeName);
&.v-avatar--size-#{$sizeName} {
font-size: #{$size}px;
}
}
}
@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 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,73 +1,25 @@
@use "@configured-variables" as variables;
// 👉 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-on-surface), 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;
// 👉 Pagination small-select dropdown for table
// TODO: remove this class after vuetify datatable implememtation
.per-page-select {
margin-block: auto;
.v-field__input {
align-items: center;
padding: 2px;
font-size: 14px;
}
.v-field__append-inner {
align-items: center;
padding: 0;
.v-icon {
margin-inline-start: 0 !important;
}
}
}
// 👉 Typography
.font-weight-semibold {
font-weight: 600 !important;
}
.leading-normal {
line-height: normal !important;
}

View File

@@ -9,20 +9,53 @@
- You can also use variable for consistency (e.g. mx 1 rem should be applied to both vertical nav items and vertical nav header)
*/
@use "sass:map";
// 使用模板中的变量,不再进行配置
@use "@layouts/styles/variables" as layouts-vars;
@use "utils";
@use "vuetify/lib/styles/tools/functions" as *;
// 👉 Default layout
// 合并两个文件中的@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
);
$navbar-high-emphasis-text: true !default;
// 使用命名空间来避免变量冲突
@use "@layouts/styles/variables" as layouts-vars;
// 移除@forward配置已合并到template/_variables.scss
// @forward "@layouts/styles/variables" with (
// $layout-vertical-nav-collapsed-width: 68px !default,
// );
// @use "@layouts/styles/variables" as *;
$vertical-nav-horizontal-padding-custom: 1.375rem 1rem;
// We created this SCSS var to extract the start padding
// Docs: https://sass-lang.com/documentation/modules/string
// $vertical-nav-horizontal-padding => 0 8px;
// string.index(#{$vertical-nav-horizontal-padding}, " ") + 1 => 2
// string.index(#{$vertical-nav-horizontal-padding}, " ") => 1
// string.slice(0 8px, 2, -1) => 8px => $card-actions-padding-x
$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;
// 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: (
"x-small":12,
"small":14,
"default":18,
"large":20,
"x-large":24
) !default;
$theme-colors-name: (
"primary",
@@ -41,31 +74,16 @@ $default-layout-with-vertical-nav-navbar-footer-roundness: 10px !default;
$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: 0 1.125rem !default;
$vertical-nav-horizontal-padding: 1.375rem 1rem !default;
/*
We created this SCSS var to extract the start padding
Docs: https://sass-lang.com/documentation/modules/string
$vertical-nav-horizontal-padding => 0 8px;
string.index(#{$vertical-nav-horizontal-padding}, " ") + 1 => 2
string.index(#{$vertical-nav-horizontal-padding}, " ") => 1
string.slice(0 8px, 2, -1) => 8px => $card-actions-padding-x
*/
$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: layouts-vars.$layout-vertical-nav-navbar-height !default;
$vertical-nav-navbar-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
$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 0.25rem 1rem $vertical-nav-horizontal-padding-start !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;
$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;
@@ -77,22 +95,130 @@ $vertical-nav-section-title-mt: 1.5rem !default;
$vertical-nav-section-title-mb: 0.5rem !default;
// Vertical nav icons
$vertical-nav-items-icon-size: 1.5rem;
$vertical-nav-items-icon-margin-inline-end: 0.625rem;
$vertical-nav-items-icon-size: 1.5rem !default;
$vertical-nav-items-nested-icon-size: 0.9rem !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);
$plugin-ps-thumb-y-dark: rgba(var(--v-theme-surface-variant), 0.35) !default;
// 👉 Custom Variables
$avatar-font-sizes: () !default;
$avatar-font-sizes: map.deep-merge(
// 👉 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(
(
"x-small":12,
"small":14,
"default":18,
"large":20,
"x-large":24
"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
),
$avatar-font-sizes
$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
);
// 👉 Default layout
$navbar-high-emphasis-text: true !default;

View File

@@ -1,6 +1,6 @@
@use "./placeholders";
@use "@configured-variables" as variables;
@use "@core/scss/mixins" as mixins;
@use "./mixins" as mixins;
@use "vuetify/lib/styles/tools/states" as vuetifyStates;
@use "vuetify/lib/styles/tools/elevation" as elevation;
@@ -118,11 +118,6 @@
opacity: var(--v-disabled-opacity);
pointer-events: none;
}
}
// 👉 Vertical nav link
.nav-link {
@extend %nav-link;
> .router-link-exact-active {
@extend %nav-link-active;

View File

@@ -1,5 +1,44 @@
@use "sass:map";
@use "template/index";
// 保留这个引用以向后兼容但实际功能已经移至template/index.scss
// 基础变量和配置
@use "variables";
@use "mixins";
@use "utils";
// 布局相关
@use "default-layout";
@use "vertical-nav";
@use "default-layout-w-vertical-nav";
// 组件样式
@use "components";
// 工具类
@use "utilities";
// 其他样式
@use "misc";
@use "dark";
// 第三方库样式
@use "libs/perfect-scrollbar";
@use "libs/apex-chart";
@use "libs/full-calendar";
@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,67 +1,88 @@
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
@use "@configured-variables" as variables;
@use "../mixins";
// 👉 Apex chart
.apexcharts-canvas {
&line[stroke="transparent"] {
display: "none";
// For RTL alignment
.apexcharts-yaxis-texts-g {
text-align: start;
}
// Tooltip
.apexcharts-tooltip {
@include mixins_elevation.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));
border-block-end-color: rgb(var(--v-border-color));
}
}
.apexcharts-xaxistooltip-text,
.apexcharts-yaxistooltip-text {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
}
// 👉 Text color
.apexcharts-text,
.apexcharts-tooltip-text,
.apexcharts-datalabel-label,
@@ -69,19 +90,16 @@
.apexcharts-xaxistooltip-text,
.apexcharts-yaxistooltip-text,
.apexcharts-legend-text {
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;
// 👉 Annotation Label
.apexcharts-annotation-rect {
&.apexcharts-xaxis-annotation-rect,
&.apexcharts-yaxis-annotation-rect {
fill-opacity: 0.05;
stroke-opacity: 0;
}
}
}

View File

@@ -1,5 +1,5 @@
@use "@core/scss/utils";
@use "@configured-variables" as variables;
@use "../../utils";
// 👉 Application
// We need accurate vh in mobile devices as well
@@ -45,6 +45,17 @@ h6,
}
}
// 👉 Button
@if variables.$vuetify-reduce-default-compact-button-icon-size {
.v-btn--density-compact.v-btn--size-default {
.v-btn__content > svg {
block-size: 22px;
font-size: 22px;
inline-size: 22px;
}
}
}
// 👉 Card
// Removes padding-top for immediately placed v-card-text after itself
.v-card-text {
@@ -71,7 +82,9 @@ h6,
&.v-checkbox-btn,
&.v-radio,
&.v-radio-btn {
margin-inline-start: -0.5625rem;
.v-selection-control__wrapper {
margin-inline-start: -0.5625rem;
}
}
}
@@ -79,7 +92,9 @@ h6,
&.v-radio,
&.v-radio-btn,
&.v-checkbox-btn {
margin-inline-start: -0.3125rem;
.v-selection-control__wrapper {
margin-inline-start: -0.3125rem;
}
}
}
@@ -87,7 +102,9 @@ h6,
&.v-checkbox-btn,
&.v-radio,
&.v-radio-btn {
margin-inline-start: -0.6875rem;
.v-selection-control__wrapper {
margin-inline-start: -0.6875rem;
}
}
}
@@ -154,13 +171,141 @@ h6,
padding-block: 0 !important;
padding-inline: 0 !important;
> .v-ripple__container {
opacity: 0;
}
&:not(:last-child) {
padding-block-end: var(--v-card-list-gap) !important;
}
}
.v-list-item:hover,
.v-list-item:focus,
.v-list-item:active,
.v-list-item.active {
> .v-list-item__overlay {
opacity: 0 !important;
}
}
}
// 👉 Table
.v-table {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
// 👉 Divider
.v-divider {
color: rgb(var(--v-border-color));
}
// 👉 DataTable
.v-data-table {
/* stylelint-disable-next-line no-descending-specificity */
.v-checkbox-btn .v-selection-control__wrapper {
margin-inline-start: 0 !important;
}
.v-selection-control {
display: flex !important;
}
.v-pagination {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
}
// 👉 v-field
.v-field:hover .v-field__outline {
--v-field-border-opacity: var(--v-medium-emphasis-opacity);
}
// 👉 VLabel
.v-label {
opacity: 1 !important;
&:not(.v-field-label--floating) {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
}
}
// 👉 Overlay
.v-overlay__scrim,
.v-navigation-drawer__scrim {
background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity));
opacity: 1;
}
// 透明主题下全屏弹窗的overlay背景透明度调整
html[data-theme="transparent"] .v-dialog--fullscreen .v-overlay__scrim {
background: rgba(var(--v-overlay-scrim-background), 0.3);
}
// 👉 VMessages
.v-messages {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
opacity: 1;
}
// 👉 Alert close btn
.v-alert__close {
.v-btn--icon .v-icon {
--v-icon-size-multiplier: 1.5;
}
}
// 👉 Badge icon alignment
.v-badge__badge {
display: flex;
align-items: center;
justify-content: center;
}
// 👉 Dialog
.v-dialog--fullscreen {
background-color: rgb(var(--v-theme-surface));
}
// 透明主题下全屏弹窗背景透明
html[data-theme="transparent"] .v-dialog--fullscreen {
background-color: transparent !important;
}
// For dialog card title
.v-card-item + .v-card-text {
padding-block-start: 0 !important;
}
// 👉 v-slide-group (List of chips)
.v-slide-group {
.v-slide-group__container {
display: flex;
flex-wrap: wrap;
// 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

@@ -1,22 +1,24 @@
$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', '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
// 👉 General settings
$color-pack: false !default,
$body-font-family: $font-family-custom !default,
$border-radius-root: 6px !default,
// 👉 Shadow opacity
// 👉 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,
$body-font-family: $font-family-custom !default,
$border-radius-root: 6px !default,
$shadow-key-umbra: (
0: (0 0 0 0 var(--v-shadow-key-umbra-opacity)),
1: (0 2px 1px -1px var(--v-shadow-key-umbra-opacity)),
@@ -119,6 +121,18 @@ $card-transition-property-custom: box-shadow, opacity;
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": (
@@ -170,29 +184,14 @@ $card-transition-property-custom: box-shadow, opacity;
)
) !default,
// 👉 States
$states: ("activated": 0.08) !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: 20px !default,
$card-item-padding: 15px 20px !default,
$card-actions-padding: 0 12px 12px !default,
$card-title-letter-spacing: 0.0094rem !default,
$card-subtitle-opacity: 1 !default,
$card-transition-property: $card-transition-property-custom !default,
// 👉 Navigation Drawer
$navigation-drawer-color: rgba(var(--v-theme-on-surface), var(--v-high-medium-opacity)) !default,
// 👉 Table
$table-color: rgba(var(--v-theme-on-surface), var(--v-high-medium-opacity)) !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,
@@ -205,6 +204,8 @@ $card-transition-property-custom: box-shadow, opacity;
// 👉 Badge
$badge-border-color:rgb(var(--v-theme-surface)) !default,
$badge-dot-height: 0.5rem !default,
$badge-dot-width: 0.5rem !default,
// 👉 Button
$button-height: 38px !default,
@@ -212,6 +213,7 @@ $card-transition-property-custom: box-shadow, opacity;
$button-border-radius: 5px !default,
$button-padding-ratio: 1.7 !default,
$button-text-letter-spacing: 0.025rem !default,
$button-icon-density: ("default": 0.5, "comfortable": -2, "compact": -3) !default,
// 👉 Dialog
$dialog-card-header-padding: 20px !default,
@@ -220,6 +222,7 @@ $card-transition-property-custom: box-shadow, opacity;
// 👉 Chip
$chip-label-border-radius: 4px !default,
$chip-close-size: 20px !default,
// 👉 Expansion panel
$expansion-panel-title-padding: 16px 20px !default,
@@ -232,9 +235,6 @@ $card-transition-property-custom: box-shadow, opacity;
// 👉 Menu
$menu-content-border-radius: 5px !default,
// 👉 List
$list-subheader-text-opacity: 1 !default,
// 👉 Snackbar
$snackbar-background:#212121 !default,
$snackbar-border-radius: 4px !default,
@@ -243,7 +243,12 @@ $card-transition-property-custom: box-shadow, opacity;
// 👉 Tabs
$tabs-height: 40px !default,
// 👉 Timeline
// 👉 Slider
$slider-track-active-size: 4px !default,
$slider-thumb-label-padding: 4px 12px !default,
$slider-thumb-label-font-size: 0.875rem !default,
// 👉 Timeline
$timeline-dot-size: 34px !default,
$timeline-dot-divider-background: transparent !default,
@@ -252,4 +257,7 @@ $card-transition-property-custom: box-shadow, opacity;
// 👉 Navigation Drawer
$navigation-drawer-scrim-opacity:0.5 !default,
// 👉 Table
$table-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)),
);

View File

@@ -1 +1,2 @@
@use "variables";
@use "overrides";

View File

@@ -1,3 +1,21 @@
%layout-navbar {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
// Vertical nav scrolled sticky elevated nav
%default-layout-vertical-nav-scrolled-sticky-elevated-nav {
background-color: rgb(var(--v-theme-surface));
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
}
// Floating navbar and sticky elevated navbar scrolled
%default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled {
background-color: rgb(var(--v-theme-surface));
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
}
// Floating navbar overlay
%default-layout-vertical-nav-floating-navbar-overlay {
backdrop-filter: blur(8px);
background-color: rgba(var(--v-theme-surface), 0.9);
}

View File

@@ -1,7 +1,7 @@
@use "@core/scss/mixins";
@use "../mixins";
@use "@configured-variables" as variables;
@use "vuetify/lib/styles/tools/states" as vuetifyStates;
@use "@core/scss/utils";
@use "../utils";
// Nav items styles (including section title)
%vertical-nav-item {

View File

@@ -1,193 +0,0 @@
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
@use "@configured-variables" as variables;
@use "mixins";
// 👉 Alert
.v-alert {
.v-alert__close {
.v-icon {
block-size: 20px !important;
font-size: 20px !important;
inline-size: 20px !important;
}
}
&:not(.v-alert--prominent) .v-alert__prepend {
.v-icon {
block-size: 1.375rem !important;
font-size: 1.375rem !important;
inline-size: 1.375rem !important;
}
}
.v-alert-title {
line-height: 1.5rem;
margin-block-end: 0.25rem;
}
}
// 👉 Avatar font-size
.v-avatar {
@include mixins.avatar-font-sizes($map: variables.$avatar-font-sizes);
}
// 👉 Button
.v-btn {
/* stylelint-disable-next-line no-descending-specificity */
&:not(.v-btn--icon) .v-icon {
--v-icon-size-multiplier: 0.9525 !important;
}
}
// 👉 Chip
.v-chip.v-chip--size-default .v-avatar {
--v-avatar-height: 24px;
}
.v-chip.v-chip--density-comfortable {
line-height: 1;
}
// 👉 Expansion Panel
.v-expansion-panel {
.v-expansion-panel-text {
font-size: 1rem;
}
}
// 👉 Tooltip
.v-tooltip > .v-overlay__content {
font-weight: 500;
line-height: 0.875rem;
}
// 👉 List
// 👉 Tab with pill support
.v-tabs.v-tabs-pill {
.v-tab.v-btn {
border-radius: 6px !important;
}
}
// 👉 Timeline added box shadow
.v-timeline-item {
.v-timeline-divider__dot {
.v-timeline-divider__inner-dot {
box-shadow: 0 0 0 0.1875rem rgb(var(--v-theme-on-surface-variant));
@each $color-name in variables.$theme-colors-name {
&.bg-#{$color-name} {
box-shadow: 0 0 0 0.1875rem rgba(var(--v-theme-#{$color-name}), 0.12);
}
}
}
}
}
// 👉 Timeline Outlined style
.v-timeline-variant-outlined.v-timeline {
.v-timeline-divider__dot {
.v-timeline-divider__inner-dot {
box-shadow: inset 0 0 0 0.125rem rgb(var(--v-theme-on-surface-variant));
@each $color-name in variables.$theme-colors-name {
background-color: rgb(var(--v-theme-surface)) !important;
&.bg-#{$color-name} {
box-shadow: inset 0 0 0 0.125rem rgb(var(--v-theme-#{$color-name}));
}
}
}
}
}
// 👉 Expansion panels
.v-expansion-panel-title,
.v-expansion-panel-title--active,
.v-expansion-panel-title:hover,
.v-expansion-panel-title:focus,
.v-expansion-panel-title:focus-visible,
.v-expansion-panel-title--active:focus,
.v-expansion-panel-title--active:hover {
.v-expansion-panel-title__overlay {
opacity: 0 !important;
}
}
// 👉 Set Elevation when panel open
.v-expansion-panels:not(.v-expansion-panels--variant-accordion) {
.v-expansion-panel.v-expansion-panel--active {
.v-expansion-panel__shadow {
@include mixins_elevation.elevation(3);
}
}
}
// 👉 Slider
.v-slider-thumb {
.v-slider-thumb__label {
background: rgb(117, 117, 117);
color: rgb(var(--v-theme-on-primary));
&::before {
color: rgb(117, 117, 117);
}
}
}
// 👉 Switch
.v-switch {
.v-selection-control:not(.v-selection-control--dirty) .v-switch__thumb {
color: #fff;
}
}
// 👉 Table
.v-table--density-default > .v-table__wrapper > table > tbody > tr > td,
.v-table--density-default > .v-table__wrapper > table > thead > tr > td,
.v-table--density-default > .v-table__wrapper > table > tfoot > tr > td {
block-size: 50px !important;
}
.v-table {
--v-table-header-height: 54px !important;
th {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
font-size: 0.75rem;
.v-data-table-header__content {
display: flex;
justify-content: space-between;
}
}
.v-selection-control {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
font-size: 1rem;
}
}
.v-data-table {
th {
background: rgb(var(--v-table-header-background)) !important;
}
}
// 👉 Pagination
.v-pagination {
.v-btn {
border-radius: 4px;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 14px;
font-weight: 400;
}
}
// 👉 SnackBar
.v-snackbar--variant-elevated {
@include mixins.elevation(6);
}

View File

@@ -1,101 +0,0 @@
@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 {
/* stylelint-disable-next-line scss/no-global-function-names */
$size: map.get($map, $sizeName);
&.v-avatar--size-#{$sizeName} {
font-size: #{$size}px;
}
}
}
@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,25 +0,0 @@
.bg-var-theme-background {
background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity)) !important;
}
// 👉 Pagination small-select dropdown for table
// TODO: remove this class after vuetify datatable implememtation
.per-page-select {
margin-block: auto;
.v-field__input {
align-items: center;
padding: 2px;
font-size: 14px;
}
.v-field__append-inner {
align-items: center;
padding: 0;
.v-icon {
margin-inline-start: 0 !important;
}
}
}

View File

@@ -1,41 +0,0 @@
@use "sass:string";
/*
This function is helpful when we have multi dimensional value
Assume we have padding variable `$nav-padding-horizontal: 10px;`
With above variable let's say we use it in some style:
```scss
.selector {
margin-left: $nav-padding-horizontal;
}
```
Now, problem is we can also have value as `$nav-padding-horizontal: 10px 15px;`
In this case above style will be invalid.
This function will extract the left most value from the variable value.
$nav-padding-horizontal: 10px; => 10px;
$nav-padding-horizontal: 10px 15px; => 10px;
This is safe:
```scss
.selector {
margin-left: get-first-value($nav-padding-horizontal);
}
```
*/
@function get-first-value($var) {
$start-at: string.index(#{$var}, " ");
@if $start-at {
@return string.slice(
#{$var},
0,
$start-at
);
} @else {
@return $var;
}
}

View File

@@ -1,227 +0,0 @@
@use "sass:map";
@use "utils";
@use "vuetify/lib/styles/tools/functions" as *;
$vertical-nav-horizontal-padding-custom: 1.375rem 1rem;
// We created this SCSS var to extract the start padding
// Docs: https://sass-lang.com/documentation/modules/string
// $vertical-nav-horizontal-padding => 0 8px;
// string.index(#{$vertical-nav-horizontal-padding}, " ") + 1 => 2
// string.index(#{$vertical-nav-horizontal-padding}, " ") => 1
// string.slice(0 8px, 2, -1) => 8px => $card-actions-padding-x
$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;
// 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: (
"x-small":12,
"small":14,
"default":18,
"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,42 +0,0 @@
@use "sass:map";
// 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,106 +0,0 @@
@use "@configureTheme" as theme;
@use "@configured-variables" as variables;
@use "../mixins";
// 👉 Apex chart
.apexcharts-canvas {
// For RTL alignment
.apexcharts-yaxis-texts-g {
text-align: start;
}
// Tooltip
.apexcharts-tooltip {
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 {
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-marker {
transition: none;
}
// 👉 stroke-dasharray
.apexcharts-radialbar,
.apexcharts-radialbar-slice-current {
stroke-linecap: round;
}
.apexcharts-xaxistooltip,
.apexcharts-yaxistooltip {
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-block-end-color: rgb(var(--v-border-color));
}
}
// 👉 Text color
.apexcharts-text,
.apexcharts-tooltip-text,
.apexcharts-datalabel-label,
.apexcharts-datalabel,
.apexcharts-xaxistooltip-text,
.apexcharts-yaxistooltip-text,
.apexcharts-legend-text {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity)) !important;
font-family: inherit !important;
}
// 👉 Annotation Label
.apexcharts-annotation-rect {
&.apexcharts-xaxis-annotation-rect,
&.apexcharts-yaxis-annotation-rect {
fill-opacity: 0.05;
stroke-opacity: 0;
}
}
}

View File

@@ -1,301 +0,0 @@
@use "@configured-variables" as variables;
@use "../../../utils";
// 👉 Application
// We need accurate vh in mobile devices as well
.v-application__wrap {
/* stylelint-disable-next-line liberty/use-logical-spec */
min-height: calc(var(--vh, 1vh) * 100);
}
// 👉 Typography
h1,
h2,
h3,
h4,
h5,
h6,
.text-h1,
.text-h2,
.text-h3,
.text-h4,
.text-h5,
.text-h6,
.text-button,
.text-overline,
.v-card-title {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
}
.text-body-1,
.text-body-2,
.text-subtitle-1,
.text-subtitle-2 {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
}
// 👉 Grid
// Remove margin-bottom of v-input_details inside grid (validation error message)
.v-row {
.v-col,
[class^="v-col-*"] {
.v-input__details {
margin-block-end: 0;
}
}
}
// 👉 Button
@if variables.$vuetify-reduce-default-compact-button-icon-size {
.v-btn--density-compact.v-btn--size-default {
.v-btn__content > svg {
block-size: 22px;
font-size: 22px;
inline-size: 22px;
}
}
}
// 👉 Card
// Removes padding-top for immediately placed v-card-text after itself
.v-card-text {
& + & {
padding-block-start: 0 !important;
}
}
/*
👉 Checkbox & Radio Ripple
TODO Checkbox and switch component. Remove it when vuetify resolve the extra spacing: https://github.com/vuetifyjs/vuetify/issues/15519
We need this because form elements likes checkbox and switches are by default set to height of textfield height which is way big than we want
Tested with checkbox & switches
*/
.v-checkbox.v-input,
.v-switch.v-input {
--v-input-control-height: auto;
flex: unset;
}
.v-selection-control--density-comfortable {
&.v-checkbox-btn,
&.v-radio,
&.v-radio-btn {
.v-selection-control__wrapper {
margin-inline-start: -0.5625rem;
}
}
}
.v-selection-control--density-compact {
&.v-radio,
&.v-radio-btn,
&.v-checkbox-btn {
.v-selection-control__wrapper {
margin-inline-start: -0.3125rem;
}
}
}
.v-selection-control--density-default {
&.v-checkbox-btn,
&.v-radio,
&.v-radio-btn {
.v-selection-control__wrapper {
margin-inline-start: -0.6875rem;
}
}
}
.v-radio-group {
.v-selection-control-group {
.v-radio:not(:last-child) {
margin-inline-end: 0.9rem;
}
}
}
/*
👉 Tabs
Disable tab transition
This is for tabs where we don't have card wrapper to tabs and have multiple cards as tab content.
This class will disable transition and adds `overflow: unset` on `VWindow` to allow spreading shadow
*/
.disable-tab-transition {
overflow: unset !important;
.v-window__container {
block-size: auto !important;
}
.v-window-item:not(.v-window-item--active) {
display: none !important;
}
.v-window__container .v-window-item {
transform: none !important;
}
}
// 👉 List
.v-list {
// Set icons opacity to .87
.v-list-item__prepend > .v-icon,
.v-list-item__append > .v-icon {
opacity: var(--v-high-emphasis-opacity);
}
}
// 👉 Card list
/*
Custom class
Remove list spacing inside card
This is because card title gets padding of 20px and list item have padding of 16px. Moreover, list container have padding-bottom as well.
*/
.card-list {
--v-card-list-gap: 20px;
&.v-list {
padding-block: 0;
}
.v-list-item {
min-block-size: unset;
min-block-size: auto !important;
padding-block: 0 !important;
padding-inline: 0 !important;
> .v-ripple__container {
opacity: 0;
}
&:not(:last-child) {
padding-block-end: var(--v-card-list-gap) !important;
}
}
.v-list-item:hover,
.v-list-item:focus,
.v-list-item:active,
.v-list-item.active {
> .v-list-item__overlay {
opacity: 0 !important;
}
}
}
// 👉 Divider
.v-divider {
color: rgb(var(--v-border-color));
}
// 👉 DataTable
.v-data-table {
/* stylelint-disable-next-line no-descending-specificity */
.v-checkbox-btn .v-selection-control__wrapper {
margin-inline-start: 0 !important;
}
.v-selection-control {
display: flex !important;
}
.v-pagination {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
}
// 👉 v-field
.v-field:hover .v-field__outline {
--v-field-border-opacity: var(--v-medium-emphasis-opacity);
}
// 👉 VLabel
.v-label {
opacity: 1 !important;
&:not(.v-field-label--floating) {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
}
}
// 👉 Overlay
.v-overlay__scrim,
.v-navigation-drawer__scrim {
background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity)) !important;
opacity: 1 !important;
}
// 👉 VMessages
.v-messages {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
opacity: 1;
}
// 👉 Alert close btn
.v-alert__close {
.v-btn--icon .v-icon {
--v-icon-size-multiplier: 1.5;
}
}
// 👉 Badge icon alignment
.v-badge__badge {
display: flex;
align-items: center;
justify-content: center;
}
// 👉 Dialog
.v-dialog--fullscreen {
background-color: rgb(var(--v-theme-surface));
}
// For dialog card title
.v-card-item + .v-card-text {
padding-block-start: 0 !important;
}
// 👉 v-slide-group (List of chips)
.v-slide-group {
.v-slide-group__container {
display: flex;
flex-wrap: wrap;
// 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

@@ -1,263 +0,0 @@
$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', '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,
$body-font-family: $font-family-custom !default,
$border-radius-root: 6px !default,
$shadow-key-umbra: (
0: (0 0 0 0 var(--v-shadow-key-umbra-opacity)),
1: (0 2px 1px -1px var(--v-shadow-key-umbra-opacity)),
2: (0 3px 1px -2px var(--v-shadow-key-umbra-opacity)),
// Modified
3: (0 4px 14px -4px var(--v-shadow-key-umbra-opacity)),
4: (0 2px 4px -1px var(--v-shadow-key-umbra-opacity)),
5: (0 3px 5px -1px var(--v-shadow-key-umbra-opacity)),
// Modified
6: (0 4px 5px -2px var(--v-shadow-key-umbra-opacity)),
7: (0 4px 5px -2px var(--v-shadow-key-umbra-opacity)),
8: (0 5px 5px -3px var(--v-shadow-key-umbra-opacity)),
9: (0 5px 6px -3px var(--v-shadow-key-umbra-opacity)),
10: (0 6px 6px -3px var(--v-shadow-key-umbra-opacity)),
11: (0 6px 7px -4px var(--v-shadow-key-umbra-opacity)),
12: (0 7px 8px -4px var(--v-shadow-key-umbra-opacity)),
13: (0 7px 8px -4px var(--v-shadow-key-umbra-opacity)),
14: (0 7px 9px -4px var(--v-shadow-key-umbra-opacity)),
15: (0 8px 9px -5px var(--v-shadow-key-umbra-opacity)),
16: (0 8px 10px -5px var(--v-shadow-key-umbra-opacity)),
17: (0 8px 11px -5px var(--v-shadow-key-umbra-opacity)),
18: (0 9px 11px -5px var(--v-shadow-key-umbra-opacity)),
19: (0 9px 12px -6px var(--v-shadow-key-umbra-opacity)),
20: (0 10px 13px -6px var(--v-shadow-key-umbra-opacity)),
21: (0 10px 13px -6px var(--v-shadow-key-umbra-opacity)),
22: (0 10px 14px -6px var(--v-shadow-key-umbra-opacity)),
23: (0 11px 14px -7px var(--v-shadow-key-umbra-opacity)),
24: (0 11px 15px -7px var(--v-shadow-key-umbra-opacity))
) !default,
$shadow-key-penumbra: (
0: (0 0 0 0 $shadow-key-penumbra-opacity-custom),
1: (0 1px 1px 0 $shadow-key-penumbra-opacity-custom),
2: (0 2px 2px 0 $shadow-key-penumbra-opacity-custom),
// Modified
3: (0 4px 8px -4px $shadow-key-penumbra-opacity-custom),
4: (0 4px 5px 0 $shadow-key-penumbra-opacity-custom),
5: (0 5px 8px 0 $shadow-key-penumbra-opacity-custom),
// Modified
6: (0 2px 10px 1px $shadow-key-penumbra-opacity-custom),
7: (0 7px 10px 1px $shadow-key-penumbra-opacity-custom),
8: (0 8px 10px 1px $shadow-key-penumbra-opacity-custom),
9: (0 9px 12px 1px $shadow-key-penumbra-opacity-custom),
10: (0 10px 14px 1px $shadow-key-penumbra-opacity-custom),
11: (0 11px 15px 1px $shadow-key-penumbra-opacity-custom),
12: (0 12px 17px 2px $shadow-key-penumbra-opacity-custom),
13: (0 13px 19px 2px $shadow-key-penumbra-opacity-custom),
14: (0 14px 21px 2px $shadow-key-penumbra-opacity-custom),
15: (0 15px 22px 2px $shadow-key-penumbra-opacity-custom),
16: (0 16px 24px 2px $shadow-key-penumbra-opacity-custom),
17: (0 17px 26px 2px $shadow-key-penumbra-opacity-custom),
18: (0 18px 28px 2px $shadow-key-penumbra-opacity-custom),
19: (0 19px 29px 2px $shadow-key-penumbra-opacity-custom),
20: (0 20px 31px 3px $shadow-key-penumbra-opacity-custom),
21: (0 21px 33px 3px $shadow-key-penumbra-opacity-custom),
22: (0 22px 35px 3px $shadow-key-penumbra-opacity-custom),
23: (0 23px 36px 3px $shadow-key-penumbra-opacity-custom),
24: (0 24px 38px 3px $shadow-key-penumbra-opacity-custom)
) !default,
$shadow-key-ambient: (
0: (0 0 0 0 $shadow-key-ambient-opacity-custom),
1: (0 1px 3px 0 $shadow-key-ambient-opacity-custom),
2: (0 1px 5px 0 $shadow-key-ambient-opacity-custom),
// Modified
3: (0 4px 8px -4px $shadow-key-ambient-opacity-custom),
4: (0 1px 10px 0 $shadow-key-ambient-opacity-custom),
5: (0 1px 14px 0 $shadow-key-ambient-opacity-custom),
// Modified
6: (0 2px 16px 1px $shadow-key-ambient-opacity-custom),
7: (0 2px 16px 1px $shadow-key-ambient-opacity-custom),
8: (0 3px 14px 2px $shadow-key-ambient-opacity-custom),
9: (0 3px 16px 2px $shadow-key-ambient-opacity-custom),
10: (0 4px 18px 3px $shadow-key-ambient-opacity-custom),
11: (0 4px 20px 3px $shadow-key-ambient-opacity-custom),
12: (0 5px 22px 4px $shadow-key-ambient-opacity-custom),
13: (0 5px 24px 4px $shadow-key-ambient-opacity-custom),
14: (0 5px 26px 4px $shadow-key-ambient-opacity-custom),
15: (0 6px 28px 5px $shadow-key-ambient-opacity-custom),
16: (0 6px 30px 5px $shadow-key-ambient-opacity-custom),
17: (0 6px 32px 5px $shadow-key-ambient-opacity-custom),
18: (0 7px 34px 6px $shadow-key-ambient-opacity-custom),
19: (0 7px 36px 6px $shadow-key-ambient-opacity-custom),
20: (0 8px 38px 7px $shadow-key-ambient-opacity-custom),
21: (0 8px 40px 7px $shadow-key-ambient-opacity-custom),
22: (0 8px 42px 7px $shadow-key-ambient-opacity-custom),
23: (0 9px 44px 8px $shadow-key-ambient-opacity-custom),
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": (
"weight": 500,
"line-height": 7rem,
"letter-spacing": -0.0938rem
),
"h2": (
"weight": 500,
"line-height": 4.5rem,
"letter-spacing": -0.0313rem
),
"h3": (
"weight": 500,
"line-height": 3.5rem
),
"h4": (
"weight": 500,
"line-height": 2.625rem,
"letter-spacing": 0.0156rem
),
"h5": (
"weight": 500,
"line-height": 2rem
),
"h6": (
"letter-spacing": 0.0094rem
),
"subtitle-1": (
"letter-spacing": 0.0094rem
),
"subtitle-2": (
"line-height": 1.375rem,
"letter-spacing": 0.0063rem,
),
"body-1": (
"letter-spacing": 0.0094rem,
),
"body-2": (
"letter-spacing": 0.0094rem,
),
"caption": (
"letter-spacing": 0.025rem,
),
"overline": (
"weight": 400,
"line-height": 1.125rem,
"letter-spacing": 0.0625rem,
)
) !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-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,
// 👉 Alert
$alert-title-font-size: 1rem !default,
$alert-border-radius: 5px !default,
$alert-title-letter-spacing: 0.15px !default,
// 👉 Badge
$badge-border-color:rgb(var(--v-theme-surface)) !default,
$badge-dot-height: 0.5rem !default,
$badge-dot-width: 0.5rem !default,
// 👉 Button
$button-height: 38px !default,
$button-elevation: ("default": 3, "hover": 4, "active": 8) !default,
$button-border-radius: 5px !default,
$button-padding-ratio: 1.7 !default,
$button-text-letter-spacing: 0.025rem !default,
$button-icon-density: ("default": 0.5, "comfortable": -2, "compact": -3) !default,
// 👉 Dialog
$dialog-card-header-padding: 20px !default,
$dialog-card-header-text-padding-top: 0 !default,
$dialog-card-text-padding: 20px !default,
// 👉 Chip
$chip-label-border-radius: 4px !default,
$chip-close-size: 20px !default,
// 👉 Expansion panel
$expansion-panel-title-padding: 16px 20px !default,
$expansion-panel-title-font-size: 1rem !default,
$expansion-panel-disabled-overlay: 0 !default,
$expansion-panel-active-title-min-height: 51px !default,
$expansion-panel-title-min-height: 51px !default,
$expansion-panel-text-padding: 0 20px 20px !default,
// 👉 Menu
$menu-content-border-radius: 5px !default,
// 👉 Snackbar
$snackbar-background:#212121 !default,
$snackbar-border-radius: 4px !default,
$snackbar-color: rgb(var(--v-theme-on-primary)) !default,
// 👉 Tabs
$tabs-height: 40px !default,
// 👉 Slider
$slider-track-active-size: 4px !default,
$slider-thumb-label-padding: 4px 12px !default,
$slider-thumb-label-font-size: 0.875rem !default,
// 👉 Timeline
$timeline-dot-size: 34px !default,
$timeline-dot-divider-background: transparent !default,
// 👉 Overlay
$overlay-opacity: 0.5 !default,
// 👉 Navigation Drawer
$navigation-drawer-scrim-opacity:0.5 !default,
// 👉 Table
$table-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)),
);

View File

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

View File

@@ -1,25 +0,0 @@
.layout-blank {
.misc-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.25rem;
overflow: hidden;
.misc-footer-img {
position: absolute;
inline-size: 100%;
inset-block-end: 0;
}
.misc-footer-tree {
position: absolute;
z-index: 1;
}
}
.misc-avatar {
z-index: 1;
}
}

View File

@@ -1,54 +0,0 @@
.layout-blank {
.auth-wrapper {
min-block-size: calc(var(--vh, 1vh) * 100);
.auth-footer-mask {
position: absolute;
inset-block-end: 0;
min-inline-size: 100%;
}
.auth-footer-start-tree,
.auth-footer-end-tree {
position: absolute;
z-index: 1;
}
.auth-footer-start-tree {
inset-block-end: 0;
inset-inline-start: 0;
}
.auth-footer-end-tree {
inset-block-end: 0;
inset-inline-end: 0;
}
.auth-illustration {
z-index: 1;
}
}
.auth-card {
z-index: 1 !important;
}
}
@media (min-width: 960px) {
.skin--bordered {
.auth-card-v2 {
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
}
}
.auth-logo {
position: absolute;
z-index: 1;
inset-block-start: 2rem;
inset-inline-start: 2.3rem;
}
.auth-card-v2 {
background-color: rgb(var(--v-theme-surface));
}

View File

@@ -1,45 +0,0 @@
@use "@configured-variables" as variables;
@use "misc";
@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);
// If navbar is contained => Squeeze navbar content on scroll
@if variables.$layout-vertical-nav-navbar-is-contained {
padding-inline: 1rem;
}
}
%default-layout-vertical-nav-floating-navbar-overlay {
isolation: isolate;
&::after {
position: absolute;
z-index: -1;
/* stylelint-disable property-no-vendor-prefix */
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
/* stylelint-enable */
background:
linear-gradient(
180deg,
rgba(var(--v-theme-background), 70%) 44%,
rgba(var(--v-theme-background), 43%) 73%,
rgba(var(--v-theme-background), 0%)
);
background-repeat: repeat;
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: 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 +0,0 @@
%layout-navbar {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}

View File

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

View File

@@ -1,120 +0,0 @@
%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,8 +0,0 @@
%nav-link-active {
background:
linear-gradient(
-72.47deg,
rgb(var(--v-theme-primary)) 22.16%,
rgba(var(--v-theme-primary), 0.7) 76.47%
) !important;
}

View File

@@ -1,64 +0,0 @@
@use "@configured-variables" as variables;
// Add divider around section title
%vertical-nav-section-title {
/*
We will use this to add gap between divider and text.
Moreover, we will use this to adjust the `flex-basis` property of left divider
*/
$divider-gap: 0.625rem;
// Thanks: https://stackoverflow.com/a/62359101/10796681
.title-text {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
column-gap: $divider-gap;
&::before,
&::after {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
content: "";
}
&::after {
flex: 1 1 auto;
}
&::before {
flex: 0 1 calc(variables.$vertical-nav-horizontal-padding-start - $divider-gap);
margin-inline-start: -#{variables.$vertical-nav-horizontal-padding-start};
}
}
// Update the margin-inline-end when vertical nav is in mini state. We done same for link & group.
@at-root {
.layout-nav-type-vertical.layout-vertical-nav-collapsed .layout-vertical-nav:not(.hovered) .nav-section-title {
margin-inline: 4px 0;
}
}
}
%vertical-nav-item-interactive {
// Add pill shape styles
block-size: 2.625rem !important;
border-end-end-radius: 3.125rem !important;
border-end-start-radius: 0 !important;
border-start-end-radius: 3.125rem !important;
border-start-start-radius: 0 !important;
}
%vertical-nav-item-interactive {
// Wobble effect
// transition: margin-inline 0.4s ease-in-out;
// will-change: margin-inline;
transition: margin-inline 0.15s ease-in-out;
will-change: margin-inline;
// Reduce margin inline end when vertical nav is in collapsed mode and not hovered
.layout-nav-type-vertical.layout-vertical-nav-collapsed .layout-vertical-nav:not(.hovered) & {
margin-inline: 0 5px;
}
}

View File

@@ -2,8 +2,7 @@ import ColorThief from 'colorthief'
// 将 RGB 转换为十六进制
function rgbStringToHex(rgbArray: number[]): string {
if (rgbArray.length !== 3 || rgbArray.some(isNaN))
throw new Error('Invalid RGB string format')
if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format')
const [r, g, b] = rgbArray
@@ -21,3 +20,27 @@ export async function getDominantColor(image: HTMLImageElement): Promise<string>
const dominantColor = colorThief.getColor(image)
return rgbStringToHex(dominantColor)
}
// 预加载图片
export async function preloadImage(url: string): Promise<boolean> {
return new Promise(resolve => {
const img = new Image()
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
// 设置超时,防止图片长时间加载
const timeout = setTimeout(() => {
img.src = ''
resolve(false)
}, 5000) // 5秒超时
img.src = url
// 如果图片已经缓存onload可能不会触发
if (img.complete) {
clearTimeout(timeout)
resolve(true)
}
})
}

View File

@@ -65,3 +65,6 @@ export function getQueryValue(key: string, url = window.location.href): string {
const res = reg.exec(url)
return res ? res[1] : ''
}
// 导出 navigator 相关函数
export { isMobileDevice, isIOSDevice, isAndroidDevice } from './navigator'

View File

@@ -43,3 +43,56 @@ export const isPWA = async (): Promise<boolean> => {
}
return (window.navigator as any).standalone === true
}
// 同步检测PWA显示模式
export const isPWADisplayMode = (): boolean => {
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone ||
document.referrer.includes('android-app://')
)
}
// 全面的PWA检测推荐使用
export const checkPWAStatus = async () => {
const hasServiceWorker = await isPWA()
const isStandaloneMode = isPWADisplayMode()
return {
// 是否有PWA功能Service Worker
hasPWAFeatures: hasServiceWorker,
// 是否在独立显示模式下运行
isStandaloneMode,
// 综合判断更宽松的检测在移动设备上默认启用PWA功能
isPWAEnvironment: hasServiceWorker || isStandaloneMode || isMobileDevice(),
// 完整的PWA体验既有功能又在独立模式下运行
isFullPWA: hasServiceWorker && isStandaloneMode,
}
}
// 检测是否为移动设备
export const isMobileDevice = (): boolean => {
// 检查用户代理字符串
const userAgent = navigator.userAgent || ''
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i
// 检查触摸屏支持
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0
// 检查屏幕尺寸小于768px认为是移动设备
const isMobileSize = window.innerWidth < 768
return mobileRegex.test(userAgent) || hasTouchScreen || isMobileSize
}
// 检测是否为iOS设备
export const isIOSDevice = (): boolean => {
const userAgent = navigator.userAgent.toLowerCase()
return /iphone|ipad|ipod/.test(userAgent) && !(window as any).MSStream
}
// 检测是否为Android设备
export const isAndroidDevice = (): boolean => {
const userAgent = navigator.userAgent.toLowerCase()
return /android/.test(userAgent)
}

View File

@@ -1,209 +0,0 @@
<script lang="ts">
import { Transition } from 'vue'
import { useDisplay } from 'vuetify'
import VerticalNav from '@layouts/components/VerticalNav.vue'
export default defineComponent({
setup(props, { slots }) {
const isOverlayNavActive = ref(false)
const isLayoutOverlayVisible = ref(false)
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)
const route = useRoute()
const { mdAndDown } = useDisplay()
// 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)
const scrollDistance = ref(window.scrollY)
onMounted(() => {
window.addEventListener('scroll', () => {
scrollDistance.value = window.scrollY
})
})
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']?.(),
},
)
// 👉 Navbar
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
h(
'div',
{ class: 'navbar-content-container' },
slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
}),
),
])
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?.()),
),
)
// 👉 根据路由 meta 决定 footer 高度
const shouldShowFooter = !route.meta.hideFooter
// 👉 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(
'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 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;
}
}
}
}
}
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
.layout-navbar {
width: 100%;
}
}
</style>

View File

@@ -17,11 +17,34 @@ export default defineComponent({
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
const scrollDistance = ref(window.scrollY)
const isDialogOpen = ref(false)
const wasScrolledBeforeDialog = ref(false)
// 监听弹窗状态变化
const checkDialogState = () => {
const wasDialogOpen = isDialogOpen.value
isDialogOpen.value = document.documentElement.classList.contains('v-overlay-scroll-blocked')
// 当弹窗刚打开时,记录当前的滚动状态
if (!wasDialogOpen && isDialogOpen.value) {
wasScrolledBeforeDialog.value = scrollDistance.value > 0
}
}
onMounted(() => {
window.addEventListener('scroll', () => {
scrollDistance.value = window.scrollY
})
// 初始检查弹窗状态
checkDialogState()
// 监听 DOM 变化以检测弹窗状态
const observer = new MutationObserver(checkDialogState)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
})
return () => {
@@ -38,15 +61,25 @@ export default defineComponent({
)
// 👉 Navbar
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
h(
'div',
{ class: 'navbar-content-container' },
slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
}),
),
])
const navbar = h(
'header',
{ class: ['layout-navbar navbar-blur'] },
[
h(
'div',
{ class: 'navbar-content-container' },
[
slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
}),
// 👉 Dynamic Header Tab in NavBar
slots['dynamic-header-tab']?.()
? h('div', { class: 'layout-dynamic-header-tab' }, slots['dynamic-header-tab']?.())
: null,
].filter(Boolean),
),
].filter(Boolean),
)
const main = h(
'main',
@@ -86,7 +119,7 @@ export default defineComponent({
'layout-navbar-fixed',
mdAndDown.value && 'layout-overlay-nav',
route.meta.layoutWrapperClasses,
scrollDistance.value && 'window-scrolled',
(scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
],
},
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
@@ -127,7 +160,9 @@ export default defineComponent({
inset-block-start: 0;
.navbar-content-container {
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
block-size: calc(
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
);
}
@at-root {
@@ -135,10 +170,6 @@ export default defineComponent({
.layout-navbar {
@if variables.$layout-vertical-nav-navbar-is-contained {
@include mixins.boxed-content;
} @else {
.navbar-content-container {
// @include mixins.boxed-content;
}
}
}
}

View File

@@ -6,8 +6,9 @@
html {
background: rgb(var(--v-theme-background));
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
overflow-y: overlay;
min-block-size: 100vh;
min-block-size: 100dvh;
overflow-y: hidden;
}
body {
@@ -40,8 +41,8 @@ body,
// TODO: Use grid gutter variable here;
padding-block: 1.5rem;
padding-inline: 0.5rem;
padding-block-start: calc(env(safe-area-inset-top) + 4.5rem);
padding-inline: 0.5rem;
// display: flex;display

View File

@@ -3,13 +3,15 @@ import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useI18n } from 'vue-i18n'
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n'
// 国际化
const { t } = useI18n()
import { checkAndEmitUnreadMessages } from '@/utils/badge'
import { preloadImage } from './@core/utils/image'
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
import { themeManager } from '@/utils/themeManager'
// 生效主题
const { global: globalTheme } = useTheme()
@@ -21,21 +23,20 @@ globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
const localeValue = getBrowserLocale()
setI18nLanguage(localeValue as SupportedLocale)
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 显示状态
const show = ref(false)
// 检查是否登录
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
// 生成背景图片key
const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'))
// 背景图片
const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
let backgroundRotationTimer: NodeJS.Timeout | null = null
// ApexCharts 全局配置
declare global {
@@ -44,108 +45,94 @@ declare global {
}
}
if (window.Apex) {
// 数据标签
window.Apex.dataLabels = {
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
// 如果有小数点,保留两位小数,否则保留整数
const data = w.config.series[seriesIndex]
return data.toFixed(data % 1 === 0 ? 0 : 1)
},
}
// 图例
window.Apex.legend = {
labels: {
useSeriesColors: true,
},
}
// 标题
window.Apex.title = {
style: {
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
},
// 配置 ApexCharts 全局选项
function configureApexCharts() {
if (typeof window !== 'undefined' && window.Apex) {
try {
// 获取当前主题
const currentTheme = globalTheme.name.value
const isDark = currentTheme === 'dark' || currentTheme === 'transparent'
// 数据标签
window.Apex.dataLabels = {
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
// 如果有小数点,保留两位小数,否则保留整数
const data = w.config.series[seriesIndex]
return data.toFixed(data % 1 === 0 ? 0 : 1)
},
}
// 图例
window.Apex.legend = {
labels: {
useSeriesColors: true,
},
}
// 标题
window.Apex.title = {
style: {
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
},
}
// 鼠标悬浮提示
window.Apex.tooltip = {
theme: isDark ? 'dark' : 'light',
}
} catch (error) {
console.warn('ApexCharts 全局配置失败:', error)
}
}
}
// 更新data-theme属性以便CSS选择器能正确匹配
function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
// 确保body元素也有相同的主题属性以便更好地选择弹出窗口
document.body.setAttribute('data-theme', themeName)
}
// 获取背景图片
async function fetchBackgroundImages() {
try {
backgroundImages.value = await api.get(`/login/wallpapers`)
const controller = new AbortController()
backgroundImages.value = await api.get(`/login/wallpapers`, {
signal: controller.signal,
})
activeImageIndex.value = 0
} catch (e) {
console.error(e)
throw e
}
}
// 背景图片轮换函数
function rotateBackgroundImage() {
if (backgroundImages.value.length > 1) {
// 计算下一个图片索引
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
// 预加载下一张图片
preloadImage(backgroundImages.value[nextIndex]).then(success => {
// 只有图片成功加载才切换
if (success) {
activeImageIndex.value = nextIndex
}
})
}
}
// 开始背景图片轮换
function startBackgroundRotation() {
if (backgroundRotationTimer) clearInterval(backgroundRotationTimer)
// 清除现有定时器
removeBackgroundTimer('background-rotation')
if (backgroundImages.value.length > 1) {
backgroundRotationTimer = setInterval(() => {
// 计算下一个图片索引
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
// 预加载下一张图片
preloadImage(backgroundImages.value[nextIndex]).then(success => {
// 只有图片成功加载才切换
if (success) {
activeImageIndex.value = nextIndex
}
})
}, 10000) // 每10秒切换一次
}
}
// 预加载图片
function preloadImage(url: string): Promise<boolean> {
return new Promise(resolve => {
const img = new Image()
const imageUrl = getImgUrl(url)
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
// 设置超时,防止图片长时间加载
const timeout = setTimeout(() => {
img.src = ''
resolve(false)
}, 5000) // 5秒超时
img.src = imageUrl
// 如果图片已经缓存onload可能不会触发
if (img.complete) {
clearTimeout(timeout)
resolve(true)
}
})
}
// 计算图片地址
function getImgUrl(url: string) {
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && isLogin.value)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}
// 处理页面可见性变化
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
// 如果已有背景图片数据,直接重启轮换
if (backgroundImages.value.length > 0) {
startBackgroundRotation()
}
// 如果没有背景图片数据,重新获取
else {
fetchBackgroundImages().then(() => startBackgroundRotation())
}
// 使用优化的定时器管理器,后台时自动暂停
addBackgroundTimer(
'background-rotation',
rotateBackgroundImage,
10000, // 每10秒切换一次
{
runInBackground: false, // 后台时不运行
skipInitialRun: true, // 不需要立即执行
},
)
}
}
@@ -153,71 +140,127 @@ function handleVisibilityChange() {
function animateAndRemoveLoader() {
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
if (loadingBg) {
// 先添加完成动画类
loadingBg.classList.add('loading-complete')
// 等待动画完成后再移除元素
setTimeout(() => {
removeEl('#loading-bg')
// 将background属性从html的style中移除
document.documentElement.style.removeProperty('background')
// 显示页面
show.value = true
}, 500) // 与CSS动画持续时间匹配
removeEl('#loading-bg')
document.documentElement.style.removeProperty('background')
}
}
onMounted(() => {
// 检查PWA状态并移除加载界面
async function removeLoadingWithStateCheck() {
try {
// 设置各个组件的加载状态
globalLoadingStateManager.setLoadingState('pwa-state', true)
globalLoadingStateManager.setLoadingState('global-settings', true)
globalLoadingStateManager.setLoadingState('background-images', true)
// 静默检查PWA状态恢复
const pwaController = (window as any).pwaStateController
if (pwaController) {
await pwaController.waitForStateRestore()
}
globalLoadingStateManager.setLoadingState('pwa-state', false)
// 并行加载关键资源
await Promise.all([
globalSettingsStore.initialize().then(() => {
globalLoadingStateManager.setLoadingState('global-settings', false)
}),
new Promise(resolve => {
setTimeout(() => {
globalLoadingStateManager.setLoadingState('background-images', false)
resolve(void 0)
}, 50)
}),
])
// 等待所有加载完成
await globalLoadingStateManager.waitForAllComplete()
// 移除加载界面
animateAndRemoveLoader()
// 检查未读消息
checkAndEmitUnreadMessages()
} catch (error) {
// 即使出错也要移除加载界面
globalLoadingStateManager.reset()
animateAndRemoveLoader()
}
}
// 加载背景图片
async function loadBackgroundImages(retryCount = 0) {
const maxRetries = 3
try {
await fetchBackgroundImages()
startBackgroundRotation()
} catch (error: any) {
const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED'
if (retryCount < maxRetries) {
const baseDelay = isAbortError ? 1000 : 3000
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
setTimeout(() => {
loadBackgroundImages(retryCount + 1)
}, retryDelay)
}
}
}
onMounted(async () => {
// 配置 ApexCharts
configureApexCharts()
// 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value)
// 加载背景图片并开始轮换
fetchBackgroundImages().then(() => startBackgroundRotation())
// 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue)
// 添加页面可见性变化监听
document.addEventListener('visibilitychange', handleVisibilityChange)
// 监听主题变化
watch(
() => globalTheme.name.value,
newTheme => {
// 更新HTML主题属性
updateHtmlThemeAttribute(newTheme)
// 重新配置ApexCharts以适应新主题
configureApexCharts()
},
)
// 加载背景图片
loadBackgroundImages()
// 使用优化后的加载界面移除逻辑
ensureRenderComplete(() => {
nextTick(() => {
setTimeout(() => {
// 移除加载动画
animateAndRemoveLoader()
}, 1500)
})
nextTick(removeLoadingWithStateCheck)
})
})
onUnmounted(() => {
// 移除页面可见性监听
document.removeEventListener('visibilitychange', handleVisibilityChange)
// 清除轮换定时器
if (backgroundRotationTimer) {
clearInterval(backgroundRotationTimer)
backgroundRotationTimer = null
}
// 清除背景轮换定时器
removeBackgroundTimer('background-rotation')
})
</script>
<template>
<div class="app-wrapper">
<!-- 透明主题背景 -->
<template v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)">
<div class="background-container">
<div
v-for="(imageUrl, index) in backgroundImages"
:key="index"
class="background-image"
:class="{ 'active': index === activeImageIndex }"
:style="{ backgroundImage: `url(${getImgUrl(imageUrl)})` }"
></div>
<!-- 全局磨砂层 -->
<div v-if="isLogin" class="global-blur-layer"></div>
</div>
</template>
<VApp v-show="show" :class="{ 'transparent-app': isTransparentTheme }">
<div v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)" class="background-container">
<div
v-for="(imageUrl, index) in backgroundImages"
:key="`bg-${index}-${loginStateKey}`"
class="background-image"
:class="{ 'active': index === activeImageIndex }"
:style="{ 'backgroundImage': `url(${imageUrl})` }"
/>
<!-- 全局磨砂层 -->
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
</div>
<!-- 页面内容 -->
<VApp>
<RouterView />
<!-- PWA安装提示 -->
<PWAInstallPrompt />
</VApp>
</div>
</template>

View File

@@ -26,6 +26,11 @@ export const storageAttributes = [
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'smb',
icon: 'mdi-folder-network-outline',
remote: true,
},
]
export const storageIconDict = storageAttributes.reduce((dict, item) => {
@@ -335,6 +340,14 @@ export const actionStepOptions = [
title: i18n.global.t('actionStep.transferFile'),
value: '整理文件',
},
{
title: i18n.global.t('actionStep.invokePlugin'),
value: '调用插件',
},
{
title: i18n.global.t('actionStep.note'),
value: '备注',
},
]
// 操作步骤字典

View File

@@ -1,6 +1,8 @@
import axios from 'axios'
import router from '@/router'
import { useAuthStore } from '@/stores'
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
// 创建axios实例
const api = axios.create({
@@ -17,6 +19,9 @@ declare global {
// 将 API 实例暴露到全局,供插件使用
window.MoviePilotAPI = api
// 初始化请求优化器(必须在其他拦截器之前)
initializeRequestOptimizer(api)
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store
@@ -28,15 +33,47 @@ api.interceptors.request.use(config => {
return config
})
// 离线状态管理
const globalOfflineStatus = useGlobalOfflineStatus()
// 添加响应拦截器
api.interceptors.response.use(
response => {
// 成功响应时,清除应用离线状态并重置连续错误计数
globalOfflineStatus.setAppOffline(false)
globalOfflineStatus.resetConsecutiveErrors()
return response.data
},
error => {
if (!error.response) {
// 请求超时
return Promise.reject(new Error(error))
// 网络错误或请求超时 - 通知离线状态管理系统
const isNetworkError =
error.code === 'NETWORK_ERROR' ||
error.code === 'ERR_NETWORK' ||
error.code === 'ECONNABORTED' ||
error.name === 'NetworkError'
if (isNetworkError) {
let reason = 'Network connection failed'
if (error.code === 'ECONNABORTED') {
reason = 'Request timeout'
}
// 记录网络错误,只有连续三次才会设置为离线模式
globalOfflineStatus.recordNetworkError(reason)
}
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
// 网络连接问题
return Promise.reject(new Error('Network connection failed, please check your network status'))
} else if (error.code === 'ECONNABORTED') {
// 请求超时
return Promise.reject(new Error('Request timeout, please try again later'))
} else if (error.name === 'AbortError') {
// 请求被中止(路由切换等)
return Promise.reject(new Error('Request cancelled'))
}
// 其他网络错误
return Promise.reject(new Error(error.message || 'Network error'))
} else if (error.response.status === 403) {
// 认证 Store
const authStore = useAuthStore()

View File

@@ -144,6 +144,42 @@ export interface SubscribeShare {
episode_group?: string
}
// 工作流分享
export interface WorkflowShare {
// 分享ID
id?: string
// 工作流ID
workflow_id?: string
// 分享标题
share_title?: string
// 分享说明
share_comment?: string
// 分享人
share_user?: string
// 分享人唯一ID
share_uid?: string
// 工作流名称
name?: string
// 工作流描述
description?: string
// 定时器
timer?: string
// 触发类型timer-定时触发 event-事件触发 manual-手动触发
trigger_type?: string
// 事件类型当trigger_type为event时使用
event_type?: string
// 动作列表
actions?: any[]
// 动作流
flows?: any[]
// 上下文
context?: string
// 时间
date?: string
// 复用次数
count?: number
}
// 历史记录
export interface TransferHistory {
// ID
@@ -484,7 +520,7 @@ export interface SiteUserData {
// 用户名
username?: string
// 用户ID
userid?: number
userid?: string
// 用户等级
user_level?: string
// 加入时间
@@ -565,9 +601,9 @@ export interface NotExistMediaInfo {
// 插件
export interface Plugin {
id?: string
id: string
// 插件名称
plugin_name?: string
plugin_name: string
// 插件描述
plugin_desc?: string
// 插件图标
@@ -632,9 +668,7 @@ export interface DashboardItem {
// 页面元素
elements: RenderProps[]
// 渲染方式
render_mode: string
// 组件地址
component_url: string
render_mode?: string
}
// 种子信息
@@ -771,6 +805,8 @@ export interface MetaInfo {
audio_term: string
// 资源类型+特效
edition: string
// 流媒体平台
web_source: string
// 应用的自定义识别词
apply_words: string[]
}
@@ -954,6 +990,8 @@ export interface MediaServerPlayItem {
link?: string
// 播放百分比
percent?: number
// 媒体服务器类型
server_type?: string
}
// 媒体服务器媒体库
@@ -974,6 +1012,8 @@ export interface MediaServerLibrary {
image_list?: string[]
// 链接
link?: string
// 媒体服务器类型
server_type?: string
}
// 消息通知
@@ -1010,6 +1050,8 @@ export interface SystemNotification {
text: string
// 通知时间
date: string
// 是否已读
read?: boolean
}
// 下载器配置
@@ -1290,6 +1332,10 @@ export interface Workflow {
description?: string
// 定时器
timer?: string
// 触发类型timer-定时触发 event-事件触发 manual-手动触发
trigger_type?: string
// 事件类型当trigger_type为event时使用
event_type?: string
// 状态
state?: string
// 当前执行动作
@@ -1307,3 +1353,59 @@ export interface Workflow {
// 最后执行时间
last_time?: string
}
// 种子缓存项
export interface TorrentCacheItem {
// 种子hash用于操作标识
hash: string
// 站点域名
domain: string
// 种子标题
title: string
// 种子描述
description?: string
// 种子大小
size: number
// 发布时间
pubdate?: string
// 站点名称
site_name?: string
// 识别的媒体名称
media_name?: string
// 识别的媒体年份
media_year?: string
// 识别的媒体类型
media_type?: string
// 季集信息
season_episode?: string
// 资源信息
resource_term?: string
// 种子链接
enclosure?: string
// 详情页面
page_url?: string
// 海报图片
poster_path?: string
// 背景图片
backdrop_path?: string
}
// 种子缓存数据
export interface TorrentCacheData {
// 缓存数量
count: number
// 站点数量
sites: number
// 缓存数据
data: TorrentCacheItem[]
}
// 订阅分享统计
export interface SubscribeShareStatistics {
// 分享人
share_user?: string
// 分享数量
share_count?: number
// 总复用人次
total_reuse_count?: number
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -3,7 +3,6 @@ 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 { useDisplay } from 'vuetify'
import { storageIconDict } from '@/api/constants'
// 输入参数
@@ -29,12 +28,6 @@ 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',
@@ -136,6 +129,12 @@ const sort = ref('name')
// 是否显示目录树
const showDirTree = ref(false)
// 拖动分隔条相关
const navigatorWidth = ref(280) // 初始宽度
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartWidth = ref(0)
// 计算属性
const storagesArray = computed(() => {
return props.storages?.map(item => ({
@@ -181,19 +180,57 @@ 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)'
})
// 阻止选择事件
function preventSelect(event: Event) {
event.preventDefault()
return false
}
// 文件列表大小限制
const fileListStyle = computed(() => {
return appMode
? 'height: calc(100vh - 14rem - env(safe-area-inset-bottom) - 7rem)'
: 'height: calc(100vh - 14rem - env(safe-area-inset-bottom)'
})
// 拖动分隔条相关方法
function startDrag(event: MouseEvent) {
event.preventDefault() // 阻止默认行为
event.stopPropagation() // 阻止事件冒泡
isDragging.value = true
dragStartX.value = event.clientX
dragStartWidth.value = navigatorWidth.value
document.addEventListener('mousemove', handleDrag, { passive: false })
document.addEventListener('mouseup', stopDrag, { passive: false })
document.addEventListener('selectstart', preventSelect) // 阻止选择开始
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
;(document.body.style as any).webkitUserSelect = 'none' // Safari兼容
;(document.body.style as any).mozUserSelect = 'none' // Firefox兼容
}
function handleDrag(event: MouseEvent) {
if (!isDragging.value) return
event.preventDefault() // 阻止默认行为
const deltaX = event.clientX - dragStartX.value
const newWidth = dragStartWidth.value + deltaX
// 设置最小和最大宽度限制
const minWidth = 200
const maxWidth = window.innerWidth * 0.6
navigatorWidth.value = Math.max(minWidth, Math.min(maxWidth, newWidth))
}
function stopDrag() {
isDragging.value = false
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('selectstart', preventSelect)
document.body.style.cursor = ''
document.body.style.userSelect = ''
;(document.body.style as any).webkitUserSelect = ''
;(document.body.style as any).mozUserSelect = ''
}
</script>
<template>
@@ -211,7 +248,7 @@ const fileListStyle = computed(() => {
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<div class="flex" :style="scrollStyle">
<div class="flex">
<FileNavigator
v-if="showDirTree"
:storage="activeStorage"
@@ -219,8 +256,14 @@ const fileListStyle = computed(() => {
:items="fileListItems"
:endpoints="endpoints"
:axios="axios"
:style="{ width: `${navigatorWidth}px`, minWidth: `${navigatorWidth}px` }"
@navigate="pathChanged"
/>
<!-- 拖动分隔条 -->
<div v-if="showDirTree" class="divider" :class="{ 'divider-dragging': isDragging }" @mousedown="startDrag">
<div class="divider-line"></div>
<VIcon class="divider-icon" size="small">mdi-drag-vertical</VIcon>
</div>
<FileList
:item="item"
:storage="activeStorage"
@@ -229,8 +272,8 @@ const fileListStyle = computed(() => {
:axios="axios"
:refreshpending="refreshPending"
:sort="sort"
:listStyle="fileListStyle"
:showTree="showDirTree"
:style="{ flex: 1 }"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@@ -243,3 +286,64 @@ const fileListStyle = computed(() => {
</div>
</div>
</template>
<style scoped>
.divider {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
cursor: col-resize;
inline-size: 4px;
transition: background-color 0.2s ease;
user-select: none;
}
.divider:hover {
background-color: rgba(var(--v-theme-on-surface), 0.08);
}
.divider-dragging {
background-color: rgba(var(--v-theme-primary), 0.12) !important;
}
.divider-line {
background-color: rgba(var(--v-theme-outline), 0.3);
block-size: 100%;
inline-size: 1px;
transition: background-color 0.2s ease;
user-select: none;
}
.divider-dragging .divider-line {
background-color: rgb(var(--v-theme-primary)) !important;
}
.divider:hover .divider-line {
background-color: rgba(var(--v-theme-primary), 0.8);
}
.divider-icon {
position: absolute;
z-index: 1;
padding: 2px;
border-radius: 2px;
background-color: rgba(var(--v-theme-surface), 0.9);
color: rgba(var(--v-theme-on-surface-variant), 0.6);
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
}
.divider-dragging .divider-icon {
background-color: rgba(var(--v-theme-surface), 0.95);
color: rgb(var(--v-theme-primary));
opacity: 1;
}
.divider:hover .divider-icon {
color: rgba(var(--v-theme-primary), 0.9);
opacity: 1;
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import page404 from '@images/pages/404.svg'
// 国际化
const { t } = useI18n()
@@ -19,16 +20,7 @@ interface Props {
<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>
<div class="pulse-ring"></div>
<img :src="page404" alt="404" />
</div>
<!-- 标题 -->
@@ -57,8 +49,7 @@ interface Props {
justify-content: center;
inline-size: 100%;
min-block-size: 300px;
padding-block: 3rem;
padding-inline: 1rem;
padding-block-start: 3rem;
text-align: center;
}
@@ -68,109 +59,17 @@ interface Props {
display: flex;
align-items: center;
justify-content: center;
block-size: 100px;
inline-size: 100px;
margin-block: 0 2rem;
inline-size: 15rem;
margin-block: 0 1rem;
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;
font-size: 1.5rem;
font-weight: 500;
margin-block-end: 0.75rem;
text-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
}
@@ -181,69 +80,15 @@ interface Props {
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;
inline-size: 60px;
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;
font-size: 1rem;
margin-block-end: 1rem;
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

@@ -0,0 +1,201 @@
<script setup lang="ts">
import { usePWAInstall } from '@/composables/usePWAInstall'
import { useAuthStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
const { t, locale, messages } = useI18n()
const { isInstalled, showInstallPrompt, getInstallInstructions } = usePWAInstall()
const showBanner = ref(false)
const showInstructions = ref(false)
const dismissed = ref(false)
// 检查是否登录
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
// 检查当前是不是https环境
const isHttps = computed(() => {
return window.location.protocol === 'https:'
})
// 检查是否应该显示横幅
const shouldShowBanner = computed(() => {
return !isInstalled.value && !dismissed.value && !showInstructions.value && isLogin.value && isHttps.value
})
// 显示延迟(避免立即显示)
onMounted(() => {
setTimeout(() => {
// 检查本地存储,看用户是否已经关闭过提示
const dismissedTime = localStorage.getItem('pwa-install-dismissed')
if (dismissedTime) {
const dismissedDate = new Date(dismissedTime)
const now = new Date()
const daysDiff = (now.getTime() - dismissedDate.getTime()) / (1000 * 60 * 60 * 24)
// 如果距离上次关闭不到30天不显示
if (daysDiff < 30) {
dismissed.value = true
return
}
}
showBanner.value = true
}, 5000) // 5秒后显示
})
// 处理安装
const handleInstall = async () => {
const installed = await showInstallPrompt()
if (installed) {
showBanner.value = false
// 显示成功消息
useToast().success(t('pwa.installSuccess'))
} else {
// 如果用户拒绝,显示手动安装说明
showInstructions.value = true
}
}
// 关闭横幅
const dismissBanner = () => {
showBanner.value = false
dismissed.value = true
// 记录关闭时间
localStorage.setItem('pwa-install-dismissed', new Date().toISOString())
}
// 获取平台特定的安装说明
const instructions = computed(() => {
const rawInstructions = getInstallInstructions()
const platformKey = rawInstructions.platformKey
// 获取平台显示名称
const platformName = t(`pwa.platforms.${platformKey}`)
// 直接使用t函数获取安装步骤避免编译对象的问题
const steps = []
const maxSteps = 10 // 最大步骤数,防止无限循环
for (let i = 0; i < maxSteps; i++) {
try {
const stepKey = `pwa.installSteps.${platformKey}.${i}`
const stepText = t(stepKey)
// 如果返回的是键名本身,说明没有找到对应的翻译
if (stepText === stepKey) {
break
}
steps.push(stepText)
} catch (error) {
// 如果出现错误,说明没有更多步骤
break
}
}
return {
platform: platformName,
steps,
}
})
</script>
<template>
<!-- 安装横幅 -->
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-300"
enter-from-class="translate-y-full opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-300"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-full opacity-0"
>
<VCard v-if="shouldShowBanner && showBanner" class="pwa-install-banner">
<div class="banner-content">
<VIcon icon="mdi-cellphone-link" size="24" class="me-3" />
<div class="flex-grow-1">
<div class="font-weight-medium">{{ t('pwa.installApp') }}</div>
<div class="text-sm opacity-70">{{ t('pwa.installDescription') }}</div>
</div>
<VBtn color="primary" size="small" variant="flat" @click="handleInstall">
{{ t('pwa.install') }}
</VBtn>
<VBtn icon size="small" variant="text" @click="dismissBanner">
<VIcon icon="mdi-close" />
</VBtn>
</div>
</VCard>
</Transition>
</Teleport>
<!-- 手动安装说明对话框 -->
<VDialog v-model="showInstructions" max-width="500">
<VCard>
<VCardItem>
<VCardTitle class="d-flex align-center">
<VIcon icon="mdi-information-outline" class="me-2" />
{{ t('pwa.installGuide') }}
</VCardTitle>
</VCardItem>
<VCardText>
<div class="mb-4">
<div class="text-subtitle-1 mb-2">
{{ t('pwa.installInstructions', { platform: instructions.platform }) }}
</div>
<VList density="compact">
<VListItem
v-for="(step, index) in instructions.steps"
:key="index"
:prepend-icon="`mdi-numeric-${index + 1}-circle`"
>
<VListItemTitle>{{ step }}</VListItemTitle>
</VListItem>
</VList>
</div>
<VAlert type="info" variant="tonal" density="compact">
{{ t('pwa.installNote') }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" variant="text" @click="showInstructions = false">
{{ t('pwa.gotIt') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.pwa-install-banner {
position: fixed;
z-index: 1000;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 12px;
background: rgb(var(--v-theme-surface));
box-shadow: 0 4px 20px rgba(0, 0, 0, 10%);
inset-block-end: 5rem;
inset-inline: 20px;
}
.banner-content {
display: flex;
align-items: center;
padding: 16px;
gap: 8px;
}
@media (width >= 600px) {
.pwa-install-banner {
inset-inline: auto 20px;
max-inline-size: 400px;
}
}
</style>

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
@@ -16,8 +17,10 @@ function imageLoadHandler() {
}
// 跳转播放
function goPlay() {
if (props.media?.link) window.open(props.media?.link, '_blank')
async function goPlay() {
if (props.media?.link) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
}
}
// 计算图片地址
@@ -48,16 +51,18 @@ const getImgUrl = computed(() => {
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1
class="mb-1 text-white text-shadow font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ..."
<template #default>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
{{ props.media?.title }}
</h1>
<span class="text-shadow">{{ props.media?.subtitle }}</span>
</VCardText>
<h1
class="mb-1 text-white text-shadow font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ..."
>
{{ props.media?.title }}
</h1>
<span class="text-shadow text-sm">{{ props.media?.subtitle }}</span>
</VCardText>
</template>
</VImg>
</template>
<div class="w-full absolute bottom-0">

View File

@@ -1,10 +1,14 @@
<script lang="ts" setup>
import { CustomRule } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import filter_svg from '@images/svg/filter.svg'
import { cloneDeep } from 'lodash-es'
import { innerFilterRules } from '@/api/constants'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
@@ -83,6 +87,12 @@ function saveRuleInfo() {
emit('done')
}
// 验证规则ID输入
function validateRuleId() {
// 只允许英文和数字,不允许空格
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
}
// 按钮点击
function onClose() {
emit('close')
@@ -106,8 +116,20 @@ function onClose() {
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem">
<VCard :title="t('customRule.title', { id: props.rule.id })" class="rounded-t">
<VDialog
v-if="ruleInfoDialog"
v-model="ruleInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-filter-outline" class="me-2" />
</template>
<VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
@@ -121,6 +143,8 @@ function onClose() {
:hint="t('customRule.hint.ruleId')"
persistent-hint
active
prepend-inner-icon="mdi-identifier"
@input="validateRuleId"
/>
</VCol>
<VCol cols="12" md="6">
@@ -131,6 +155,7 @@ function onClose() {
:hint="t('customRule.hint.ruleName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12">
@@ -141,6 +166,7 @@ function onClose() {
:hint="t('customRule.hint.include')"
persistent-hint
active
prepend-inner-icon="mdi-plus-circle"
/>
</VCol>
<VCol cols="12">
@@ -151,6 +177,7 @@ function onClose() {
:hint="t('customRule.hint.exclude')"
persistent-hint
active
prepend-inner-icon="mdi-minus-circle"
/>
</VCol>
<VCol cols="6">
@@ -161,6 +188,7 @@ function onClose() {
:hint="t('customRule.hint.sizeRange')"
persistent-hint
active
prepend-inner-icon="mdi-harddisk"
/>
</VCol>
<VCol cols="6">
@@ -171,6 +199,7 @@ function onClose() {
:hint="t('customRule.hint.seeders')"
persistent-hint
active
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="6">
@@ -181,13 +210,14 @@ function onClose() {
:hint="t('customRule.hint.publishTime')"
persistent-hint
active
prepend-inner-icon="mdi-calendar-clock"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">{{
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">{{
t('customRule.action.confirm')
}}</VBtn>
</VCardActions>

View File

@@ -214,7 +214,7 @@ watch(
<VForm>
<VRow>
<VCol cols="6">
<VSelect
<VAutocomplete
v-model="props.directory.media_type"
variant="underlined"
:items="typeItems"
@@ -223,7 +223,7 @@ watch(
/>
</VCol>
<VCol cols="6">
<VSelect
<VAutocomplete
v-model="props.directory.media_category"
variant="underlined"
:items="getCategories"
@@ -231,7 +231,7 @@ watch(
/>
</VCol>
<VCol cols="4">
<VSelect
<VAutocomplete
v-model="props.directory.storage"
variant="underlined"
:items="resourceStorageOptions"
@@ -277,7 +277,7 @@ watch(
/>
</VCol>
<VCol cols="4">
<VSelect
<VAutocomplete
v-model="props.directory.library_storage"
variant="underlined"
:items="libraryStorageOptions"

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import { formatFileSize } from '@/@core/utils/formatters'
import { DownloaderConf } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import type { DownloaderInfo } from '@/api/types'
import qbittorrent_image from '@images/logos/qbittorrent.png'
import transmission_image from '@images/logos/transmission.png'
@@ -10,9 +10,15 @@ import custom_image from '@images/logos/downloader.png'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { downloaderDict } from '@/api/constants'
import { useDisplay } from 'vuetify'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
const { useConditionalDataRefresh } = useBackgroundOptimization()
// 定义输入
const props = defineProps({
@@ -39,9 +45,6 @@ const emit = defineEmits(['close', 'done', 'change'])
// 提示框
const $toast = useToast()
// timeout定时器
let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 上传速率
const upload_rate = ref(0)
@@ -60,9 +63,15 @@ const downloaderInfo = ref<DownloaderConf>({
config: {},
})
// 下载器是否应该刷新数据的计算属性
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
// 调用API查询下载器数据
async function loadDownloaderInfo() {
if (!props.allowRefresh) {
if (!shouldRefresh.value) {
// 当下载器被禁用时,重置速率数据
upload_rate.value = 0
download_rate.value = 0
return
}
try {
@@ -75,11 +84,6 @@ async function loadDownloaderInfo() {
if (res) {
upload_rate.value = res.upload_speed
download_rate.value = res.download_speed
// 定时查询
clearTimeout(timeoutTimer)
if (props.downloader.enabled) {
timeoutTimer = setTimeout(loadDownloaderInfo, 3000)
}
}
} catch (e) {
console.log(e)
@@ -137,14 +141,17 @@ function onClose() {
emit('close')
}
onMounted(async () => {
if (props.downloader.enabled) {
await loadDownloaderInfo()
}
})
// 使用条件性数据刷新定时器(只在下载器启用时运行)
const { stop: stopRefresh } = useConditionalDataRefresh(
`downloader-${props.downloader.name}`,
loadDownloaderInfo,
shouldRefresh, // 响应式条件只有当allowRefresh为true且downloader启用时才运行
3000, // 3秒间隔
true, // 立即执行一次
)
onUnmounted(() => {
if (timeoutTimer) clearTimeout(timeoutTimer)
stopRefresh()
})
</script>
<template>
@@ -183,13 +190,27 @@ onUnmounted(() => {
</div>
</div>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
</div>
</VCardText>
</VCard>
</VHover>
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.downloader.name} - ${t('downloader.title')}`">
<VDialog
v-if="downloaderInfoDialog"
v-model="downloaderInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-download" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
@@ -215,6 +236,7 @@ onUnmounted(() => {
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -225,6 +247,7 @@ onUnmounted(() => {
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
@@ -234,6 +257,7 @@ onUnmounted(() => {
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
@@ -244,6 +268,7 @@ onUnmounted(() => {
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
@@ -292,6 +317,7 @@ onUnmounted(() => {
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -302,6 +328,7 @@ onUnmounted(() => {
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
@@ -311,6 +338,7 @@ onUnmounted(() => {
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
@@ -321,6 +349,7 @@ onUnmounted(() => {
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
@@ -332,6 +361,7 @@ onUnmounted(() => {
:hint="t('downloader.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
@@ -341,13 +371,14 @@ onUnmounted(() => {
:hint="t('downloader.nameRequired')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -6,6 +6,7 @@ import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
const props = defineProps({
info: Object as PropType<DownloadingInfo>,
downloaderName: String,
})
// 是否显示卡片
@@ -42,16 +43,15 @@ function imageLoadHandler() {
imageLoaded.value = true
}
// 计算文本类
function getTextClass() {
return imageLoaded.value ? 'text-white' : ''
}
// 下载状态控制
async function toggleDownload() {
const operation = isDownloading.value ? 'stop' : 'start'
try {
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`)
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`, {
params: {
name: props.downloaderName,
},
})
if (result.success) isDownloading.value = !isDownloading.value
} catch (error) {
@@ -62,7 +62,7 @@ async function toggleDownload() {
// 删除下截
async function deleteDownload() {
try {
await api.delete(`download/${props.info?.hash}`)
await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName } })
cardState.value = false
} catch (error) {
console.error(error)
@@ -71,35 +71,52 @@ async function deleteDownload() {
</script>
<template>
<VCard v-if="cardState" :key="props.info?.hash">
<VCard v-if="cardState" :key="props.info?.hash" class="flex flex-col h-full" min-height="150">
<template #image>
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover class="brightness-50" @load="imageLoadHandler" />
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none downloading-card-background"></div>
</template>
</VImg>
</template>
<VCardTitle class="break-words whitespace-normal" :class="getTextClass()">
{{ props.info?.media.title || props.info?.name }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
</VCardTitle>
<div>
<VCardTitle class="break-words whitespace-normal text-white">
{{ props.info?.media.title || props.info?.name }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
</VCardTitle>
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()">
{{ props.info?.title }}
</VCardSubtitle>
<VCardSubtitle class="break-words whitespace-normal text-white">
{{ props.info?.title }}
</VCardSubtitle>
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()">
{{ getSpeedText() }}
</VCardText>
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
{{ getSpeedText() }}
</VCardText>
<VCardText v-if="getPercentage() > 0" :class="getTextClass()">
<VProgressLinear :model-value="getPercentage()" />
</VCardText>
<VCardText v-if="getPercentage() > 0" class="text-white">
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
</VCardText>
<VCardActions class="justify-space-between">
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
<VCardActions class="justify-space-between">
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
</div>
</VCard>
</template>
<style lang="scss" scoped>
.downloading-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
</style>

View File

@@ -56,7 +56,7 @@ onMounted(() => {
<VCardTitle>{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
<VRow>
<VCol>
<VSelect
<VAutocomplete
v-model="props.rules"
variant="underlined"
:items="selectFilterOptions"

View File

@@ -3,11 +3,15 @@ import draggable from 'vuedraggable'
import { copyToClipboard } from '@/@core/utils/navigator'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
import filter_group_svg from '@images/svg/filter-group.svg'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
@@ -219,8 +223,14 @@ function onClose() {
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem">
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`" class="rounded-t">
<VDialog
v-if="groupInfoDialog"
v-model="groupInfoDialog"
scrollable
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
<VDialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
@@ -233,26 +243,29 @@ function onClose() {
:hint="t('filterRule.groupName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
<VAutocomplete
v-model="groupInfo.media_type"
:label="t('filterRule.mediaType')"
:items="mediaTypeItems"
:hint="t('filterRule.mediaType')"
persistent-hint
active
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
<VAutocomplete
v-model="groupInfo.category"
:items="getCategories"
:label="t('filterRule.category')"
:hint="t('filterRule.category')"
persistent-hint
active
prepend-inner-icon="mdi-folder-open"
/>
</VCol>
</VRow>
@@ -280,17 +293,17 @@ function onClose() {
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" variant="tonal" @click="addFilterCard">
<VBtn color="primary" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" variant="tonal" @click="importRules('priority')">
<VBtn color="success" @click="importRules('priority')">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" variant="tonal" @click="shareRules">
<VBtn color="info" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -4,6 +4,7 @@ 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'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
@@ -36,16 +37,18 @@ function imageErrorHandler() {
// 默认图片
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
if (props.media?.server_type === 'plex') return plex
else if (props.media?.server_type === 'emby') return emby
else if (props.media?.server_type === 'jellyfin') return jellyfin
else if (props.media?.server_type === 'trimemedia') return trimemedia
else return plex
}
// 跳转播放
function goPlay() {
if (props.media?.link) window.open(props.media?.link, '_blank')
async function goPlay() {
if (props.media?.link) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
}
}
// 生成图片代理路径
@@ -69,20 +72,18 @@ async function drawImages(imageList: string[]) {
if (!canvas) return getDefaultImage()
// 画布参数
const POSTER_WIDTH = (canvas.width - 32) / 4
const POSTER_HEIGHT = canvas.height * 0.75 - 8
const MARGIN_WIDTH = 4
const MARGIN_HEIGHT = 4
const REFLECTION_HEIGHT = POSTER_HEIGHT / 2
const REFLECTION_SHOW_HEIGHT = canvas.height / 4
const POSTER_WIDTH = (canvas.width - 40) / 4 // 左右边框8px + 3个间隔24px = 40px
const POSTER_HEIGHT = 256 // 上方海报高256
const MARGIN_WIDTH = 8 // 左右间隔为8
const MARGIN_HEIGHT = 4 // 海报和倒影之间的间隔为4
const REFLECTION_HEIGHT = canvas.height - POSTER_HEIGHT - MARGIN_HEIGHT // 下方倒影使用剩余全部高度
// 获取画布上下文
const ctx = canvas.getContext('2d')
if (!ctx) return getDefaultImage()
// 设置背景色为黑色
ctx.fillStyle = '#000000'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 设置背景色为透明
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 绘制图片
async function drawImageWithReflection(imgSrc: string, index: number) {
@@ -101,12 +102,12 @@ async function drawImages(imageList: string[]) {
} catch (error) {
console.error(error)
ctx.fillStyle = '#e5e7eb'
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), MARGIN_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT)
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), 0, POSTER_WIDTH, POSTER_HEIGHT)
return
}
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
const y = MARGIN_HEIGHT
const y = 0 // 海报紧贴顶部
ctx.drawImage(img, x, y, POSTER_WIDTH, POSTER_HEIGHT)
@@ -120,17 +121,18 @@ async function drawImages(imageList: string[]) {
img.width,
img.height,
x,
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
0,
POSTER_WIDTH,
REFLECTION_HEIGHT,
)
const gradient = ctx.createLinearGradient(0, REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT, 0, REFLECTION_HEIGHT)
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height - (POSTER_HEIGHT + MARGIN_HEIGHT))
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.7)')
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = gradient
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_SHOW_HEIGHT)
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)
ctx.restore()
}
@@ -163,20 +165,22 @@ onMounted(async () => {
@click="goPlay"
>
<template #image>
<canvas ref="canvasRef" class="w-full h-full hidden" />
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.name }}
</h1>
</VCardText>
<template #default>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.name }}
</h1>
</VCardText>
</template>
</VImg>
</template>
</VCard>

View File

@@ -4,17 +4,18 @@ 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 { useToast } from 'vue-toastification'
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 router from '@/router'
import { useUserStore, useGlobalSettingsStore } from '@/stores'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants'
import { hasPermission } from '@/utils/permission'
// 国际化
const { t } = useI18n()
@@ -27,7 +28,9 @@ const props = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 用户 Store
const userStore = useUserStore()
@@ -231,9 +234,6 @@ async function handleCheckSubscribe() {
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
@@ -242,7 +242,6 @@ async function handleCheckExists() {
season: props.media?.season,
mtype: props.media?.type,
},
signal,
})
if (result.success) isExists.value = true
@@ -254,16 +253,13 @@ async function handleCheckExists() {
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) {
try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
// AbortController 现在由全局请求优化器自动管理
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season,
title: props.media?.title,
},
signal,
})
return result.id || null
@@ -472,17 +468,29 @@ onBeforeUnmount(() => {
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
>
<span class="font-bold">{{ props.media?.year }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
<span class="font-semibold text-sm">{{ props.media?.year }}</span>
<h1 class="media-card-title font-bold mb-2 text-white line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
<p class="media-card-overview line-clamp-3 overflow-hidden text-ellipsis ...">
{{ props.media?.overview }}
</p>
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
<div v-else class="flex align-center justify-between">
<IconBtn icon="mdi-magnify" color="white" @click.stop="clickSearch" />
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
<IconBtn
v-if="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
icon="mdi-magnify"
color="white"
size="small"
@click.stop="clickSearch"
/>
<VSpacer />
<IconBtn
icon="mdi-heart"
:color="isSubscribed ? 'error' : 'white'"
size="small"
@click.stop="handleSubscribe"
/>
</div>
</VCardText>
<!-- 类型角标 -->
@@ -548,3 +556,14 @@ onBeforeUnmount(() => {
@close="chooseSiteDialog = false"
/>
</template>
<style scoped>
.media-card-title {
font-size: 1.125rem;
line-height: 1.25rem;
}
.media-card-overview {
font-size: 0.875rem;
line-height: 1rem;
}
</style>

View File

@@ -47,10 +47,12 @@ function openTmdbPage(type: string, tmdbId: number) {
</div>
<div class="flex-grow">
<VCardItem class="pb-1">
<VCardTitle class="text-center text-md-left">
<div class="text-center text-md-left text-h6 font-weight-bold line-clamp-2 overflow-hidden text-ellipsis">
{{ context?.media_info?.title || context?.meta_info?.name }}
{{ context?.meta_info?.season_episode }}
</VCardTitle>
<span v-if="context?.meta_info?.season_episode" class="text-sm text-medium-emphasis align-top">
{{ context?.meta_info?.season_episode }}
</span>
</div>
<VCardSubtitle class="text-center text-md-left">
{{ context?.media_info?.year || context?.meta_info?.year }}
</VCardSubtitle>
@@ -87,6 +89,9 @@ function openTmdbPage(type: string, tmdbId: number) {
{{ context?.media_info?.tmdb_id }}
</VChip>
<!-- meta_info -->
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
{{ context?.meta_info?.web_source }}
</VChip>
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.edition }}
</VChip>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import emby_image from '@images/logos/emby.png'
import jellyfin_image from '@images/logos/jellyfin.png'
import plex_image from '@images/logos/plex.png'
@@ -10,6 +10,10 @@ import api from '@/api'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { mediaServerDict } from '@/api/constants'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
@@ -196,11 +200,25 @@ onMounted(() => {
<span class="me-2 mb-1">自定义媒体服务器</span>
</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.mediaserver.name} - ${t('common.config')}`" class="rounded-t">
<VDialog
v-if="mediaServerInfoDialog"
v-model="mediaServerInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-cog" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
@@ -219,6 +237,7 @@ onMounted(() => {
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -229,6 +248,7 @@ onMounted(() => {
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
@@ -239,6 +259,7 @@ onMounted(() => {
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
@@ -248,10 +269,11 @@ onMounted(() => {
:hint="t('mediaserver.embyApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VSelect
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
@@ -262,6 +284,7 @@ onMounted(() => {
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
@@ -275,6 +298,7 @@ onMounted(() => {
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -285,6 +309,7 @@ onMounted(() => {
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
@@ -295,6 +320,7 @@ onMounted(() => {
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
@@ -304,10 +330,11 @@ onMounted(() => {
:hint="t('mediaserver.jellyfinApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VSelect
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
@@ -318,6 +345,7 @@ onMounted(() => {
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
@@ -331,6 +359,7 @@ onMounted(() => {
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -341,6 +370,7 @@ onMounted(() => {
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
@@ -351,10 +381,16 @@ onMounted(() => {
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="mediaServerInfo.config.username" :label="t('mediaserver.username')" active />
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
@@ -362,10 +398,11 @@ onMounted(() => {
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VSelect
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
@@ -376,6 +413,7 @@ onMounted(() => {
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
@@ -389,6 +427,7 @@ onMounted(() => {
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -399,6 +438,7 @@ onMounted(() => {
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
@@ -409,6 +449,7 @@ onMounted(() => {
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
@@ -418,25 +459,11 @@ onMounted(() => {
:hint="t('mediaserver.plexTokenHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
<VCol cols="12">
<VSelect
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
@@ -447,6 +474,7 @@ onMounted(() => {
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
@@ -458,16 +486,22 @@ onMounted(() => {
:label="t('mediaserver.type')"
:hint="t('mediaserver.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField :label="t('common.name')" :hint="t('mediaserver.nameRequired')" persistent-hint />
<VTextField
:label="t('common.name')"
:hint="t('mediaserver.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -10,6 +10,9 @@ const props = defineProps({
height: String,
})
// 定义事件
const emit = defineEmits(['imageload'])
// 图片是否加载完成
const isImageLoaded = ref(false)
@@ -19,6 +22,7 @@ const imageLoadError = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
emit('imageload')
}
// 链接打开新窗口
@@ -55,7 +59,14 @@ function replaceNewLine(value: string) {
position="top"
@load="imageLoaded"
@error="imageLoadError = true"
/>
min-height="10rem"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div
v-if="

View File

@@ -6,9 +6,14 @@ import vocechat_image from '@images/logos/vocechat.png'
import synologychat_image from '@images/logos/synologychat.png'
import slack_image from '@images/logos/slack.webp'
import chrome_image from '@images/logos/chrome.png'
import { useToast } from 'vue-toast-notification'
import custom_image from '@images/logos/notification.png'
import { useToast } from 'vue-toastification'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
const { t } = useI18n()
@@ -51,6 +56,7 @@ const notificationTypeNames: { [key: string]: string } = {
synologychat: t('notification.synologychat.name'),
slack: t('notification.slack.name'),
webpush: t('notification.webpush.name'),
custom: t('setting.notification.custom'),
}
// 消息类型下拉字典
@@ -105,7 +111,7 @@ const getIcon = computed(() => {
case 'webpush':
return chrome_image
default:
return wechat_image
return custom_image
}
})
@@ -131,12 +137,26 @@ function onClose() {
</div>
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
<VImg :src="getIcon" cover class="mt-7 me-1" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.notification.name} - ${t('notification.config')}`" class="rounded-t">
<VDialogCloseBtn v-model="notificationInfoDialog" />
<VDialog
v-if="notificationInfoDialog"
v-model="notificationInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-cog" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.notification.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="notificationInfoDialog = false" />
<VDivider />
<VCardText>
<VForm>
@@ -145,7 +165,7 @@ function onClose() {
<VSwitch v-model="notificationInfo.enabled" :label="t('notification.enabled')" />
</VCol>
<VCol cols="12">
<VSelect
<VAutocomplete
v-model="notificationInfo.switchs"
:items="notificationTypes"
:label="t('notification.type')"
@@ -154,6 +174,7 @@ function onClose() {
clearable
chips
persistent-hint
prepend-inner-icon="mdi-bell-outline"
/>
</VCol>
</VRow>
@@ -165,6 +186,7 @@ function onClose() {
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -173,6 +195,7 @@ function onClose() {
:label="t('notification.wechat.corpId')"
:hint="t('notification.wechat.corpIdHint')"
persistent-hint
prepend-inner-icon="mdi-domain"
/>
</VCol>
<VCol cols="12" md="6">
@@ -181,6 +204,7 @@ function onClose() {
:label="t('notification.wechat.appId')"
:hint="t('notification.wechat.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
@@ -189,6 +213,7 @@ function onClose() {
:label="t('notification.wechat.appSecret')"
:hint="t('notification.wechat.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
@@ -197,6 +222,7 @@ function onClose() {
:label="t('notification.wechat.proxy')"
:hint="t('notification.wechat.proxyHint')"
persistent-hint
prepend-inner-icon="mdi-server-network"
/>
</VCol>
<VCol cols="12" md="6">
@@ -205,6 +231,7 @@ function onClose() {
:label="t('notification.wechat.token')"
:hint="t('notification.wechat.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
@@ -213,6 +240,7 @@ function onClose() {
:label="t('notification.wechat.encodingAesKey')"
:hint="t('notification.wechat.encodingAesKeyHint')"
persistent-hint
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
@@ -222,10 +250,11 @@ function onClose() {
:placeholder="t('notification.wechat.adminsPlaceholder')"
:hint="t('notification.wechat.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'telegram'">
<VRow v-else-if="notificationInfo.type == 'telegram'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
@@ -233,6 +262,7 @@ function onClose() {
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -241,6 +271,7 @@ function onClose() {
:label="t('notification.telegram.token')"
:hint="t('notification.telegram.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
@@ -249,6 +280,7 @@ function onClose() {
:label="t('notification.telegram.chatId')"
:hint="t('notification.telegram.chatIdHint')"
persistent-hint
prepend-inner-icon="mdi-chat"
/>
</VCol>
<VCol cols="12" md="6">
@@ -258,6 +290,7 @@ function onClose() {
:placeholder="t('notification.telegram.usersPlaceholder')"
:hint="t('notification.telegram.usersHint')"
persistent-hint
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="12" md="6">
@@ -267,10 +300,21 @@ function onClose() {
:placeholder="t('notification.telegram.adminsPlaceholder')"
:hint="t('notification.telegram.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.API_URL"
:label="t('notification.telegram.apiUrl')"
:placeholder="t('notification.telegram.apiUrlPlaceholder')"
:hint="t('notification.telegram.apiUrlHint')"
persistent-hint
prepend-inner-icon="mdi-web"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'slack'">
<VRow v-else-if="notificationInfo.type == 'slack'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
@@ -278,6 +322,7 @@ function onClose() {
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -287,6 +332,7 @@ function onClose() {
:placeholder="t('notification.slack.oauthTokenPlaceholder')"
:hint="t('notification.slack.oauthTokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
@@ -296,6 +342,7 @@ function onClose() {
:placeholder="t('notification.slack.appTokenPlaceholder')"
:hint="t('notification.slack.appTokenHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
@@ -305,10 +352,11 @@ function onClose() {
:placeholder="t('notification.slack.channelPlaceholder')"
:hint="t('notification.slack.channelHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'synologychat'">
<VRow v-else-if="notificationInfo.type == 'synologychat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
@@ -316,6 +364,7 @@ function onClose() {
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -324,6 +373,7 @@ function onClose() {
:label="t('notification.synologychat.webhook')"
:hint="t('notification.synologychat.webhookHint')"
persistent-hint
prepend-inner-icon="mdi-webhook"
/>
</VCol>
<VCol cols="12" md="6">
@@ -332,10 +382,11 @@ function onClose() {
:label="t('notification.synologychat.token')"
:hint="t('notification.synologychat.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'vocechat'">
<VRow v-else-if="notificationInfo.type == 'vocechat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
@@ -343,6 +394,7 @@ function onClose() {
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -351,6 +403,7 @@ function onClose() {
:label="t('notification.vocechat.host')"
:hint="t('notification.vocechat.hostHint')"
persistent-hint
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
@@ -359,6 +412,7 @@ function onClose() {
:label="t('notification.vocechat.apiKey')"
:hint="t('notification.vocechat.apiKeyHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
@@ -368,10 +422,11 @@ function onClose() {
:placeholder="t('notification.vocechat.channelIdPlaceholder')"
:hint="t('notification.vocechat.channelIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'webpush'">
<VRow v-else-if="notificationInfo.type == 'webpush'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
@@ -379,6 +434,7 @@ function onClose() {
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
@@ -387,13 +443,35 @@ function onClose() {
:label="t('notification.webpush.username')"
:hint="t('notification.webpush.usernameHint')"
persistent-hint
prepend-inner-icon="mdi-account"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.type"
:label="t('notification.type')"
:hint="t('notification.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:hint="t('notification.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveNotificationInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
<VBtn @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -2,6 +2,7 @@
import personIcon from '@images/misc/person-icon.png'
import type { Person } from '@/api/types'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
const personProps = defineProps({
person: Object as PropType<Person>,
@@ -10,7 +11,9 @@ const personProps = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 当前人物
const personInfo = ref(personProps.person)
@@ -82,14 +85,12 @@ function goPersonDetail() {
}"
@click.stop="goPersonDetail"
>
<div
class="person-card relative transform-gpu cursor-pointer rounded transition duration-150 ease-in-out scale-100 ring-gray-700"
>
<div class="person-card relative cursor-pointer 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">
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
<VAvatar
size="120"
size="100"
:class="{
'ring-1 ring-gray-700': isImageLoaded,
}"
@@ -100,10 +101,7 @@ function goPersonDetail() {
<div class="w-full truncate text-center font-bold">
{{ getPersonName() }}
</div>
<div
class="overflow-hidden whitespace-normal text-center text-sm"
style="display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2"
>
<div class="overflow-hidden whitespace-normal text-center text-sm text-ellipsis line-clamp-2">
{{ getPersonCharacter() }}
</div>
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import VersionHistory from '../misc/VersionHistory.vue'
import api from '@/api'
import type { Plugin } from '@/api/types'
@@ -36,7 +36,17 @@ const $toast = useToast()
const progressDialog = ref(false)
// 进度框文本
const progressText = ref('正在安装插件...')
const progressText = ref('')
// 获取当前插件的标签
const pluginLabels = computed(() => {
if (!props.plugin?.plugin_label) return []
return props.plugin.plugin_label
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0)
})
// 图片是否加载完成
const isImageLoaded = ref(false)
@@ -96,7 +106,9 @@ const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.plugin_icon,
)}&cache=true`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
@@ -167,51 +179,77 @@ const dropdownItems = ref([
}"
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
class="flex-grow"
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
>
<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">
<VCardText class="px-2 pt-2 pb-0">
<VCardTitle
class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ..."
class="text-white px-2 pb-0 text-lg 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>
<span class="text-sm mt-1 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>
</VCardText>
<div class="relative flex flex-row items-start px-2 justify-between grow">
<div class="relative flex-1 min-w-0">
<div
class="text-white text-sm px-2 py-1 text-shadow overflow-hidden ..."
:class="{ 'line-clamp-3': !props.plugin?.plugin_label, 'line-clamp-2': props.plugin?.plugin_label }"
>
{{ props.plugin?.plugin_desc }}
</div>
<!-- 插件标签 -->
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2">
<VChip
v-for="tag in pluginLabels"
:key="tag"
size="x-small"
variant="tonal"
color="info"
class="me-1 mb-1"
tile
>
{{ tag }}
</VChip>
</div>
</div>
<div class="relative flex-shrink-0 self-center pb-3">
<VAvatar size="48">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</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">
<VCardText
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
>
<div class="flex flex-nowrap items-center w-full pe-10">
<div class="flex flex-nowrap max-w-40 items-center align-middle">
<VIcon icon="mdi-github" class="me-1" />
<a
class="overflow-hidden text-ellipsis whitespace-nowrap"
:href="props.plugin?.author_url"
target="_blank"
@click.stop
>
{{ props.plugin?.plugin_author }}
</a>
</div>
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
<VIcon size="small" icon="mdi-download" />
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
</div>
</div>
<div class="absolute bottom-0 right-0">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VIcon size="small" 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">

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import api from '@/api'
import type { Plugin } from '@/api/types'
import { isNullOrEmptyObject } from '@core/utils'
@@ -10,7 +10,12 @@ import VersionHistory from '@/components/misc/VersionHistory.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
import LoggingView from '@/views/system/LoggingView.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
@@ -54,6 +59,9 @@ const progressDialog = ref(false)
// 插件数据页面
const pluginInfoDialog = ref(false)
// 实时日志弹窗
const loggingDialog = ref(false)
// 进度框文本
const progressText = ref('正在更新插件...')
@@ -69,6 +77,18 @@ const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 插件分身对话框
const pluginCloneDialog = ref(false)
// 插件分身表单
const cloneForm = ref({
suffix: '',
name: '',
description: '',
version: '',
icon: '',
})
// 监听动作标识如为true则打开详情
watch(
() => props.action,
@@ -120,7 +140,12 @@ async function uninstallPlugin() {
// 通知父组件刷新
emit('remove')
} else {
$toast.error(t('plugin.uninstallFailed', { name: props.plugin?.plugin_name, message: result.message }))
$toast.error(
t('plugin.uninstallFailed', {
name: props.plugin?.plugin_name,
message: result.message,
}),
)
}
} catch (error) {
console.error(error)
@@ -145,7 +170,9 @@ const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.plugin_icon,
)}&cache=true`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
@@ -155,7 +182,7 @@ const authorPath: Ref<string> = computed(() => {
// 网络图片则使用代理后返回
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.author_url + '.png',
)}`
)}&cache=true`
})
// 重置插件
@@ -174,7 +201,12 @@ async function resetPlugin() {
// 通知父组件刷新
emit('save')
} else {
$toast.error(t('plugin.resetFailed', { name: props.plugin?.plugin_name, message: result.message }))
$toast.error(
t('plugin.resetFailed', {
name: props.plugin?.plugin_name,
message: result.message,
}),
)
}
} catch (error) {
console.error(error)
@@ -205,7 +237,12 @@ async function updatePlugin() {
// 通知父组件刷新
emit('save')
} else {
$toast.error(t('plugin.updateFailed', { name: props.plugin?.plugin_name, message: result.message }))
$toast.error(
t('plugin.updateFailed', {
name: props.plugin?.plugin_name,
message: result.message,
}),
)
}
} catch (error) {
console.error(error)
@@ -237,6 +274,54 @@ function configDone() {
emit('save')
}
// 显示插件分身对话框
function showPluginClone() {
cloneForm.value = {
suffix: '',
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
version: props.plugin?.plugin_version || '1.0',
icon: props.plugin?.plugin_icon || '',
}
pluginCloneDialog.value = true
}
// 执行插件分身
async function executePluginClone() {
if (!cloneForm.value.suffix.trim()) {
$toast.error(t('plugin.suffixRequired'))
return
}
try {
progressDialog.value = true
progressText.value = t('plugin.cloning', { name: props.plugin?.plugin_name })
const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {
suffix: cloneForm.value.suffix.trim(),
name: cloneForm.value.name.trim(),
description: cloneForm.value.description.trim(),
version: cloneForm.value.version.trim(),
icon: cloneForm.value.icon.trim(),
})
progressDialog.value = false
if (result.success) {
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.value.name }))
pluginCloneDialog.value = false
// 通知父组件刷新
emit('remove')
} else {
$toast.error(t('plugin.cloneFailed', { message: result.message }))
}
} catch (error) {
progressDialog.value = false
$toast.error(t('plugin.cloneFailedGeneral'))
console.error(error)
}
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -257,6 +342,16 @@ const dropdownItems = ref([
click: showPluginConfig,
},
},
{
title: t('plugin.clone'),
value: 8,
show: true,
props: {
prependIcon: 'mdi-content-copy',
color: 'info',
click: showPluginClone,
},
},
{
title: t('plugin.update'),
value: 3,
@@ -294,7 +389,7 @@ const dropdownItems = ref([
props: {
prependIcon: 'mdi-file-document-outline',
click: () => {
openLoggerWindow()
loggingDialog.value = true
},
},
},
@@ -328,7 +423,7 @@ watch(
</script>
<template>
<div>
<div class="h-full">
<!-- 插件卡片 -->
<VHover>
<template #default="hover">
@@ -344,50 +439,63 @@ watch(
}"
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
class="flex-grow"
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
/>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
<VBadge v-if="props.plugin?.state" dot inline color="success" />
<VCardText class="px-2 pt-2 pb-0">
<VCardTitle
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
>
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
{{ props.plugin?.plugin_name }}
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
</VCardTitle>
<VCardText class="px-2 py-0 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center cursor-move">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</VCardText>
<div class="relative flex flex-row items-start px-2 justify-between grow">
<div class="relative flex-1 min-w-0">
<div class="px-2 py-1 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</div>
</div>
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
<VAvatar size="48">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span class="author-info">
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
<VIcon v-if="!isAvatarLoaded" icon="mdi-github" class="me-1" />
</VImg>
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
<div class="me-n3 absolute bottom-1 right-3">
<VCardText
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
>
<div class="flex flex-nowrap items-center w-full pe-10">
<div class="flex flex-nowrap max-w-40 items-center align-middle">
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
<template #default>
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
</template>
</VImg>
<a
:href="props.plugin?.author_url"
target="_blank"
@click.stop
class="overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ props.plugin?.plugin_author }}
</a>
</div>
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
<VIcon size="small" icon="mdi-download" />
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
</span>
</div>
<div class="absolute bottom-0 right-0">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
@@ -439,7 +547,7 @@ watch(
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" max-height="85vh" scrollable>
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
@@ -455,6 +563,144 @@ watch(
</VCardItem>
</VCard>
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="60rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('plugin.logTitle') }}
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</VDialog>
<!-- 插件分身对话框 -->
<VDialog
v-if="pluginCloneDialog"
v-model="pluginCloneDialog"
width="600"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-content-copy" class="me-2" />
</template>
<VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>
<VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="pluginCloneDialog = false" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.suffix"
:label="t('plugin.suffix') + ' *'"
:placeholder="t('plugin.suffixPlaceholder')"
:hint="t('plugin.suffixHint')"
persistent-hint
:rules="[
v => !!v || t('plugin.suffixRequired'),
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
v => v.length <= 20 || t('plugin.suffixLengthError'),
]"
required
prepend-inner-icon="mdi-tag"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.name"
:label="t('plugin.cloneName')"
:placeholder="t('plugin.cloneNamePlaceholder')"
:hint="t('plugin.cloneNameHint')"
persistent-hint
prepend-inner-icon="mdi-rename-box"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="cloneForm.description"
:label="t('plugin.cloneDescriptionLabel')"
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
:hint="t('plugin.cloneDescriptionHint')"
persistent-hint
prepend-inner-icon="mdi-text"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.version"
:label="t('plugin.cloneVersion')"
:placeholder="t('plugin.cloneVersionPlaceholder')"
:hint="t('plugin.cloneVersionHint')"
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.icon"
:label="t('plugin.cloneIcon')"
:placeholder="t('plugin.cloneIconPlaceholder')"
:hint="t('plugin.cloneIconHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
<!-- 重要提醒 -->
<VCol cols="12">
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
<div class="text-body-2">
<strong>{{ t('common.notice') }}</strong
>{{ t('plugin.cloneNotice') }}
</div>
</VAlert>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn
color="primary"
@click="executePluginClone"
prepend-icon="mdi-content-copy"
class="px-5"
:disabled="!cloneForm.suffix.trim()"
>
{{ t('plugin.createClone') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>
@@ -469,11 +715,6 @@ watch(
inset: 0;
}
.author-info {
display: flex;
align-items: center;
}
.author-avatar {
border-radius: 50%;
block-size: 24px;

View File

@@ -0,0 +1,663 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 文件夹配置接口
interface FolderConfig {
plugins?: string[]
order?: number
background?: string
icon?: string
color?: string
gradient?: string
showIcon?: boolean
}
// 输入参数
const props = defineProps({
folderName: String,
pluginCount: Number,
folderConfig: {
type: Object as PropType<FolderConfig>,
default: () => ({}),
},
width: String,
height: String,
})
// 定义触发的自定义事件
const emit = defineEmits(['open', 'delete', 'rename', 'update-config'])
// 多语言
const { t } = useI18n()
// 响应式显示
const display = useDisplay()
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 菜单显示状态
const menuVisible = ref(false)
// 重命名对话框
const renameDialog = ref(false)
// 设置对话框
const settingDialog = ref(false)
// 新名称
const newFolderName = ref('')
// 默认颜色
const defaultColor = '#2196F3'
// 默认图标
const defaultIcon = 'mdi-folder'
// 默认渐变
const defaultGradient =
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8s) 100%)'
// 文件夹设置
const folderSettings = ref<FolderConfig>({
background: '',
icon: defaultIcon,
color: defaultColor,
gradient: defaultGradient,
showIcon: true,
})
// 计算背景图片
const backgroundImage = computed(() => {
return props.folderConfig.background || folderSettings.value.background
})
// 预设图标选项
const iconOptions = [
'mdi-folder',
'mdi-folder-star',
'mdi-folder-heart',
'mdi-folder-cog',
'mdi-folder-music',
'mdi-folder-image',
'mdi-folder-video',
'mdi-folder-download',
'mdi-folder-network',
'mdi-folder-special',
]
// 预设颜色选项
const colorOptions = [
'#2196F3', // 蓝色
'#4CAF50', // 绿色
'#FF9800', // 橙色
'#9C27B0', // 紫色
'#F44336', // 红色
'#607D8B', // 蓝灰色
'#795548', // 棕色
'#E91E63', // 粉色
]
// 预设渐变选项
const gradientOptions = [
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
]
// 计算背景渐变
const backgroundGradient = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.gradient || settings.gradient || gradientOptions[0]
})
// 计算图标
const folderIcon = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.icon || settings.icon || defaultIcon
})
// 计算图标颜色
const iconColor = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.color || settings.color || defaultColor
})
// 计算是否显示图标
const shouldShowIcon = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.showIcon !== undefined ? config.showIcon : settings.showIcon !== undefined ? settings.showIcon : true
})
// 监听props变化更新本地设置
watch(
() => props.folderConfig,
newConfig => {
if (newConfig) {
folderSettings.value = {
...folderSettings.value,
...newConfig,
}
}
},
{ deep: true, immediate: true },
)
// 打开文件夹
function openFolder() {
emit('open', props.folderName)
}
// 重命名文件夹
function showRenameDialog() {
newFolderName.value = props.folderName || ''
renameDialog.value = true
}
// 确认重命名
async function confirmRename() {
if (!newFolderName.value.trim()) {
$toast.error(t('folder.folderNameCannotBeEmpty'))
return
}
if (newFolderName.value === props.folderName) {
renameDialog.value = false
return
}
try {
emit('rename', props.folderName, newFolderName.value)
renameDialog.value = false
} catch (error) {
console.error(error)
}
}
// 删除文件夹
async function deleteFolder() {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('folder.confirmDeleteFolder', { folderName: props.folderName }),
})
if (!isConfirmed) return
try {
emit('delete', props.folderName)
} catch (error) {
console.error(error)
}
}
// 显示设置对话框
function showSettingDialog() {
folderSettings.value = {
background: props.folderConfig?.background || '',
icon: props.folderConfig?.icon || defaultIcon,
color: props.folderConfig?.color || defaultColor,
gradient: props.folderConfig?.gradient || gradientOptions[0],
showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,
}
settingDialog.value = true
}
// 保存设置
function saveSettings() {
const config = {
...props.folderConfig,
...folderSettings.value,
}
emit('update-config', props.folderName, config)
settingDialog.value = false
$toast.success(t('folder.folderSettingsSaved'))
}
// 弹出菜单
const dropdownItems = ref([
{
title: t('folder.settingAppearance'),
value: 0,
show: true,
props: {
prependIcon: 'mdi-palette',
click: showSettingDialog,
},
},
{
title: t('folder.rename'),
value: 1,
show: true,
props: {
prependIcon: 'mdi-pencil',
click: showRenameDialog,
},
},
{
title: t('folder.deleteFolder'),
value: 2,
show: true,
props: {
prependIcon: 'mdi-delete',
color: 'error',
click: deleteFolder,
},
},
])
</script>
<template>
<div class="h-full">
<!-- 文件夹卡片 -->
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:ripple="false"
:width="props.width"
:height="props.height"
min-height="8.5rem"
@click="openFolder"
class="plugin-folder-card h-full"
:class="{
'plugin-folder-card--mobile': display.mobile,
'plugin-folder-card--hover': hover.isHovering,
}"
>
<template v-if="backgroundImage" #image>
<VImg :src="backgroundImage" cover position="top"> </VImg>
</template>
<!-- 背景遮罩当有背景图片时 -->
<div v-if="backgroundImage" class="plugin-folder-card__overlay" />
<!-- 背景渐变层 -->
<div v-else class="plugin-folder-card__bg" :style="{ background: backgroundGradient }" />
<!-- 卡片内容 -->
<div class="plugin-folder-card__content">
<!-- 主体内容 -->
<div class="plugin-folder-card__body" :class="{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }">
<!-- 文件夹图标 -->
<div v-if="shouldShowIcon" class="plugin-folder-card__icon-container">
<VIcon
:icon="folderIcon"
:size="display.mobile ? 56 : 72"
:color="iconColor"
:class="{ 'cursor-move': display.mdAndUp.value }"
/>
</div>
<!-- 文件夹信息 -->
<div
class="plugin-folder-card__info"
:class="{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
>
<!-- 文件夹名称 -->
<h3 class="plugin-folder-card__name">
{{ props.folderName }}
</h3>
<!-- 插件数量 -->
<p class="plugin-folder-card__count">{{ t('folder.pluginCount', { count: props.pluginCount }) }}</p>
</div>
</div>
<!-- 更多菜单按钮 - 右下角 -->
<div class="absolute top-0 right-0">
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<IconBtn v-bind="menuProps" @click.stop>
<VIcon size="small" icon="mdi-dots-vertical" class="text-white" />
</IconBtn>
</template>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" size="16" />
</template>
<VListItemTitle class="text-body-2">{{ item.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</div>
</div>
</VCard>
</template>
</VHover>
<!-- 重命名对话框 -->
<VDialog v-if="renameDialog" v-model="renameDialog" max-width="400">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="renameDialog = false" />
<VDivider />
<VCardText>
<VTextField
v-model="newFolderName"
:label="t('folder.folderName')"
variant="outlined"
autofocus
@keyup.enter="confirmRename"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 设置对话框 -->
<VDialog
v-if="settingDialog"
v-model="settingDialog"
max-width="600"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="settingDialog = false" />
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="mr-2" />
{{ t('folder.folderAppearanceSettings') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<!-- 显示图标开关 -->
<VCol cols="12">
<VSwitch
v-model="folderSettings.showIcon"
:label="t('folder.showFolderIcon')"
color="primary"
hide-details
/>
</VCol>
<!-- 图标选择 -->
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.icon') }}</VCardSubtitle>
<div class="icon-grid">
<VBtn
v-for="icon in iconOptions"
icon
:key="icon"
:variant="folderSettings.icon === icon ? 'tonal' : 'text'"
:color="folderSettings.icon === icon ? 'primary' : 'default'"
size="large"
class="ma-1"
@click="folderSettings.icon = icon"
>
<VIcon :icon="icon" size="24" />
</VBtn>
</div>
</VCol>
<!-- 颜色选择 -->
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.iconColor') }}</VCardSubtitle>
<div class="color-grid">
<VBtn
v-for="color in colorOptions"
:key="color"
:variant="folderSettings.color === color ? 'tonal' : 'text'"
:color="color"
size="large"
class="ma-1 color-btn"
:style="{ backgroundColor: color }"
@click="folderSettings.color = color"
>
<VIcon v-if="folderSettings.color === color" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<!-- 渐变背景选择 -->
<VCol cols="12">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.backgroundGradient') }}</VCardSubtitle>
<div class="gradient-grid">
<VBtn
v-for="(gradient, index) in gradientOptions"
:key="index"
:variant="folderSettings.gradient === gradient ? 'tonal' : 'text'"
class="ma-1 gradient-btn"
:style="{ background: gradient }"
size="large"
@click="folderSettings.gradient = gradient"
>
<VIcon v-if="folderSettings.gradient === gradient" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<!-- 自定义背景图片 -->
<VCol cols="12">
<VTextField
v-model="folderSettings.background"
:label="t('folder.customBackgroundImageURL')"
placeholder="https://example.com/image.jpg"
variant="outlined"
:hint="t('folder.customBackgroundImageHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>
<style lang="scss" scoped>
.plugin-folder-card {
position: relative;
overflow: hidden;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&--hover {
transform: translateY(-4px);
}
&__bg {
position: absolute;
z-index: 0;
inset: 0;
outline: none;
}
&__overlay {
position: absolute;
z-index: 1;
background: rgba(0, 0, 0, 60%);
inset: 0;
}
&__content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
padding: 16px;
block-size: 100%;
padding-block-end: 12px;
.plugin-folder-card--mobile & {
padding: 12px;
padding-block-end: 10px;
}
}
&__body {
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 16px;
padding-block: 0;
padding-inline: 8px;
.plugin-folder-card--mobile & {
gap: 12px;
padding-block: 0;
padding-inline: 4px;
}
&--no-icon {
align-items: flex-start;
justify-content: flex-start;
padding: 16px;
gap: 0;
.plugin-folder-card--mobile & {
padding: 12px;
gap: 0;
}
}
}
&__icon-container {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
&__info {
flex: 1;
min-block-size: 0;
text-align: start;
&--no-icon {
flex: none;
text-align: start;
}
}
&__name {
display: -webkit-box;
overflow: hidden;
margin: 0;
-webkit-box-orient: vertical;
color: white;
font-size: 1.1rem;
font-weight: 600;
-webkit-line-clamp: 1;
line-clamp: 1;
line-height: 1.3;
max-inline-size: none;
text-overflow: ellipsis;
text-shadow: 0 2px 4px rgba(0, 0, 0, 50%);
.plugin-folder-card--mobile & {
font-size: 1rem;
}
.plugin-folder-card__info--no-icon & {
font-size: 1.3rem;
font-weight: 700;
-webkit-line-clamp: 2;
line-clamp: 2;
margin-block-end: 4px;
.plugin-folder-card--mobile & {
font-size: 1.2rem;
}
}
}
&__count {
color: white;
font-size: 0.85rem;
margin-block: 2px 0;
margin-inline: 0;
opacity: 0.9;
text-shadow: 0 1px 2px rgba(0, 0, 0, 50%);
.plugin-folder-card--mobile & {
font-size: 0.8rem;
}
.plugin-folder-card__info--no-icon & {
font-size: 0.9rem;
margin-block-start: 0;
.plugin-folder-card--mobile & {
font-size: 0.85rem;
}
}
}
}
// 设置对话框样式
.icon-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
max-block-size: 200px;
overflow-y: auto;
}
.color-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
}
.gradient-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
max-block-size: 200px;
overflow-y: auto;
}
.color-btn {
border-radius: 8px !important;
block-size: 60px !important;
min-inline-size: 60px !important;
}
.gradient-btn {
border-radius: 8px !important;
block-size: 60px !important;
min-inline-size: 120px !important;
}
</style>

View File

@@ -0,0 +1,183 @@
<script lang="ts" setup>
import PluginCard from './PluginCard.vue'
import PluginFolderCard from './PluginFolderCard.vue'
interface MixedSortItem {
type: 'folder' | 'plugin'
id: string
data: any
order: number
}
interface Props {
item: MixedSortItem
pluginStatistics?: { [key: string]: number }
pluginActions?: { [key: string]: boolean }
showRemoveButton?: boolean
}
const props = withDefaults(defineProps<Props>(), {
pluginStatistics: () => ({}),
pluginActions: () => ({}),
showRemoveButton: false,
})
const emit = defineEmits<{
openFolder: [folderName: string]
deleteFolder: [folderName: string]
renameFolder: [oldName: string, newName: string]
updateFolderConfig: [folderName: string, config: any]
refreshData: []
actionDone: [pluginId: string]
removeFromFolder: [pluginId: string]
dropToFolder: [event: DragEvent, folderName: string]
}>()
// 拖拽事件处理
function handleDragOver(event: DragEvent) {
// 只有当拖拽的是插件时才允许放入文件夹
if (props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
event.dataTransfer!.dropEffect = 'move'
const target = event.currentTarget as HTMLElement
target.classList.add('drag-over')
}
}
function handleDragEnter(event: DragEvent) {
if (props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
}
}
function handleDragLeave(event: DragEvent) {
if (props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget as HTMLElement
target.classList.remove('drag-over')
}
}
function handleDropToFolder(event: DragEvent) {
if (props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget as HTMLElement
target.classList.remove('drag-over')
emit('dropToFolder', event, props.item.id)
}
}
</script>
<template>
<div class="mixed-sort-card-wrapper h-full">
<!-- 文件夹卡片 -->
<div
v-if="item.type === 'folder'"
class="drop-zone h-full"
:data-plugin-id="item.id"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDropToFolder"
>
<PluginFolderCard
:folder-name="item.data.name"
:plugin-count="item.data.pluginCount"
:folder-config="item.data.config"
@open="$emit('openFolder', item.id)"
@delete="$emit('deleteFolder', item.id)"
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
@update-config="(folderName, config) => $emit('updateFolderConfig', folderName, config)"
/>
</div>
<!-- 插件卡片 -->
<div v-else-if="item.type === 'plugin'" class="plugin-item-wrapper h-full" :data-plugin-id="item.id">
<PluginCard
:count="pluginStatistics[item.id] || 0"
:plugin="item.data"
:action="pluginActions[item.id] || false"
@remove="$emit('refreshData')"
@save="$emit('refreshData')"
@action-done="$emit('actionDone', item.id)"
/>
<!-- 移出文件夹按钮(仅在文件夹内显示) -->
<VBtn
v-if="showRemoveButton"
icon="mdi-folder-remove"
variant="text"
color="warning"
size="small"
class="remove-from-folder-btn"
@click="$emit('removeFromFolder', item.id)"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.mixed-sort-card-wrapper {
block-size: 100%;
inline-size: 100%;
// 确保拖拽时的边界清晰
&.sortable-chosen {
opacity: 0.5;
}
&.sortable-ghost {
border: 2px dashed #2196f3;
border-radius: 16px;
background: rgba(33, 150, 243, 10%);
opacity: 0.3;
}
}
// 拖拽相关样式
.drop-zone {
position: relative;
isolation: isolate; // 创建新的层叠上下文
transition: all 0.3s ease;
&.drag-over {
border: 2px dashed #2196f3;
border-radius: 16px;
box-shadow: 0 0 20px rgba(33, 150, 243, 50%);
transform: scale(1.02);
}
}
.plugin-item-wrapper {
position: relative;
isolation: isolate; // 创建新的层叠上下文
.remove-from-folder-btn {
position: absolute;
z-index: 10;
border-radius: 50%;
backdrop-filter: blur(4px);
background: rgba(255, 255, 255, 10%);
inset-block-start: 4px;
inset-inline-end: 4px;
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover .remove-from-folder-btn {
opacity: 1;
}
}
// 拖拽时的样式优化
.mixed-sort-card-wrapper.sortable-drag {
.remove-from-folder-btn {
display: none !important;
}
}
</style>

View File

@@ -2,6 +2,7 @@
import type { PropType } from 'vue'
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
@@ -31,8 +32,10 @@ const getImgUrl = computed(() => {
})
// 跳转播放
function goPlay(isHovering: boolean | null = false) {
if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
async function goPlay(isHovering: boolean | null = false) {
if (props.media?.link && isHovering) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
}
}
</script>
@@ -80,8 +83,8 @@ function goPlay(isHovering: boolean | null = false) {
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
@click.stop="goPlay(hover.isHovering)"
>
<span class="font-bold">{{ props.media?.subtitle }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
<span class="font-semibold text-sm">{{ props.media?.subtitle }}</span>
<h1 class="mb-1 text-white font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
</VCardText>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import noImage from '@images/logos/site.webp'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
@@ -11,7 +11,11 @@ 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'
import { useConfirm } from '@/composables/useConfirm'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
@@ -20,10 +24,11 @@ const { t } = useI18n()
const cardProps = defineProps({
site: Object as PropType<Site>,
data: Object as PropType<SiteUserData>,
stats: Object as PropType<SiteStatistic>,
})
// 定义触发的自定义事件
const emit = defineEmits(['update', 'remove'])
const emit = defineEmits(['update', 'remove', 'refresh-stats'])
// 确认框
const createConfirm = useConfirm()
@@ -52,9 +57,6 @@ const resourceDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 站点使用统计
const siteStats = ref<SiteStatistic>({})
// 查询站点图标
async function getSiteIcon() {
try {
@@ -80,16 +82,8 @@ async function testSite() {
testButtonText.value = t('site.testConnectivity')
testButtonDisable.value = false
getSiteStats()
} catch (error) {
console.error(error)
}
}
// 查询站点使用统计
async function getSiteStats() {
try {
siteStats.value = await api.get(`site/statistic/${cardProps.site?.domain}`)
// 测试完成后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
} catch (error) {
console.error(error)
}
@@ -136,16 +130,17 @@ async function deleteSiteInfo() {
// 根据站点状态显示不同的状态图标
const statColor = computed(() => {
if (isNullOrEmptyObject(siteStats.value)) {
if (!cardProps.stats || isNullOrEmptyObject(cardProps.stats)) {
return 'secondary'
}
if (siteStats.value?.lst_state == 1) {
if (cardProps.stats?.lst_state === 1) {
return 'error'
} else if (siteStats.value?.lst_state == 0) {
if (!siteStats.value?.seconds) return 'secondary'
if (siteStats.value?.seconds >= 5) return 'warning'
} else if (cardProps.stats?.lst_state === 0) {
if (!cardProps.stats?.seconds) return 'secondary'
if (cardProps.stats?.seconds >= 5) return 'warning'
return 'success'
}
return 'secondary'
})
// 数据百分比计算
@@ -181,19 +176,20 @@ function saveSite() {
// 更新站点Cookie UA后的回调
function onSiteCookieUpdated() {
siteCookieDialog.value = false
getSiteStats()
// Cookie更新后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
}
// 资源浏览弹窗关闭后的回调
function onSiteResourceDone() {
resourceDialog.value = false
getSiteStats()
// 资源操作完成后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
getSiteStats()
})
</script>
@@ -214,7 +210,7 @@ onMounted(() => {
elevation="0"
rounded="lg"
hover
@click="siteEditDialog = true"
@click="handleResourceBrowse"
>
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
@@ -224,7 +220,7 @@ onMounted(() => {
<!-- 顶部图标和站点名称 -->
<div class="flex items-center mb-1">
<!-- 站点图标 -->
<VAvatar tile rounded="lg" size="32" class="me-2 cursor-move">
<VAvatar tile rounded="lg" size="32" class="me-2" :class="{ 'cursor-move': display.mdAndUp.value }">
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>
<div class="w-full h-full">
@@ -293,21 +289,20 @@ onMounted(() => {
</div>
<!-- 右侧操作按钮区 -->
<VSheet
class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1 transform translate-x-full transition-transform duration-200"
>
<VSheet class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
<!-- 测试按钮 -->
<VBtn
icon
variant="text"
density="comfortable"
class="mb-1 relative w-10 h-10 min-w-10 flex items-center justify-center rounded-full"
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
:disabled="testButtonDisable"
@click.stop="testSite"
size="36"
>
<div class="relative flex items-center justify-center w-full h-full">
<div
class="w-[22px] h-[22px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
:class="statColor"
></div>
</div>
@@ -322,29 +317,29 @@ onMounted(() => {
</VBtn>
<!-- 用户数据按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUserData">
<VIcon icon="mdi-chart-bell-curve" size="small" />
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
<VIcon icon="mdi-chart-bell-curve" size="20" />
</VBtn>
<!-- 更新按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUpdate">
<VIcon icon="mdi-refresh" size="small" />
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
<VIcon icon="mdi-refresh" size="20" />
</VBtn>
<!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto">
<VIcon icon="mdi-dots-vertical" size="small" />
<VBtn icon variant="text" class="mt-auto" size="36">
<VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList>
<VListItem @click="handleResourceBrowse" base-color="info">
<VListItem @click="siteEditDialog = true" base-color="info">
<template #prepend>
<VIcon icon="mdi-web" size="small" />
<VIcon icon="mdi-file-edit-outline" size="20" />
</template>
<VListItemTitle>{{ t('site.browseResources') }}</VListItemTitle>
<VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>
</VListItem>
<VListItem @click="deleteSiteInfo">
<template #prepend>
<VIcon icon="mdi-delete-outline" size="small" color="error" />
<VIcon icon="mdi-delete-outline" size="20" color="error" />
</template>
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
</VListItem>
@@ -386,12 +381,6 @@ onMounted(() => {
</template>
<style scoped>
.site-card:hover {
.site-card-actions {
transform: translateX(0);
}
}
.site-status-indicator {
position: absolute;
z-index: 1;
@@ -430,15 +419,15 @@ onMounted(() => {
/* 上传下载条样式 */
.upload-bar {
animation: pulse-width 2s infinite;
background: linear-gradient(90deg, #4d79ff, #07f);
box-shadow: 0 0 4px rgba(0, 119, 255, 50%);
animation: pulse-width 2s infinite;
}
.download-bar {
animation: pulse-width 2s infinite;
background: linear-gradient(90deg, #42d392, #00b77e);
box-shadow: 0 0 4px rgba(0, 183, 126, 50%);
animation: pulse-width 2s infinite;
}
/* 测试状态点样式 */
@@ -446,22 +435,22 @@ onMounted(() => {
position: absolute;
z-index: 1;
border-radius: 50%;
block-size: 70%;
content: '';
height: 70%;
width: 70%;
top: 15%;
left: 15%;
inline-size: 70%;
inset-block-start: 15%;
inset-inline-start: 15%;
}
.pulse-dot::after {
position: absolute;
z-index: 2;
border-radius: 50%;
block-size: 100%;
content: '';
height: 100%;
width: 100%;
top: 0;
left: 0;
inline-size: 100%;
inset-block-start: 0;
inset-inline-start: 0;
}
.pulse-dot.error::before {
@@ -508,11 +497,11 @@ onMounted(() => {
.spinner-circle {
position: absolute;
border: 1px solid rgba(var(--v-theme-primary), 0.2);
border-top-color: rgba(var(--v-theme-primary), 1);
border-radius: 50%;
width: 100%;
height: 100%;
animation: spin 0.8s linear infinite;
block-size: 100%;
border-block-start-color: rgba(var(--v-theme-primary), 1);
inline-size: 100%;
}
/* 动画关键帧 */
@@ -522,6 +511,7 @@ onMounted(() => {
opacity: 0.85;
transform: scaleX(0.95);
}
50% {
opacity: 1;
transform: scaleX(1.05);
@@ -532,9 +522,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-error), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0);
}
@@ -544,9 +536,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-warning), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0);
}
@@ -556,9 +550,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-success), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0);
}
@@ -568,9 +564,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-secondary), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0);
}
@@ -580,6 +578,7 @@ onMounted(() => {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
@@ -589,8 +588,22 @@ onMounted(() => {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.site-card-actions {
opacity: 0;
transform: translateX(100%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
visibility: hidden;
}
.site-card:hover .site-card-actions {
opacity: 1;
transform: translateX(0);
visibility: visible;
}
</style>

View File

@@ -5,17 +5,22 @@ import storage_png from '@images/misc/storage.png'
import alipan_png from '@images/misc/alipan.webp'
import u115_png from '@images/misc/u115.png'
import rclone_png from '@images/misc/rclone.png'
import alist_png from '@images/misc/alist.svg'
import alist_png from '@images/misc/openlist.svg'
import custom_png from '@images/misc/database.png'
import smb_png from '@images/misc/smb.png'
import api from '@/api'
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
import { useToast } from 'vue-toast-notification'
import SmbConfigDialog from '../dialog/SmbConfigDialog.vue'
import { useToast } from 'vue-toastification'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { storageIconDict } from '@/api/constants'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
@@ -62,6 +67,8 @@ const u115AuthDialog = ref(false)
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// SMB配置对话框
const smbConfigDialog = ref(false)
// 自定义存储配置对话框
const customConfigDialog = ref(false)
@@ -80,6 +87,9 @@ function openStorageDialog() {
case 'alist':
aListConfigDialog.value = true
break
case 'smb':
smbConfigDialog.value = true
break
case 'local':
$toast.info(t('storage.noConfigNeeded'))
break
@@ -102,6 +112,8 @@ const getIcon = computed(() => {
return rclone_png
case 'alist':
return alist_png
case 'smb':
return smb_png
default:
return custom_png
}
@@ -140,6 +152,7 @@ function handleDone() {
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
smbConfigDialog.value = false
customConfigDialog.value = false
// 更新存储
storage_ref.value.name = customName.value
@@ -159,14 +172,14 @@ function onClose() {
<template>
<div>
<VCard variant="tonal" @click="openStorageDialog">
<VDialogCloseBtn v-if="!storageIconDict[storage.type]" @click="onClose" />
<VDialogCloseBtn @click="onClose" class="absolute top-1 right-1" />
<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)">{{ t('storage.notConfigured') }}</div>
</div>
<VImg :src="getIcon" cover class="mt-7" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-8" 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" />
@@ -200,9 +213,25 @@ function onClose() {
@close="aListConfigDialog = false"
@done="handleDone"
/>
<VDialog v-if="customConfigDialog" v-model="customConfigDialog" scrollable max-width="30rem">
<SmbConfigDialog
v-if="smbConfigDialog"
v-model="smbConfigDialog"
:conf="props.storage.config || {}"
@close="smbConfigDialog = false"
@done="handleDone"
/>
<VDialog
v-if="customConfigDialog"
v-model="customConfigDialog"
scrollable
max-width="30rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-cog" />
</template>
<VCardTitle>{{ t('storage.custom') }}</VCardTitle>
<VDialogCloseBtn v-model="customConfigDialog" />
</VCardItem>
@@ -215,16 +244,21 @@ function onClose() {
:label="t('storage.type')"
:hint="t('storage.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-database"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="customName" :label="t('storage.name')" persistent-hint active />
<VTextField
v-model="customName"
:label="t('storage.name')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="handleDone" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'
@@ -9,6 +9,11 @@ import api from '@/api'
import type { Subscribe } from '@/api/types'
import router from '@/router'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
@@ -16,13 +21,23 @@ const { t } = useI18n()
// 输入参数
const props = defineProps({
media: Object as PropType<Subscribe>,
batchMode: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
const emit = defineEmits(['remove', 'save', 'select'])
// 确认框
const createConfirm = useConfirm()
@@ -290,105 +305,136 @@ function onSubscribeEditRemove() {
subscribeEditDialog.value = false
emit('remove')
}
// 处理卡片点击事件
function handleCardClick() {
if (props.batchMode) {
// 批量模式下触发选择事件
emit('select')
} else {
// 非批量模式下打开编辑弹窗
editSubscribeDialog()
}
}
</script>
<template>
<div>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
<div
class="w-full h-full rounded-lg overflow-hidden"
:class="{
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'opacity-70': subscribeState === 'S',
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
}"
min-height="170"
@click="editSubscribeDialog"
:ripple="false"
>
<div class="me-n3 absolute top-1 right-2">
<IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
<div v-if="subscribeState === 'P'" class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none" />
</template>
<div>
<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" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div class="mr-2 min-w-0 text-lg font-bold text-white">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap py-3">
<div class="flex align-center">
<IconBtn
v-if="props.media?.total_episode"
v-bind="props"
icon="mdi-progress-download"
color="white"
class="me-1"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-4 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
</div>
<IconBtn v-if="props.media?.username" icon="mdi-account" color="white" class="me-1" />
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
/>
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
:class="{
'opacity-70': subscribeState === 'S',
}"
rounded="0"
min-height="150"
@click="handleCardClick"
:ripple="!props.batchMode"
>
<div class="me-n3 absolute top-1 right-4">
<IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
</div>
</VCard>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
<div
v-if="subscribeState === 'P'"
class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none"
/>
</template>
<div>
<VCardText class="flex items-center pt-3 pb-2">
<div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
v-if="imageLoaded"
:class="{ 'cursor-move': display.mdAndUp.value }"
>
<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" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap px-3">
<div class="flex align-center">
<IconBtn
v-if="props.media?.total_episode"
size="small"
v-bind="props"
icon="mdi-progress-download"
color="white"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-2 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
</div>
<IconBtn v-if="props.media?.username" icon="mdi-account" size="small" color="white" />
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText
v-if="lastUpdateText"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
>
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
/>
</div>
</div>
</VCard>
</div>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
@@ -419,6 +465,6 @@ function onSubscribeEditRemove() {
</template>
<style lang="scss" scoped>
.subscribe-card-background {
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
</style>

View File

@@ -4,6 +4,7 @@ import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
import { useGlobalSettingsStore } from '@/stores'
// 输入参数
const props = defineProps({
@@ -14,7 +15,9 @@ const props = defineProps({
const emit = defineEmits(['delete'])
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 图片是否加载完成
const imageLoaded = ref(false)
@@ -97,64 +100,71 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
<div
class="w-full h-full rounded-lg overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
min-height="170"
@click="showForkSubscribe"
>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
rounded="0"
min-height="150"
@click="showForkSubscribe"
>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<template #default>
<div class="absolute inset-0 subscribe-card-background"></div>
</template>
</VImg>
</template>
<div class="h-full flex flex-col">
<VCardText class="flex items-center pa-3 pb-1 grow">
<div class="h-auto w-16 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">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div 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" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center pl-2 xl:pl-4">
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.share_title }}
<div class="flex flex-col justify-center pl-2 xl:pl-4">
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.share_title }}
</div>
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
{{ props.media?.share_comment }}
</div>
</div>
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
{{ props.media?.share_comment }}
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
<div class="text-subtitle-2 me-4 text-white">
{{ props.media?.share_user }}
</div>
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
{{ props.media?.count.toLocaleString() }}
</span>
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
<div class="text-subtitle-2 me-4 text-white">
{{ props.media?.share_user }}
</div>
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
{{ props.media?.count.toLocaleString() }}
</span>
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-calcdar" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-calcdar" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</div>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
@@ -179,6 +189,6 @@ function doDelete() {
</template>
<style lang="scss" scoped>
.subscribe-card-background {
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { formatFileSize } from '@/@core/utils/formatters'
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
@@ -196,8 +196,19 @@ onMounted(() => {
{{ meta?.subtitle || torrent?.description }}
</div>
<!-- 发布时间 -->
<div v-if="torrent?.pubdate" class="d-flex align-center justify-start mb-2">
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
<span class="text-sm text-medium-emphasis">{{ formatDateDifference(torrent.pubdate) }}</span>
</div>
<!-- 资源标签区 -->
<div class="d-flex flex-wrap gap-1 mb-2">
<!-- 流媒体平台 -->
<VChip v-if="meta?.web_source" class="chip-web-source rounded-sm" size="x-small" variant="elevated">
{{ meta?.web_source }}
</VChip>
<!-- 版本标签 -->
<VChip v-if="meta?.edition" class="chip-edition rounded-sm" size="x-small" variant="elevated">
{{ meta?.edition }}
@@ -283,7 +294,7 @@ onMounted(() => {
v-for="(item, index) in props.more"
:key="index"
@click.stop="handleAddDownload(item)"
class="border-b border-opacity-5 hover:bg-primary-lighten-5"
class="hover:bg-primary-lighten-5"
>
<template v-slot:prepend>
<div class="d-flex align-center gap-1">
@@ -406,6 +417,11 @@ onMounted(() => {
color: white;
}
.chip-web-source {
background-color: #8000ff;
color: white;
}
.chip-edition {
background-color: #f44336;
color: white;

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