Compare commits

..

151 Commits

Author SHA1 Message Date
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
146 changed files with 26129 additions and 3708 deletions

View File

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

16626
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.5.7-1",
"version": "2.6.3",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -45,6 +45,7 @@
"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,7 +57,7 @@
"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",
@@ -104,6 +105,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",

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);
}
}

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

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

@@ -16,14 +16,14 @@ $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} {
border-radius: 0 0 variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
}
// #{$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

View File

@@ -1,72 +1,45 @@
%blurry-bg {
position: relative;
background: transparent;
box-shadow: none;
.v-theme--light & {
backdrop-filter: blur(16px);
background: rgba(var(--v-theme-surface), 0.9);
box-shadow: 0 0 8px 0 rgba(var(--v-theme-on-surface), 0.1);
}
&::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: all 0.2s ease-in-out;
.v-theme--dark & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 1) 0%,
rgba(var(--v-theme-background), 0.8) 20%,
rgba(var(--v-theme-background), 0.6) 40%,
rgba(var(--v-theme-background), 0.4) 60%,
rgba(var(--v-theme-background), 0.2) 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.8) 20%,
rgba(var(--v-theme-background), 0.6) 40%,
rgba(var(--v-theme-background), 0.4) 60%,
rgba(var(--v-theme-background), 0.2) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
}
background: rgba(var(--v-theme-background), 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
@media (width > 768px) {
.v-theme--transparent & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0.5) 0%,
rgba(var(--v-theme-background), 0.4) 20%,
rgba(var(--v-theme-background), 0.3) 40%,
rgba(var(--v-theme-background), 0.2) 60%,
rgba(var(--v-theme-background), 0.1) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
@media (width <= 640px) {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0.9) 0%,
rgba(var(--v-theme-background), 0.7) 20%,
rgba(var(--v-theme-background), 0.5) 40%,
rgba(var(--v-theme-background), 0.3) 60%,
rgba(var(--v-theme-background), 0.1) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
}
backdrop-filter: blur(5px);
background: rgba(var(--v-theme-background), 0.1);
}
}
@media (width <= 768px) {
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: all 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 & {
background: rgba(var(--v-theme-background), 0.3);
}
}
}
}

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

@@ -43,3 +43,44 @@ 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,
}
}
// 检测是否为移动设备
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
}

View File

@@ -38,15 +38,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',
@@ -127,7 +137,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 +147,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

@@ -3,10 +3,14 @@ import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n'
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'
// 生效主题
const { global: globalTheme } = useTheme()
@@ -18,13 +22,13 @@ globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
const localeValue = getBrowserLocale()
setI18nLanguage(localeValue as SupportedLocale)
// 显示状态
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'))
@@ -32,7 +36,6 @@ 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 {
@@ -41,182 +44,197 @@ 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秒切换一次
// 使用优化的定时器管理器,后台时自动暂停
addBackgroundTimer(
'background-rotation',
rotateBackgroundImage,
10000, // 每10秒切换一次
{
runInBackground: false, // 后台时不运行
skipInitialRun: true, // 不需要立即执行
},
)
}
}
// 预加载图片
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)
}
})
}
// 添加logo动画效果并延迟移除加载界面
function animateAndRemoveLoader() {
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
if (loadingBg) {
// 先添加完成动画类
loadingBg.classList.add('loading-complete')
removeEl('#loading-bg')
document.documentElement.style.removeProperty('background')
}
}
// 等待动画完成后再移除元素
setTimeout(() => {
removeEl('#loading-bg')
// 将background属性从html的style中移除
document.documentElement.style.removeProperty('background')
// 显示页面
show.value = true
}, 500) // 与CSS动画持续时间匹配
// 检查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() {
await fetchBackgroundImages()
.then(() => {
startBackgroundRotation()
})
.catch(() => {
// 3秒后重试
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()
}, 3000)
})
loadBackgroundImages(retryCount + 1)
}, retryDelay)
}
}
}
onMounted(async () => {
// 配置 ApexCharts
configureApexCharts()
// 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value)
// 默认隐藏页面
show.value = false
// 监听主题变化
watch(
() => globalTheme.name.value,
newTheme => {
// 更新HTML主题属性
updateHtmlThemeAttribute(newTheme)
// 重新配置ApexCharts以适应新主题
configureApexCharts()
},
)
// 加载背景图片
await loadBackgroundImages()
loadBackgroundImages()
// 移除加载动画
// 使用优化后的加载界面移除逻辑
ensureRenderComplete(() => {
nextTick(() => {
setTimeout(() => {
// 移除加载动画,显示页面
animateAndRemoveLoader()
// 页面完全显示后,检查未读消息
setTimeout(() => {
checkAndEmitUnreadMessages()
}, 1000)
}, 1500)
})
})
// 添加页面可见性变化监听
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
loadBackgroundImages()
// 页面恢复可见时检查未读消息
setTimeout(() => {
checkAndEmitUnreadMessages()
}, 500)
}
})
// 添加PWA的页面恢复事件监听
window.addEventListener('pageshow', event => {
// persisted属性为true表示页面是从bfcache中恢复的
if (event.persisted) {
loadBackgroundImages()
// PWA恢复时检查未读消息
setTimeout(() => {
checkAndEmitUnreadMessages()
}, 500)
}
nextTick(removeLoadingWithStateCheck)
})
})
onUnmounted(() => {
// 移除页面可见性监听
document.removeEventListener('visibilitychange', () => {})
// 移除PWA的页面恢复事件监听
window.removeEventListener('pageshow', () => {})
// 清除轮换定时器
if (backgroundRotationTimer) {
clearInterval(backgroundRotationTimer)
backgroundRotationTimer = null
}
// 清除背景轮换定时器
removeBackgroundTimer('background-rotation')
})
</script>
@@ -230,13 +248,15 @@ onUnmounted(() => {
class="background-image"
:class="{ 'active': index === activeImageIndex }"
:style="{ 'backgroundImage': `url(${imageUrl})` }"
></div>
/>
<!-- 全局磨砂层 -->
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
</div>
<!-- 页面内容 -->
<VApp v-show="show" :class="{ 'transparent-app': isTransparentTheme }">
<VApp :class="{ 'transparent-app': isTransparentTheme }">
<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) => {

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,45 @@ api.interceptors.request.use(config => {
return config
})
// 离线状态管理
const globalOfflineStatus = useGlobalOfflineStatus()
// 添加响应拦截器
api.interceptors.response.use(
response => {
// 成功响应时,清除应用离线状态
globalOfflineStatus.setAppOffline(false)
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.setAppOffline(true, 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

@@ -769,6 +769,8 @@ export interface MetaInfo {
audio_term: string
// 资源类型+特效
edition: string
// 流媒体平台
web_source: string
// 应用的自定义识别词
apply_words: string[]
}

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

@@ -5,6 +5,7 @@ import FileNavigator from './filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { useDisplay } from 'vuetify'
import { storageIconDict } from '@/api/constants'
import { usePWA } from '@/composables/usePWA'
// 输入参数
const props = defineProps({
@@ -33,7 +34,8 @@ const emit = defineEmits(['pathchanged'])
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
const fileIcons = {
// 压缩包
@@ -241,14 +243,14 @@ function stopDrag() {
// 外层DIV大小控制
const scrollStyle = computed(() => {
return appMode
return appMode.value
? 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom) - 7rem)'
: 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom)'
})
// 文件列表大小限制
const fileListStyle = computed(() => {
return appMode
return appMode.value
? 'height: calc(100vh - 14rem - env(safe-area-inset-bottom) - 7rem)'
: 'height: calc(100vh - 14rem - env(safe-area-inset-bottom)'
})

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,198 @@
<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)
// 检查是否应该显示横幅
const shouldShowBanner = computed(() => {
return !isInstalled.value && !dismissed.value && !showInstructions.value && isLogin.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,6 +1,6 @@
<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'

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'
@@ -11,12 +11,14 @@ 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({
@@ -43,9 +45,6 @@ const emit = defineEmits(['close', 'done', 'change'])
// 提示框
const $toast = useToast()
// timeout定时器
let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 上传速率
const upload_rate = ref(0)
@@ -64,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 {
@@ -79,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)
@@ -141,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>
@@ -187,7 +190,7 @@ 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>

View File

@@ -3,7 +3,7 @@ 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'

View File

@@ -4,12 +4,12 @@ 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'
@@ -28,7 +28,9 @@ const props = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 用户 Store
const userStore = useUserStore()
@@ -232,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,
@@ -243,7 +242,6 @@ async function handleCheckExists() {
season: props.media?.season,
mtype: props.media?.type,
},
signal,
})
if (result.success) isExists.value = true
@@ -255,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

View File

@@ -87,6 +87,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'
@@ -200,7 +200,7 @@ 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>

View File

@@ -7,7 +7,7 @@ import synologychat_image from '@images/logos/synologychat.png'
import slack_image from '@images/logos/slack.webp'
import chrome_image from '@images/logos/chrome.png'
import custom_image from '@images/logos/notification.png'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'

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)

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'
@@ -106,7 +106,7 @@ 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}`
})

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import api from '@/api'
import type { Plugin } from '@/api/types'
@@ -170,7 +170,7 @@ 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}`
})
@@ -180,7 +180,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`
})
// 重置插件

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'

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'
@@ -24,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()
@@ -56,9 +57,6 @@ const resourceDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 站点使用统计
const siteStats = ref<SiteStatistic>({})
// 查询站点图标
async function getSiteIcon() {
try {
@@ -84,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)
}
@@ -140,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'
})
// 数据百分比计算
@@ -185,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>

View File

@@ -5,17 +5,18 @@ 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'
// 显示器宽度
@@ -66,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)
@@ -84,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
@@ -106,6 +112,8 @@ const getIcon = computed(() => {
return rclone_png
case 'alist':
return alist_png
case 'smb':
return smb_png
default:
return custom_png
}
@@ -144,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
@@ -163,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" />
@@ -204,6 +213,13 @@ function onClose() {
@close="aListConfigDialog = false"
@done="handleDone"
/>
<SmbConfigDialog
v-if="smbConfigDialog"
v-model="smbConfigDialog"
:conf="props.storage.config || {}"
@close="smbConfigDialog = false"
@done="handleDone"
/>
<VDialog
v-if="customConfigDialog"
v-model="customConfigDialog"

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
@@ -10,6 +10,7 @@ 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()
@@ -23,7 +24,9 @@ const props = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])

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)

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 }}
@@ -406,6 +417,11 @@ onMounted(() => {
color: white;
}
.chip-web-source {
background-color: #8000FF;
color: white;
}
.chip-edition {
background-color: #f44336;
color: white;

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'
@@ -154,7 +154,18 @@ onMounted(() => {
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
</div>
<!-- 发布时间 -->
<div v-if="torrent?.pubdate" class="d-flex align-center 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 }}
@@ -254,6 +265,11 @@ onMounted(() => {
color: white;
}
.chip-web-source {
background-color: #8000ff;
color: white;
}
.chip-edition {
background-color: #f44336;
color: white;

View File

@@ -3,7 +3,7 @@ import api from '@/api'
import { Subscribe, User } from '@/api/types'
import { useUserStore } from '@/stores'
import avatar1 from '@images/avatars/avatar-1.png'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
import { useDisplay } from 'vuetify'

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { Workflow } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'

View File

@@ -3,9 +3,10 @@ import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { VBtn } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
// 国际化
const { t } = useI18n()
@@ -19,7 +20,9 @@ const props = defineProps({
const emit = defineEmits(['fork', 'delete', 'close'])
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 提示框
const $toast = useToast()

View File

@@ -3,7 +3,7 @@ import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import FormRender from '../render/FormRender.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'

View File

@@ -4,12 +4,17 @@ import type { Plugin } from '@/api/types'
import PageRender from '@/components/render/PageRender.vue'
import api from '@/api'
import { loadRemoteComponent } from '@/utils/federationLoader'
import { usePWA } from '@/composables/usePWA'
// 输入参数
const props = defineProps({
plugin: {
type: Object as PropType<Plugin>,
},
show_switch: {
type: Boolean,
default: true,
},
})
// 定义事件
@@ -18,7 +23,8 @@ const emit = defineEmits(['close', 'save', 'switch'])
// 显示器宽度
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
// 是否刷新
const isRefreshed = ref(false)
@@ -130,6 +136,7 @@ onMounted(() => {
</div>
</VCardText>
<VFab
v-if="show_switch"
icon="mdi-cog"
location="bottom"
size="x-large"
@@ -146,6 +153,7 @@ onMounted(() => {
<component
:is="dynamicComponent"
:api="api"
:show_switch="show_switch"
@action="handleAction"
@switch="emit('switch')"
@close="emit('close')"

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import { useDisplay } from 'vuetify'

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import api from '@/api'
import { transferTypeOptions } from '@/api/constants'
@@ -8,9 +8,12 @@ import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { FileItem, StorageConf, TransferDirectoryConf, TransferForm } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
// 国际化
const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization()
// 显示器宽度
const display = useDisplay()
@@ -24,10 +27,12 @@ const props = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 当前识别类型
const mediaSource = ref(globalSettings.data?.RECOGNIZE_SOURCE || 'themoviedb')
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
// 定义事件
const emit = defineEmits(['done', 'close'])
@@ -46,8 +51,8 @@ const $toast = useToast()
// TMDB选择对话框
const mediaSelectorDialog = ref(false)
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 进度是否激活
const progressActive = ref(false)
// 整理进度条
const progressDialog = ref(false)
@@ -186,22 +191,34 @@ async function handleTransferLog(logid: number, background: boolean = false) {
}
}
// 进度SSE消息处理函数
function handleProgressMessage(event: MessageEvent) {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
}
}
// 使用优化的进度SSE连接
const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`,
handleProgressMessage,
'reorganize-progress',
progressActive
)
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = t('dialog.reorganize.processing')
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
}
}
progressActive.value = true
progressSSE.start()
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
progressActive.value = false
progressSSE.stop()
}
// 整理文件

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import type { DownloaderConf, Site } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { numberValidator, requiredValidator } from '@/@validators'

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import { Site } from '@/api/types'
import { requiredValidator } from '@/@validators'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'

View File

@@ -253,6 +253,8 @@ async function fetchSiteUserData() {
try {
const result: { [key: string]: any } = await api.get(`site/userdata/${props.site?.id}`)
if (result.success) {
// 使用nextTick确保DOM更新完成后再更新图表数据
await nextTick()
siteDatas.value = result.data.sort((a: { updated_day: any }, b: { updated_day: any }) =>
(a.updated_day || '').localeCompare(b.updated_day || ''),
)
@@ -276,8 +278,11 @@ async function refreshSiteData() {
progressDialog.value = false
}
onBeforeMount(async () => {
await fetchSiteUserData()
onBeforeMount(() => {
// 延迟加载,确保组件完全挂载
nextTick(() => {
fetchSiteUserData()
})
})
</script>

View File

@@ -0,0 +1,131 @@
<script lang="ts" setup>
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 多语言支持
const { t } = useI18n()
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done', 'close'])
// 完成
async function handleDone() {
await saveSmbConfig()
emit('done')
}
// 重置配置
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/smb')
if (result.success) {
// 重置成功
handleDone()
}
} catch (e) {
console.error(e)
}
}
// 保存 SMB 设置
async function saveSmbConfig() {
try {
await api.post(`storage/save/smb`, props.conf)
} catch (e) {
console.error(e)
}
}
</script>
<template>
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<template #prepend>
<VIcon icon="mdi-folder-network-outline" class="me-2" />
</template>
<VCardTitle>
{{ t('dialog.smbConfig.title') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="props.conf.host"
:hint="t('dialog.smbConfig.hostHint')"
:label="t('dialog.smbConfig.host')"
persistent-hint
prepend-inner-icon="mdi-server"
placeholder="192.168.1.100"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="props.conf.share"
:hint="t('dialog.smbConfig.shareHint')"
:label="t('dialog.smbConfig.share')"
persistent-hint
prepend-inner-icon="mdi-folder-network"
placeholder="shared_folder"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="props.conf.username"
:hint="t('dialog.smbConfig.usernameHint')"
:label="t('dialog.smbConfig.username')"
persistent-hint
prepend-inner-icon="mdi-account"
placeholder="your_username"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="props.conf.password"
:hint="t('dialog.smbConfig.passwordHint')"
:label="t('dialog.smbConfig.password')"
persistent-hint
prepend-inner-icon="mdi-lock"
placeholder="your_password"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="props.conf.domain"
:hint="t('dialog.smbConfig.domainHint')"
:label="t('dialog.smbConfig.domain')"
persistent-hint
prepend-inner-icon="mdi-domain"
placeholder="WORKGROUP"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.smbConfig.reset') }}
</VBtn>
<VSpacer />
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.smbConfig.complete') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { numberValidator } from '@/@validators'
import api from '@/api'
import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
@@ -283,6 +283,7 @@ onMounted(() => {
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<VDialogCloseBtn @click="emit('close')" />
<template #prepend>
<VIcon icon="mdi-clipboard-list-outline" class="me-2" />
</template>
@@ -300,7 +301,6 @@ onMounted(() => {
</VCardSubtitle>
</VCardItem>
<VCardText>
<VDialogCloseBtn @click="emit('close')" />
<VForm @submit.prevent="() => {}">
<VTabs v-model="activeTab" show-arrows>
<VTab value="basic">

View File

@@ -4,6 +4,7 @@ import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
import { PropType } from 'vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
// 国际化
const { t } = useI18n()
@@ -17,7 +18,9 @@ const props = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 季详情
const seasonInfos = ref<MediaSeason[]>([])

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Subscribe, SubscribeShare } from '@/api/types'

View File

@@ -4,9 +4,11 @@ import api from '@/api'
import { FileItem, TransferQueue } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
// 多语言支持
const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization()
// 显示器宽度
const display = useDisplay()
@@ -16,9 +18,6 @@ const emit = defineEmits(['close'])
// 数据列表
const dataList = ref<TransferQueue[]>([])
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 整理进度文本
const progressText = ref(t('dialog.transferQueue.processing'))
@@ -28,6 +27,9 @@ const progressValue = ref(0)
// 数据可刷新标志
const refreshFlag = ref(false)
// 进度是否激活
const progressActive = ref(false)
// 活动标签
const activeTab = ref('')
@@ -91,42 +93,54 @@ async function remove_queue_task(fileitem: FileItem) {
}
}
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = t('dialog.transferQueue.processing')
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
if (!progress.enable) {
progressText.value = t('dialog.transferQueue.processing')
progressValue.value = 0
if (refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
}
return
// 进度SSE消息处理函数
function handleProgressMessage(event: MessageEvent) {
const progress = JSON.parse(event.data)
if (progress) {
if (!progress.enable) {
progressText.value = t('dialog.transferQueue.processing')
progressValue.value = 0
if (refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
}
progressText.value = progress.text
progressValue.value = progress.value
if (progress.value >= 100 && refreshFlag.value) {
return
}
progressText.value = progress.text
progressValue.value = progress.value
if (progress.value >= 100 && refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
} else {
if (progress.value > 0 && refreshFlag.value && progress.text?.includes('整理完成')) {
refreshFlag.value = false
get_transfer_queue()
} else {
if (progress.value > 0 && refreshFlag.value && progress.text?.includes('整理完成')) {
refreshFlag.value = false
get_transfer_queue()
} else {
refreshFlag.value = true
}
refreshFlag.value = true
}
}
}
}
// 使用优化的进度SSE连接
const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`,
handleProgressMessage,
'transfer-queue-progress',
progressActive
)
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = t('dialog.transferQueue.processing')
progressActive.value = true
progressSSE.start()
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
progressActive.value = false
progressSSE.stop()
}
onMounted(() => {

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import type { User } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import api from '@/api'

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'

View File

@@ -4,7 +4,7 @@ import { VueFlow, useVueFlow, type Connection, type GraphNode } from '@vue-flow/
import { MiniMap } from '@vue-flow/minimap'
import useDragAndDrop from '@core/utils/workflow'
import { Workflow } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import api from '@/api'
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
@@ -207,7 +207,6 @@ const isMacOS = computed(() => {
</VBtn>
</VToolbarItems>
<VToolbarTitle> {{ t('dialog.workflowActions.title') }} - {{ workflow?.name }} </VToolbarTitle>
<VSpacer></VSpacer>
<VToolbarItems>
<VBtn icon variant="text" @click="importCodeDialog = true" class="ms-2">
<VIcon size="24" color="white" icon="mdi-import" />

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import type { Workflow } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { requiredValidator } from '@/@validators'

View File

@@ -2,7 +2,7 @@
import type { AxiosRequestConfig } from 'axios'
import type { PropType } from 'vue'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
import { formatBytes } from '@core/utils/formatters'
import type { Context, EndPoints, FileItem } from '@/api/types'
@@ -11,9 +11,11 @@ import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify'
import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
// 国际化
const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization()
// 显示器宽度
const display = useDisplay()
@@ -24,7 +26,7 @@ const inProps = defineProps({
storage: String,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Object as PropType<any>,
type: Function,
required: true,
},
refreshpending: Boolean,
@@ -105,8 +107,8 @@ const nameTestDialog = ref(false)
// 弹出菜单
const dropdownItems = ref<{ [key: string]: any }[]>([])
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 进度是否激活
const progressActive = ref(false)
// 目录过滤
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.name.includes(filter.value)))
@@ -530,22 +532,34 @@ async function batchScrape() {
})
}
// 进度SSE消息处理函数
function handleProgressMessage(event: MessageEvent) {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
}
}
// 使用优化的进度SSE连接
const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`,
handleProgressMessage,
'file-batch-rename-progress',
progressActive
)
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = t('common.pleaseWait')
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
}
}
progressActive.value = true
progressSSE.start()
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
progressActive.value = false
progressSSE.stop()
}
onMounted(() => {
@@ -554,196 +568,202 @@ onMounted(() => {
</script>
<template>
<VCard class="d-flex flex-column w-full h-full rounded-t-0" :class="{ 'rounded-s-0': showTree }">
<div v-if="!loading" class="flex">
<IconBtn v-if="display.mdAndUp.value">
<VIcon v-if="showTree" icon="mdi-file-tree" @click="switchFileTree(false)" />
<VIcon v-else icon="mdi-file-tree-outline" @click="switchFileTree(true)" />
</IconBtn>
<VTextField
v-if="!isFile"
v-model="filter"
hide-details
flat
density="compact"
variant="plain"
:placeholder="t('common.search')"
prepend-inner-icon="mdi-filter-outline"
class="mx-2"
rounded
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="!isFile" @click="changeSelectMode">
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
<VIcon color="primary" v-else>mdi-select</VIcon>
</IconBtn>
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
<VIcon color="primary"> mdi-text-recognition </VIcon>
</IconBtn>
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
<VIcon color="primary"> mdi-download </VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="list_files">
<VIcon color="primary"> mdi-refresh </VIcon>
</IconBtn>
<!-- 批量操作按钮 -->
<span v-if="selected.length > 0">
<IconBtn @click.stop="batchScrape">
<VIcon color="primary" icon="mdi-auto-fix" />
<div>
<VCard class="d-flex flex-column w-full h-full rounded-t-0" :class="{ 'rounded-s-0': showTree }">
<div v-if="!loading" class="flex">
<IconBtn v-if="display.mdAndUp.value">
<VIcon v-if="showTree" icon="mdi-file-tree" @click="switchFileTree(false)" />
<VIcon v-else icon="mdi-file-tree-outline" @click="switchFileTree(true)" />
</IconBtn>
<IconBtn @click.stop="showBatchTransfer">
<VIcon color="primary" icon="mdi-folder-arrow-right" />
<VTextField
v-if="!isFile"
v-model="filter"
hide-details
flat
density="compact"
variant="plain"
:placeholder="t('common.search')"
prepend-inner-icon="mdi-filter-outline"
class="mx-2"
rounded
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="!isFile" @click="changeSelectMode">
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
<VIcon color="primary" v-else>mdi-select</VIcon>
</IconBtn>
<IconBtn @click.stop="batchDelete">
<VIcon icon="mdi-delete-outline" color="error" />
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
<VIcon color="primary"> mdi-text-recognition </VIcon>
</IconBtn>
</span>
</div>
<LoadingBanner v-if="loading" />
<!-- 文件详情 -->
<VCardText v-else-if="isFile && !isImage && items.length > 0" class="text-center break-all">
<div v-if="items[0]?.thumbnail" class="flex justify-center">
<VImg max-width="15rem" cover :src="items[0]?.thumbnail" class="rounded border">
<template #placeholder>
<VSkeletonLoader class="object-cover w-full h-full" />
</template>
</VImg>
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
<VIcon color="primary"> mdi-download </VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="list_files">
<VIcon color="primary"> mdi-refresh </VIcon>
</IconBtn>
<!-- 批量操作按钮 -->
<span v-if="selected.length > 0">
<IconBtn @click.stop="batchScrape">
<VIcon color="primary" icon="mdi-auto-fix" />
</IconBtn>
<IconBtn @click.stop="showBatchTransfer">
<VIcon color="primary" icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn @click.stop="batchDelete">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</span>
</div>
<div class="text-xl text-high-emphasis mt-3">{{ items[0]?.name }}</div>
<p class="mt-2" v-if="items[0]?.size && items[0].modify_time">
{{ t('file.size') }}{{ formatBytes(items[0]?.size || 0) }}<br />
{{ t('file.modifyTime') }}{{ formatTime(items[0]?.modify_time || 0) }}
</p>
</VCardText>
<!-- 图片 -->
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
<VImg :src="currentImgLink" max-width="100%" max-height="100%" />
</VCardText>
<!-- 目录和文件列表 -->
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList class="text-high-emphasis">
<VVirtualScroll :items="[...dirs, ...files]" :style="listStyle">
<template #default="{ item }">
<VHover>
<template #default="hover">
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
<template #prepend>
<VListItemAction v-if="selectMode">
<VCheckbox v-model="selected" :value="item" />
</VListItemAction>
<template v-else>
<VIcon
v-if="inProps.icons && item.extension"
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
/>
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder" />
<VIcon v-else icon="mdi-file-outline" />
</template>
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle v-if="item.size">
{{ formatBytes(item.size) }}
</VListItemSubtitle>
<template #append>
<IconBtn v-if="display.smAndDown.value && !selectMode">
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(menu, i) in dropdownItems" :key="i">
<VListItem v-if="menu.show" :base-color="menu.props.color" @click="menu.props.click(item)">
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
<IconBtn @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn @click.stop="scrape(item)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
<IconBtn @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</span>
</template>
</VListItem>
</template>
</VHover>
</template>
</VVirtualScroll>
</VList>
</VCardText>
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
{{ t('file.noFiles') }}
</VCardText>
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5">
{{ t('file.emptyDirectory') }}
</VCardText>
</VCard>
<!-- 重命名弹窗 -->
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="35rem">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="renamePopper = false" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-model="newName"
:label="t('file.newName')"
:loading="renameLoading"
prepend-inner-icon="mdi-format-text"
/>
</VCol>
<VCol cols="12" v-if="currentItem && currentItem.type == 'dir'">
<VSwitch v-model="renameAll" :label="t('file.includeSubfolders')" />
</VCol>
</VRow>
<LoadingBanner v-if="loading" />
<!-- 文件详情 -->
<VCardText v-else-if="isFile && !isImage && items.length > 0" class="text-center break-all">
<div v-if="items[0]?.thumbnail" class="flex justify-center">
<VImg max-width="15rem" cover :src="items[0]?.thumbnail" class="rounded border">
<template #placeholder>
<VSkeletonLoader class="object-cover w-full h-full" />
</template>
</VImg>
</div>
<div class="text-xl text-high-emphasis mt-3">{{ items[0]?.name }}</div>
<p class="mt-2" v-if="items[0]?.size && items[0].modify_time">
{{ t('file.size') }}{{ formatBytes(items[0]?.size || 0) }}<br />
{{ t('file.modifyTime') }}{{ formatTime(items[0]?.modify_time || 0) }}
</p>
</VCardText>
<!-- 图片 -->
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
<VImg :src="currentImgLink" max-width="100%" max-height="100%" />
</VCardText>
<!-- 目录和文件列表 -->
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList class="text-high-emphasis">
<VVirtualScroll :items="[...dirs, ...files]" :style="listStyle">
<template #default="{ item }">
<VHover>
<template #default="hover">
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
<template #prepend>
<VListItemAction v-if="selectMode">
<VCheckbox v-model="selected" :value="item" />
</VListItemAction>
<template v-else>
<VIcon
v-if="inProps.icons && item.extension"
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
/>
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder" />
<VIcon v-else icon="mdi-file-outline" />
</template>
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle v-if="item.size">
{{ formatBytes(item.size) }}
</VListItemSubtitle>
<template #append>
<IconBtn v-if="display.smAndDown.value && !selectMode">
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(menu, i) in dropdownItems" :key="i">
<VListItem
v-if="menu.show"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
<IconBtn @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn @click.stop="scrape(item)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
<IconBtn @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</span>
</template>
</VListItem>
</template>
</VHover>
</template>
</VVirtualScroll>
</VList>
</VCardText>
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
{{ t('file.noFiles') }}
</VCardText>
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5">
{{ t('file.emptyDirectory') }}
</VCardText>
<VCardActions>
<VBtn color="success" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
{{ t('file.autoRecognizeName') }}
</VBtn>
<VBtn :disabled="!newName" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 文件整理弹窗 -->
<ReorganizeDialog
v-if="transferPopper"
v-model="transferPopper"
:items="transferItems"
:target_storage="inProps.storage"
@done="transferDone"
@close="transferPopper = false"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- 识别结果对话框 -->
<MediaInfoDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
:context="nameTestResult"
@close="nameTestDialog = false"
/>
<!-- 重命名弹窗 -->
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="35rem">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="renamePopper = false" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-model="newName"
:label="t('file.newName')"
:loading="renameLoading"
prepend-inner-icon="mdi-format-text"
/>
</VCol>
<VCol cols="12" v-if="currentItem && currentItem.type == 'dir'">
<VSwitch v-model="renameAll" :label="t('file.includeSubfolders')" />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="success" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
{{ t('file.autoRecognizeName') }}
</VBtn>
<VBtn :disabled="!newName" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 文件整理弹窗 -->
<ReorganizeDialog
v-if="transferPopper"
v-model="transferPopper"
:items="transferItems"
:target_storage="inProps.storage"
@done="transferDone"
@close="transferPopper = false"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- 识别结果对话框 -->
<MediaInfoDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
:context="nameTestResult"
@close="nameTestDialog = false"
/>
</div>
</template>

View File

@@ -27,7 +27,7 @@ const props = defineProps({
},
endpoints: Object,
axios: {
type: Object as PropType<any>,
type: Function,
required: true,
},
})

View File

@@ -24,7 +24,7 @@ const inProps = defineProps({
},
endpoints: Object as PropType<EndPoints>,
axios: {
type: Object as PropType<any>,
type: Function,
required: true,
},
})

View File

@@ -8,6 +8,7 @@ import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'
import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
import AnalyticsNetwork from '@/views/dashboard/AnalyticsNetwork.vue'
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
@@ -81,6 +82,7 @@ onUnmounted(() => {
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
<AnalyticsCpu v-else-if="config?.id === 'cpu'" :allowRefresh="props.allowRefresh" />
<AnalyticsMemory v-else-if="config?.id === 'memory'" :allowRefresh="props.allowRefresh" />
<AnalyticsNetwork v-else-if="config?.id === 'network'" :allowRefresh="props.allowRefresh" />
<MediaServerLibrary v-else-if="config?.id === 'library'" />
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
<MediaServerLatest v-else-if="config?.id === 'latest'" />

View File

@@ -0,0 +1,281 @@
import { onMounted, onUnmounted, ref, type Ref } from 'vue'
import { sseManagerSingleton } from '@/utils/sseManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
/**
* 后台优化组合函数
* 统一管理SSE连接和定时器优化iOS后台性能
*/
export function useBackgroundOptimization() {
/**
* 使用优化的SSE连接
* @param url SSE连接地址
* @param messageHandler 消息处理函数
* @param listenerId 监听器ID用于区分不同的监听器
* @param options 选项
*/
const useSSE = (
url: string,
messageHandler: (event: MessageEvent) => void,
listenerId: string,
options?: {
backgroundCloseDelay?: number
reconnectDelay?: number
maxReconnectAttempts?: number
},
) => {
const manager = sseManagerSingleton.getManager(url, options)
onMounted(() => {
manager.addMessageListener(listenerId, messageHandler)
})
onUnmounted(() => {
manager.removeMessageListener(listenerId)
})
return {
manager,
readyState: () => manager.readyState,
close: () => manager.removeMessageListener(listenerId),
}
}
/**
* 使用优化的定时器
* @param id 定时器ID
* @param callback 回调函数
* @param interval 间隔时间(毫秒)
* @param options 选项
*/
const useTimer = (
id: string,
callback: () => void,
interval: number,
options?: {
runInBackground?: boolean
skipInitialRun?: boolean
},
) => {
onMounted(() => {
addBackgroundTimer(id, callback, interval, options)
})
onUnmounted(() => {
removeBackgroundTimer(id)
})
return {
remove: () => removeBackgroundTimer(id),
}
}
/**
* 使用延迟SSE连接类似原来的setTimeout延迟
* @param url SSE连接地址
* @param messageHandler 消息处理函数
* @param listenerId 监听器ID
* @param delay 延迟时间(毫秒)
* @param options SSE选项
*/
const useDelayedSSE = (
url: string,
messageHandler: (event: MessageEvent) => void,
listenerId: string,
delay: number = 3000,
options?: Parameters<typeof useSSE>[3],
) => {
const manager = sseManagerSingleton.getManager(url, options)
onMounted(() => {
setTimeout(() => {
manager.addMessageListener(listenerId, messageHandler)
}, delay)
})
onUnmounted(() => {
manager.removeMessageListener(listenerId)
})
return {
manager,
readyState: () => manager.readyState,
close: () => manager.removeMessageListener(listenerId),
}
}
/**
* 使用进度SSE连接用于进度监听
* @param url SSE连接地址
* @param messageHandler 消息处理函数
* @param listenerId 监听器ID
* @param isActive 是否激活的响应式变量
*/
const useProgressSSE = (
url: string,
messageHandler: (event: MessageEvent) => void,
listenerId: string,
isActive: Ref<boolean>,
) => {
const manager = sseManagerSingleton.getManager(url, {
backgroundCloseDelay: 1000, // 进度SSE更快关闭
reconnectDelay: 1000,
maxReconnectAttempts: 5,
})
const startProgress = () => {
if (isActive.value) {
manager.addMessageListener(listenerId, messageHandler)
}
}
const stopProgress = () => {
manager.removeMessageListener(listenerId)
}
onUnmounted(() => {
stopProgress()
})
return {
start: startProgress,
stop: stopProgress,
manager,
}
}
/**
* 使用数据刷新定时器(用于仪表盘等数据刷新)
* @param id 定时器ID
* @param loadDataFunc 加载数据函数
* @param interval 刷新间隔(毫秒)
* @param immediate 是否立即执行
*/
const useDataRefresh = (
id: string,
loadDataFunc: () => Promise<void> | void,
interval: number = 3000,
immediate: boolean = true,
) => {
const loading = ref(false)
const wrappedLoadData = async () => {
if (loading.value) return
loading.value = true
try {
await loadDataFunc()
} catch (error) {
console.error(`数据刷新失败 [${id}]:`, error)
} finally {
loading.value = false
}
}
onMounted(async () => {
if (immediate) {
await wrappedLoadData()
}
addBackgroundTimer(id, wrappedLoadData, interval, {
runInBackground: false, // 后台不刷新数据
skipInitialRun: true, // 已经手动执行过了
})
})
onUnmounted(() => {
removeBackgroundTimer(id)
})
return {
loading,
refresh: wrappedLoadData,
stop: () => removeBackgroundTimer(id),
}
}
/**
* 使用条件性数据刷新定时器(用于需要动态启停的场景)
* @param id 定时器ID
* @param loadDataFunc 加载数据函数
* @param condition 条件响应式引用为true时启动定时器
* @param interval 刷新间隔(毫秒)
* @param immediate 是否立即执行
*/
const useConditionalDataRefresh = (
id: string,
loadDataFunc: () => Promise<void> | void,
condition: Ref<boolean>,
interval: number = 3000,
immediate: boolean = true,
) => {
const loading = ref(false)
const isTimerActive = ref(false)
const wrappedLoadData = async () => {
if (loading.value || !condition.value) return
loading.value = true
try {
await loadDataFunc()
} catch (error) {
console.error(`条件数据刷新失败 [${id}]:`, error)
} finally {
loading.value = false
}
}
const startTimer = () => {
if (!isTimerActive.value && condition.value) {
addBackgroundTimer(id, wrappedLoadData, interval, {
runInBackground: false,
skipInitialRun: !immediate,
})
isTimerActive.value = true
}
}
const stopTimer = () => {
if (isTimerActive.value) {
removeBackgroundTimer(id)
isTimerActive.value = false
}
}
onMounted(() => {
if (condition.value) {
startTimer()
}
// 监听条件变化
watch(condition, (newValue: boolean) => {
if (newValue) {
startTimer()
} else {
stopTimer()
}
})
})
onUnmounted(() => {
stopTimer()
})
return {
loading,
refresh: wrappedLoadData,
stop: stopTimer,
start: startTimer,
isActive: isTimerActive,
}
}
return {
useSSE,
useTimer,
useDelayedSSE,
useProgressSSE,
useDataRefresh,
useConditionalDataRefresh,
}
}

View File

@@ -0,0 +1,118 @@
interface CacheInfo {
cacheSizes: Record<string, number>
totalSize: number
totalSizeMB: string
}
export function useCacheManager() {
const cacheInfo = ref<CacheInfo | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// 发送消息到Service Worker
async function sendMessageToSW(message: any): Promise<any> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service Worker not supported')
}
const registration = await navigator.serviceWorker.ready
const messageChannel = new MessageChannel()
return new Promise((resolve, reject) => {
messageChannel.port1.onmessage = (event) => {
if (event.data.success) {
resolve(event.data)
} else {
reject(new Error(event.data.error || 'Unknown error'))
}
}
registration.active?.postMessage(message, [messageChannel.port2])
})
}
// 获取缓存信息
async function getCacheInfo() {
isLoading.value = true
error.value = null
try {
const response = await sendMessageToSW({ type: 'GET_CACHE_INFO' })
cacheInfo.value = response.cacheInfo
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to get cache info'
console.error('Failed to get cache info:', err)
} finally {
isLoading.value = false
}
}
// 清理缓存
async function cleanupCaches() {
isLoading.value = true
error.value = null
try {
const response = await sendMessageToSW({ type: 'CLEANUP_CACHES' })
cacheInfo.value = response.cacheInfo
return true
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to cleanup caches'
console.error('Failed to cleanup caches:', err)
return false
} finally {
isLoading.value = false
}
}
// 格式化缓存大小
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 获取缓存使用百分比假设最大100MB
function getCacheUsagePercentage(totalSize: number): number {
const maxSize = 100 * 1024 * 1024 // 100MB
return Math.min((totalSize / maxSize) * 100, 100)
}
// 监听Service Worker消息
function handleSWMessage(event: MessageEvent) {
if (event.data && event.data.type === 'CACHE_SIZE_UPDATE') {
cacheInfo.value = event.data.data
}
}
onMounted(() => {
// 获取初始缓存信息
getCacheInfo()
// 监听Service Worker消息
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleSWMessage)
}
})
onUnmounted(() => {
// 移除事件监听
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleSWMessage)
}
})
return {
cacheInfo,
isLoading,
error,
getCacheInfo,
cleanupCaches,
formatSize,
getCacheUsagePercentage,
}
}

View File

@@ -0,0 +1,185 @@
import type { ComputedRef, Ref } from 'vue'
import { useTabStateRestore } from '@/composables/useStateRestore'
// 动态标签页相关类型
interface DynamicHeaderTabButton {
icon: string
color?: string | ComputedRef<string>
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
size?: string
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
dataAttr?: string // 用于VMenu定位的data属性
}
interface DynamicHeaderTabItem {
title: string
icon?: string
tab: string
}
interface DynamicHeaderTabConfig {
items: DynamicHeaderTabItem[]
modelValue: string
appendButtons?: DynamicHeaderTabButton[]
routePath?: string
onUpdateModelValue?: (value: string) => void
}
export function useDynamicHeaderTab() {
const route = useRoute()
// 尝试从inject获取
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')
// 注册动态标签页
const registerHeaderTab = (config: {
items: DynamicHeaderTabItem[] | ComputedRef<DynamicHeaderTabItem[]> | Ref<DynamicHeaderTabItem[]>
modelValue: Ref<string>
appendButtons?: DynamicHeaderTabButton[]
enableStateRestore?: boolean
}) => {
// 集成PWA状态恢复功能
const enablePWARestore = config.enableStateRestore !== false // 默认启用
const pwaTabState = enablePWARestore ? useTabStateRestore(config.modelValue.value) : null
// 如果启用了PWA状态恢复先尝试恢复状态
if (pwaTabState && pwaTabState.activeTab.value) {
config.modelValue.value = pwaTabState.activeTab.value
}
const tabConfig: DynamicHeaderTabConfig = {
items: Array.isArray(config.items) ? config.items : config.items.value,
modelValue: config.modelValue.value,
appendButtons: config.appendButtons,
routePath: route.path,
onUpdateModelValue: (value: string) => {
config.modelValue.value = value
// 同步到PWA状态
if (pwaTabState && value) {
pwaTabState.activeTab.value = value
}
},
}
// 如果启用了PWA状态恢复监听PWA状态变化并同步到modelValue
if (pwaTabState) {
watch(pwaTabState.activeTab, newTab => {
if (newTab && newTab !== config.modelValue.value) {
config.modelValue.value = newTab
// 更新tabConfig并重新注册
tabConfig.modelValue = newTab
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
}
}
})
}
// 监听modelValue变化并更新配置
watch(config.modelValue, newValue => {
tabConfig.modelValue = newValue
// 同步到PWA状态
if (pwaTabState && newValue) {
pwaTabState.activeTab.value = newValue
}
// 重新注册以更新值
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
} else if (typeof window !== 'undefined') {
// 使用全局方法作为备用
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
if (globalRegister) {
globalRegister(tabConfig)
}
}
})
// 如果items是computed或ref也需要监听其变化
if (!Array.isArray(config.items)) {
watch(
config.items,
newItems => {
tabConfig.items = newItems
// 重新注册以更新items
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
} else if (typeof window !== 'undefined') {
// 使用全局方法作为备用
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
if (globalRegister) {
globalRegister(tabConfig)
}
}
},
{ deep: true },
)
}
// 注册函数
const doRegister = () => {
// 确保路由路径是最新的
tabConfig.routePath = route.path
// 确保items是最新的
tabConfig.items = Array.isArray(config.items) ? config.items : config.items.value
// 确保modelValue是最新的
tabConfig.modelValue = config.modelValue.value
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
} else if (typeof window !== 'undefined') {
// 使用全局方法作为备用
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
if (globalRegister) {
globalRegister(tabConfig)
}
}
}
// 取消注册函数
const doUnregister = () => {
if (unregisterDynamicHeaderTab) {
unregisterDynamicHeaderTab()
}
}
// 初始注册延迟到下个tick确保路由已经完全切换
nextTick(() => {
doRegister()
})
// 处理页面激活时重新注册支持keep-alive缓存的页面
onActivated(() => {
nextTick(() => {
doRegister()
})
})
// 处理页面失活时取消注册支持keep-alive缓存的页面
onDeactivated(() => {
doUnregister()
})
// 在组件卸载时取消注册
onUnmounted(() => {
doUnregister()
})
}
// 取消注册
const unregisterHeaderTab = () => {
if (unregisterDynamicHeaderTab) {
unregisterDynamicHeaderTab()
}
}
return {
registerHeaderTab,
unregisterHeaderTab,
}
}
// 导出类型以供其他地方使用
export type { DynamicHeaderTabButton, DynamicHeaderTabItem, DynamicHeaderTabConfig }

View File

@@ -0,0 +1,61 @@
import { ref, computed } from 'vue'
import { useOnline } from '@vueuse/core'
// 全局状态
const isAppOffline = ref(false)
const appOfflineReason = ref('')
// 全局离线状态管理
export function useGlobalOfflineStatus() {
const isOnline = useOnline()
// 综合离线状态(网络离线 或 应用离线)
const isOffline = computed(() => !isOnline.value || isAppOffline.value)
// 是否可以执行网络操作
const canPerformNetworkAction = computed(() => isOnline.value && !isAppOffline.value)
// 设置应用离线状态
const setAppOffline = (offline: boolean, reason?: string) => {
isAppOffline.value = offline
appOfflineReason.value = reason || ''
}
// 获取离线消息
const getOfflineMessage = () => {
if (!isOnline.value) {
return appOfflineReason.value
}
if (isAppOffline.value) {
return appOfflineReason.value
}
return ''
}
return {
isOnline,
isOffline,
canPerformNetworkAction,
setAppOffline,
getOfflineMessage,
}
}
// 单个组件的离线状态
export function useOfflineStatus(initialMessage?: string) {
const { isOnline, isOffline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
const message = computed(() => {
if (initialMessage) {
return initialMessage
}
return getOfflineMessage()
})
return {
isOnline,
isOffline,
canPerformNetworkAction,
message,
}
}

76
src/composables/usePWA.ts Normal file
View File

@@ -0,0 +1,76 @@
import { ref, computed, onMounted } from 'vue'
import { useDisplay } from 'vuetify'
import { checkPWAStatus, isPWADisplayMode } from '@/@core/utils/navigator'
// 全局PWA状态确保只初始化一次
const globalPwaStatus = ref<{
hasPWAFeatures: boolean
isStandaloneMode: boolean
isPWAEnvironment: boolean
isFullPWA: boolean
} | null>(null)
const globalLoading = ref(false)
let initPromise: Promise<void> | null = null
// 全局初始化函数
async function initializePWAGlobally() {
if (initPromise) return initPromise
if (globalPwaStatus.value !== null || globalLoading.value) return Promise.resolve()
initPromise = new Promise(async resolve => {
globalLoading.value = true
try {
globalPwaStatus.value = await checkPWAStatus()
} catch (error) {
console.error('Failed to detect PWA status', error)
// 即使检测失败,也设置一个合理的默认值
globalPwaStatus.value = {
hasPWAFeatures: false,
isStandaloneMode: isPWADisplayMode(),
isPWAEnvironment: isPWADisplayMode(),
isFullPWA: false,
}
} finally {
globalLoading.value = false
// 无论成功还是失败都解决Promise
resolve()
}
})
return initPromise
}
export function usePWA() {
const display = useDisplay()
// 基于新的PWA状态结构
const pwaMode = computed(() => {
return globalPwaStatus.value?.isPWAEnvironment ?? false
})
const appMode = computed(() => {
return pwaMode.value && display.mdAndDown.value
})
// 详细的PWA状态信息
const pwaStatus = computed(() => globalPwaStatus.value)
// 自动初始化PWA检测
onMounted(() => {
initializePWAGlobally().catch(console.error)
})
// 如果是在服务端或首次调用,立即开始初始化
if (typeof window !== 'undefined' && globalPwaStatus.value === null && !globalLoading.value) {
initializePWAGlobally().catch(console.error)
}
return {
pwaMode,
appMode,
pwaStatus,
loading: globalLoading,
initializePWA: initializePWAGlobally,
}
}

View File

@@ -0,0 +1,182 @@
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[]
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed'
platform: string
}>
prompt(): Promise<void>
}
declare global {
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent
}
}
export function usePWAInstall() {
const isInstallable = ref(false)
const isInstalled = ref(false)
const installPrompt = ref<BeforeInstallPromptEvent | null>(null)
const installOutcome = ref<'accepted' | 'dismissed' | null>(null)
// 检查是否已安装通过检查display-mode
const checkIfInstalled = () => {
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
const isFullscreen = window.matchMedia('(display-mode: fullscreen)').matches
const isMinimalUI = window.matchMedia('(display-mode: minimal-ui)').matches
const isWindowControlsOverlay = window.matchMedia('(display-mode: window-controls-overlay)').matches
// iOS Safari特殊检查
const isIOSStandalone = (window.navigator as any).standalone === true
return isStandalone || isFullscreen || isMinimalUI || isWindowControlsOverlay || isIOSStandalone
}
// 显示安装提示
const showInstallPrompt = async () => {
if (!installPrompt.value) {
console.warn('No install prompt available')
return false
}
try {
// 显示浏览器的安装提示
await installPrompt.value.prompt()
// 等待用户响应
const { outcome } = await installPrompt.value.userChoice
installOutcome.value = outcome
// 如果用户接受安装,清除安装提示
if (outcome === 'accepted') {
isInstallable.value = false
installPrompt.value = null
isInstalled.value = true
}
return outcome === 'accepted'
} catch (error) {
console.error('Failed to show install prompt:', error)
return false
}
}
// 处理安装事件
const handleBeforeInstallPrompt = (e: BeforeInstallPromptEvent) => {
// 阻止默认行为
e.preventDefault()
// 保存安装提示
installPrompt.value = e
isInstallable.value = true
}
// 处理应用安装成功事件
const handleAppInstalled = () => {
isInstalled.value = true
isInstallable.value = false
installPrompt.value = null
}
// 检查是否支持 PWA 安装
// 使用 "onbeforeinstallprompt" 事件的存在性来判断,而不是检查
// BeforeInstallPromptEvent 构造函数(在运行时并不存在)。
// 对于不触发 beforeinstallprompt 的 iOS Safari同样允许通过
// "添加到主屏幕" 的方式安装,因此这里也认为是支持的。
const isPWASupported = computed(() => {
const hasServiceWorker = 'serviceWorker' in navigator
const supportsInstallPromptEvent = 'onbeforeinstallprompt' in window
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
return hasServiceWorker && (supportsInstallPromptEvent || isIOS)
})
// 获取安装指南(针对不同平台)
const getInstallInstructions = () => {
const ua = navigator.userAgent
const isIOS = /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream
const isAndroid = /Android/.test(ua)
const isSafari = /Safari/.test(ua) && !/Chrome/.test(ua) && !/Edg/.test(ua)
const isChrome = /Chrome/.test(ua) && !/Edg/.test(ua)
const isEdge = /Edg/.test(ua)
const isFirefox = /Firefox/.test(ua)
if (isEdge) {
return {
platform: 'Microsoft Edge',
platformKey: 'edge',
}
} else if (isIOS && isSafari) {
return {
platform: 'iOS Safari',
platformKey: 'ios',
}
} else if (isAndroid && isChrome) {
return {
platform: 'Android Chrome',
platformKey: 'android',
}
} else if (isFirefox && isAndroid) {
return {
platform: 'Android Firefox',
platformKey: 'android',
}
} else if (isFirefox) {
return {
platform: 'Firefox',
platformKey: 'firefox',
}
} else if (isChrome) {
return {
platform: 'Chrome',
platformKey: 'chrome',
}
} else if (isSafari) {
return {
platform: 'Safari',
platformKey: 'safari',
}
} else if (isAndroid) {
return {
platform: 'Mobile Browser',
platformKey: 'mobile',
}
} else {
return {
platform: 'Desktop Browser',
platformKey: 'desktop',
}
}
}
onMounted(() => {
// 检查是否已安装
isInstalled.value = checkIfInstalled()
// 监听安装提示事件
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
// 监听安装成功事件
window.addEventListener('appinstalled', handleAppInstalled)
// 监听display-mode变化
const mediaQuery = window.matchMedia('(display-mode: standalone)')
mediaQuery.addEventListener('change', e => {
isInstalled.value = e.matches
})
})
onUnmounted(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
})
return {
isInstallable,
isInstalled,
isPWASupported,
installOutcome,
showInstallPrompt,
getInstallInstructions,
}
}

View File

@@ -0,0 +1,278 @@
import { ref, computed, onMounted, onBeforeUnmount, readonly, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { usePWA } from './usePWA'
// 下拉手势配置类型
export interface PullDownConfig {
START_THRESHOLD: number // 开始下拉的最小距离
SHOW_INDICATOR: number // 显示指示器的距离
TRIGGER_THRESHOLD: number // 触发回调的距离
MAX_PULL_DISTANCE: number // 最大下拉距离
PULL_RESISTANCE: number // 下拉阻力系数
CONTENT_FOLLOW_RATIO: number // 页面内容跟随比例
TOLERANCE: number // 手指抖动容忍度
}
// 下拉手势选项
export interface PullDownOptions {
config?: Partial<PullDownConfig>
// 检查是否可以使用下拉手势的函数
canUsePullGesture?: () => boolean
// 触发回调
onTrigger?: () => void
// 是否启用默认true
enabled?: boolean
}
// 默认配置
const DEFAULT_CONFIG: PullDownConfig = {
START_THRESHOLD: 20,
SHOW_INDICATOR: 60,
TRIGGER_THRESHOLD: 100,
MAX_PULL_DISTANCE: 200,
PULL_RESISTANCE: 0.75,
CONTENT_FOLLOW_RATIO: 0.4,
TOLERANCE: 80,
}
export function usePullDownGesture(options: PullDownOptions = {}) {
const display = useDisplay()
const { appMode } = usePWA()
// 合并配置
const config = { ...DEFAULT_CONFIG, ...options.config }
// 状态管理
const isPulling = ref(false)
const startY = ref(0)
const pullDistance = ref(0)
const initialScrollTop = ref(0)
const hasDialogOpen = ref(false)
const lastDialogCheckTime = ref(0)
const DIALOG_CHECK_INTERVAL = 500
// 计算属性
const contentTransform = computed(() => {
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
const moveDistance = pullDistance.value * config.CONTENT_FOLLOW_RATIO
return `translateY(${moveDistance}px)`
})
const contentTransition = computed(() => {
return isPulling.value ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
})
const showPullIndicator = computed(() => {
return isPulling.value && pullDistance.value >= config.SHOW_INDICATOR
})
const indicatorRotation = computed(() => {
if (!isPulling.value) return 0
const progress = Math.min(
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
1,
)
return progress * 180
})
const indicatorOpacity = computed(() => {
if (!isPulling.value) return 0
const progress = Math.min(
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
1,
)
return 0.7 + progress * 0.3
})
const indicatorTransform = computed(() => {
return `translate(-50%, ${Math.min(60 + pullDistance.value - config.SHOW_INDICATOR, 70)}px)`
})
// 弹窗检测函数
const hasOpenDialog = (excludeSelector?: string) => {
try {
const dialogSelectors = [
'.v-overlay--active:not(.v-overlay--scroll-blocked)',
'.v-dialog--active',
'.v-menu--active',
'.v-bottom-sheet--active',
'.v-snackbar--active',
'[role="dialog"]:not([style*="display: none"])',
'.modal:not(.d-none):not([style*="display: none"])',
'[aria-modal="true"]:not([style*="display: none"])',
]
for (const selector of dialogSelectors) {
const elements = document.querySelectorAll(selector)
if (elements.length > 0) {
// 如果需要排除特定元素如QuickAccess面板
if (excludeSelector && elements.length === 1) {
const element = elements[0]
if (element.closest(excludeSelector)) {
continue
}
}
return true
}
}
return false
} catch (error) {
console.warn('检测弹窗状态时出错:', error)
return true
}
}
// 事件处理函数
const handleTouchStart = (event: TouchEvent) => {
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
// 检查是否可以使用下拉手势
if (options.canUsePullGesture && !options.canUsePullGesture()) return
// 检查是否有弹窗打开
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
lastDialogCheckTime.value = Date.now()
if (hasDialogOpen.value) return
const touch = event.touches[0]
startY.value = touch.clientY
// 重置下拉状态
isPulling.value = false
pullDistance.value = 0
// 记录开始时的滚动位置
initialScrollTop.value = window.scrollY || document.documentElement.scrollTop || 0
}
const handleTouchMove = (event: TouchEvent) => {
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
// 检查是否可以使用下拉手势
if (options.canUsePullGesture && !options.canUsePullGesture()) return
// 只在必要时重新检测弹窗
const currentTime = Date.now()
if (currentTime - lastDialogCheckTime.value > DIALOG_CHECK_INTERVAL) {
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
lastDialogCheckTime.value = currentTime
}
if (hasDialogOpen.value) {
isPulling.value = false
pullDistance.value = 0
return
}
const touch = event.touches[0]
const deltaY = touch.clientY - startY.value
if (isPulling.value) {
if (deltaY > -config.TOLERANCE) {
pullDistance.value = Math.max(0, Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE))
event.preventDefault()
} else {
isPulling.value = false
pullDistance.value = 0
}
} else {
if (deltaY > config.START_THRESHOLD) {
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
isPulling.value = true
pullDistance.value = Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE)
event.preventDefault()
}
}
}
}
const handleTouchEnd = () => {
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
// 检查是否可以使用下拉手势
if (options.canUsePullGesture && !options.canUsePullGesture()) return
// 重置弹窗检测标志
hasDialogOpen.value = false
lastDialogCheckTime.value = 0
if (isPulling.value && pullDistance.value >= config.TRIGGER_THRESHOLD) {
// 达到触发阈值,执行回调
options.onTrigger?.()
}
// 停止拖拽状态
isPulling.value = false
// 延迟重置其他状态
setTimeout(() => {
pullDistance.value = 0
startY.value = 0
}, 300)
}
// 生命周期管理
let eventsAdded = false
const addEventListeners = () => {
if (!eventsAdded && appMode.value) {
document.addEventListener('touchstart', handleTouchStart, { passive: false })
document.addEventListener('touchmove', handleTouchMove, { passive: false })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
eventsAdded = true
}
}
const removeEventListeners = () => {
if (eventsAdded) {
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('touchend', handleTouchEnd)
eventsAdded = false
}
}
// PWA状态确定后一次性决定是否添加事件监听器
onMounted(() => {
// 等待PWA检测完成后添加事件监听器
const stopWatcher = watch(
appMode,
newValue => {
if (newValue) {
addEventListeners()
// PWA状态确定后停止监听
stopWatcher()
}
},
{ immediate: true },
)
})
onBeforeUnmount(() => {
removeEventListeners()
})
return {
// 状态
isPulling: readonly(isPulling),
pullDistance: readonly(pullDistance),
// 计算属性
contentTransform,
contentTransition,
showPullIndicator,
indicatorRotation,
indicatorOpacity,
indicatorTransform,
// 配置
config,
// 工具函数
hasOpenDialog,
}
}

View File

@@ -0,0 +1,113 @@
import type { Plugin } from '@/api/types'
const RECENT_PLUGINS_KEY = 'moviepilot_recent_plugins'
const MAX_RECENT_PLUGINS = 3
interface RecentPlugin {
id: string
plugin_name: string
plugin_icon?: string
has_page: boolean
state: boolean
plugin_id: string
access_time: number
}
// 将Plugin转换为RecentPlugin
function pluginToRecentPlugin(plugin: Plugin): RecentPlugin {
return {
id: plugin.id || '',
plugin_name: plugin.plugin_name || '',
plugin_icon: plugin.plugin_icon,
has_page: plugin.has_page || false,
state: plugin.state || false,
plugin_id: plugin.id || '',
access_time: Date.now(),
}
}
// 将RecentPlugin转换为Plugin
function recentPluginToPlugin(recentPlugin: RecentPlugin): Plugin {
return {
id: recentPlugin.id,
plugin_name: recentPlugin.plugin_name,
plugin_icon: recentPlugin.plugin_icon,
has_page: recentPlugin.has_page,
state: recentPlugin.state,
plugin_id: recentPlugin.plugin_id,
} as Plugin
}
export function useRecentPlugins() {
// 获取最近访问的插件
function getRecentPlugins(): Plugin[] {
try {
const stored = localStorage.getItem(RECENT_PLUGINS_KEY)
if (!stored) return []
const recentPlugins: RecentPlugin[] = JSON.parse(stored)
// 按访问时间倒序排列
return recentPlugins.sort((a, b) => b.access_time - a.access_time).map(recentPluginToPlugin)
} catch (error) {
console.error(error)
return []
}
}
// 添加插件到最近访问
function addRecentPlugin(plugin: Plugin) {
try {
if (!plugin.id || !plugin.has_page) return
const stored = localStorage.getItem(RECENT_PLUGINS_KEY)
let recentPlugins: RecentPlugin[] = stored ? JSON.parse(stored) : []
// 移除已存在的相同插件(如果有的话)
recentPlugins = recentPlugins.filter(p => p.id !== plugin.id)
// 添加新的插件到开头
recentPlugins.unshift(pluginToRecentPlugin(plugin))
// 限制最大数量
if (recentPlugins.length > MAX_RECENT_PLUGINS) {
recentPlugins = recentPlugins.slice(0, MAX_RECENT_PLUGINS)
}
localStorage.setItem(RECENT_PLUGINS_KEY, JSON.stringify(recentPlugins))
} catch (error) {
console.error(error)
}
}
// 清除所有最近访问记录
function clearRecentPlugins() {
try {
localStorage.removeItem(RECENT_PLUGINS_KEY)
} catch (error) {
console.error(error)
}
}
// 移除特定插件
function removeRecentPlugin(pluginId: string) {
try {
const stored = localStorage.getItem(RECENT_PLUGINS_KEY)
if (!stored) return
let recentPlugins: RecentPlugin[] = JSON.parse(stored)
recentPlugins = recentPlugins.filter(p => p.id !== pluginId)
localStorage.setItem(RECENT_PLUGINS_KEY, JSON.stringify(recentPlugins))
} catch (error) {
console.error(error)
}
}
return {
getRecentPlugins,
addRecentPlugin,
clearRecentPlugins,
removeRecentPlugin,
}
}

View File

@@ -0,0 +1,159 @@
import { ref, watch, onBeforeUnmount, readonly } from 'vue'
// 滚动锁定配置选项
export interface ScrollLockOptions {
// 是否在组件卸载时自动恢复滚动默认true
autoRestore?: boolean
// 是否保存和恢复滚动位置默认true
preserveScrollPosition?: boolean
// 自定义锁定时的样式
lockStyles?: {
overflow?: string
position?: string
width?: string
}
}
// 默认配置
const DEFAULT_OPTIONS: Required<ScrollLockOptions> = {
autoRestore: true,
preserveScrollPosition: true,
lockStyles: {
overflow: 'hidden',
position: 'fixed',
width: '100%',
},
}
export function useScrollLock(options: ScrollLockOptions = {}) {
const config = { ...DEFAULT_OPTIONS, ...options }
// 状态管理
const isLocked = ref(false)
const savedScrollPosition = ref(0)
const originalBodyStyles = ref<{ [key: string]: string }>({})
const originalDocumentStyles = ref<{ [key: string]: string }>({})
// 保存当前滚动位置
const saveScrollPosition = () => {
if (config.preserveScrollPosition) {
savedScrollPosition.value =
window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
}
}
// 保存原始样式
const saveOriginalStyles = () => {
// 保存 body 样式
originalBodyStyles.value = {
overflow: document.body.style.overflow,
position: document.body.style.position,
top: document.body.style.top,
width: document.body.style.width,
}
// 保存 documentElement 样式
originalDocumentStyles.value = {
overflow: document.documentElement.style.overflow,
}
}
// 锁定滚动
const lockScroll = () => {
if (isLocked.value) return
// 保存当前状态
saveScrollPosition()
saveOriginalStyles()
// 应用锁定样式
document.body.style.overflow = config.lockStyles.overflow || 'hidden'
document.body.style.position = config.lockStyles.position || 'fixed'
document.body.style.width = config.lockStyles.width || '100%'
document.documentElement.style.overflow = config.lockStyles.overflow || 'hidden'
// 如果需要保持滚动位置设置top偏移
if (config.preserveScrollPosition) {
document.body.style.top = `-${savedScrollPosition.value}px`
}
isLocked.value = true
}
// 恢复滚动
const restoreScroll = () => {
if (!isLocked.value) return
// 恢复原始样式
document.body.style.overflow = originalBodyStyles.value.overflow || ''
document.body.style.position = originalBodyStyles.value.position || ''
document.body.style.top = originalBodyStyles.value.top || ''
document.body.style.width = originalBodyStyles.value.width || ''
document.documentElement.style.overflow = originalDocumentStyles.value.overflow || ''
// 恢复滚动位置
if (config.preserveScrollPosition) {
window.scrollTo(0, savedScrollPosition.value)
}
isLocked.value = false
}
// 切换滚动锁定状态
const toggleScrollLock = (lock?: boolean) => {
const shouldLock = lock !== undefined ? lock : !isLocked.value
if (shouldLock) {
lockScroll()
} else {
restoreScroll()
}
}
// 监听响应式值的变化
const watchTarget = (target: any) => {
return watch(
target,
newValue => {
toggleScrollLock(!!newValue)
},
{ immediate: false },
)
}
// 生命周期清理
onBeforeUnmount(() => {
if (config.autoRestore && isLocked.value) {
restoreScroll()
}
})
return {
// 状态
isLocked: readonly(isLocked),
savedScrollPosition: readonly(savedScrollPosition),
// 方法
lockScroll,
restoreScroll,
toggleScrollLock,
watchTarget,
// 工具方法
saveScrollPosition,
}
}
// 便捷的自动监听版本
export function useScrollLockWithWatch(target: any, options: ScrollLockOptions = {}) {
const scrollLock = useScrollLock(options)
// 自动监听目标值的变化
const stopWatcher = scrollLock.watchTarget(target)
// 返回所有功能 + 停止监听的方法
return {
...scrollLock,
stopWatcher,
}
}

View File

@@ -0,0 +1,192 @@
/**
* PWA状态恢复组合式API
* 提供2个专门的hooks路由、标签页
*/
import { ref, onMounted, onUnmounted, watch, inject } from 'vue'
import { useRoute } from 'vue-router'
import type { StateRestore } from '@/plugins/stateRestore'
// =============================================================================
// 1. 动态标签页状态恢复
// =============================================================================
/**
* 动态标签页状态恢复Hook
* 自动保存和恢复v-tabs的当前激活标签
*/
export function useTabStateRestore(defaultTab?: string) {
const route = useRoute()
const stateRestore = inject<StateRestore>('stateRestore')
const activeTab = ref<string>(defaultTab || '')
// 保存标签页状态
const saveTabState = (tab: string) => {
if (stateRestore && tab) {
stateRestore.tab.saveTabState(route.path, tab)
}
}
// 恢复标签页状态
const restoreTabState = () => {
if (stateRestore) {
const savedTab = stateRestore.tab.getTabState(route.path)
if (savedTab) {
activeTab.value = savedTab
console.log(`恢复标签页状态: ${route.path} -> ${savedTab}`)
return true
}
}
return false
}
// 监听activeTab变化自动保存
watch(activeTab, newTab => {
if (newTab) {
saveTabState(newTab)
}
})
// 组件挂载时恢复状态
onMounted(() => {
// 先尝试恢复,如果没有保存的状态则使用默认值
if (!restoreTabState() && defaultTab) {
activeTab.value = defaultTab
}
})
// 监听全局恢复事件
const handleRestore = () => {
restoreTabState()
}
onMounted(() => {
window.addEventListener('pwa-state-restore', handleRestore)
})
onUnmounted(() => {
window.removeEventListener('pwa-state-restore', handleRestore)
})
return {
activeTab,
saveTabState,
restoreTabState,
}
}
// =============================================================================
// 2. 路由状态恢复
// =============================================================================
/**
* 路由状态恢复Hook
* 获取路由恢复信息,主要用于调试和监控
*/
export function useRouteStateRestore() {
const stateRestore = inject<StateRestore>('stateRestore')
const lastRestoredRoute = ref<any>(null)
// 获取上次保存的路由
const getLastSavedRoute = () => {
if (stateRestore) {
return stateRestore.route.restoreRoute()
}
return null
}
// 手动保存当前路由
const saveCurrentRoute = () => {
if (stateRestore) {
stateRestore.route.saveCurrentRoute()
}
}
// 清除路由状态
const clearRouteState = () => {
if (stateRestore) {
stateRestore.route.clearRoute()
}
}
// 监听全局恢复事件
const handleRestore = (event: Event) => {
const customEvent = event as CustomEvent
if (customEvent.detail?.route) {
lastRestoredRoute.value = customEvent.detail.route
}
}
onMounted(() => {
window.addEventListener('pwa-state-restore', handleRestore)
})
onUnmounted(() => {
window.removeEventListener('pwa-state-restore', handleRestore)
})
return {
lastRestoredRoute,
getLastSavedRoute,
saveCurrentRoute,
clearRouteState,
}
}
// =============================================================================
// 3. 全量状态恢复Hook
// =============================================================================
/**
* 全量状态恢复Hook
* 用于清理所有状态或获取统计信息
*/
export function useStateRestore() {
const stateRestore = inject<StateRestore>('stateRestore')
// 清除所有状态
const clearAllStates = () => {
if (stateRestore) {
stateRestore.clearAllStates()
console.log('已清除所有PWA状态')
}
}
// 获取状态统计
const getStateStats = () => {
if (!stateRestore) return null
return {
hasRoute: !!stateRestore.route.restoreRoute(),
// 可以扩展更多统计信息
}
}
return {
clearAllStates,
getStateStats,
stateRestore,
}
}
// =============================================================================
// 4. 快捷Hook组合
// =============================================================================
/**
* 页面级状态恢复Hook
* 组合路由和标签页状态恢复功能,适用于有标签页的页面
*/
export function usePageStateRestore(defaultTab?: string) {
const tabs = defaultTab ? useTabStateRestore(defaultTab) : null
const route = useRouteStateRestore()
const global = useStateRestore()
return {
tabs,
route,
global,
}
}

View File

@@ -7,17 +7,27 @@ import UserNofification from '@/layouts/components/UserNotification.vue'
import SearchBar from '@/layouts/components/SearchBar.vue'
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import QuickAccess from '@/layouts/components/QuickAccess.vue'
import HeaderTab from '@/layouts/components/HeaderTab.vue'
import { useUserStore } from '@/stores'
import { getNavMenus } from '@/router/i18n-menu'
import { NavMenu } from '@/@layouts/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { filterMenusByPermission } from '@/utils/permission'
import { onUnreadMessage } from '@/utils/badge'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
import { useScrollLockWithWatch } from '@/composables/useScrollLock'
import { usePWA } from '@/composables/usePWA'
import OfflinePage from '@/layouts/components/OfflinePage.vue'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
const display = useDisplay()
const appMode = inject('pwaMode')
// PWA模式检测
const { appMode } = usePWA()
const { t } = useI18n()
const route = useRoute()
// 用户 Store
const userStore = useUserStore()
@@ -49,6 +59,145 @@ const organizeMenus = ref<NavMenu[]>([])
// 系统菜单项
const systemMenus = ref<NavMenu[]>([])
// 插件快速访问相关状态
const showPluginQuickAccess = ref(false)
// 离线状态管理
const { setAppOffline, isOffline } = useGlobalOfflineStatus()
// 动态标签页相关
// 定义动态标签页类型
interface DynamicHeaderTab {
items: Array<{ title: string; icon: string; tab: string }>
modelValue: string
appendButtons?: Array<{
icon: string
color?: string | ComputedRef<string>
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
size?: string
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
dataAttr?: string
}>
routePath?: string // 用于标识哪个路由注册的
onUpdateModelValue?: (value: string) => void // 用于通知值更新
}
// 提供动态标签页注册和获取的方法
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
// 提供一个方法让其他组件注册动态标签页
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
// 保存注册标签页的路由路径
tab.routePath = route.path
// 强制更新,确保响应式系统能检测到变化
dynamicHeaderTab.value = { ...tab }
}
// 提供一个方法让其他组件取消注册动态标签页
const unregisterDynamicHeaderTab = () => {
dynamicHeaderTab.value = null
}
// 标签页值更新处理
const handleTabChange = (newValue: string) => {
if (dynamicHeaderTab.value) {
dynamicHeaderTab.value.modelValue = newValue
// 通知注册的页面更新值
if (dynamicHeaderTab.value.onUpdateModelValue) {
dynamicHeaderTab.value.onUpdateModelValue(newValue)
}
}
}
// 添加全局注册方法,解决注入不可用的问题
if (typeof window !== 'undefined') {
// 确保在浏览器环境中
;(window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__ = registerDynamicHeaderTab
}
// 提供给其他组件使用
provide('registerDynamicHeaderTab', registerDynamicHeaderTab)
provide('unregisterDynamicHeaderTab', unregisterDynamicHeaderTab)
// 监听路由变化来清除动态标签页
watch(
() => route.path,
() => {
// 使用nextTick确保新页面的组件已经挂载完成
nextTick(() => {
// 如果当前标签页不属于新路由,则清除
if (dynamicHeaderTab.value && dynamicHeaderTab.value.routePath !== route.path) {
dynamicHeaderTab.value = null
}
})
},
{ immediate: false },
)
// 显示动态标签页
const showDynamicHeaderTab = computed(() => {
return (
dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path
)
})
// 在组件销毁时清理
onUnmounted(() => {
dynamicHeaderTab.value = null
// 清理全局方法
if (typeof window !== 'undefined') {
delete (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
}
})
// 监听Service Worker消息
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'OFFLINE_STATUS') {
if (event.data.offline) {
setAppOffline(true, t('common.serverConnectionFailed'))
} else {
setAppOffline(false)
}
}
}
// 使用滚动锁定 composable自动监听showPluginQuickAccess的变化
useScrollLockWithWatch(showPluginQuickAccess)
// 检查是否可以使用下拉手势
const canUsePullGesture = () => {
// 检查是否在dashboard页面
const isDashboard = route.path === '/dashboard' || route.path === '/'
// 检查是否是管理员
const isAdmin = superUser.value
// 检查插件快速访问面板是否已显示
const quickAccessOpen = showPluginQuickAccess.value
// 检查是否离线
const offline = isOffline.value
return isDashboard && isAdmin && !quickAccessOpen && !offline
}
// 使用下拉手势 composable
const {
pullDistance,
contentTransform,
contentTransition,
showPullIndicator,
indicatorRotation,
indicatorOpacity,
indicatorTransform,
config: PULL_CONFIG,
} = usePullDownGesture({
enabled: true,
canUsePullGesture,
onTrigger: () => {
showPluginQuickAccess.value = true
},
})
// 根据分类获取菜单列表
const getMenuList = (header: string) => {
// 使用国际化菜单
@@ -74,6 +223,16 @@ function handleUnreadMessage(count: number) {
}
}
// 关闭插件快速访问
function handleClosePluginQuickAccess() {
showPluginQuickAccess.value = false
}
// 点击插件后关闭
function handlePluginClick() {
showPluginQuickAccess.value = false
}
onMounted(() => {
// 获取菜单列表
startMenus.value = getMenuList(t('menu.start'))
@@ -85,18 +244,53 @@ onMounted(() => {
// 监听全局未读消息事件
const unsubscribe = onUnreadMessage(handleUnreadMessage)
// 监听Service Worker消息
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
// 组件卸载时清理监听
onBeforeUnmount(() => {
unsubscribe()
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
})
})
</script>
<template>
<VerticalNavLayout>
<!-- 👉 Offline Page -->
<OfflinePage />
<!-- 👉 Pull Down Indicator -->
<div
v-if="appMode && showPullIndicator"
class="pull-indicator"
:style="{
opacity: indicatorOpacity,
transform: indicatorTransform,
}"
>
<div
class="indicator-icon"
:style="{
transform: `scale(${
1 + Math.min((pullDistance - PULL_CONFIG.SHOW_INDICATOR) / PULL_CONFIG.MAX_PULL_DISTANCE, 0.5) * 0.3
}) rotate(${indicatorRotation}deg)`,
}"
>
<VIcon
icon="mdi-gesture-swipe-down"
size="24"
:color="pullDistance >= PULL_CONFIG.TRIGGER_THRESHOLD ? 'success' : 'primary'"
/>
</div>
</div>
<VerticalNavLayout :style="{ '--navbar-tab-height': showDynamicHeaderTab ? '2.5rem' : '0px' }">
<!-- 👉 Navbar -->
<template #navbar="{ toggleVerticalOverlayNavActive }">
<div class="d-flex h-100 align-center mx-1">
<div class="d-flex h-14 align-center mx-1">
<!-- 👉 Vertical Nav Toggle -->
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
<VIcon icon="mdi-menu" />
@@ -155,22 +349,124 @@ onMounted(() => {
</template>
<template #after-vertical-nav-items />
<!-- 👉 Pages -->
<slot />
<!-- 👉 Dynamic Header Tab -->
<template #dynamic-header-tab>
<div v-if="showDynamicHeaderTab">
<HeaderTab
:items="dynamicHeaderTab!.items"
:model-value="dynamicHeaderTab!.modelValue"
@update:model-value="handleTabChange"
>
<template #append>
<template v-for="button in dynamicHeaderTab!.appendButtons" :key="button.icon">
<VBtn
v-if="typeof button.show === 'boolean' ? button.show !== false : (button.show as any)?.value !== false"
:icon="button.icon"
:variant="button.variant || 'text'"
:color="typeof button.color === 'string' ? button.color : (button.color as any)?.value || 'gray'"
:size="button.size || 'default'"
:class="button.class || 'settings-icon-button'"
:data-menu-activator="button.dataAttr"
@click="button.action"
/>
</template>
</template>
</HeaderTab>
</div>
</template>
<!-- 👉 下拉跟随动画 -->
<div
class="main-content-wrapper"
:style="{
transform: contentTransform,
transition: contentTransition,
paddingTop: showDynamicHeaderTab ? '3rem' : '0px',
}"
>
<slot />
</div>
<!-- 👉 Footer -->
<template #footer>
<Footer />
</template>
</VerticalNavLayout>
<!-- 👉 Plugin Quick Access -->
<QuickAccess
v-if="appMode"
:visible="showPluginQuickAccess"
:pull-distance="pullDistance"
@close="handleClosePluginQuickAccess"
@plugin-click="handlePluginClick"
/>
</template>
<style lang="scss" scoped>
.meta-key {
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 6px;
block-size: 1.5625rem;
line-height: 1.3125rem;
padding-block: 0.125rem;
padding-inline: 0.25rem;
.main-content-wrapper {
backface-visibility: hidden;
block-size: 100%;
inline-size: 100%;
transform: translateZ(0);
will-change: transform;
}
.pull-indicator {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 50%;
backdrop-filter: blur(20px);
background: rgba(var(--v-theme-surface), 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 10%), 0 1px 3px rgba(0, 0, 0, 6%);
inset-block-start: 80px;
inset-inline-start: 50%;
pointer-events: none;
transform: translateX(-50%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.indicator-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--v-theme-primary), 0.08);
block-size: 40px;
inline-size: 40px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 透明主题适配 */
html[class*='transparent'] .pull-indicator,
html[class*='mica'] .pull-indicator,
html[class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 20%);
background: rgba(255, 255, 255, 95%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 12%), 0 4px 16px rgba(0, 0, 0, 8%);
}
html[class*='transparent'] .indicator-icon,
html[class*='mica'] .indicator-icon,
html[class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.12);
}
html[data-theme='dark'][class*='transparent'] .pull-indicator,
html[data-theme='dark'][class*='mica'] .pull-indicator,
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 10%);
background: rgba(18, 18, 18, 95%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 30%), 0 4px 16px rgba(0, 0, 0, 20%);
}
html[data-theme='dark'][class*='transparent'] .indicator-icon,
html[data-theme='dark'][class*='mica'] .indicator-icon,
html[data-theme='dark'][class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.15);
}
</style>

View File

@@ -5,9 +5,11 @@ import { NavMenu } from '@/@layouts/types'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores'
import { filterMenusByPermission } from '@/utils/permission'
import { usePWA } from '@/composables/usePWA'
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
const { t, locale } = useI18n()
// 判断当前是否为英文环境
@@ -245,8 +247,8 @@ const showDynamicButton = computed(() => {
.footer-nav-card {
position: relative;
overflow: hidden;
backdrop-filter: blur(16px);
background-color: rgba(var(--v-theme-surface), 0.6);
backdrop-filter: blur(24px);
background-color: rgba(var(--v-theme-surface), 0.3);
pointer-events: auto;
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useTabStateRestore } from '@/composables/useStateRestore'
const props = defineProps({
modelValue: {
type: String,
@@ -8,23 +10,52 @@ const props = defineProps({
type: Array as PropType<{ title: string; icon: string; tab: string }[]>,
default: () => [],
},
// 新增是否启用PWA状态恢复
enableStateRestore: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['update:modelValue'])
const currentValue = ref(props.modelValue)
// 集成PWA状态恢复功能
const pwaTabState = props.enableStateRestore ? useTabStateRestore(props.modelValue) : null
// 使用PWA状态恢复的activeTab或本地状态
const currentValue = ref(pwaTabState?.activeTab.value || props.modelValue)
// 监听currentValue变化同时更新PWA状态和父组件
watch(currentValue, newVal => {
emit('update:modelValue', newVal)
// 如果启用了PWA状态恢复同步更新PWA状态
if (pwaTabState && newVal) {
pwaTabState.activeTab.value = newVal
}
})
// 监听父组件的modelValue变化
watch(
() => props.modelValue,
value => {
currentValue.value = value
// 同步到PWA状态
if (pwaTabState && value) {
pwaTabState.activeTab.value = value
}
},
)
// 如果启用了PWA状态恢复监听PWA状态变化
if (pwaTabState) {
watch(pwaTabState.activeTab, newTab => {
if (newTab && newTab !== currentValue.value) {
currentValue.value = newTab
emit('update:modelValue', newTab)
}
})
}
// Ref for the tabs container
const tabsContainerRef = ref<HTMLElement | null>(null)
// State for showing the scroll indicator
@@ -38,7 +69,8 @@ const scrollTabs = (direction: 'left' | 'right') => {
const el = tabsContainerRef.value
if (!el) return
const scrollAmount = 200 // 可以根据需要调整滚动量
// 可以根据需要调整滚动量
const scrollAmount = 200
const scrollPosition = direction === 'left' ? el.scrollLeft - scrollAmount : el.scrollLeft + scrollAmount
el.scrollTo({
@@ -77,9 +109,6 @@ onMounted(async () => {
// Initial check for tabs indicator after DOM update
await nextTick() // Ensure element is rendered
updateTabsIndicator()
// Listen for scroll events specifically on the tabs container
tabsContainerRef.value?.addEventListener('scroll', updateTabsIndicator, { passive: true })
})
onUnmounted(() => {
@@ -90,7 +119,7 @@ onUnmounted(() => {
})
</script>
<template>
<div class="tab-header rounded-t-lg">
<div class="tab-header">
<VBtn v-if="showLeftButton" class="scroll-button left-button" @click="scrollTabs('left')" variant="text" icon>
<VIcon icon="tabler-chevron-left" size="small" color="secondary" />
</VBtn>
@@ -117,17 +146,11 @@ onUnmounted(() => {
</template>
<style scoped lang="scss">
.tab-header {
position: sticky;
z-index: 10;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
backdrop-filter: blur(10px);
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.05);
inset-block-start: 0;
margin-block-end: 16px;
padding-block: 8px;
padding-inline: 16px;
transition: all 0.3s ease;
}
.scroll-button {
@@ -191,6 +214,7 @@ onUnmounted(() => {
.header-tab-icon {
color: rgba(var(--v-theme-on-background), 0.6);
margin-inline-end: 6px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
transition: color 0.2s ease;
}
@@ -206,6 +230,7 @@ onUnmounted(() => {
font-weight: 600;
padding-block: 6px;
padding-inline: 14px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 10%);
transition: all 0.2s ease;
white-space: nowrap;
@@ -224,6 +249,7 @@ onUnmounted(() => {
&.active {
color: rgb(var(--v-theme-primary));
text-shadow: 0 1px 3px rgba(0, 0, 0, 15%);
&::after {
transform: translateX(-50%) scaleX(1);
@@ -231,6 +257,7 @@ onUnmounted(() => {
.header-tab-icon {
color: rgb(var(--v-theme-primary));
text-shadow: 0 1px 3px rgba(0, 0, 0, 15%);
}
}

View File

@@ -0,0 +1,344 @@
<script setup lang="ts">
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
interface Props {
type?: 'offline' | 'online'
}
const props = withDefaults(defineProps<Props>(), {
type: 'offline',
})
const { t } = useI18n()
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
// 重试连接
const retrying = ref(false)
const handleRetry = async () => {
if (retrying.value) return
retrying.value = true
try {
// 尝试发送一个简单的请求来检测网络
await fetch('/favicon.ico?' + new Date().getTime(), {
method: 'HEAD',
cache: 'no-cache',
})
// 如果成功,等待一下让状态更新
setTimeout(() => {
retrying.value = false
}, 1000)
} catch (error) {
retrying.value = false
}
}
// 当网络恢复时自动隐藏页面
const shouldShow = computed(() => {
return !canPerformNetworkAction.value
})
// 状态文本
const statusText = computed(() => {
if (props.type === 'online') {
return t('app.onlineMessage')
}
return getOfflineMessage()
})
// 图标
const statusIcon = computed(() => {
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
})
// 颜色主题
const colorTheme = computed(() => {
return props.type === 'online' ? 'success' : 'error'
})
// 动画时长
const ENTER_DURATION = 600
const LEAVE_DURATION = 400
// 进入动画
function onEnter(el: HTMLElement, done: () => void) {
// 初始状态
el.style.opacity = '0'
el.style.transform = 'scale(0.9)'
el.style.filter = 'blur(10px)'
// 强制重绘
el.offsetHeight
// 应用过渡
el.style.transition = `all ${ENTER_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`
// 目标状态
requestAnimationFrame(() => {
el.style.opacity = '1'
el.style.transform = 'scale(1)'
el.style.filter = 'blur(0)'
})
// 动画完成
setTimeout(done, ENTER_DURATION)
}
// 离开动画
function onLeave(el: HTMLElement, done: () => void) {
// 应用过渡
el.style.transition = `all ${LEAVE_DURATION}ms cubic-bezier(0.4, 0, 1, 1)`
// 目标状态
requestAnimationFrame(() => {
el.style.opacity = '0'
el.style.transform = 'scale(1.1)'
el.style.filter = 'blur(20px)'
})
// 动画完成
setTimeout(done, LEAVE_DURATION)
}
</script>
<template>
<Teleport to="body">
<Transition
:css="false"
@enter="onEnter"
@leave="onLeave"
>
<div v-if="shouldShow" class="offline-page" ref="offlinePage">
<div class="offline-container" :class="{ 'container-animate': shouldShow }">
<!-- 状态图标 -->
<div class="status-icon-wrapper">
<div class="status-icon-bg">
<VIcon :icon="statusIcon" size="64" :color="colorTheme" />
</div>
</div>
<!-- 主要信息 -->
<div class="content-section">
<h1 class="offline-title">
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
</h1>
<p class="offline-message">
{{ statusText }}
</p>
<!-- 重试按钮 -->
<div class="action-section">
<VBtn
v-if="props.type === 'offline'"
:loading="retrying"
:color="colorTheme"
size="large"
variant="flat"
@click="handleRetry"
>
<VIcon icon="mdi-refresh" class="me-2" />
{{ retrying ? t('common.checking') : t('common.retry') }}
</VBtn>
</div>
<!-- 状态指示器 -->
<div class="status-indicators">
<VChip
:color="isOnline ? 'success' : 'error'"
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
variant="tonal"
class="me-2"
>
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
</VChip>
<VChip
:color="canPerformNetworkAction ? 'success' : 'warning'"
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
variant="tonal"
>
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
</VChip>
</div>
</div>
<!-- 底部信息 -->
<div class="footer-section">
<p class="app-info">{{ t('app.moviepilot') }}</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.offline-page {
position: fixed;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(10px);
background: linear-gradient(135deg, rgb(var(--v-theme-surface)) 0%, rgb(var(--v-theme-surface-variant)) 100%);
inset: 0;
will-change: transform, opacity, filter;
}
.offline-container {
padding: 40px;
border-radius: 24px;
background: rgb(var(--v-theme-surface));
box-shadow: 0 20px 40px rgba(0, 0, 0, 10%), 0 0 0 1px rgba(var(--v-border-color), var(--v-border-opacity));
inline-size: 100%;
max-inline-size: 500px;
text-align: center;
opacity: 0;
transform: translateY(20px);
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.offline-page .offline-container.container-animate {
opacity: 1;
transform: translateY(0);
transition-delay: 0.2s;
}
.status-icon-wrapper {
margin-block-end: 32px;
}
.status-icon-bg {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--v-theme-surface-variant), 0.5);
block-size: 120px;
inline-size: 120px;
margin-block: 0;
margin-inline: auto;
}
.status-icon-bg {
animation: iconPulse 3s ease-in-out infinite;
}
.status-icon-bg::before {
position: absolute;
z-index: -1;
border-radius: 50%;
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
content: '';
inset: -4px;
opacity: 0.1;
animation: iconGlow 2s ease-in-out infinite alternate;
}
@keyframes iconPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes iconGlow {
0% {
opacity: 0.1;
transform: scale(1);
}
100% {
opacity: 0.3;
transform: scale(1.1);
}
}
.content-section {
margin-block-end: 32px;
}
.offline-title {
color: rgb(var(--v-theme-on-surface));
font-size: 2rem;
font-weight: 600;
margin-block-end: 16px;
}
.offline-message {
color: rgb(var(--v-theme-on-surface));
font-size: 1.1rem;
line-height: 1.6;
margin-block-end: 32px;
opacity: 0.7;
}
.action-section {
margin-block-end: 32px;
}
.status-indicators {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.help-section {
margin-block-end: 32px;
}
.help-panels {
text-align: start;
}
.footer-section {
opacity: 0.7;
}
.app-info {
color: rgb(var(--v-theme-on-surface));
font-size: 0.875rem;
}
/* 移动端优化 */
@media (width <= 600px) {
.offline-container {
padding: 24px;
margin: 16px;
}
.offline-title {
font-size: 1.5rem;
}
.offline-message {
font-size: 1rem;
}
.status-icon-bg {
block-size: 100px;
inline-size: 100px;
}
.status-indicators {
flex-direction: column;
align-items: center;
}
}
/* 暗黑模式优化 */
.v-theme--dark .offline-page {
background: linear-gradient(135deg, rgb(var(--v-theme-surface)) 0%, rgba(var(--v-theme-surface-variant), 0.8) 100%);
}
.v-theme--dark .offline-container {
box-shadow: 0 20px 40px rgba(0, 0, 0, 30%), 0 0 0 1px rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>

View File

@@ -0,0 +1,709 @@
<script setup lang="ts">
import api from '@/api'
import type { Plugin } from '@/api/types'
import noImage from '@images/logos/plugin.png'
import { useI18n } from 'vue-i18n'
import { useRecentPlugins } from '@/composables/useRecentPlugins'
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
import { VCard } from 'vuetify/components'
import { getDominantColor } from '@/@core/utils/image'
// 国际化
const { t } = useI18n()
// 最近访问插件管理
const { getRecentPlugins, addRecentPlugin } = useRecentPlugins()
// 输入参数
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
pullDistance: {
type: Number,
default: 0,
},
})
// 事件
const emit = defineEmits<{
(e: 'close'): void
(e: 'plugin-click', plugin: Plugin): void
}>()
// 有详情页面的插件列表
const pluginsWithPage = ref<Plugin[]>([])
// 最近访问的插件列表
const recentPlugins = ref<Plugin[]>([])
// 是否加载中
const loading = ref(false)
// 各插件的图标加载状态
const pluginIconLoadError = ref<Record<string, boolean>>({})
// 各插件的背景颜色
const pluginBackgroundColors = ref<Record<string, string>>({})
// 上滑关闭配置常量
const SWIPE_CONFIG = {
START_THRESHOLD: 10, // 开始检测上滑的最小距离
CLOSE_THRESHOLD: 100, // 触发关闭的距离
MAX_DRAG_DISTANCE: 1000, // 最大拖拽距离
VELOCITY_THRESHOLD: 0.8, // 快速滑动速度阈值 (px/ms)
}
// 上滑关闭相关状态
const isDraggingToClose = ref(false)
const dragOffset = ref(0)
const startY = ref(0)
const lastY = ref(0)
const lastTime = ref(0)
const velocity = ref(0)
const startedFromBottomArea = ref(false)
// 插件弹窗相关状态
const showPluginDataDialog = ref(false)
const currentPlugin = ref<Plugin | null>(null)
// 计算显示状态
const isVisible = computed(() => {
return props.visible
})
// 处理插件图标加载错误
function handleIconError(plugin: Plugin) {
pluginIconLoadError.value[plugin.id] = true
}
// 处理插件图标加载完成
async function handleIconLoaded(src: string | undefined, plugin: Plugin) {
if (!src) return
try {
// 创建一个临时的img元素来获取图片数据
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = async () => {
try {
// 从图片中提取背景色
const backgroundColor = await getDominantColor(img)
pluginBackgroundColors.value[plugin.id] = backgroundColor
} catch (error) {
// 如果提取失败,使用默认颜色
pluginBackgroundColors.value[plugin.id] = '#28A9E1'
}
}
img.onerror = () => {
// 如果加载失败,使用默认颜色
pluginBackgroundColors.value[plugin.id] = '#28A9E1'
}
img.src = src
} catch (error) {
// 如果提取失败,使用默认颜色
pluginBackgroundColors.value[plugin.id] = '#28A9E1'
}
}
// 获取插件背景颜色
function getPluginBackgroundColor(plugin: Plugin): string {
return pluginBackgroundColors.value[plugin.id] || '#28A9E1'
}
// 计算整个组件的transform包含拖动偏移
const componentTransform = computed(() => {
let baseTransform = ''
if (props.visible) {
baseTransform = 'translateY(0)'
} else {
baseTransform = 'translateY(-100%)'
}
// 如果正在拖动关闭,添加拖动偏移(向上拖拽为负值,让面板向上移动)
if (isDraggingToClose.value) {
return `${baseTransform} translateY(-${dragOffset.value}px)`
}
return baseTransform
})
// 计算组件透明度
const componentOpacity = computed(() => {
return props.visible ? 1 : 0
})
// 计算插件图标路径
function getPluginIcon(plugin: Plugin): string {
if (!plugin.plugin_icon) return noImage
if (pluginIconLoadError.value[plugin.id]) return noImage
// 如果是网络图片则使用代理后返回
if (plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(plugin?.plugin_icon)}&cache=true`
return `./plugin_icon/${plugin?.plugin_icon}`
}
// 获取有详情页面的插件
async function fetchPluginsWithPage() {
if (loading.value) return
try {
loading.value = true
const allPlugins: Plugin[] = await api.get('plugin/', {
params: {
state: 'installed',
},
})
// 只保留有详情页面且已启用的插件
pluginsWithPage.value = allPlugins
.filter(plugin => plugin.has_page)
.sort((a, b) => {
// 按插件名称排序
return (a.plugin_name || '').localeCompare(b.plugin_name || '')
})
} catch (error) {
console.error('获取插件列表失败:', error)
} finally {
loading.value = false
}
}
// 加载最近访问的插件
function loadRecentPlugins() {
recentPlugins.value = getRecentPlugins()
}
// 点击插件
function handlePluginClick(plugin: Plugin) {
// 添加到最近访问列表
addRecentPlugin(plugin)
// 更新最近访问列表显示
loadRecentPlugins()
emit('plugin-click', plugin)
// 设置当前插件并显示数据弹窗
currentPlugin.value = plugin
showPluginDataDialog.value = true
}
// 关闭面板
function handleClose() {
emit('close')
}
// 关闭插件数据弹窗
function handleClosePluginDataDialog() {
showPluginDataDialog.value = false
currentPlugin.value = null
}
// 监听可见性变化,加载数据
watch(
() => isVisible.value,
visible => {
if (visible) {
fetchPluginsWithPage()
loadRecentPlugins()
}
},
{ immediate: true },
)
onMounted(() => {
if (isVisible.value) {
fetchPluginsWithPage()
loadRecentPlugins()
}
})
// 处理触摸开始
function handleTouchStart(event: TouchEvent) {
if (!props.visible) return
const touch = event.touches[0]
if (!touch) return
// 检查是否从 bottom-drag-area 开始触摸
const target = event.target as HTMLElement
startedFromBottomArea.value = !!target.closest('.bottom-drag-area')
startY.value = touch.clientY
lastY.value = touch.clientY
lastTime.value = Date.now()
velocity.value = 0
// 重置拖拽状态
isDraggingToClose.value = false
dragOffset.value = 0
}
// 处理触摸移动
function handleTouchMove(event: TouchEvent) {
if (!props.visible) return
const touch = event.touches[0]
if (!touch) return
// 只有从 bottom-drag-area 开始的触摸才处理上滑关闭
if (!startedFromBottomArea.value) return
const currentY = touch.clientY
const currentTime = Date.now()
const deltaY = startY.value - currentY // 向上为正值
const timeDelta = currentTime - lastTime.value
// 计算速度
if (timeDelta > 0) {
const moveDistance = lastY.value - currentY
velocity.value = moveDistance / timeDelta
}
// 如果已经开始拖拽,继续拖拽
if (isDraggingToClose.value) {
if (deltaY >= 0) {
// 向上拖拽,更新偏移量
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
event.preventDefault()
} else {
// 向下拖拽,停止拖拽
isDraggingToClose.value = false
dragOffset.value = 0
}
} else {
// 还没开始拖拽,检查是否应该开始
if (deltaY > SWIPE_CONFIG.START_THRESHOLD) {
isDraggingToClose.value = true
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
event.preventDefault()
}
}
lastY.value = currentY
lastTime.value = currentTime
}
// 处理触摸结束
function handleTouchEnd() {
if (!props.visible) return
// 只有从 bottom-drag-area 开始的触摸才处理上滑关闭
if (!startedFromBottomArea.value) return
if (isDraggingToClose.value) {
// 判断是否应该关闭:距离超过阈值或者快速上滑
const shouldClose =
dragOffset.value >= SWIPE_CONFIG.CLOSE_THRESHOLD || velocity.value >= SWIPE_CONFIG.VELOCITY_THRESHOLD
if (shouldClose) {
emit('close')
}
// 重置拖拽状态
isDraggingToClose.value = false
dragOffset.value = 0
}
// 重置所有状态
startY.value = 0
lastY.value = 0
velocity.value = 0
startedFromBottomArea.value = false
}
// 点击底部空白区域关闭
function handleBackdropClick(event: MouseEvent) {
const target = event.target as HTMLElement
// 点击根容器或底部提示区域时关闭
if (
target.classList.contains('plugin-quick-access') ||
target.classList.contains('footer-hint') ||
target.classList.contains('hint-text') ||
target.classList.contains('bottom-drag-area')
) {
emit('close')
}
}
</script>
<template>
<VCard
:ripple="false"
class="plugin-quick-access"
:class="{ 'visible': isVisible }"
:style="{
opacity: componentOpacity,
transform: componentTransform,
transition: isDraggingToClose ? 'none' : 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
}"
@click="handleBackdropClick"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- 顶部指示器 -->
<div class="top-indicator"></div>
<!-- 标题栏 -->
<div class="header">
<div class="header-title">{{ t('plugin.quickAccess') }}</div>
<VBtn icon variant="text" @click="handleClose" class="close-btn">
<VIcon icon="mdi-close" />
</VBtn>
</div>
<!-- 插件网格 -->
<div class="plugin-grid">
<!-- 加载状态 -->
<LoadingBanner v-if="loading" />
<!-- 最近访问 -->
<template v-else>
<div class="section-header">
<div class="section-title">{{ t('plugin.recentlyUsed') }}</div>
</div>
<div v-if="recentPlugins.length > 0" class="recent-plugins-row">
<div
v-for="plugin in recentPlugins"
:key="`recent-${plugin.id}`"
class="plugin-item"
@click="handlePluginClick(plugin)"
>
<VBadge dot :color="plugin.state ? 'success' : 'secondary'" location="top end">
<div
class="plugin-icon"
:style="{
background: `${getPluginBackgroundColor(plugin)}`,
}"
>
<VImg
:src="getPluginIcon(plugin)"
:alt="plugin.plugin_name"
cover
@error="handleIconError(plugin)"
@load="src => handleIconLoaded(src, plugin)"
class="rounded-lg"
/>
</div>
</VBadge>
<div class="plugin-name">{{ plugin.plugin_name }}</div>
</div>
</div>
<!-- 没有最近访问时显示"无" -->
<div v-else class="no-recent-plugins">
<VIcon icon="mdi-puzzle-outline" size="24" color="grey" />
</div>
<!-- 所有插件 -->
<div v-if="pluginsWithPage.length > 0" class="section-header with-margin">
<div class="section-title">{{ t('plugin.allPlugins') }}</div>
</div>
<div v-if="pluginsWithPage.length > 0" class="all-plugins-grid">
<div
v-for="plugin in pluginsWithPage"
:key="plugin.id"
class="plugin-item"
@click="handlePluginClick(plugin)"
>
<VBadge
dot
:color="plugin.state ? 'success' : 'secondary'"
location="top end"
:offset-x="-1"
:offset-y="-1"
>
<div
class="plugin-icon"
:style="{
background: `${getPluginBackgroundColor(plugin)}`,
}"
>
<VImg
:src="getPluginIcon(plugin)"
:alt="plugin.plugin_name"
cover
@load="src => handleIconLoaded(src, plugin)"
@error="handleIconError(plugin)"
class="rounded-lg"
/>
</div>
</VBadge>
<div class="plugin-name">{{ plugin.plugin_name }}</div>
</div>
</div>
<!-- 空状态只有在没有插件时显示 -->
<div v-else-if="pluginsWithPage.length === 0" class="empty-state">
<VIcon icon="mdi-puzzle-outline" size="48" color="grey" />
<div class="empty-text">{{ t('plugin.noPluginsWithPage') }}</div>
</div>
</template>
</div>
<!-- 底部拖动区域 -->
<div class="bottom-drag-area" @click="handleBackdropClick">
<!-- 底部指示器 -->
<div class="bottom-indicator">
<div
class="indicator-bar bottom"
:class="{ 'dragging': isDraggingToClose }"
:style="{
transform: isDraggingToClose
? `scaleX(${Math.min(dragOffset / SWIPE_CONFIG.CLOSE_THRESHOLD, 1.5)})`
: 'scaleX(1)',
background: isDraggingToClose
? dragOffset >= SWIPE_CONFIG.CLOSE_THRESHOLD
? 'rgba(var(--v-theme-success), 0.8)'
: 'rgba(var(--v-theme-primary), 0.8)'
: 'rgba(var(--v-theme-on-surface), 0.12)',
}"
></div>
</div>
</div>
</VCard>
<!-- 插件数据弹窗 -->
<PluginDataDialog
v-if="showPluginDataDialog && currentPlugin"
v-model="showPluginDataDialog"
:plugin="currentPlugin"
:show_switch="false"
@close="handleClosePluginDataDialog"
/>
</template>
<style lang="scss" scoped>
.plugin-quick-access {
position: fixed;
z-index: 9999;
display: flex;
overflow: hidden;
flex-direction: column;
backdrop-filter: blur(32px);
background: rgba(var(--v-theme-surface), 0.95);
block-size: 100vh;
block-size: 100dvh;
inset-block-start: 0;
inset-inline: 0;
opacity: 0;
padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
pointer-events: none;
transform: translateY(-100%);
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
&.visible {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
}
.top-indicator {
display: flex;
justify-content: center;
padding-block: 12px 8px;
padding-inline: 0;
}
// 底部相关样式
.bottom-indicator {
display: flex;
justify-content: center;
padding-block: 8px 12px;
padding-inline: 0;
.indicator-bar.bottom {
border-radius: 2px;
background: rgba(var(--v-theme-on-surface), 0.12);
block-size: 4px;
inline-size: 30vw;
transform-origin: center;
transition: all 0.2s ease;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 0 16px;
padding-inline: 20px;
.header-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 20px;
font-weight: 600;
}
.close-btn {
opacity: 0.6;
&:hover {
background: rgba(var(--v-theme-on-surface), 0.04);
opacity: 1;
}
}
}
.plugin-grid {
display: flex;
overflow: hidden auto;
flex: 1;
flex-direction: column;
gap: 16px;
min-block-size: 0;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none; // IE/Edge
overscroll-behavior: contain;
padding-block: 24px;
padding-inline: 20px;
// 隐藏滚动条
scrollbar-width: none; // Firefox
touch-action: pan-y;
&::-webkit-scrollbar {
display: none; // WebKit 浏览器
}
}
.section-header {
display: flex;
align-items: center;
gap: 12px;
margin-inline: 0;
.section-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 16px;
font-weight: 600;
white-space: nowrap;
}
}
.no-recent-plugins {
display: flex;
align-items: center;
justify-content: center;
padding-inline: 0;
}
.recent-plugins-row {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
padding-block: 0;
padding-inline: 0;
}
.all-plugins-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
}
.plugin-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 12px;
block-size: 120px;
cursor: pointer;
gap: 4px;
transition: all 0.2s ease;
&:hover {
background: rgba(var(--v-theme-on-surface), 0.04);
transform: translateY(-2px);
}
&:active {
background: rgba(var(--v-theme-on-surface), 0.08);
transform: translateY(0);
}
}
.plugin-icon {
position: relative;
display: flex;
overflow: hidden;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 16px;
block-size: 64px;
inline-size: 64px;
transition: all 0.2s ease;
.plugin-item:hover & {
transform: scale(1.02);
}
}
.plugin-name {
display: -webkit-box;
overflow: hidden;
flex-shrink: 0;
-webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 12px;
font-weight: 500;
-webkit-line-clamp: 2;
line-height: 1.2;
max-block-size: 2.4em;
text-align: center;
word-break: break-all;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
grid-column: 1 / -1;
padding-block: 40px;
padding-inline: 0;
.empty-text {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 14px;
}
}
.bottom-drag-area {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding-block: 8px 0;
padding-inline: 20px;
}
@media (hover: none) and (pointer: coarse) {
.plugin-item:hover {
background: transparent;
transform: none;
}
.plugin-item:active {
background: rgba(var(--v-theme-on-surface), 0.08);
}
}
// 深色模式适配
html[data-theme='dark'] .plugin-quick-access {
background: rgba(var(--v-theme-surface), 0.9);
}
</style>

View File

@@ -2,8 +2,10 @@
import { formatDateDifference } from '@core/utils/formatters'
import { SystemNotification } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
const { t } = useI18n()
const { useDelayedSSE } = useBackgroundOptimization()
// 是否有新消息
const hasNewMessage = ref(false)
@@ -11,9 +13,6 @@ const hasNewMessage = ref(false)
// 通知列表
const notificationList = ref<SystemNotification[]>([])
// 事件源
let eventSource: EventSource | null = null
// 弹窗
const appsMenu = ref(false)
@@ -27,30 +26,27 @@ function markAllAsRead() {
appsMenu.value = false
}
// SSE持续接收消息
function startSSEMessager() {
// 延迟 3 秒启动 SSE避免相关认证信息尚未写入 Cookie 导致 403
setTimeout(() => {
eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message`)
eventSource.addEventListener('message', event => {
if (event.data) {
const noti: SystemNotification = JSON.parse(event.data)
notificationList.value.unshift(noti)
hasNewMessage.value = true
}
})
}, 3000)
// 消息处理函数
function handleMessage(event: MessageEvent) {
if (event.data) {
const noti: SystemNotification = JSON.parse(event.data)
notificationList.value.unshift(noti)
hasNewMessage.value = true
}
}
// 页面加载时,加载当前用户数据
onBeforeMount(async () => {
startSSEMessager()
})
// 页面卸载时,关闭事件源
onBeforeUnmount(() => {
if (eventSource) eventSource.close()
})
// 使用优化的SSE连接延迟3秒启动避免认证问题
useDelayedSSE(
`${import.meta.env.VITE_API_BASE_URL}system/message`,
handleMessage,
'user-notification',
3000,
{
backgroundCloseDelay: 5000,
reconnectDelay: 3000,
maxReconnectAttempts: 3
}
)
</script>
<template>
@@ -89,7 +85,7 @@ onBeforeUnmount(() => {
</VCardItem>
<VDivider />
<div class="notification-list-container">
<div v-if="notificationList.length > 0" class="h-full overflow-y-auto">
<div v-if="notificationList.length > 0">
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1">
<template #prepend>
<VAvatar rounded>
@@ -122,7 +118,8 @@ onBeforeUnmount(() => {
<style scoped>
.notification-list-container {
overflow: hidden;
max-block-size: 50vh;
overflow-y: auto;
scrollbar-width: thin;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import router from '@/router'
import avatar1 from '@images/avatars/avatar-1.png'
import api from '@/api'

View File

@@ -4,6 +4,7 @@ import useDragAndDrop from '@core/utils/workflow'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { actionStepDict } from '@/api/constants'
import { usePWA } from '@/composables/usePWA'
interface ActionItem {
name: string
@@ -13,7 +14,8 @@ interface ActionItem {
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
const { t } = useI18n()
const { onDragStart } = useDragAndDrop()

View File

@@ -19,6 +19,11 @@ export default {
noData: 'No data',
noContent: 'No relevant content found',
all: 'All',
active: 'Active',
inactive: 'Inactive',
filter: 'Filter',
noMatchingData: 'No matching data',
tryChangingFilters: 'Try changing filters',
default: 'Default',
name: 'Name',
create: 'Create',
@@ -44,6 +49,17 @@ export default {
pageText: '{0}-{1} of {2}',
noDataText: 'No data',
loadingText: 'Loading...',
networkRequired: 'This feature requires network connection',
networkDisconnected: 'Network connection lost',
featuresLimited: 'Some features may be limited',
serverConnectionFailed: 'Server connection failed',
troubleshooting: 'Troubleshooting',
checking: 'Checking',
retry: 'Retry',
networkOnline: 'Network Online',
networkOffline: 'Network Offline',
serviceAvailable: 'Service Available',
serviceUnavailable: 'Service Unavailable',
},
mediaType: {
movie: 'Movie',
@@ -115,6 +131,7 @@ export default {
},
app: {
moviepilot: 'MoviePilot',
slogan: 'Intelligent Movie & TV Media Library Management Tool',
recommend: 'Recommend',
subscribeMovie: 'Movie Subscription',
subscribeTv: 'TV Subscription',
@@ -126,6 +143,80 @@ export default {
restartTip: 'After restart, you will be logged out and need to log in again.',
restartTimeout: 'Restart timeout, the system may need more time to recover, please refresh the page manually later',
restartFailed: 'Restart failed, please check system status',
offline: 'Offline Mode',
offlineMessage: 'Network connection lost, some features may be limited',
online: 'Online Mode',
onlineMessage: 'Network connection restored',
},
pwa: {
installApp: 'Install MoviePilot App',
installDescription: 'Get better offline experience and performance',
install: 'Install',
installSuccess: 'App installed successfully!',
installGuide: 'Installation Guide',
installInstructions: 'Install MoviePilot on {platform}:',
installNote: 'After installation, you can quickly access MoviePilot from your home screen and enjoy offline features.',
gotIt: 'Got it',
// Platform specific descriptions
platforms: {
ios: 'iOS',
android: 'Android',
chrome: 'Chrome',
edge: 'Edge',
firefox: 'Firefox',
safari: 'Safari',
desktop: 'Desktop',
mobile: 'Mobile',
other: 'Other Browser',
},
// Installation steps
installSteps: {
ios: {
0: 'Tap the share button at the bottom of the browser',
1: 'Select "Add to Home Screen"',
2: 'Tap "Add" to confirm installation',
},
android: {
0: 'Tap the browser menu (three dots)',
1: 'Select "Add to Home Screen" or "Install App"',
2: 'Tap "Install" to confirm',
},
chrome: {
0: 'Click the install icon in the address bar',
1: 'Or click "Install MoviePilot" in the browser menu',
2: 'Click "Install" to confirm',
},
edge: {
0: 'Click the app icon in the address bar',
1: 'Select "Install this site as an app"',
2: 'Click "Install" to confirm',
},
firefox: {
0: 'Click the install icon in the address bar',
1: 'Select "Install"',
2: 'Confirm installation to desktop',
},
safari: {
0: 'Click the share button',
1: 'Select "Add to Home Screen"',
2: 'Tap "Add" to confirm',
},
desktop: {
0: 'Click the install icon in the address bar',
1: 'Select "Install App"',
2: 'Follow the prompts to complete installation',
},
mobile: {
0: 'Tap the browser menu',
1: 'Select "Add to Home Screen"',
2: 'Confirm installation',
},
other: {
0: 'Look for "Install" option in your browser',
1: 'Usually in the address bar or menu',
2: 'Follow the prompts to complete installation',
},
},
},
login: {
wallpapers: 'Wallpapers',
@@ -575,6 +666,9 @@ export default {
scheduler: 'Background Tasks',
cpu: 'CPU',
memory: 'Memory',
network: 'Network Traffic',
upload: 'Upload',
download: 'Download',
library: 'My Media Library',
playing: 'Continue Watching',
latest: 'Recently Added',
@@ -733,7 +827,7 @@ export default {
others: 'Others',
},
notFound: {
title: 'Page Not Found ⚠️',
title: '⚠️ Page Not Found',
description: 'The page you tried to access does not exist. Please check if the address is correct.',
backButton: 'Go Back',
},
@@ -748,6 +842,7 @@ export default {
sortSite: 'Site',
sortSize: 'Size',
sortSeeder: 'Seeder',
sortPublishTime: 'Publish Time',
filterSite: 'Site',
filterSeason: 'Season',
filterFreeState: 'Free State',
@@ -773,7 +868,8 @@ export default {
alipan: 'Aliyun Drive',
u115: '115 Cloud',
rclone: 'RClone',
alist: 'AList',
alist: 'OpenList',
smb: 'SMB Network Share',
custom: 'Custom',
},
filterRules: {
@@ -883,6 +979,10 @@ export default {
testing: 'Testing ...',
testSuccess: '{name} connectivity test successful, ready to use!',
testFailed: '{name} connectivity test failed: {message}',
connectionNormal: 'Connection Normal',
connectionSlow: 'Connection Slow',
connectionFailed: 'Connection Failed',
connectionUnknown: 'Connection Unknown',
deleteConfirm: 'Are you sure you want to delete this site?',
deleteSuccess: '{name} deleted successfully!',
deleteFailed: '{name} deletion failed: {message}',
@@ -1654,8 +1754,8 @@ export default {
reset: 'Reset',
},
alistConfig: {
title: 'Alist Configuration',
serverUrl: 'Alist server address',
title: 'OpenList Configuration',
serverUrl: 'OpenList server address',
username: 'Username',
password: 'Password',
tokenUrl: 'Token acquisition address',
@@ -1668,6 +1768,21 @@ export default {
complete: 'Complete',
reset: 'Reset',
},
smbConfig: {
title: 'SMB Network Share Configuration',
host: 'SMB Server Address',
hostHint: 'IP address or hostname of the SMB server',
share: 'Share Name',
shareHint: 'Name of the shared folder to connect to',
username: 'Username',
usernameHint: 'SMB login username',
password: 'Password',
passwordHint: 'SMB login password',
domain: 'Domain',
domainHint: 'SMB domain name, such as WORKGROUP or domain controller name',
complete: 'Complete',
reset: 'Reset',
},
workflowAddEdit: {
addTitle: 'Add Workflow',
editTitle: 'Edit Workflow',
@@ -2172,6 +2287,12 @@ export default {
cloneFailed: 'Plugin clone creation failed: {message}',
cloneFailedGeneral: 'Plugin clone creation failed',
logTitle: 'Plugin Logging',
quickAccess: 'Quick Access',
noPluginsWithPage: 'No plugins with detail pages available',
tapToOpen: 'Tap to Return',
recentlyUsed: 'Recently Used',
allPlugins: 'All Plugins',
noRecentPlugins: 'None',
},
profile: {
personalInfo: 'Personal Information',

View File

@@ -19,6 +19,11 @@ export default {
noData: '暂无数据',
noContent: '没有找到相关内容',
all: '全部',
active: '激活',
inactive: '未激活',
filter: '筛选',
noMatchingData: '没有符合条件的数据',
tryChangingFilters: '请尝试更改筛选条件',
default: '默认',
name: '名称',
create: '新建',
@@ -44,6 +49,17 @@ export default {
pageText: '{0}-{1} 共 {2} 条',
noDataText: '没有数据',
loadingText: '加载中...',
networkRequired: '此功能需要网络连接',
networkDisconnected: '网络连接已断开',
featuresLimited: '部分功能可能受限',
serverConnectionFailed: '服务器连接失败',
troubleshooting: '疑难解答',
checking: '检查中',
retry: '重试',
networkOnline: '网络在线',
networkOffline: '网络离线',
serviceAvailable: '服务可用',
serviceUnavailable: '服务不可用',
},
mediaType: {
movie: '电影',
@@ -115,6 +131,7 @@ export default {
},
app: {
moviepilot: 'MoviePilot',
slogan: '智能影视媒体库管理工具',
recommend: '推荐',
subscribeMovie: '电影订阅',
subscribeTv: '电视剧订阅',
@@ -126,6 +143,80 @@ export default {
restartTip: '重启后,您将被注销并需要重新登录。',
restartTimeout: '重启超时,系统可能需要更长时间恢复,请稍后手动刷新页面',
restartFailed: '重启失败,请检查系统状态',
offline: '离线模式',
offlineMessage: '网络连接已断开,部分功能可能受限',
online: '在线模式',
onlineMessage: '网络连接已恢复',
},
pwa: {
installApp: '安装 MoviePilot 应用',
installDescription: '获得更好的离线体验和性能',
install: '安装',
installSuccess: '应用安装成功!',
installGuide: '安装指南',
installInstructions: '在 {platform} 上安装 MoviePilot',
installNote: '安装后,您可以从主屏幕快速访问 MoviePilot并享受离线功能。',
gotIt: '知道了',
// 平台特定的说明
platforms: {
ios: 'iOS',
android: 'Android',
chrome: 'Chrome',
edge: 'Edge',
firefox: 'Firefox',
safari: 'Safari',
desktop: '桌面设备',
mobile: '移动设备',
other: '其他浏览器',
},
// 安装步骤
installSteps: {
ios: {
0: '点击浏览器底部的分享按钮',
1: '选择"添加到主屏幕"',
2: '点击"添加"确认安装',
},
android: {
0: '点击浏览器菜单(三个点)',
1: '选择"添加到主屏幕"或"安装应用"',
2: '点击"安装"确认',
},
chrome: {
0: '点击地址栏右侧的安装图标',
1: '或者点击浏览器菜单中的"安装 MoviePilot"',
2: '点击"安装"确认',
},
edge: {
0: '点击地址栏右侧的"应用可用"图标',
1: '在弹出的面板中点击"安装"按钮',
2: '在确认对话框中点击"安装"',
},
firefox: {
0: '点击地址栏右侧的安装图标',
1: '选择"安装"',
2: '确认安装到桌面',
},
safari: {
0: '点击分享按钮',
1: '选择"添加到主屏幕"',
2: '点击"添加"确认',
},
desktop: {
0: '点击地址栏右侧的安装图标',
1: '选择"安装应用"',
2: '按照提示完成安装',
},
mobile: {
0: '点击浏览器菜单',
1: '选择"添加到主屏幕"',
2: '确认安装',
},
other: {
0: '查找浏览器中的"安装"选项',
1: '通常在地址栏或菜单中',
2: '按照提示完成安装',
},
},
},
login: {
wallpapers: '壁纸',
@@ -573,6 +664,9 @@ export default {
scheduler: '后台任务',
cpu: 'CPU',
memory: '内存',
network: '网络流量',
upload: '上行',
download: '下行',
library: '我的媒体库',
playing: '继续观看',
latest: '最近添加',
@@ -730,7 +824,7 @@ export default {
others: '其他',
},
notFound: {
title: '页面不存在 ⚠️',
title: '⚠️ 页面不存在',
description: '您想要访问的页面不存在,请检查地址是否正确。',
backButton: '返回',
},
@@ -745,6 +839,7 @@ export default {
sortSite: '站点',
sortSize: '大小',
sortSeeder: '做种数',
sortPublishTime: '发布时间',
filterSite: '站点',
filterSeason: '季',
filterFreeState: '促销状态',
@@ -770,7 +865,8 @@ export default {
alipan: '阿里云盘',
u115: '115网盘',
rclone: 'RClone',
alist: 'AList',
alist: 'OpenList',
smb: 'SMB网络共享',
custom: '自定义',
},
filterRules: {
@@ -880,6 +976,10 @@ export default {
testing: '测试中 ...',
testSuccess: '{name} 连通性测试成功,可正常使用!',
testFailed: '{name} 连通性测试失败:{message}',
connectionNormal: '连接正常',
connectionSlow: '连接缓慢',
connectionFailed: '连接失败',
connectionUnknown: '连接未知',
deleteConfirm: '是否确认删除站点?',
deleteSuccess: '{name} 删除成功!',
deleteFailed: '{name} 删除失败:{message}',
@@ -1632,8 +1732,8 @@ export default {
reset: '重置',
},
alistConfig: {
title: 'Alist配置',
serverUrl: 'Alist服务地址',
title: 'OpenList配置',
serverUrl: 'OpenList服务地址',
username: '用户名',
password: '密码',
tokenUrl: '获取Token地址',
@@ -1646,6 +1746,21 @@ export default {
complete: '完成',
reset: '重置',
},
smbConfig: {
title: 'SMB网络共享配置',
host: 'SMB服务器地址',
hostHint: 'SMB服务器的IP地址或主机名',
share: '共享名称',
shareHint: '要连接的共享文件夹名称',
username: '用户名',
usernameHint: 'SMB登录用户名',
password: '密码',
passwordHint: 'SMB登录密码',
domain: '域名',
domainHint: 'SMB域名如WORKGROUP或域控制器名称',
complete: '完成',
reset: '重置',
},
workflowAddEdit: {
addTitle: '添加工作流',
editTitle: '编辑工作流',
@@ -2147,6 +2262,12 @@ export default {
cloneFailed: '插件分身创建失败:{message}',
cloneFailedGeneral: '插件分身创建失败',
logTitle: '插件日志',
quickAccess: '快速访问',
tapToOpen: '点击返回主界面',
noPluginsWithPage: '暂无可用插件',
recentlyUsed: '最近使用',
allPlugins: '所有插件',
noRecentPlugins: '无',
},
profile: {
personalInfo: '个人信息',

View File

@@ -19,6 +19,11 @@ export default {
noData: '暫無數據',
noContent: '沒有找到相關內容',
all: '全部',
active: '激活',
inactive: '未激活',
filter: '篩選',
noMatchingData: '沒有符合條件的數據',
tryChangingFilters: '請嘗試更改篩選條件',
default: '默認',
name: '名稱',
create: '新建',
@@ -44,6 +49,17 @@ export default {
pageText: '{0}-{1} 共 {2} 條',
noDataText: '沒有數據',
loadingText: '加載中...',
networkRequired: '此功能需要網絡連接',
networkDisconnected: '網絡連接已斷開',
featuresLimited: '部分功能可能受限',
serverConnectionFailed: '服務器連接失敗',
troubleshooting: '疑難排解',
checking: '檢查中',
retry: '重試',
networkOnline: '網絡在線',
networkOffline: '網絡離線',
serviceAvailable: '服務可用',
serviceUnavailable: '服務不可用',
},
mediaType: {
movie: '電影',
@@ -115,6 +131,7 @@ export default {
},
app: {
moviepilot: 'MoviePilot',
slogan: '智能影視媒體庫管理工具',
recommend: '推薦',
subscribeMovie: '電影訂閱',
subscribeTv: '電視劇訂閱',
@@ -127,6 +144,80 @@ export default {
restartTip: '重啟後,您將被註銷並需要重新登錄。',
restartTimeout: '重啟超時,系統可能需要更長時間恢復,請稍後手動刷新頁面',
restartFailed: '重啟失敗,請檢查系統狀態',
offline: '離線模式',
offlineMessage: '網絡連接已斷開,部分功能可能受限',
online: '在線模式',
onlineMessage: '網絡連接已恢復',
},
pwa: {
installApp: '安裝 MoviePilot 應用',
installDescription: '獲得更好的離線體驗和性能',
install: '安裝',
installSuccess: '應用安裝成功!',
installGuide: '安裝指南',
installInstructions: '在 {platform} 上安裝 MoviePilot',
installNote: '安裝後,您可以從主屏幕快速訪問 MoviePilot並享受離線功能。',
gotIt: '知道了',
// 平台特定的說明
platforms: {
ios: 'iOS',
android: 'Android',
chrome: 'Chrome',
edge: 'Edge',
firefox: 'Firefox',
safari: 'Safari',
desktop: '桌面設備',
mobile: '移動設備',
other: '其他瀏覽器',
},
// 安裝步驟
installSteps: {
ios: {
0: '點擊瀏覽器底部的分享按鈕',
1: '選擇"添加到主屏幕"',
2: '點擊"添加"確認安裝',
},
android: {
0: '點擊瀏覽器菜單(三個點)',
1: '選擇"添加到主屏幕"或"安裝應用"',
2: '點擊"安裝"確認',
},
chrome: {
0: '點擊地址欄右側的安裝圖標',
1: '或者點擊瀏覽器菜單中的"安裝 MoviePilot"',
2: '點擊"安裝"確認',
},
edge: {
0: '點擊地址欄右側的應用圖標',
1: '選擇"安裝此站點為應用"',
2: '點擊"安裝"確認',
},
firefox: {
0: '點擊地址欄右側的安裝圖標',
1: '選擇"安裝"',
2: '確認安裝到桌面',
},
safari: {
0: '點擊分享按鈕',
1: '選擇"添加到主屏幕"',
2: '點擊"添加"確認',
},
desktop: {
0: '點擊地址欄右側的安裝圖標',
1: '選擇"安裝應用"',
2: '按照提示完成安裝',
},
mobile: {
0: '點擊瀏覽器菜單',
1: '選擇"添加到主屏幕"',
2: '確認安裝',
},
other: {
0: '查找瀏覽器中的"安裝"選項',
1: '通常在地址欄或菜單中',
2: '按照提示完成安裝',
},
},
},
login: {
wallpapers: '壁紙',
@@ -571,6 +662,9 @@ export default {
scheduler: '後台任務',
cpu: 'CPU',
memory: '內存',
network: '網絡流量',
upload: '上行',
download: '下行',
library: '我的媒體庫',
playing: '繼續觀看',
latest: '最近添加',
@@ -728,7 +822,7 @@ export default {
others: '其他',
},
notFound: {
title: '頁面不存在 ⚠️',
title: '⚠️ 頁面不存在',
description: '您想要訪問的頁面不存在,請檢查地址是否正確。',
backButton: '返回',
},
@@ -743,6 +837,7 @@ export default {
sortSite: '站點',
sortSize: '大小',
sortSeeder: '做種數',
sortPublishTime: '發布時間',
filterSite: '站點',
filterSeason: '季',
filterFreeState: '促銷狀態',
@@ -768,7 +863,8 @@ export default {
alipan: '阿里雲盤',
u115: '115網盤',
rclone: 'RClone',
alist: 'AList',
alist: 'OpenList',
smb: 'SMB網路共享',
custom: '自定義',
},
@@ -879,6 +975,10 @@ export default {
testing: '測試中 ...',
testSuccess: '{name} 連通性測試成功,可正常使用!',
testFailed: '{name} 連通性測試失敗:{message}',
connectionNormal: '連接正常',
connectionSlow: '連接緩慢',
connectionFailed: '連接失敗',
connectionUnknown: '連接未知',
deleteConfirm: '是否確認刪除站點?',
deleteSuccess: '{name} 刪除成功!',
deleteFailed: '{name} 刪除失敗:{message}',
@@ -1631,8 +1731,8 @@ export default {
reset: '重置',
},
alistConfig: {
title: 'Alist配置',
serverUrl: 'Alist服務地址',
title: 'OpenList配置',
serverUrl: 'OpenList服務地址',
username: '用戶名',
password: '密碼',
tokenUrl: '獲取Token地址',
@@ -1645,6 +1745,21 @@ export default {
complete: '完成',
reset: '重置',
},
smbConfig: {
title: 'SMB網路共享配置',
host: 'SMB伺服器地址',
hostHint: 'SMB伺服器的IP地址或主機名',
share: '共享名稱',
shareHint: '要連接的共享資料夾名稱',
username: '用戶名',
usernameHint: 'SMB登入用戶名',
password: '密碼',
passwordHint: 'SMB登入密碼',
domain: '域名',
domainHint: 'SMB域名如WORKGROUP或域控制器名稱',
complete: '完成',
reset: '重置',
},
workflowAddEdit: {
addTitle: '新增工作流',
editTitle: '編輯工作流',
@@ -2146,6 +2261,12 @@ export default {
cloneFailed: '插件分身創建失敗:{message}',
cloneFailedGeneral: '插件分身創建失敗',
logTitle: '插件日誌',
quickAccess: '快速訪問',
noPluginsWithPage: '暫無可展示的插件',
tapToOpen: '點擊返回主界面',
recentlyUsed: '最近使用',
allPlugins: '所有插件',
noRecentPlugins: '無',
},
profile: {
personalInfo: '個人信息',

View File

@@ -18,12 +18,10 @@ import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
import { CronVuetify } from '@vue-js-cron/vuetify'
// 4. 工具函数和其他辅助模块
import { isPWA } from './@core/utils/navigator'
import { loadRemoteComponents } from './utils/federationLoader'
import { fetchGlobalSettings } from './utils/globalSetting'
// 5. 其他插件和功能模块
import ToastPlugin from 'vue-toast-notification'
import Toast from 'vue-toastification'
import ConfirmDialog from '@/composables/useConfirm'
import VueApexCharts from 'vue3-apexcharts'
@@ -45,64 +43,65 @@ import HeaderTab from './layouts/components/HeaderTab.vue'
// 7. 样式文件 - 合并为单一导入
import '@/styles/main.scss'
// 8. 状态恢复插件
import stateRestorePlugin from '@/plugins/stateRestore'
// 9. 后台优化工具
import { backgroundManager } from '@/utils/backgroundManager'
import { sseManagerSingleton } from '@/utils/sseManager'
// 创建Vue实例
const app = createApp(App)
// 注册pinia
// 1. 注册pinia
app.use(pinia)
// 初始化配置
async function initializeApp() {
try {
// 是否为PWA
const pwaMode = await isPWA()
app.provide('pwaMode', pwaMode)
// 全局设置
const globalSettings = await fetchGlobalSettings()
app.provide('globalSettings', globalSettings)
// 加载并注册远程联邦组
await loadRemoteComponents()
} catch (error) {
console.error('Failed to initialize app', error)
}
}
// 注册全局组件
initializeApp().then(() => {
// 1. 注册 UI 框架
app.use(vuetify)
// 2. 注册路由
app.use(router)
// 3. 注册全局组件
app
.component('VAceEditor', VAceEditor)
.component('VApexChart', VueApexCharts)
.component('VCronVuetify', CronVuetify)
.component('VDialogCloseBtn', DialogCloseBtn)
.component('VScrollToTopBtn', ScrollToTopBtn)
.component('VMediaCard', MediaCard)
.component('VPosterCard', PosterCard)
.component('VBackdropCard', BackdropCard)
.component('VPersonCard', PersonCard)
.component('VMediaInfoCard', MediaInfoCard)
.component('VTorrentCard', TorrentCard)
.component('VMediaIdSelector', MediaIdSelector)
.component('VCronField', CronField)
.component('VPathField', PathField)
.component('VHeaderTab', HeaderTab)
.component('VPageContentTitle', PageContentTitle)
// 5. 注册其他插件
app
.use(PerfectScrollbarPlugin)
.use(ToastPlugin, {
position: 'bottom-right',
})
.use(ConfirmDialog)
.use(i18n)
.mount('#app')
// 异步加载远程组件(不阻塞启动)
loadRemoteComponents().catch(error => {
console.error('Failed to load remote components', error)
})
// 2. 注册 UI 框架
app.use(vuetify)
// 3. 注册路由
app.use(router)
// 4. 注册状态恢复插
app.use(stateRestorePlugin)
// 5. 注册全局组件
app
.component('VAceEditor', VAceEditor)
.component('VApexChart', VueApexCharts)
.component('VCronVuetify', CronVuetify)
.component('VDialogCloseBtn', DialogCloseBtn)
.component('VScrollToTopBtn', ScrollToTopBtn)
.component('VMediaCard', MediaCard)
.component('VPosterCard', PosterCard)
.component('VBackdropCard', BackdropCard)
.component('VPersonCard', PersonCard)
.component('VMediaInfoCard', MediaInfoCard)
.component('VTorrentCard', TorrentCard)
.component('VMediaIdSelector', MediaIdSelector)
.component('VCronField', CronField)
.component('VPathField', PathField)
.component('VHeaderTab', HeaderTab)
.component('VPageContentTitle', PageContentTitle)
// 6. 注册其他插件
app
.use(PerfectScrollbarPlugin)
.use(Toast, {
position: 'bottom-right',
hideProgressBar: true,
})
.use(ConfirmDialog)
.use(i18n)
.mount('#app')
// 页面卸载时清理后台管理器
window.addEventListener('beforeunload', () => {
backgroundManager.destroy()
sseManagerSingleton.closeAllManagers()
})

View File

@@ -7,11 +7,13 @@ const { t } = useI18n()
</script>
<template>
<NoDataFound error-code="404" :error-title="t('notFound.title')" :error-description="t('notFound.description')">
<template #button>
<VBtn to="/" class="mt-10">
{{ t('notFound.backButton') }}
</VBtn>
</template>
</NoDataFound>
<div class="pt-10">
<NoDataFound error-code="404" :error-title="t('notFound.title')" :error-description="t('notFound.description')">
<template #button>
<VBtn to="/" class="mt-10" prepend-icon="mdi-home">
{{ t('notFound.backButton') }}
</VBtn>
</template>
</NoDataFound>
</div>
</template>

View File

@@ -34,6 +34,8 @@ function getApiPath(paths: string[] | string) {
<VPageContentTitle :title="title" />
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths || '')" :params="route.query" />
<MediaCardListView v-else :apipath="getApiPath(props.paths || '')" :params="route.query" />
<VScrollToTopBtn />
<Teleport to="body" v-if="route.path === '/browse'">
<VScrollToTopBtn />
</Teleport>
</div>
</template>

View File

@@ -9,13 +9,18 @@ import { useDisplay } from 'vuetify'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { VCardActions } from 'vuetify/components'
import { usePWA } from '@/composables/usePWA'
// 国际化
const { t } = useI18n()
// APP
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
// 路由
const route = useRoute()
// 从用户 Store 中获取superuser信息
const superUser = useUserStore().superUser
@@ -46,6 +51,7 @@ const enableConfig = ref<{ [key: string]: boolean }>({
weeklyOverview: false,
cpu: false,
memory: false,
network: false,
library: true,
playing: true,
latest: true,
@@ -112,6 +118,14 @@ const dashboardConfigs = ref<DashboardItem[]>([
cols: { cols: 12, md: 6 },
elements: [],
},
{
id: 'network',
name: t('dashboard.network'),
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
elements: [],
},
{
id: 'library',
name: t('dashboard.library'),
@@ -342,16 +356,18 @@ onDeactivated(() => {
</draggable>
<!-- 底部操作按钮只在非移动设备上显示 -->
<VFab
v-if="!appMode"
icon="mdi-view-dashboard-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="dialog = true"
/>
<Teleport to="body" v-if="route.path === '/dashboard'">
<VFab
v-if="!appMode"
icon="mdi-view-dashboard-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="dialog = true"
/>
</Teleport>
<!-- 弹窗根据配置生成选项 -->
<VDialog v-if="dialog" v-model="dialog" max-width="35rem" :fullscreen="!display.mdAndUp.value" scrollable>

View File

@@ -9,12 +9,16 @@ import { DiscoverSource } from '@/api/types'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 路由
const route = useRoute()
const activeTab = ref('')
// 本地存储键值
@@ -119,6 +123,26 @@ async function saveTabOrder() {
}
}
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
// 注册动态标签页在setup阶段但使用computed保证响应性
registerHeaderTab({
items: discoverTabItems, // 传递computed值会自动响应变化
modelValue: activeTab,
appendButtons: [
{
icon: 'mdi-order-alphabetical-ascending',
variant: 'text',
color: 'grey',
class: 'settings-icon-button',
action: () => {
orderConfigDialog.value = true
},
},
],
})
onBeforeMount(async () => {
initDiscoverTabs()
await loadOrderConfig()
@@ -133,25 +157,18 @@ onBeforeMount(async () => {
onActivated(async () => {
await loadExtraDiscoverSources()
sortSubscribeOrder()
// 如果当前没有选中任何标签页,或者当前选中的标签页不存在,则选中第一个标签页
if (!activeTab.value || !discoverTabs.value.find(tab => tab.mediaid_prefix === activeTab.value)) {
if (discoverTabs.value.length > 0) {
activeTab.value = discoverTabs.value[0].mediaid_prefix
}
}
})
</script>
<template>
<div>
<VHeaderTab :items="discoverTabItems" v-model="activeTab">
<template #append>
<VBtn
icon="mdi-order-alphabetical-ascending"
variant="text"
color="grey"
size="default"
class="settings-icon-button"
@click="orderConfigDialog = true"
/>
</template>
</VHeaderTab>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindowItem value="themoviedb">
<transition name="fade-slide" appear>
<div>
@@ -228,7 +245,9 @@ onActivated(async () => {
</VCard>
</VDialog>
<!-- 快速滚动到顶部按钮 -->
<VScrollToTopBtn />
<Teleport to="body" v-if="route.path === '/discover'">
<VScrollToTopBtn />
</Teleport>
</div>
</template>
<style lang="scss" scoped>

View File

@@ -4,12 +4,13 @@ import { DownloaderConf } from '@/api/types'
import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
// 国际化
const { t } = useI18n()
const route = useRoute()
const activeTab = ref(route.query.tab)
const activeTab = ref<string>((route.query.tab as string) || '')
// 下载器
const downloaders = ref<DownloaderConf[]>([])
@@ -22,6 +23,9 @@ const downloaderItems = computed(() => {
}))
})
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
@@ -33,19 +37,30 @@ async function loadDownloaderSetting() {
}
}
// 注册动态标签页
const registerTabs = () => {
if (downloaderItems.value.length > 0) {
registerHeaderTab({
items: downloaderItems,
modelValue: activeTab,
})
}
}
onMounted(async () => {
await loadDownloaderSetting()
registerTabs()
})
onActivated(async () => {
loadDownloaderSetting()
await loadDownloaderSetting()
registerTabs()
})
</script>
<template>
<div v-if="downloaders.length > 0">
<VHeaderTab :items="downloaderItems" v-model="activeTab" />
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindowItem v-for="item in downloaders" :value="item.name">
<transition name="fade-slide" appear>
<div>

View File

@@ -4,15 +4,22 @@ import { RecommendSource } from '@/api/types'
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 路由
const route = useRoute()
// 当前选择的分类
const currentCategory = ref(t('recommend.all'))
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
const viewList = reactive<{ apipath: string; linkurl: string; title: string; type: string }[]>([
{
apipath: 'recommend/tmdb_trending',
@@ -165,7 +172,7 @@ async function saveConfig() {
}
// 标签图标映射
const categoryItems: Record<string, string>[] = [
const categoryItems = computed(() => [
{
title: t('recommend.all'),
icon: 'mdi-filmstrip-box-multiple',
@@ -191,7 +198,24 @@ const categoryItems: Record<string, string>[] = [
icon: 'mdi-trophy',
tab: t('recommend.categoryRankings'),
},
]
])
// 注册动态标签页
registerHeaderTab({
items: categoryItems,
modelValue: currentCategory,
appendButtons: [
{
icon: 'mdi-tune',
variant: 'text',
color: 'grey',
class: 'settings-icon-button',
action: () => {
dialog.value = true
},
},
],
})
onBeforeMount(async () => {
await loadConfig()
@@ -202,26 +226,12 @@ onMounted(async () => {
})
onActivated(async () => {
loadExtraRecommendSources()
await loadExtraRecommendSources()
})
</script>
<template>
<div class="mp-recommend">
<!-- 页面顶部控制栏 -->
<VHeaderTab :items="categoryItems" v-model="currentCategory">
<template #append>
<VBtn
icon="mdi-tune"
variant="text"
color="grey"
size="default"
class="settings-icon-button"
@click="dialog = true"
/>
</template>
</VHeaderTab>
<!-- 滚动内容区域 -->
<div class="recommend-content">
<TransitionGroup name="fade">
@@ -293,7 +303,9 @@ onActivated(async () => {
</VDialog>
<!-- 快速滚动到顶部按钮 -->
<VScrollToTopBtn />
<Teleport to="body" v-if="route.path === '/recommend'">
<VScrollToTopBtn />
</Teleport>
</div>
</template>
@@ -362,12 +374,6 @@ onActivated(async () => {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.setting-label {
color: rgba(var(--v-theme-on-surface), 0.8);
font-size: 0.9rem;
transition: color 0.2s ease;
}
.setting-item {
position: relative;
overflow: hidden;
@@ -399,37 +405,47 @@ onActivated(async () => {
&.动漫::before {
background-color: #ff9800;
} // Orange
&.::before {
&.排行::before {
background-color: #9c27b0;
} // Purple
&:hover {
border-color: rgba(var(--v-theme-on-surface), 0.15);
background-color: rgba(var(--v-theme-surface-variant), 0.6);
&.enabled {
border-color: rgba(var(--v-theme-primary), 0.3);
background-color: rgba(var(--v-theme-primary), 0.1);
}
&.enabled {
border-color: rgba(var(--v-theme-primary), 0.5);
background-color: rgba(var(--v-theme-primary), 0.05);
.setting-label {
color: rgb(var(--v-theme-primary));
font-weight: 500;
}
&:hover {
box-shadow: 0 4px 12px rgba(var(--v-theme-on-surface), 0.1);
transform: translateY(-2px);
}
}
.setting-item-inner {
display: flex;
align-items: center;
gap: 8px;
}
.setting-check {
margin-inline-end: 8px;
flex-shrink: 0;
}
/* Remove old tune button styles if they exist */
.tune-button {
display: none; // Hide the old button definitively
.setting-label {
flex: 1;
color: rgba(var(--v-theme-on-surface), 0.8);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
transition: color 0.2s ease;
}
.enabled .setting-label {
color: rgba(var(--v-theme-primary), 0.9);
}
@media (width <= 600px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -6,9 +6,11 @@ import type { Context } from '@/api/types'
import TorrentCardListView from '@/views/torrent/TorrentCardListView.vue'
import TorrentRowListView from '@/views/torrent/TorrentRowListView.vue'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
// 国际化
const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization()
// 路由参数
const route = useRoute()
@@ -55,8 +57,8 @@ const progressValue = ref(0)
// 进度是否有效
const progressEnabled = ref(false)
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 进度是否激活
const progressActive = ref(false)
// 错误标题
const errorTitle = ref(t('resource.noData'))
@@ -68,51 +70,53 @@ const errorDescription = ref(t('resource.noResourceFound'))
const watchProgressValue = watch(
progressValue,
debounce(async () => {
if (progressEventSource.value && progressValue.value < 100) {
if (progressActive.value && progressValue.value < 100) {
console.warn('卡进度超时 关闭进度条')
stopLoadingProgress()
}
}, 60_000),
)
// 进度SSE消息处理函数
function handleProgressMessage(event: MessageEvent) {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
progressEnabled.value = progress.enable
}
}
// 使用优化的进度SSE连接
const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/search`,
handleProgressMessage,
'resource-search-progress',
progressActive,
)
// 使用SSE监听加载进度
function startLoadingProgress() {
watchProgressValue.resume()
progressText.value = t('resource.searching')
progressValue.value = 0
progressEnabled.value = false
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/search`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
progressEnabled.value = progress.enable
}
}
// 添加错误处理
progressEventSource.value.onerror = () => {
setTimeout(() => {
stopLoadingProgress()
}, 1000)
}
progressActive.value = true
progressSSE.start()
}
// 停止监听加载进度
function stopLoadingProgress() {
watchProgressValue.pause()
if (progressEventSource.value) {
progressEventSource.value.close()
progressEventSource.value = undefined
progressActive.value = false
progressSSE.stop()
// 确保进度显示100%,然后再渐进清零
progressValue.value = 100
setTimeout(() => {
progressValue.value = 0
progressEnabled.value = false
}, 1500) // 延长到1.5秒,让用户有足够时间看到完成状态
}
// 确保进度显示100%,然后再渐进清零
progressValue.value = 100
setTimeout(() => {
progressValue.value = 0
progressEnabled.value = false
}, 1500) // 延长到1.5秒,让用户有足够时间看到完成状态
}
// 设置视图类型
@@ -276,13 +280,17 @@ onUnmounted(() => {
<!-- 无数据显示 -->
<div v-else-if="isRefreshed && !isViewChanging" class="d-flex flex-column align-center justify-center py-8">
<NoDataFound :errorTitle="errorTitle" :errorDescription="errorDescription" />
<VBtn class="mt-4" color="primary" prepend-icon="mdi-magnify" to="/">{{ t('resource.backToHome') }}</VBtn>
<VBtn rounded="pill" class="mt-4" color="primary" prepend-icon="mdi-home" to="/">
{{ t('resource.backToHome') }}
</VBtn>
</div>
<!-- 初始加载状态 -->
<LoadingBanner v-else-if="!isRefreshed && !(progressEnabled || progressValue > 0)" />
<!-- 滚动到顶部按钮 -->
<VScrollToTopBtn />
<Teleport to="body" v-if="route.path === '/resource'">
<VScrollToTopBtn />
</Teleport>
</div>
</template>
@@ -294,7 +302,6 @@ onUnmounted(() => {
justify-content: center;
inset-block-start: env(safe-area-inset-top);
inset-inline: 0;
padding-block-start: 4rem;
}
.search-progress-card {

View File

@@ -13,17 +13,34 @@ import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
import AccountSettingCache from '@/views/setting/AccountSettingCache.vue'
import { getSettingTabs } from '@/router/i18n-menu'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
const route = useRoute()
const activeTab = ref(route.query.tab)
const activeTab = ref((route.query.tab as string) || '')
const settingTabs = computed(() => getSettingTabs())
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
// 注册动态标签页
registerHeaderTab({
items: settingTabs.value,
modelValue: activeTab,
})
// 注册动态标签页
onMounted(() => {
// 设置初始activeTab值
if (!activeTab.value && settingTabs.value.length > 0) {
activeTab.value = settingTabs.value[0].tab
}
})
</script>
<template>
<div>
<VHeaderTab :items="settingTabs" v-model="activeTab" />
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<!-- 系统 -->
<VWindowItem value="system">
<transition name="fade-slide" appear>

View File

@@ -4,6 +4,7 @@ import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
@@ -14,7 +15,7 @@ const route = useRoute()
const subType = route.meta.subType?.toString()
const subId = ref(route.query.id as string)
const activeTab = ref(route.query.tab)
const activeTab = ref((route.query.tab as string) || '')
const shareViewKey = ref(0)
// 获取标签页
@@ -46,89 +47,66 @@ const searchShares = () => {
searchShareDialog.value = false
shareViewKey.value++
}
// VMenu activator选择器
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
// 注册动态标签页
registerHeaderTab({
items: subscribeTabs.value,
modelValue: activeTab,
appendButtons: [
{
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: computed(() => (subscribeFilter.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'filter-btn',
action: () => {
filterSubscribeDialog.value = true
},
show: computed(() => activeTab.value === 'mysub'),
},
{
icon: 'mdi-movie-search-outline',
variant: 'text',
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'search-btn',
action: () => {
searchShareDialog.value = true
},
show: computed(() => activeTab.value === 'share'),
},
{
icon: 'mdi-clipboard-edit-outline',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
subscribeEditDialog.value = true
},
show: computed(() => activeTab.value === 'mysub'),
},
],
})
// 注册动态标签页
onMounted(() => {
// 设置初始activeTab值
if (!activeTab.value && subscribeTabs.value.length > 0) {
activeTab.value = subscribeTabs.value[0].tab
}
})
</script>
<template>
<div>
<VHeaderTab :items="subscribeTabs" v-model="activeTab">
<template #append>
<VMenu
v-if="activeTab === 'mysub'"
v-model="filterSubscribeDialog"
width="20rem"
:close-on-content-click="false"
scrim
>
<template #activator="{ props }">
<VBtn
icon="mdi-filter-multiple-outline"
variant="text"
:color="subscribeFilter ? 'primary' : 'gray'"
size="default"
class="settings-icon-button"
v-bind="props"
/>
</template>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
{{ t('subscribe.filterSubscriptions') }}
</VCardTitle>
<VDialogCloseBtn @click="filterSubscribeDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="subscribeFilter" :label="t('subscribe.name')" clearable density="comfortable" />
</VCardText>
</VCard>
</VMenu>
<VMenu
v-if="activeTab === 'share'"
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
scrim
>
<template #activator="{ props }">
<VBtn
icon="mdi-movie-search-outline"
variant="text"
:color="shareKeyword ? 'primary' : 'gray'"
size="default"
class="settings-icon-button"
v-bind="props"
/>
</template>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
{{ t('subscribe.searchShares') }}
</VCardTitle>
<VDialogCloseBtn @click="searchShareDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable">
<template #append>
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
</template>
</VTextField>
</VCardText>
</VCard>
</VMenu>
<VBtn
v-if="activeTab === 'mysub'"
icon="mdi-clipboard-edit-outline"
variant="text"
color="gray"
size="default"
class="settings-icon-button"
@click="subscribeEditDialog = true"
/>
</template>
</VHeaderTab>
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
<VWindowItem value="mysub">
<transition name="fade-slide" appear>
<div>
@@ -152,6 +130,58 @@ const searchShares = () => {
</VWindowItem>
</VWindow>
<!-- 订阅过滤弹窗 -->
<Teleport to="body" v-if="filterSubscribeDialog">
<VMenu
v-model="filterSubscribeDialog"
width="20rem"
:close-on-content-click="false"
:activator="filterActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
{{ t('subscribe.filterSubscriptions') }}
</VCardTitle>
<VDialogCloseBtn @click="filterSubscribeDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="subscribeFilter" :label="t('subscribe.name')" clearable density="comfortable" />
</VCardText>
</VCard>
</VMenu>
</Teleport>
<!-- 搜索订阅分享弹窗 -->
<Teleport to="body" v-if="searchShareDialog">
<VMenu
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
:activator="searchActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
{{ t('subscribe.searchShares') }}
</VCardTitle>
<VDialogCloseBtn @click="searchShareDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable">
<template #append>
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
</template>
</VTextField>
</VCardText>
</VCard>
</VMenu>
</Teleport>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
@@ -163,3 +193,9 @@ const searchShares = () => {
/>
</div>
</template>
<style scoped>
.content-window {
margin-block-start: 0;
}
</style>

219
src/plugins/stateRestore.ts Normal file
View File

@@ -0,0 +1,219 @@
/**
* PWA状态恢复插件 - 极简版
* 只专注2个核心功能路由、标签页
*/
import type { App } from 'vue'
// =============================================================================
// 1. 路由状态管理器
// =============================================================================
class RouteStateManager {
private readonly STORAGE_KEY = 'pwa-current-route'
// 保存当前路由
saveCurrentRoute() {
const route = {
path: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
timestamp: Date.now(),
}
sessionStorage.setItem(this.STORAGE_KEY, JSON.stringify(route))
}
// 恢复路由
restoreRoute() {
try {
const saved = sessionStorage.getItem(this.STORAGE_KEY)
if (!saved) return null
const route = JSON.parse(saved)
// 检查是否过期1小时
if (Date.now() - route.timestamp > 60 * 60 * 1000) {
this.clearRoute()
return null
}
return route
} catch {
return null
}
}
// 清除路由状态
clearRoute() {
sessionStorage.removeItem(this.STORAGE_KEY)
}
// 初始化路由恢复
init() {
// 监听路由变化,自动保存
window.addEventListener('popstate', () => this.saveCurrentRoute())
// 页面隐藏时保存
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.saveCurrentRoute()
}
})
// 页面卸载时保存
window.addEventListener('beforeunload', () => {
this.saveCurrentRoute()
})
}
}
// =============================================================================
// 2. 动态标签页状态管理器
// =============================================================================
class TabStateManager {
private readonly STORAGE_KEY = 'pwa-active-tabs'
// 保存标签页状态
saveTabState(routePath: string, activeTab: string) {
try {
const allTabs = this.getAllTabStates()
allTabs[routePath] = {
activeTab,
timestamp: Date.now(),
}
sessionStorage.setItem(this.STORAGE_KEY, JSON.stringify(allTabs))
} catch (error) {
console.warn('保存标签页状态失败:', error)
}
}
// 获取标签页状态
getTabState(routePath: string): string | null {
try {
const allTabs = this.getAllTabStates()
const tabState = allTabs[routePath]
if (!tabState) return null
// 检查是否过期1小时
if (Date.now() - tabState.timestamp > 60 * 60 * 1000) {
delete allTabs[routePath]
sessionStorage.setItem(this.STORAGE_KEY, JSON.stringify(allTabs))
return null
}
return tabState.activeTab
} catch {
return null
}
}
// 获取所有标签页状态
private getAllTabStates(): Record<string, any> {
try {
const saved = sessionStorage.getItem(this.STORAGE_KEY)
return saved ? JSON.parse(saved) : {}
} catch {
return {}
}
}
// 清除标签页状态
clearTabState(routePath?: string) {
if (routePath) {
const allTabs = this.getAllTabStates()
delete allTabs[routePath]
sessionStorage.setItem(this.STORAGE_KEY, JSON.stringify(allTabs))
} else {
sessionStorage.removeItem(this.STORAGE_KEY)
}
}
}
// =============================================================================
// 3. 主状态恢复管理器
// =============================================================================
class StateRestore {
public route = new RouteStateManager()
public tab = new TabStateManager()
// 初始化
init() {
this.route.init()
this.setupAutoRestore()
}
// 设置自动恢复
private setupAutoRestore() {
// 页面显示时检查是否需要恢复状态
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
this.checkAndRestore()
}
})
// 页面加载完成后恢复状态
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => this.checkAndRestore(), 100)
})
} else {
setTimeout(() => this.checkAndRestore(), 100)
}
}
// 检查并恢复状态
private checkAndRestore() {
// 1. 恢复路由(如果当前路径与保存的不同)
const savedRoute = this.route.restoreRoute()
if (savedRoute && savedRoute.path !== window.location.pathname) {
const fullPath = savedRoute.path + savedRoute.search + savedRoute.hash
console.log('恢复路由:', fullPath)
window.history.replaceState(null, '', fullPath)
}
// 2. 发送恢复事件,让组件自行处理标签页恢复
window.dispatchEvent(
new CustomEvent('pwa-state-restore', {
detail: {
route: savedRoute,
tabs: this.tab,
},
}),
)
}
// 清除所有状态
clearAllStates() {
this.route.clearRoute()
this.tab.clearTabState()
}
}
// =============================================================================
// 4. Vue插件安装
// =============================================================================
const stateRestore = new StateRestore()
export default {
install(app: App) {
// 注册全局属性
app.config.globalProperties.$stateRestore = stateRestore
// 提供注入
app.provide('stateRestore', stateRestore)
// 初始化
stateRestore.init()
console.log('PWA状态恢复插件已安装路由 + 标签页)')
},
}
// 导出管理器实例
export { stateRestore }
// 导出类型
export type { RouteStateManager, TabStateManager, StateRestore }

View File

@@ -168,7 +168,7 @@ const theme: VuetifyOptions['theme'] = {
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#000000',
'background': '#1C1C1C',
'on-background': '#E7E3FC',
'surface': 'rgba(30, 30, 30, 0.3)',
'on-surface': '#E7E3FC',

View File

@@ -1,6 +1,7 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { configureNProgress } from '@/api/nprogress'
import { useAuthStore } from '@/stores'
import { setNavigatingState as setRequestNavigatingState } from '@/utils/requestOptimizer'
// Nprogress
configureNProgress()
@@ -208,23 +209,11 @@ const router = createRouter({
],
})
const abortControllers = new Set<AbortController>()
// 注册中止控制器
function registerAbortController(controller: AbortController) {
abortControllers.add(controller)
}
// 中止所有组件的任务
function abortAllControllers() {
for (const controller of abortControllers) {
controller.abort()
}
abortControllers.clear()
}
// 路由导航守卫
router.beforeEach(async (to: any, from: any, next: any) => {
// 设置导航状态 - 同时中断API请求
setRequestNavigatingState(true)
// 认证 Store
const authStore = useAuthStore()
// 总是记录非login路由
@@ -233,15 +222,19 @@ router.beforeEach(async (to: any, from: any, next: any) => {
if (to.meta.requiresAuth && !isAuthenticated) {
// 用户未登录,重定向到登录页
setRequestNavigatingState(false)
next('/login')
} else {
// 清理所有中止控制器
abortAllControllers()
next()
}
})
// 路由导航完成后
router.afterEach(() => {
setTimeout(() => {
setRequestNavigatingState(false)
}, 100)
})
// 导出默认对象
export default router
// 另行导出其他功能
export { registerAbortController }

View File

@@ -1,15 +1,32 @@
import { createHandlerBoundToURL, cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
declare let self: ServiceWorkerGlobalScope
// Service Worker 类型声明
declare let self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision?: string }>
}
cleanupOutdatedCaches()
// 缓存版本控制
const CACHE_VERSION = 'v1.0.0'
const CACHE_NAMES = {
appShell: `app-shell-${CACHE_VERSION}`,
static: `static-resources-${CACHE_VERSION}`,
images: `image-cache-${CACHE_VERSION}`,
fonts: `font-cache-${CACHE_VERSION}`,
api: `api-cache-${CACHE_VERSION}`,
tmdb: `tmdb-image-cache-${CACHE_VERSION}`,
pages: `pages-cache-${CACHE_VERSION}`,
}
// self.__WB_MANIFEST is default injection point
precacheAndRoute(self.__WB_MANIFEST)
// to allow work offline
registerRoute(new NavigationRoute(createHandlerBoundToURL('index.html'), { denylist: [/^(\/[\w-]+)*\/api/] }))
// 缓存大小限制
const CACHE_SIZE_LIMITS = {
appShell: { maxEntries: 10, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
static: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30天
images: { maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30天
fonts: { maxEntries: 50, maxAgeSeconds: 365 * 24 * 60 * 60 }, // 1年
api: { maxEntries: 500, maxAgeSeconds: 24 * 60 * 60 }, // 24小时
tmdb: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
pages: { maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
}
// 通知选项
const options = {
@@ -43,38 +60,67 @@ async function setStoredUnreadCount(count: number): Promise<void> {
// 简单的IndexedDB包装器
async function openDB(): Promise<IDBDatabase> {
// Bump the version to add the new "sync" store while keeping existing data intact
return new Promise((resolve, reject) => {
const request = indexedDB.open('mp_badge_db', 1)
const request = indexedDB.open('mp_badge_db', 2)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = event => {
const db = (event.target as IDBOpenDBRequest).result
// Badge store (existing)
if (!db.objectStoreNames.contains('badge')) {
db.createObjectStore('badge')
}
// Dedicated store for offline-sync items
if (!db.objectStoreNames.contains('sync')) {
db.createObjectStore('sync')
}
}
})
}
async function get(key: string): Promise<any> {
// 获取IndexedDB中的数据
async function get(key: string, storeName: string = 'badge'): Promise<any> {
const db = await openDB()
return new Promise((resolve, reject) => {
const transaction = db.transaction(['badge'], 'readonly')
const store = transaction.objectStore('badge')
const tx = db.transaction([storeName], 'readonly')
const store = tx.objectStore(storeName)
const request = store.get(key)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
}
async function set(key: string, value: any): Promise<void> {
// 保存数据到IndexedDB
async function set(key: string, value: any, storeName: string = 'badge'): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const transaction = db.transaction(['badge'], 'readwrite')
const store = transaction.objectStore('badge')
const request = store.put(value, key)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
const tx = db.transaction([storeName], 'readwrite')
const store = tx.objectStore(storeName)
store.put(value, key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
// 删除IndexedDB中的数据确保事务完成
async function del(key: string, storeName: string = 'badge'): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction([storeName], 'readwrite')
const store = tx.objectStore(storeName)
store.delete(key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
@@ -83,9 +129,9 @@ async function updateBadge(count: number) {
if ('setAppBadge' in navigator) {
try {
if (count > 0) {
await navigator.setAppBadge(count)
await navigator.setAppBadge!(count)
} else {
await navigator.clearAppBadge()
await navigator.clearAppBadge!()
}
} catch (error) {
console.error('Failed to update app badge:', error)
@@ -97,7 +143,7 @@ async function updateBadge(count: number) {
async function clearBadge() {
if ('clearAppBadge' in navigator) {
try {
await navigator.clearAppBadge()
await navigator.clearAppBadge!()
await setStoredUnreadCount(0)
} catch (error) {
console.error('Failed to clear app badge:', error)
@@ -105,9 +151,348 @@ async function clearBadge() {
}
}
// 清理旧版本缓存
async function deleteOldCaches() {
const cacheWhitelist = Object.values(CACHE_NAMES)
const cacheNames = await caches.keys()
await Promise.all(
cacheNames.map(async cacheName => {
if (!cacheWhitelist.includes(cacheName)) {
console.log('Deleting old cache:', cacheName)
return caches.delete(cacheName)
}
}),
)
}
// 获取缓存大小
async function getCacheSize(cacheName: string): Promise<number> {
if (!('estimate' in navigator.storage)) {
return 0
}
try {
const cache = await caches.open(cacheName)
const keys = await cache.keys()
let totalSize = 0
for (const request of keys) {
const response = await cache.match(request)
if (response) {
const blob = await response.blob()
totalSize += blob.size
}
}
return totalSize
} catch (error) {
console.error('Failed to get cache size:', error)
return 0
}
}
// 监控缓存大小
async function monitorCacheSize() {
const cacheSizes: Record<string, number> = {}
let totalSize = 0
for (const [key, cacheName] of Object.entries(CACHE_NAMES)) {
const size = await getCacheSize(cacheName)
cacheSizes[key] = size
totalSize += size
}
// 发送缓存统计信息给客户端
const clients = await self.clients.matchAll()
clients.forEach(client => {
client.postMessage({
type: 'CACHE_SIZE_UPDATE',
data: {
cacheSizes,
totalSize,
totalSizeMB: (totalSize / 1024 / 1024).toFixed(2),
},
})
})
return { cacheSizes, totalSize }
}
// 清理过期缓存条目
async function cleanupExpiredCaches() {
for (const [key, cacheName] of Object.entries(CACHE_NAMES)) {
const limit = CACHE_SIZE_LIMITS[key as keyof typeof CACHE_SIZE_LIMITS]
if (!limit) continue
try {
const cache = await caches.open(cacheName)
const keys = await cache.keys()
// 如果缓存条目超过限制,删除最老的条目
if (keys.length > limit.maxEntries) {
const deleteCount = keys.length - limit.maxEntries
console.log(`Cleaning up ${deleteCount} entries from ${cacheName}`)
// 删除最老的条目(假设数组开头是最老的)
for (let i = 0; i < deleteCount; i++) {
await cache.delete(keys[i])
}
}
} catch (error) {
console.error(`Failed to cleanup cache ${cacheName}:`, error)
}
}
}
// 安装事件
self.addEventListener('install', () => {
// 强制等待中的Service Worker立即成为活动的Service Worker
self.skipWaiting()
})
// 激活事件
self.addEventListener('activate', event => {
event.waitUntil(
(async () => {
// 启用导航预载功能以提高性能
if ('navigationPreload' in self.registration) {
await self.registration.navigationPreload.enable()
}
// 清理旧版本的缓存
await deleteOldCaches()
// 清理过期的缓存条目
await cleanupExpiredCaches()
// 监控缓存大小
await monitorCacheSize()
})(),
)
// 告诉活动的Service Worker立即控制页面
self.clients.claim()
})
// 处理API请求当离线时发送消息到客户端
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
// 处理API请求
if (event.request.url.includes('/api/v1/')) {
// GET请求尝试从缓存返回
if (event.request.method === 'GET') {
event.respondWith(
(async () => {
try {
// 尝试网络请求
const networkResponse = await fetch(event.request)
return networkResponse
} catch (error) {
// 网络错误时,通知客户端当前处于离线状态
if (self.clients) {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'OFFLINE_STATUS',
offline: true,
})
})
})
}
// 尝试返回缓存的响应
const cache = await caches.open(CACHE_NAMES.api)
const cachedResponse = await cache.match(event.request)
if (cachedResponse) {
return cachedResponse
}
// 如果没有缓存,抛出错误
throw error
}
})(),
)
}
// POST/PUT/DELETE请求离线时加入同步队列
else if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(event.request.method)) {
event.respondWith(
(async () => {
try {
// 尝试网络请求
const networkResponse = await fetch(event.request)
return networkResponse
} catch (error) {
// 网络错误时,加入同步队列
await addToSyncQueue(event.request)
// 通知客户端请求已加入队列
if (self.clients) {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'REQUEST_QUEUED',
url: event.request.url,
method: event.request.method,
})
})
})
}
// 返回一个假的成功响应
return new Response(
JSON.stringify({
success: true,
queued: true,
message: '请求已加入离线队列,将在网络恢复后自动同步',
}),
{
status: 202,
headers: { 'Content-Type': 'application/json' },
},
)
}
})(),
)
}
return
}
})
// 后台同步队列
const syncQueue: Array<{
id: string
url: string
method: string
data?: any
timestamp: number
}> = []
// 添加请求到同步队列
async function addToSyncQueue(request: Request) {
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const url = request.url
const method = request.method
let data: any = null
if (method !== 'GET' && method !== 'HEAD') {
try {
data = await request.clone().text()
} catch (e) {
console.error('Failed to read request body:', e)
}
}
const syncItem = {
id,
url,
method,
data,
timestamp: Date.now(),
}
// 保存到IndexedDB (使用专用的 "sync" store)
await set(id, syncItem, 'sync')
syncQueue.push(syncItem)
// 注册后台同步
if ('sync' in self.registration) {
await self.registration.sync.register('sync-data')
}
}
// 执行同步队列中的请求
async function processSyncQueue() {
const db = await openDB()
// 先用只读事务获取所有同步项
const items: Array<any> = await new Promise((resolve, reject) => {
const tx = db.transaction(['sync'], 'readonly')
const store = tx.objectStore('sync')
const req = store.getAll()
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
// 收集需要删除的项目ID
const itemsToDelete: string[] = []
const itemsToDeleteExpired: string[] = []
for (const syncItem of items) {
const key = syncItem.id
try {
// 构建请求
const init: RequestInit = {
method: syncItem.method,
headers: {
'Content-Type': 'application/json',
},
}
if (syncItem.data) {
init.body = syncItem.data
}
// 发送请求
const response = await fetch(syncItem.url, init)
if (response.ok) {
// 成功后标记为需要删除
itemsToDelete.push(key)
// 通知客户端同步成功
const clients = await self.clients.matchAll()
clients.forEach(client => {
client.postMessage({
type: 'SYNC_SUCCESS',
syncId: syncItem.id,
url: syncItem.url,
})
})
} else {
throw new Error(`HTTP ${response.status}`)
}
} catch (error) {
console.error('Sync failed for item:', key, error)
// 如果该同步项已存在超过 24 小时,则标记为需要删除
if (Date.now() - syncItem.timestamp > 24 * 60 * 60 * 1000) {
itemsToDeleteExpired.push(key)
}
}
}
// 批量删除所有成功处理的项目和过期项目
const allItemsToDelete = [...itemsToDelete, ...itemsToDeleteExpired]
if (allItemsToDelete.length > 0) {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(['sync'], 'readwrite')
const store = tx.objectStore('sync')
// 批量删除所有标记的项目
allItemsToDelete.forEach(id => {
store.delete(id)
})
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
}
// 初始化 Workbox
cleanupOutdatedCaches()
precacheAndRoute(self.__WB_MANIFEST)
// 监听 sync 事件,处理后台同步
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'sync-data') {
event.waitUntil(processSyncQueue())
}
})
// 监听 push 事件,显示通知
self.addEventListener('push', function (event) {
console.log('notification push')
if (!event.data) {
return
}
@@ -116,7 +501,6 @@ self.addEventListener('push', function (event) {
try {
payload = event.data?.json()
} catch (err) {
console.log(err)
payload = {
title: event.data?.text(),
}
@@ -141,25 +525,12 @@ self.addEventListener('push', function (event) {
})(),
)
} catch (e) {
console.error(e)
// 静默处理错误
}
})
// 安装
self.addEventListener('install', function (e) {
console.log('worker install')
self.skipWaiting()
})
// 激活
self.addEventListener('activate', function (e) {
console.log('worker activate')
e.waitUntil(self.clients.claim())
})
// 监听通知点击事件
self.addEventListener('notificationclick', function (event) {
console.log('notification click')
const info = event.notification
if (event.action === 'close') {
info.close()
@@ -170,8 +541,6 @@ self.addEventListener('notificationclick', function (event) {
// 监听来自主应用的消息,用于清除徽章或更新徽章数量
self.addEventListener('message', function (event) {
console.log('service worker received message:', event.data)
if (event.data && event.data.type === 'CLEAR_BADGE') {
// 清除徽章
clearBadge()
@@ -179,8 +548,7 @@ self.addEventListener('message', function (event) {
event.ports[0]?.postMessage({ success: true })
})
.catch(error => {
console.error('Failed to clear badge:', error)
event.ports[0]?.postMessage({ success: false, error: error.message })
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'UPDATE_BADGE') {
// 更新徽章数量
@@ -191,8 +559,7 @@ self.addEventListener('message', function (event) {
event.ports[0]?.postMessage({ success: true })
})
.catch(error => {
console.error('Failed to update badge:', error)
event.ports[0]?.postMessage({ success: false, error: error.message })
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'GET_UNREAD_COUNT') {
// 获取未读消息数量
@@ -201,8 +568,25 @@ self.addEventListener('message', function (event) {
event.ports[0]?.postMessage({ count })
})
.catch(error => {
console.error('Failed to get unread count:', error)
event.ports[0]?.postMessage({ count: 0 })
})
} else if (event.data && event.data.type === 'CLEANUP_CACHES') {
// 手动触发缓存清理
Promise.all([deleteOldCaches(), cleanupExpiredCaches(), monitorCacheSize()])
.then(([, , cacheInfo]) => {
event.ports[0]?.postMessage({ success: true, cacheInfo })
})
.catch(error => {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'GET_CACHE_INFO') {
// 获取缓存信息
monitorCacheSize()
.then(cacheInfo => {
event.ports[0]?.postMessage({ success: true, cacheInfo })
})
.catch(error => {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
}
})

50
src/stores/global.ts Normal file
View File

@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import type { globalSettingsState } from '@/stores/types'
import { fetchGlobalSettings } from '@/utils/globalSetting'
export const useGlobalSettingsStore = defineStore('globalSettings', {
state: (): globalSettingsState => ({
data: {},
initialized: false,
loading: false,
}),
actions: {
async initialize() {
if (this.initialized || this.loading) return
this.loading = true
try {
const result = await fetchGlobalSettings()
this.data = result || {}
this.initialized = true
} catch (error) {
console.error('Failed to initialize global settings', error)
} finally {
this.loading = false
}
},
setData(data: { [key: string]: any }) {
this.data = data
this.initialized = true
},
get(key: string) {
return this.data[key]
},
reset() {
this.data = {}
this.initialized = false
this.loading = false
},
},
getters: {
isInitialized: state => state.initialized,
isLoading: state => state.loading,
getData: state => state.data,
globalSettings: state => state.data,
},
})

View File

@@ -12,5 +12,6 @@ export default pinia
// 所有的 store
import { useAuthStore } from './auth'
import { useUserStore } from './user'
import { useGlobalSettingsStore } from './global'
export { useAuthStore, useUserStore }
export { useAuthStore, useUserStore, useGlobalSettingsStore }

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