Compare commits

...

102 Commits

Author SHA1 Message Date
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
102 changed files with 6145 additions and 1524 deletions

View File

@@ -1,273 +1,239 @@
<!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="preload" href="/loader.css" as="style" />
<!-- 加载样式 -->
<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)
// 状态栏适配
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>

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.5.8",
"version": "2.6.2",
"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",
@@ -112,4 +113,4 @@
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
}
}

View File

@@ -1,6 +1,6 @@
#loading-bg {
position: fixed;
z-index: 9999;
z-index: 99999;
display: block;
background: var(--initial-loader-bg, #fff);
block-size: 100vh;
@@ -94,4 +94,4 @@
opacity: 1;
transform: rotate(1turn);
}
}
}

View File

@@ -1,6 +1,7 @@
const path = require('node:path')
const express = require('express')
const proxy = require('express-http-proxy')
const { createProxyMiddleware } = require('http-proxy-middleware')
const app = express()
const port = process.env.NGINX_PORT || 3000
@@ -14,16 +15,141 @@ const proxyConfig = {
// 静态文件服务目录
app.use(express.static(__dirname))
// 配置代理中间件将请求转发给后端API
app.use(
'/api',
proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
// 路径加上 /api 前缀
proxyReqPathResolver: (req) => {
return `/api${req.url}`
// 创建专门的SSE代理中间件
const sseProxyMiddleware = createProxyMiddleware({
target: `http://${proxyConfig.URL}:${proxyConfig.PORT}`,
changeOrigin: true,
ws: false,
timeout: 0, // 无超时
proxyTimeout: 0, // 无超时
headers: {
'Connection': 'keep-alive',
'Cache-Control': 'no-cache'
},
onProxyRes: (proxyRes, req, res) => {
// 检测SSE响应
const isSSE = proxyRes.headers['content-type'] &&
proxyRes.headers['content-type'].includes('text/event-stream');
if (isSSE) {
// 设置SSE响应头
res.writeHead(proxyRes.statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control, Content-Type, Authorization'
});
// 直接将代理响应流式传输到客户端
proxyRes.pipe(res);
// 处理客户端断开连接
req.on('close', () => {
console.log('Client disconnected from SSE stream');
if (proxyRes.destroy) {
proxyRes.destroy();
}
});
// 处理代理响应结束
proxyRes.on('end', () => {
console.log('SSE stream ended');
if (!res.headersSent) {
res.end();
}
});
// 处理代理响应错误
proxyRes.on('error', (err) => {
console.error('SSE proxy response error:', err);
if (!res.headersSent) {
res.status(500).end();
}
});
}
})
);
},
onError: (err, req, res) => {
console.error('SSE proxy error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Proxy error' });
}
}
});
// 创建普通API代理中间件
const apiProxyMiddleware = proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
// 路径加上 /api 前缀
proxyReqPathResolver: (req) => {
return `/api${req.url}`
},
proxyReqOptDecorator: (proxyReqOpts, srcReq) => {
proxyReqOpts.headers = proxyReqOpts.headers || {};
// 检测是否为SSE请求
const isSSE = srcReq.headers.accept && srcReq.headers.accept.includes('text/event-stream');
if (!isSSE) {
// 普通请求设置超时
proxyReqOpts.timeout = 600000; // 600秒超时
}
return proxyReqOpts;
},
userResDecorator: (proxyRes, proxyResData, userReq, userRes) => {
// 只处理非SSE响应
const isSSEResponse = proxyRes.headers['content-type'] &&
proxyRes.headers['content-type'].includes('text/event-stream');
if (!isSSEResponse) {
// 普通响应:正常处理
return proxyResData;
}
// SSE响应不在这里处理已经由专门的中间件处理
return proxyResData;
},
// 错误处理
proxyErrorHandler: (err, res, next) => {
// 客户端断开连接的正常情况
if (err.code === 'ECONNRESET' || err.code === 'EPIPE') {
console.log('Client disconnected:', err.code);
if (!res.headersSent) {
res.end();
}
return;
}
// 超时错误处理
if (err.code === 'ETIMEDOUT') {
console.log('Proxy request timed out:', err.code);
if (!res.headersSent) {
res.status(504).send('Gateway Timeout');
}
return;
}
// 其他错误
console.error('Proxy error:', err);
if (!res.headersSent) {
res.status(500).send('Internal Server Error');
}
}
});
// 配置API代理路由
app.use('/api', (req, res, next) => {
// 检测是否为SSE请求
const isSSE = req.headers.accept && req.headers.accept.includes('text/event-stream');
if (isSSE) {
// 使用专门的SSE代理中间件
sseProxyMiddleware(req, res, next);
} else {
// 使用普通API代理中间件
apiProxyMiddleware(req, res, next);
}
});
// 配置代理中间件将CookieCloud请求转发给后端API
app.use(

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

@@ -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,46 +1,45 @@
%blurry-bg {
position: relative;
background: transparent;
box-shadow: none;
&::before {
position: absolute;
z-index: -1;
// 磨砂渐变效果
backdrop-filter: blur(20px);
block-size: calc(env(safe-area-inset-top, 0px) + 5rem);
content: "";
inset-block-start: 0;
inset-inline: 0;
// 使用遮罩实现渐变效果
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 100%) 0%,
rgba(0, 0, 0, 90%) calc(env(safe-area-inset-top, 0px) + 1rem),
rgba(0, 0, 0, 70%) calc(env(safe-area-inset-top, 0px) + 2rem),
rgba(0, 0, 0, 50%) calc(env(safe-area-inset-top, 0px) + 3rem),
rgba(0, 0, 0, 20%) calc(env(safe-area-inset-top, 0px) + 4rem),
rgba(0, 0, 0, 0%) 100%
);
pointer-events: none;
transition: all 0.5s ease-in-out;
.v-theme--light & {
background: rgba(var(--v-theme-surface), 0.8);
}
.v-theme--dark & {
background: rgba(var(--v-theme-background), 0.6);
}
.v-theme--purple & {
background: rgba(var(--v-theme-background), 0.6);
}
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: rgba(var(--v-theme-background), 0.3);
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

@@ -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,12 @@ 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'
// 生效主题
const { global: globalTheme } = useTheme()
@@ -18,13 +20,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'))
@@ -34,6 +36,8 @@ const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
let backgroundRotationTimer: NodeJS.Timeout | null = null
// ApexCharts 全局配置
declare global {
interface Window {
@@ -41,43 +45,60 @@ 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
}
}
@@ -85,8 +106,8 @@ async function fetchBackgroundImages() {
function startBackgroundRotation() {
// 清除轮换定时器
if (backgroundRotationTimer) clearInterval(backgroundRotationTimer)
if (backgroundImages.value.length > 1) {
// 每10秒切换一次
backgroundRotationTimer = setInterval(() => {
// 计算下一个图片索引
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
@@ -97,121 +118,108 @@ function startBackgroundRotation() {
activeImageIndex.value = nextIndex
}
})
}, 10000) // 每10秒切换一次
}, 10000)
}
}
// 预加载图片
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)
@@ -230,12 +238,12 @@ 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 />
</VApp>
</div>
@@ -297,4 +305,29 @@ onUnmounted(() => {
inset-block-start: 0;
inset-inline-start: 0;
}
/* 优化加载完成动画 */
.loading-complete {
animation: fadeOutScale 0.8s ease-out forwards;
}
@keyframes fadeOutScale {
0% {
opacity: 1;
transform: scale(1);
filter: blur(0px);
}
70% {
opacity: 0.3;
transform: scale(1.05);
filter: blur(2px);
}
100% {
opacity: 0;
transform: scale(1.1);
filter: blur(5px);
}
}
</style>

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

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

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

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

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

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

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

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

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

@@ -6,6 +6,7 @@ import router from '@/router'
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

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

@@ -8,6 +8,7 @@ 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'
// 国际化
const { t } = useI18n()
@@ -24,10 +25,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'])
@@ -188,6 +191,11 @@ async function handleTransferLog(logid: number, background: boolean = false) {
// 使用SSE监听加载进度
function startLoadingProgress() {
// 在创建新连接之前,先确保任何可能存在的旧连接都被关闭了,防止因快速重复点击而产生孤儿连接。
if (progressEventSource.value) {
progressEventSource.value.close()
}
progressText.value = t('dialog.reorganize.processing')
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
progressEventSource.value.onmessage = event => {
@@ -197,6 +205,13 @@ function startLoadingProgress() {
progressValue.value = progress.value
}
}
// 发生错误时,也确保连接被关闭,避免重试等意外行为
progressEventSource.value.onerror = () => {
if (progressEventSource.value) {
progressEventSource.value.close()
}
}
}
// 停止监听加载进度

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

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

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

@@ -24,7 +24,7 @@ const inProps = defineProps({
storage: String,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Object as PropType<any>,
type: Function,
required: true,
},
refreshpending: Boolean,
@@ -554,196 +554,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,152 @@
import type { ComputedRef, Ref } from 'vue'
// 动态标签页相关类型
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[]
}) => {
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
},
}
// 监听modelValue变化并更新配置
watch(config.modelValue, newValue => {
tabConfig.modelValue = 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,
}
}

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

@@ -0,0 +1,57 @@
import { ref, computed, onMounted } from 'vue'
import { useDisplay } from 'vuetify'
import { isPWA } from '@/@core/utils/navigator'
// 全局PWA状态确保只初始化一次
const globalPwaMode = ref<boolean | null>(null)
const globalLoading = ref(false)
let initPromise: Promise<void> | null = null
// 全局初始化函数
async function initializePWAGlobally() {
if (initPromise) return initPromise
if (globalPwaMode.value !== null || globalLoading.value) return Promise.resolve()
initPromise = new Promise(async (resolve, reject) => {
globalLoading.value = true
try {
globalPwaMode.value = await isPWA()
resolve()
} catch (error) {
console.error('Failed to detect PWA mode', error)
globalPwaMode.value = false
reject(error)
} finally {
globalLoading.value = false
}
})
return initPromise
}
export function usePWA() {
const display = useDisplay()
const appMode = computed(() => {
return globalPwaMode.value && display.mdAndDown.value
})
// 自动初始化PWA检测
onMounted(() => {
initializePWAGlobally().catch(console.error)
})
// 如果是在服务端或首次调用,立即开始初始化
if (typeof window !== 'undefined' && globalPwaMode.value === null && !globalLoading.value) {
initializePWAGlobally().catch(console.error)
}
return {
pwaMode: globalPwaMode,
appMode,
loading: globalLoading,
// 保留手动初始化方法以防需要
initializePWA: initializePWAGlobally,
}
}

View File

@@ -0,0 +1,122 @@
import { ref, onMounted, onUnmounted, watch } from 'vue'
import type { PWAState } from '@/utils/pwaStateManager'
export function usePWAState() {
const isStateRestored = ref(false)
const stateRestoreCount = ref(0)
const lastRestoredState = ref<PWAState | null>(null)
const isPWAMode = ref(false)
// 检查PWA模式
const checkPWAMode = () => {
isPWAMode.value = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone ||
document.referrer.includes('android-app://')
}
// 保存当前状态
const saveCurrentState = async () => {
if (window.pwaStateController) {
await window.pwaStateController.saveCurrentState()
}
}
// 手动触发状态恢复检查
const checkStateRestore = async () => {
if (window.pwaStateController) {
// 静默检查
}
}
// 监听状态恢复事件
const handleStateRestored = (event: Event) => {
const customEvent = event as CustomEvent<{ state: PWAState }>
isStateRestored.value = true
stateRestoreCount.value++
lastRestoredState.value = customEvent.detail.state
}
// 重置状态恢复标志
const resetStateRestored = () => {
isStateRestored.value = false
lastRestoredState.value = null
}
// 检查状态管理器是否可用
const isStateManagerAvailable = () => {
return !!window.pwaStateController
}
onMounted(() => {
checkPWAMode()
// 监听状态恢复事件
window.addEventListener('pwaStateRestored', handleStateRestored)
// 监听PWA模式变化
const mediaQuery = window.matchMedia('(display-mode: standalone)')
const handleDisplayModeChange = (e: MediaQueryListEvent) => {
isPWAMode.value = e.matches
}
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleDisplayModeChange)
} else {
mediaQuery.addListener(handleDisplayModeChange)
}
onUnmounted(() => {
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener('change', handleDisplayModeChange)
} else {
mediaQuery.removeListener(handleDisplayModeChange)
}
})
})
onUnmounted(() => {
window.removeEventListener('pwaStateRestored', handleStateRestored)
})
return {
// 响应式状态
isPWAMode,
isStateRestored,
stateRestoreCount,
lastRestoredState,
// 方法
saveCurrentState,
checkStateRestore,
resetStateRestored,
isStateManagerAvailable,
checkPWAMode
}
}
// 全局PWA状态管理器
export function useGlobalPWAState() {
// 检查是否在PWA环境中
const isPWAEnvironment = () => {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone ||
document.referrer.includes('android-app://')
}
// 获取存储的状态
const getStoredState = () => {
return localStorage.getItem('mp-pwa-app-state')
}
// 清除存储的状态
const clearStoredState = () => {
localStorage.removeItem('mp-pwa-app-state')
sessionStorage.removeItem('mp-pwa-session-state')
}
return {
isPWAEnvironment,
getStoredState,
clearStoredState
}
}

View File

@@ -0,0 +1,283 @@
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 && display.mdAndDown.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已经检测完成直接添加事件监听器
if (appMode.value !== null) {
addEventListeners()
} else {
// 等待PWA检测完成从null变为boolean
const stopWatcher = watch(
appMode,
newValue => {
if (newValue !== null) {
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

@@ -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.name === 'dashboard' || route.path === '/dashboard'
// 检查是否是管理员
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

@@ -38,7 +38,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 +78,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 +88,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 +115,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 +183,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 +199,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 +218,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 +226,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,266 @@
<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'
})
</script>
<template>
<Transition
enter-active-class="transition-all duration-500"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-300"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="shouldShow" class="offline-page">
<div class="offline-container">
<!-- 状态图标 -->
<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>
</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;
}
.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;
}
.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::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;
}
.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

@@ -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,10 @@ 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',
},
login: {
wallpapers: 'Wallpapers',
@@ -575,6 +596,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 +757,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 +772,7 @@ export default {
sortSite: 'Site',
sortSize: 'Size',
sortSeeder: 'Seeder',
sortPublishTime: 'Publish Time',
filterSite: 'Site',
filterSeason: 'Season',
filterFreeState: 'Free State',
@@ -773,7 +798,8 @@ export default {
alipan: 'Aliyun Drive',
u115: '115 Cloud',
rclone: 'RClone',
alist: 'AList',
alist: 'OpenList',
smb: 'SMB Network Share',
custom: 'Custom',
},
filterRules: {
@@ -883,6 +909,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 +1684,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 +1698,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 +2217,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,10 @@ export default {
restartTip: '重启后,您将被注销并需要重新登录。',
restartTimeout: '重启超时,系统可能需要更长时间恢复,请稍后手动刷新页面',
restartFailed: '重启失败,请检查系统状态',
offline: '离线模式',
offlineMessage: '网络连接已断开,部分功能可能受限',
online: '在线模式',
onlineMessage: '网络连接已恢复',
},
login: {
wallpapers: '壁纸',
@@ -573,6 +594,9 @@ export default {
scheduler: '后台任务',
cpu: 'CPU',
memory: '内存',
network: '网络流量',
upload: '上行',
download: '下行',
library: '我的媒体库',
playing: '继续观看',
latest: '最近添加',
@@ -730,7 +754,7 @@ export default {
others: '其他',
},
notFound: {
title: '页面不存在 ⚠️',
title: '⚠️ 页面不存在',
description: '您想要访问的页面不存在,请检查地址是否正确。',
backButton: '返回',
},
@@ -745,6 +769,7 @@ export default {
sortSite: '站点',
sortSize: '大小',
sortSeeder: '做种数',
sortPublishTime: '发布时间',
filterSite: '站点',
filterSeason: '季',
filterFreeState: '促销状态',
@@ -770,7 +795,8 @@ export default {
alipan: '阿里云盘',
u115: '115网盘',
rclone: 'RClone',
alist: 'AList',
alist: 'OpenList',
smb: 'SMB网络共享',
custom: '自定义',
},
filterRules: {
@@ -880,6 +906,10 @@ export default {
testing: '测试中 ...',
testSuccess: '{name} 连通性测试成功,可正常使用!',
testFailed: '{name} 连通性测试失败:{message}',
connectionNormal: '连接正常',
connectionSlow: '连接缓慢',
connectionFailed: '连接失败',
connectionUnknown: '连接未知',
deleteConfirm: '是否确认删除站点?',
deleteSuccess: '{name} 删除成功!',
deleteFailed: '{name} 删除失败:{message}',
@@ -1632,8 +1662,8 @@ export default {
reset: '重置',
},
alistConfig: {
title: 'Alist配置',
serverUrl: 'Alist服务地址',
title: 'OpenList配置',
serverUrl: 'OpenList服务地址',
username: '用户名',
password: '密码',
tokenUrl: '获取Token地址',
@@ -1646,6 +1676,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 +2192,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,10 @@ export default {
restartTip: '重啟後,您將被註銷並需要重新登錄。',
restartTimeout: '重啟超時,系統可能需要更長時間恢復,請稍後手動刷新頁面',
restartFailed: '重啟失敗,請檢查系統狀態',
offline: '離線模式',
offlineMessage: '網絡連接已斷開,部分功能可能受限',
online: '在線模式',
onlineMessage: '網絡連接已恢復',
},
login: {
wallpapers: '壁紙',
@@ -571,6 +592,9 @@ export default {
scheduler: '後台任務',
cpu: 'CPU',
memory: '內存',
network: '網絡流量',
upload: '上行',
download: '下行',
library: '我的媒體庫',
playing: '繼續觀看',
latest: '最近添加',
@@ -728,7 +752,7 @@ export default {
others: '其他',
},
notFound: {
title: '頁面不存在 ⚠️',
title: '⚠️ 頁面不存在',
description: '您想要訪問的頁面不存在,請檢查地址是否正確。',
backButton: '返回',
},
@@ -743,6 +767,7 @@ export default {
sortSite: '站點',
sortSize: '大小',
sortSeeder: '做種數',
sortPublishTime: '發布時間',
filterSite: '站點',
filterSeason: '季',
filterFreeState: '促銷狀態',
@@ -768,7 +793,8 @@ export default {
alipan: '阿里雲盤',
u115: '115網盤',
rclone: 'RClone',
alist: 'AList',
alist: 'OpenList',
smb: 'SMB網路共享',
custom: '自定義',
},
@@ -879,6 +905,10 @@ export default {
testing: '測試中 ...',
testSuccess: '{name} 連通性測試成功,可正常使用!',
testFailed: '{name} 連通性測試失敗:{message}',
connectionNormal: '連接正常',
connectionSlow: '連接緩慢',
connectionFailed: '連接失敗',
connectionUnknown: '連接未知',
deleteConfirm: '是否確認刪除站點?',
deleteSuccess: '{name} 刪除成功!',
deleteFailed: '{name} 刪除失敗:{message}',
@@ -1631,8 +1661,8 @@ export default {
reset: '重置',
},
alistConfig: {
title: 'Alist配置',
serverUrl: 'Alist服務地址',
title: 'OpenList配置',
serverUrl: 'OpenList服務地址',
username: '用戶名',
password: '密碼',
tokenUrl: '獲取Token地址',
@@ -1645,6 +1675,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 +2191,12 @@ export default {
cloneFailed: '插件分身創建失敗:{message}',
cloneFailedGeneral: '插件分身創建失敗',
logTitle: '插件日誌',
quickAccess: '快速訪問',
noPluginsWithPage: '暫無可展示的插件',
tapToOpen: '點擊返回主界面',
recentlyUsed: '最近使用',
allPlugins: '所有插件',
noRecentPlugins: '無',
},
profile: {
personalInfo: '個人信息',

View File

@@ -18,9 +18,7 @@ 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 Toast from 'vue-toastification'
@@ -45,65 +43,99 @@ import HeaderTab from './layouts/components/HeaderTab.vue'
// 7. 样式文件 - 合并为单一导入
import '@/styles/main.scss'
// 8. PWA状态管理
import { PWAStateController } from '@/utils/pwaStateManager'
// PWA状态管理器初始化函数
const initializePWABeforeMount = async () => {
// 检查是否在PWA模式下运行
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone ||
document.referrer.includes('android-app://')
if (isPWA) {
const pwaStateController = new PWAStateController()
// 等待状态恢复完成
await pwaStateController.waitForStateRestore()
// 将状态管理器绑定到全局对象
;(window as any).pwaStateController = pwaStateController
return pwaStateController
}
return null
}
// 在创建Vue应用前初始化PWA状态管理器
const pwaStateController = await initializePWABeforeMount()
// 创建Vue实例
const app = createApp(App)
// 注册pinia
app.use(pinia)
// 初始化配置
async function initializeApp() {
try {
// 是否为PWA
const pwaMode = await isPWA()
app.provide('pwaMode', pwaMode)
// 异步加载远程组件(不阻塞启动)
loadRemoteComponents().catch(error => {
console.error('Failed to load remote components', error)
})
// 全局设置
const globalSettings = await fetchGlobalSettings()
app.provide('globalSettings', globalSettings)
// 1. 注册 UI 框架
app.use(vuetify)
// 加载并注册远程联邦组件
await loadRemoteComponents()
} catch (error) {
console.error('Failed to initialize app', error)
}
// 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)
// 4. 注册其他插件
app
.use(PerfectScrollbarPlugin)
.use(Toast, {
position: 'bottom-right',
hideProgressBar: true,
})
.use(ConfirmDialog)
.use(i18n)
.mount('#app')
// 5. 添加状态恢复事件监听器
if (pwaStateController) {
// 监听状态恢复事件
window.addEventListener('pwaStateRestored', (event: Event) => {
const customEvent = event as CustomEvent
// 可以在这里添加状态恢复后的处理逻辑
// 例如通知Vue组件状态已恢复
app.config.globalProperties.$pwaStateRestored = true
})
// 监听应用即将卸载事件,保存状态
window.addEventListener('beforeunload', () => {
if (pwaStateController) {
pwaStateController.saveCurrentState()
}
})
}
// 注册全局组件
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(Toast, {
position: 'bottom-right',
hideProgressBar: true,
})
.use(ConfirmDialog)
.use(i18n)
.mount('#app')
})
// 导出状态管理器供其他模块使用
export { pwaStateController }

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

@@ -9,13 +9,15 @@ 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()
// 从用户 Store 中获取superuser信息
const superUser = useUserStore().superUser
@@ -46,6 +48,7 @@ const enableConfig = ref<{ [key: string]: boolean }>({
weeklyOverview: false,
cpu: false,
memory: false,
network: false,
library: true,
playing: true,
latest: true,
@@ -112,6 +115,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 +353,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">
<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,6 +9,7 @@ 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()
@@ -119,6 +120,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()
@@ -130,28 +151,25 @@ 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>

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,6 +4,7 @@ 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()
@@ -13,6 +14,9 @@ const { t } = useI18n()
// 当前选择的分类
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 +169,7 @@ async function saveConfig() {
}
// 标签图标映射
const categoryItems: Record<string, string>[] = [
const categoryItems = computed(() => [
{
title: t('recommend.all'),
icon: 'mdi-filmstrip-box-multiple',
@@ -191,7 +195,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 +223,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">
@@ -362,12 +369,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 +400,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

@@ -276,7 +276,9 @@ 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>
<!-- 初始加载状态 -->

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>

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,16 +1,8 @@
import { createHandlerBoundToURL, cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
// Service Worker 类型声明
declare let self: ServiceWorkerGlobalScope
cleanupOutdatedCaches()
// 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 options = {
icon: '/logo.png',
@@ -21,6 +13,10 @@ const options = {
// 存储未读消息数量的键名
const UNREAD_COUNT_KEY = 'mp_unread_count'
// 状态管理相关的缓存名称和端点
const STATE_CACHE_NAME = 'mp-pwa-state-cache'
const STATE_ENDPOINT = '/api/pwa-state'
// 从IndexedDB获取未读消息数量
async function getStoredUnreadCount(): Promise<number> {
try {
@@ -41,6 +37,52 @@ async function setStoredUnreadCount(count: number): Promise<void> {
}
}
// 保存PWA状态到缓存
async function saveStateToCache(request: Request): Promise<Response> {
try {
const state = await request.json()
const cache = await caches.open(STATE_CACHE_NAME)
await cache.put(STATE_ENDPOINT, new Response(JSON.stringify({
...state,
timestamp: Date.now()
})))
return new Response(JSON.stringify({ success: true }))
} catch (error) {
console.error('Failed to save state to cache:', error)
return new Response(JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error) }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
}
// 从缓存获取PWA状态
async function getStateFromCache(): Promise<Response> {
try {
const cache = await caches.open(STATE_CACHE_NAME)
const response = await cache.match(STATE_ENDPOINT)
if (response) {
const state = await response.json()
return new Response(JSON.stringify(state), {
headers: { 'Content-Type': 'application/json' }
})
}
return new Response(JSON.stringify({}), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('Failed to get state from cache:', error)
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
}
// 简单的IndexedDB包装器
async function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
@@ -56,6 +98,7 @@ async function openDB(): Promise<IDBDatabase> {
})
}
// 获取IndexedDB中的数据
async function get(key: string): Promise<any> {
const db = await openDB()
return new Promise((resolve, reject) => {
@@ -67,6 +110,7 @@ async function get(key: string): Promise<any> {
})
}
// 保存数据到IndexedDB
async function set(key: string, value: any): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
@@ -105,9 +149,109 @@ async function clearBadge() {
}
}
// 安装事件
self.addEventListener('install', event => {
event.waitUntil(
(async () => {
// 预缓存关键状态数据
try {
const cache = await caches.open(STATE_CACHE_NAME)
const existingState = await cache.match(STATE_ENDPOINT)
if (existingState) {
// 预热状态数据
const state = await existingState.json()
}
} catch (error) {
// 静默处理错误
}
// 强制等待中的Service Worker立即成为活动的Service Worker
self.skipWaiting()
})()
)
})
// 激活事件
self.addEventListener('activate', event => {
event.waitUntil(
(async () => {
// 启用导航预载功能以提高性能
if ('navigationPreload' in self.registration) {
await self.registration.navigationPreload.enable()
}
// 清理旧版本的缓存
const cacheNames = await caches.keys()
await Promise.all(
cacheNames.map(cacheName => {
if (cacheName.includes('old-') || cacheName.includes('deprecated-')) {
return caches.delete(cacheName)
}
})
)
})(),
)
// 告诉活动的Service Worker立即控制页面
self.clients.claim()
})
// 处理API请求当离线时发送消息到客户端
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
// 处理PWA状态管理请求
if (url.pathname === STATE_ENDPOINT) {
if (event.request.method === 'POST') {
event.respondWith(saveStateToCache(event.request))
} else if (event.request.method === 'GET') {
event.respondWith(getStateFromCache())
}
return
}
if (event.request.url.includes('/api/v1/') && 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('api-cache')
const cachedResponse = await cache.match(event.request)
if (cachedResponse) {
return cachedResponse
}
// 如果没有缓存,抛出错误
throw error
}
})(),
)
return
}
})
// 初始化 Workbox
cleanupOutdatedCaches()
precacheAndRoute(self.__WB_MANIFEST)
// 监听 push 事件,显示通知
self.addEventListener('push', function (event) {
console.log('notification push')
if (!event.data) {
return
}
@@ -116,7 +260,6 @@ self.addEventListener('push', function (event) {
try {
payload = event.data?.json()
} catch (err) {
console.log(err)
payload = {
title: event.data?.text(),
}
@@ -140,26 +283,13 @@ self.addEventListener('push', function (event) {
await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)])
})(),
)
} 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())
} catch (e) {
// 静默处理错误
}
})
// 监听通知点击事件
self.addEventListener('notificationclick', function (event) {
console.log('notification click')
const info = event.notification
if (event.action === 'close') {
info.close()
@@ -170,7 +300,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') {
// 清除徽章
@@ -179,8 +308,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 +319,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 +328,32 @@ 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 === 'SAVE_PWA_STATE') {
// 保存PWA状态
const state = event.data.state || {}
saveStateToCache(new Request(STATE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state)
}))
.then(response => response.json())
.then(result => {
event.ports[0]?.postMessage({ success: result.success })
})
.catch(error => {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'GET_PWA_STATE') {
// 获取PWA状态
getStateFromCache()
.then(response => response.json())
.then(state => {
event.ports[0]?.postMessage({ state })
})
.catch(error => {
event.ports[0]?.postMessage({ state: {} })
})
}
})

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 }

View File

@@ -21,3 +21,12 @@ export interface userState {
// 权限
permissions: { [key: string]: any }
}
export interface globalSettingsState {
// 全局设置数据
data: { [key: string]: any }
// 是否已初始化
initialized: boolean
// 是否正在加载
loading: boolean
}

View File

@@ -8,6 +8,11 @@ html.v-overlay-scroll-blocked {
position: fixed;
}
/* 防止Chrome移动端下拉刷新干扰 */
body {
overscroll-behavior: none;
}
@media (width <= 768px){
html.v-overlay-scroll-blocked {
position: relative;
@@ -53,6 +58,18 @@ html.v-overlay-scroll-blocked {
margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
}
@media only screen and (width <= 600px){
.Vue-Toastification__container {
inline-size: 100vw;
padding-block: 4.5rem;
padding-inline: 1rem;
}
.Vue-Toastification__toast {
border-radius: 8px;
}
}
.v-dialog > .v-overlay__content > .v-card > .v-card-item {
padding: 16px;
}
@@ -344,7 +361,11 @@ html.v-overlay-scroll-blocked {
// 表格
.v-table {
border-radius: 0;
background-color: rgba(var(--v-theme-surface), 0.3);
background-color: rgba(var(--v-theme-surface), 0);
.v-table__wrapper > table > thead {
background-color: rgba(var(--v-theme-surface), 0.3);
}
}
// 页脚
@@ -384,11 +405,17 @@ html.v-overlay-scroll-blocked {
.v-skeleton-loader {
background-color: rgba(var(--v-theme-surface), 0.3);
}
// 输入框和搜索框
.v-field {
background-color: rgba(var(--v-theme-surface), 0);
}
}
// 透明主题下的弹出窗口样式
html[data-theme="transparent"] {
.v-overlay__content {
.v-overlay__content {
border-radius: 12px !important;
backdrop-filter: blur(10px) !important;
@@ -402,8 +429,8 @@ html[data-theme="transparent"] {
background-color: rgb(var(--v-theme-surface), 0.5) !important;
}
.v-table thead {
background-color: rgb(var(--v-theme-surface), 0.5) !important;
.v-table__wrapper table thead {
background-color: rgba(var(--v-theme-surface), 0.3);
}
}
}

26
src/types/pwa.d.ts vendored Normal file
View File

@@ -0,0 +1,26 @@
/**
* PWA相关的类型声明
*/
// 扩展Window接口
declare global {
interface Window {
pwaStateController?: import('@/utils/pwaStateManager').PWAStateController
orientation?: number
}
interface Navigator {
standalone?: boolean
setAppBadge?: (count: number) => Promise<void>
clearAppBadge?: () => Promise<void>
}
// 自定义事件类型
interface WindowEventMap {
'pwaStateRestored': CustomEvent<{
state: import('@/utils/pwaStateManager').PWAState
}>
}
}
export {}

View File

@@ -6,6 +6,9 @@ import {
// @ts-ignore
} from 'virtual:__federation__'
// 创建一个专用的AbortController用于federationLoader请求
const federationController = new AbortController()
// 定义远程模块接口
interface RemoteModule {
id: string
@@ -62,7 +65,9 @@ export async function loadRemoteComponent(id: string, componentName: string = 'P
*/
async function fetchRemoteModules(): Promise<RemoteModule[]> {
try {
const response = await api.get('plugin/remotes?token=moviepilot')
const response = await api.get('plugin/remotes?token=moviepilot', {
signal: federationController.signal,
})
return (response as any) || []
} catch (error) {
console.error('获取远程模块列表失败:', error)

View File

@@ -1,10 +1,16 @@
import api from '@/api'
// 创建一个专用的AbortController用于globalSetting请求
const globalSettingController = new AbortController()
export async function fetchGlobalSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global', {
params: {
token: 'moviepilot',
},
// 手动设置signal防止reqestOptimizer添加可中断的controller
signal: globalSettingController.signal,
})
return result.data || {}
} catch (error) {

View File

@@ -0,0 +1,105 @@
/**
* PWA加载状态管理器
* 用于协调不同组件的加载状态,确保所有关键资源加载完成后再显示界面
*/
export class PWALoadingStateManager {
private loadingStates: Map<string, boolean> = new Map()
private listeners: Set<(isLoading: boolean) => void> = new Set()
/**
* 设置加载状态
* @param key 状态键名
* @param loading 是否正在加载
*/
setLoadingState(key: string, loading: boolean): void {
const wasLoading = this.isAnyLoading()
this.loadingStates.set(key, loading)
const isLoading = this.isAnyLoading()
// 如果总体加载状态发生变化,通知监听器
if (wasLoading !== isLoading) {
this.notifyListeners(isLoading)
}
}
/**
* 检查是否有任何组件正在加载
*/
isAnyLoading(): boolean {
return Array.from(this.loadingStates.values()).some(loading => loading)
}
/**
* 等待所有加载完成
*/
waitForAllComplete(): Promise<void> {
return new Promise((resolve) => {
if (!this.isAnyLoading()) {
resolve()
return
}
const checkComplete = () => {
if (!this.isAnyLoading()) {
resolve()
} else {
// 检查间隔
setTimeout(checkComplete, 50)
}
}
checkComplete()
})
}
/**
* 添加状态变化监听器
* @param listener 监听器函数
*/
addListener(listener: (isLoading: boolean) => void): void {
this.listeners.add(listener)
}
/**
* 移除状态变化监听器
* @param listener 监听器函数
*/
removeListener(listener: (isLoading: boolean) => void): void {
this.listeners.delete(listener)
}
/**
* 通知所有监听器
* @param isLoading 是否正在加载
*/
private notifyListeners(isLoading: boolean): void {
this.listeners.forEach(listener => {
try {
listener(isLoading)
} catch (error) {
// 静默处理错误
}
})
}
/**
* 获取当前加载状态详情
*/
getLoadingStates(): Record<string, boolean> {
return Object.fromEntries(this.loadingStates)
}
/**
* 重置所有加载状态
*/
reset(): void {
const wasLoading = this.isAnyLoading()
this.loadingStates.clear()
if (wasLoading) {
this.notifyListeners(false)
}
}
}
// 全局实例
export const globalLoadingStateManager = new PWALoadingStateManager()

View File

@@ -0,0 +1,703 @@
/**
* PWA状态管理器
* 用于在iOS设备上防止后台被杀时丢失状态提供状态恢复功能
*/
// 应用状态接口
export interface PWAState {
url: string
scrollPosition: number
orientation: number
timestamp: number
appData?: any
formData?: Record<string, any>
userSelections?: {
selectedItems: string[]
activeTab?: string
}
}
// 当前上下文接口
export interface PWAContext {
url: string
orientation: number
timestamp: number
}
/**
* 基础状态管理器使用localStorage和sessionStorage
*/
export class PWAStateManager {
private storageKey = 'mp-pwa-app-state'
private sessionKey = 'mp-pwa-session-state'
// 保存应用状态
saveState(state: PWAState): void {
try {
// 主要状态存储到localStorage
localStorage.setItem(this.storageKey, JSON.stringify({
...state,
timestamp: Date.now()
}))
// 临时状态存储到sessionStorage
sessionStorage.setItem(this.sessionKey, JSON.stringify({
scrollPosition: state.scrollPosition,
activeTab: state.appData?.activeTab,
formData: state.formData
}))
} catch (error) {
console.error('状态保存失败:', error)
}
}
// 恢复应用状态
restoreState(): PWAState | null {
try {
const savedState = localStorage.getItem(this.storageKey)
const sessionState = sessionStorage.getItem(this.sessionKey)
if (savedState) {
const state = JSON.parse(savedState)
const sessionData = sessionState ? JSON.parse(sessionState) : {}
return {
...state,
...sessionData,
isRestored: true
}
}
} catch (error) {
console.error('状态恢复失败:', error)
}
return null
}
// 清除过期状态
clearExpiredState(maxAge = 24 * 60 * 60 * 1000): void { // 24小时
try {
const savedState = localStorage.getItem(this.storageKey)
if (savedState) {
const state = JSON.parse(savedState)
if (Date.now() - state.timestamp > maxAge) {
localStorage.removeItem(this.storageKey)
sessionStorage.removeItem(this.sessionKey)
}
}
} catch (error) {
console.error('清除过期状态失败:', error)
}
}
}
/**
* IndexedDB状态管理器
*/
export class PWAIndexedDBManager {
private dbName = 'MPPWAStateDB'
private dbVersion = 1
private storeName = 'appState'
private async initDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'id' })
}
}
})
}
async saveState(state: PWAState): Promise<void> {
try {
const db = await this.initDB()
const transaction = db.transaction([this.storeName], 'readwrite')
const store = transaction.objectStore(this.storeName)
await store.put({
id: 'appState',
data: state,
timestamp: Date.now()
})
} catch (error) {
console.error('IndexedDB保存失败:', error)
}
}
async restoreState(): Promise<PWAState | null> {
try {
const db = await this.initDB()
const transaction = db.transaction([this.storeName], 'readonly')
const store = transaction.objectStore(this.storeName)
return new Promise((resolve, reject) => {
const request = store.get('appState')
request.onsuccess = () => {
const result = request.result
resolve(result ? result.data : null)
}
request.onerror = () => reject(request.error)
})
} catch (error) {
console.error('IndexedDB恢复失败:', error)
return null
}
}
}
/**
* Service Worker状态同步
*/
export class ServiceWorkerStateSync {
private stateEndpoint = '/api/pwa-state'
async saveState(state: PWAState): Promise<boolean> {
try {
const response = await fetch(this.stateEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state)
})
const result = await response.json()
return result.success
} catch (error) {
console.error('Service Worker状态保存失败:', error)
return false
}
}
async loadState(): Promise<PWAState | null> {
try {
const response = await fetch(this.stateEndpoint)
const state = await response.json()
return Object.keys(state).length > 0 ? state : null
} catch (error) {
console.error('Service Worker状态加载失败:', error)
return null
}
}
// 使用MessageChannel与Service Worker通信
async saveStateViaMessage(state: PWAState): Promise<boolean> {
return new Promise((resolve) => {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
resolve(event.data.success)
}
navigator.serviceWorker.controller.postMessage({
type: 'SAVE_PWA_STATE',
state
}, [channel.port2])
} else {
resolve(false)
}
})
}
async loadStateViaMessage(): Promise<PWAState | null> {
return new Promise((resolve) => {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
resolve(event.data.state || null)
}
navigator.serviceWorker.controller.postMessage({
type: 'GET_PWA_STATE'
}, [channel.port2])
} else {
resolve(null)
}
})
}
}
/**
* 状态恢复决策器
*/
export class StateRestoreDecision {
private maxStateAge = 60 * 60 * 1000 // 60分钟延长有效期
shouldRestoreState(savedState: PWAState | null, currentContext: PWAContext): boolean {
if (!savedState) return false
// 检查状态年龄 - 更宽松的过期检查
if (this.isStateExpired(savedState)) {
return false
}
// URL匹配检查 - 更宽松的匹配策略
if (!this.isUrlCompatible(savedState.url, currentContext.url)) {
// 即使URL不匹配也可以恢复一些基础状态如滚动位置除外
return true
}
// 设备方向变化不阻止状态恢复
if (this.isOrientationChanged(savedState, currentContext)) {
// 继续恢复
}
return true
}
private isStateExpired(savedState: PWAState): boolean {
return Date.now() - savedState.timestamp > this.maxStateAge
}
private isUrlCompatible(savedUrl: string, currentUrl: string): boolean {
if (!savedUrl || !currentUrl) return false
try {
const savedPath = new URL(savedUrl).pathname
const currentPath = new URL(currentUrl).pathname
return savedPath === currentPath
} catch {
return false
}
}
private isOrientationChanged(savedState: PWAState, currentContext: PWAContext): boolean {
return savedState.orientation !== currentContext.orientation
}
}
/**
* 页面可见性状态管理器
*/
export class VisibilityStateManager {
private stateManager: PWAStateManager
private blurTimer: number | null = null
private isRestoring = false
private restorePromise: Promise<void> | null = null
constructor(stateManager: PWAStateManager) {
this.stateManager = stateManager
this.setupVisibilityListener()
}
private setupVisibilityListener(): void {
// 监听页面可见性变化
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.handlePageHidden()
} else {
this.handlePageVisible()
}
})
// 监听页面卸载
window.addEventListener('beforeunload', () => {
this.handlePageUnload()
})
// 监听页面焦点变化
window.addEventListener('blur', () => {
this.handlePageBlur()
})
window.addEventListener('focus', () => {
this.handlePageFocus()
})
}
private handlePageHidden(): void {
const currentState = this.getCurrentAppState()
this.stateManager.saveState(currentState)
}
private handlePageVisible(): void {
if (this.isRestoring) return
this.isRestoring = true
this.restorePromise = this.performStateRestore()
}
private async performStateRestore(): Promise<void> {
try {
const restoredState = this.stateManager.restoreState()
if (restoredState) {
await this.restoreAppState(restoredState)
}
} catch (error) {
// 静默处理错误
} finally {
this.isRestoring = false
}
}
private handlePageUnload(): void {
const currentState = this.getCurrentAppState()
this.stateManager.saveState(currentState)
}
private handlePageBlur(): void {
if (this.blurTimer) clearTimeout(this.blurTimer)
this.blurTimer = window.setTimeout(() => {
const currentState = this.getCurrentAppState()
this.stateManager.saveState(currentState)
}, 1000)
}
private handlePageFocus(): void {
if (this.blurTimer) {
clearTimeout(this.blurTimer)
this.blurTimer = null
}
}
private getCurrentAppState(): PWAState {
return {
url: window.location.href,
scrollPosition: window.scrollY,
orientation: window.orientation || 0,
timestamp: Date.now(),
appData: this.getAppSpecificState()
}
}
private async restoreAppState(state: PWAState): Promise<void> {
// 立即恢复状态,无需延迟
if (state.scrollPosition) {
window.scrollTo(0, state.scrollPosition)
}
if (state.appData) {
this.restoreAppSpecificState(state.appData)
}
// 触发状态恢复完成事件
window.dispatchEvent(new CustomEvent('pwaStateRestored', {
detail: { state }
}))
}
private getAppSpecificState(): any {
// 获取应用特定状态
return {
formData: this.getFormData(),
userSelections: this.getUserSelections()
}
}
private restoreAppSpecificState(appData: any): void {
if (appData.formData) {
this.restoreFormData(appData.formData)
}
if (appData.userSelections) {
this.restoreUserSelections(appData.userSelections)
}
}
private getFormData(): Record<string, any> {
const forms = document.querySelectorAll('form')
const formData: Record<string, any> = {}
forms.forEach((form, index) => {
const data = new FormData(form)
formData[`form-${index}`] = Object.fromEntries(data)
})
return formData
}
private restoreFormData(formData: Record<string, any>): void {
Object.entries(formData).forEach(([formId, data]) => {
const formIndex = parseInt(formId.split('-')[1])
const form = document.querySelectorAll('form')[formIndex]
if (form) {
Object.entries(data).forEach(([name, value]) => {
const input = form.querySelector(`[name="${name}"]`) as HTMLInputElement
if (input) {
input.value = value as string
}
})
}
})
}
private getUserSelections(): any {
return {
selectedItems: Array.from(document.querySelectorAll('.selected')).map(el => el.id),
activeTab: document.querySelector('.tab.active')?.id
}
}
private restoreUserSelections(selections: any): void {
if (selections.selectedItems) {
selections.selectedItems.forEach((id: string) => {
const element = document.getElementById(id)
if (element) {
element.classList.add('selected')
}
})
}
if (selections.activeTab) {
const tab = document.getElementById(selections.activeTab)
if (tab) {
tab.classList.add('active')
}
}
}
}
/**
* 完整的PWA状态管理器
*/
export class PWAStateController {
private stateManager: PWAStateManager
private indexedDBManager: PWAIndexedDBManager
private swStateSync: ServiceWorkerStateSync
private visibilityManager: VisibilityStateManager
private restoreDecision: StateRestoreDecision
private stateRestorePromise: Promise<void> | null = null
private stateRestoreResolve: (() => void) | null = null
private isRestoring = false
constructor() {
this.stateManager = new PWAStateManager()
this.indexedDBManager = new PWAIndexedDBManager()
this.swStateSync = new ServiceWorkerStateSync()
this.visibilityManager = new VisibilityStateManager(this.stateManager)
this.restoreDecision = new StateRestoreDecision()
// 创建状态恢复Promise
this.stateRestorePromise = new Promise((resolve) => {
this.stateRestoreResolve = resolve
})
this.init()
}
/**
* 等待状态恢复完成
*/
async waitForStateRestore(): Promise<void> {
return this.stateRestorePromise || Promise.resolve()
}
/**
* 获取当前是否正在恢复状态
*/
get isRestoringState(): boolean {
return this.isRestoring
}
private async init(): Promise<void> {
// 清理过期状态
this.stateManager.clearExpiredState()
// 检查是否需要恢复状态
await this.checkAndRestoreState()
// 设置定期保存
this.setupPeriodicSave()
}
private async checkAndRestoreState(): Promise<void> {
this.isRestoring = true
try {
const currentContext: PWAContext = {
url: window.location.href,
orientation: window.orientation || 0,
timestamp: Date.now()
}
// 尝试从多个来源恢复状态
const sources = [
() => this.stateManager.restoreState(),
() => this.indexedDBManager.restoreState(),
() => this.swStateSync.loadState(),
() => this.swStateSync.loadStateViaMessage()
]
for (const source of sources) {
try {
const savedState = await source()
if (this.restoreDecision.shouldRestoreState(savedState, currentContext)) {
await this.restoreState(savedState!)
return
}
} catch (error) {
// 静默处理错误
}
}
} finally {
this.isRestoring = false
// 状态恢复完成(无论成功还是失败)
if (this.stateRestoreResolve) {
this.stateRestoreResolve()
this.stateRestoreResolve = null
}
}
}
async saveCurrentState(): Promise<void> {
const state: PWAState = {
url: window.location.href,
scrollPosition: window.scrollY,
orientation: window.orientation || 0,
timestamp: Date.now(),
appData: this.getAppSpecificState()
}
// 多重保存策略
await Promise.allSettled([
this.stateManager.saveState(state),
this.indexedDBManager.saveState(state),
this.swStateSync.saveState(state),
this.swStateSync.saveStateViaMessage(state)
])
}
private async restoreState(state: PWAState): Promise<void> {
const currentUrl = window.location.href
const urlMatches = this.isUrlExactMatch(state.url, currentUrl)
// 只有在URL完全匹配时才恢复滚动位置
if (state.scrollPosition && urlMatches) {
window.scrollTo({
top: state.scrollPosition,
behavior: 'auto'
})
}
// 恢复应用特定状态 - 过滤掉不适用的状态
if (state.appData) {
this.restoreAppSpecificState(state.appData, urlMatches)
}
// 触发状态恢复事件
this.dispatchStateRestoreEvent(state)
}
private isUrlExactMatch(savedUrl: string, currentUrl: string): boolean {
try {
const saved = new URL(savedUrl)
const current = new URL(currentUrl)
return saved.pathname === current.pathname
} catch {
return false
}
}
private setupPeriodicSave(): void {
// 每30秒保存一次状态
setInterval(() => {
if (!document.hidden) {
this.saveCurrentState()
}
}, 30000)
}
private getAppSpecificState(): any {
// 可以在这里添加MoviePilot特定的状态
return {
// 路由状态
routerState: this.getRouterState(),
// 用户界面状态
uiState: this.getUIState(),
// 表单状态
formState: this.getFormState()
}
}
private getRouterState(): any {
// 获取Vue Router状态
return {
currentRoute: window.location.pathname,
query: window.location.search,
hash: window.location.hash
}
}
private getUIState(): any {
// 获取UI状态
return {
sidebarOpen: document.querySelector('.v-navigation-drawer--active') !== null,
darkMode: document.documentElement.classList.contains('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark'
}
}
private getFormState(): any {
// 获取表单状态
const forms = document.querySelectorAll('form')
const formData: Record<string, any> = {}
forms.forEach((form, index) => {
const inputs = form.querySelectorAll('input, select, textarea')
const data: Record<string, any> = {}
inputs.forEach((input) => {
const element = input as HTMLInputElement
if (element.name) {
data[element.name] = element.value
}
})
if (Object.keys(data).length > 0) {
formData[`form-${index}`] = data
}
})
return formData
}
private restoreAppSpecificState(appData: any, urlMatches: boolean = true): void {
// 总是恢复UI状态如主题等
if (appData.uiState) {
this.restoreUIState(appData.uiState)
}
// 只有在URL匹配时才恢复表单状态
if (appData.formState && urlMatches) {
this.restoreFormState(appData.formState)
}
}
private restoreUIState(uiState: any): void {
// 恢复UI状态
if (uiState.darkMode !== undefined) {
// 这里可以根据实际的主题切换逻辑来恢复
}
}
private restoreFormState(formState: any): void {
// 恢复表单状态
Object.entries(formState).forEach(([formId, data]) => {
const formIndex = parseInt(formId.split('-')[1])
const form = document.querySelectorAll('form')[formIndex]
if (form) {
Object.entries(data as Record<string, any>).forEach(([name, value]) => {
const input = form.querySelector(`[name="${name}"]`) as HTMLInputElement
if (input) {
input.value = value as string
// 触发change事件以便Vue能够响应
input.dispatchEvent(new Event('input', { bubbles: true }))
}
})
}
})
}
private dispatchStateRestoreEvent(state: PWAState): void {
const event = new CustomEvent('pwaStateRestored', {
detail: { state }
})
window.dispatchEvent(event)
}
}

View File

@@ -0,0 +1,98 @@
// 全局请求优化器
// 自动管理所有API请求的中断无需手动注册
let isNavigating = false
const activeRequests = new Set<AbortController>()
// 监听路由状态
export function setNavigatingState(navigating: boolean) {
isNavigating = navigating
if (navigating) {
// 路由切换时,中断所有未完成的请求
console.log('Navigation started - aborting active requests')
abortAllActiveRequests()
}
}
// 中断所有活跃的请求
function abortAllActiveRequests() {
for (const controller of activeRequests) {
if (!controller.signal.aborted) {
controller.abort()
}
}
activeRequests.clear()
}
// 清理已完成的请求控制器
function cleanupController(controller: AbortController) {
activeRequests.delete(controller)
}
// 初始化请求优化器
export function initializeRequestOptimizer(axiosInstance: any) {
// 拦截请求,自动添加 AbortController
axiosInstance.interceptors.request.use(
(config: any) => {
// 如果请求已经有 signal跳过避免覆盖手动设置的
if (config.signal) {
return config
}
// 创建新的 AbortController
const controller = new AbortController()
config.signal = controller.signal
// 将控制器添加到活跃列表
activeRequests.add(controller)
// 监听请求完成事件来清理控制器
const cleanup = () => cleanupController(controller)
// 监听中断事件
controller.signal.addEventListener('abort', cleanup, { once: true })
return config
},
(error: any) => {
return Promise.reject(error)
},
)
// 拦截响应,清理对应的控制器
axiosInstance.interceptors.response.use(
(response: any) => {
// 从配置中获取 signal 对应的控制器并清理
if (response.config?.signal) {
const controller = Array.from(activeRequests).find(ctrl => ctrl.signal === response.config.signal)
if (controller) {
cleanupController(controller)
}
}
return response
},
(error: any) => {
// 错误时也要清理控制器
if (error.config?.signal) {
const controller = Array.from(activeRequests).find(ctrl => ctrl.signal === error.config.signal)
if (controller) {
cleanupController(controller)
}
}
return Promise.reject(error)
},
)
console.log('Request optimizer initialized - all requests will be auto-managed')
}
// 获取当前活跃请求数量(调试用)
export function getActiveRequestsCount() {
return activeRequests.size
}
// 手动中断所有请求(备用方法)
export function abortAllRequests() {
abortAllActiveRequests()
}

View File

@@ -112,6 +112,8 @@ async function getCpuUsage() {
try {
// 请求数据
current.value = (await api.get('dashboard/cpu')) ?? 0
// 使用nextTick确保DOM更新完成后再更新图表数据
await nextTick()
// 添加到序列
series.value[0].data.push(current.value)
// 序列超过30条记录时清掉前面的
@@ -122,10 +124,13 @@ async function getCpuUsage() {
}
onMounted(() => {
getCpuUsage() // 启动定时器
refreshTimer = setInterval(() => {
// 延迟启动,确保组件完全挂载
nextTick(() => {
getCpuUsage()
}, 2000)
refreshTimer = setInterval(() => {
getCpuUsage()
}, 2000)
})
})
// 组件卸载时停止定时器
@@ -137,7 +142,9 @@ onUnmounted(() => {
})
onActivated(() => {
chartKey.value += 1
nextTick(() => {
chartKey.value += 1
})
})
</script>

View File

@@ -118,6 +118,8 @@ async function getMemorgUsage() {
try {
// 请求数据
;[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')
// 使用nextTick确保DOM更新完成后再更新图表数据
await nextTick()
series.value[0].data.push(memoryUsage.value)
// 序列超过30条记录时清掉前面的
if (series.value[0].data.length > 30) series.value[0].data.shift()
@@ -127,11 +129,14 @@ async function getMemorgUsage() {
}
onMounted(() => {
getMemorgUsage()
// 启动定时器
refreshTimer = setInterval(() => {
// 延迟启动,确保组件完全挂载
nextTick(() => {
getMemorgUsage()
}, 3000)
// 启动定时器
refreshTimer = setInterval(() => {
getMemorgUsage()
}, 3000)
})
})
// 组件卸载时停止定时器
@@ -143,7 +148,10 @@ onUnmounted(() => {
})
onActivated(() => {
chartKey.value += 1
// 使用nextTick确保DOM准备完成后再更新chartKey
nextTick(() => {
chartKey.value += 1
})
})
</script>

View File

@@ -0,0 +1,215 @@
<script setup lang="ts">
import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
import api from '@/api'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
})
const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(
() => vuetifyTheme.name.value,
() => vuetifyTheme.current.value.colors,
)
const variableTheme = controlledComputed(
() => vuetifyTheme.name.value,
() => vuetifyTheme.current.value.variables,
)
const chartKey = ref(0)
// 定时器
let refreshTimer: NodeJS.Timeout | null = null
// 时间序列 - 上行和下行流量
const series = ref([
{
name: '上行流量',
data: [0],
},
{
name: '下行流量',
data: [0],
},
])
// 当前值
const currentUpload = ref(0)
const currentDownload = ref(0)
// 格式化流量显示
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B/s'
const k = 1024
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const chartOptions = controlledComputed(
() => vuetifyTheme.name.value,
() => {
return {
chart: {
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: false },
},
tooltip: {
enabled: false,
},
grid: {
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
variableTheme.value['border-opacity']
})`,
strokeDashArray: 6,
xaxis: {
lines: { show: false },
},
yaxis: {
lines: { show: true },
},
padding: {
top: -10,
left: -7,
right: 5,
bottom: 5,
},
},
stroke: {
width: 3,
lineCap: 'butt',
curve: 'smooth',
},
colors: [currentTheme.value.warning, currentTheme.value.info],
markers: {
size: 6,
offsetY: 4,
offsetX: -2,
strokeWidth: 3,
colors: ['transparent'],
strokeColors: 'transparent',
discrete: [
{
size: 5.5,
seriesIndex: 0,
strokeColor: currentTheme.value.warning,
fillColor: currentTheme.value.surface,
},
{
size: 5.5,
seriesIndex: 1,
strokeColor: currentTheme.value.info,
fillColor: currentTheme.value.surface,
},
],
hover: { size: 7 },
},
xaxis: {
labels: { show: false },
axisTicks: { show: false },
axisBorder: { show: false },
},
yaxis: {
labels: { show: false },
},
legend: {
show: true,
position: 'top',
horizontalAlign: 'left',
fontSize: '12px',
fontFamily: 'inherit',
},
}
},
)
// 调用API接口获取最新网络流量
async function getNetworkUsage() {
if (!props.allowRefresh) return
try {
// 请求数据 - 接口返回 [上行流量, 下行流量]
const data: [number, number] = (await api.get('dashboard/network')) ?? [0, 0]
currentUpload.value = data[0] || 0
currentDownload.value = data[1] || 0
// 使用nextTick确保DOM更新完成后再更新图表数据
await nextTick()
// 添加到序列
series.value[0].data.push(currentUpload.value)
series.value[1].data.push(currentDownload.value)
// 序列超过30条记录时清掉前面的
if (series.value[0].data.length > 30) {
series.value[0].data.shift()
series.value[1].data.shift()
}
} catch (e) {
console.log(e)
}
}
onMounted(() => {
// 延迟启动,确保组件完全挂载
nextTick(() => {
getNetworkUsage()
refreshTimer = setInterval(() => {
getNetworkUsage()
}, 2000)
})
})
// 组件卸载时停止定时器
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
onActivated(() => {
nextTick(() => {
chartKey.value += 1
})
})
</script>
<template>
<VHover>
<template #default="hover">
<VCard v-bind="hover.props">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.network') }}</VCardTitle>
</VCardItem>
<VCardText>
<VApexChart :key="chartKey" type="line" :options="chartOptions" :series="series" :height="150" />
<div class="d-flex justify-space-between">
<p class="text-center font-weight-medium mb-0">
<span class="text-warning">{{ t('dashboard.upload') }}</span
>{{ formatBytes(currentUpload) }}
</p>
<p class="text-center font-weight-medium mb-0">
<span class="text-info">{{ t('dashboard.download') }}</span
>{{ formatBytes(currentDownload) }}
</p>
</div>
</VCardText>
</VCard>
</template>
</VHover>
</template>

View File

@@ -107,7 +107,8 @@ const totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0
async function getWeeklyData() {
try {
const res: number[] = await api.get('dashboard/transfer')
// 使用nextTick确保DOM更新完成后再更新图表数据
await nextTick()
series.value = [{ data: res }]
} catch (e) {
console.log(e)
@@ -115,11 +116,17 @@ async function getWeeklyData() {
}
onMounted(() => {
getWeeklyData()
// 延迟启动,确保组件完全挂载
nextTick(() => {
getWeeklyData()
})
})
onActivated(() => {
getWeeklyData()
// 使用nextTick确保DOM准备完成后再获取数据
nextTick(() => {
getWeeklyData()
})
})
</script>

View File

@@ -3,7 +3,6 @@ import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import SlideView from '@/components/slide/SlideView.vue'
import { registerAbortController } from '@/router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -28,10 +27,7 @@ const dataList = ref<MediaInfo[]>([])
async function fetchData() {
try {
if (!props.apipath) return
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
dataList.value = await api.get(props.apipath, { signal })
dataList.value = await api.get(props.apipath)
if (dataList.value.length > 0) componentLoaded.value = true
} catch (error) {
console.error(error)

View File

@@ -15,6 +15,7 @@ import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useTheme } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { hasPermission } from '@/utils/permission'
import { useGlobalSettingsStore } from '@/stores'
// 国际化
const { t } = useI18n()
@@ -28,7 +29,9 @@ const mediaProps = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 用户 Store
const userStore = useUserStore()

View File

@@ -5,6 +5,7 @@ import personIcon from '@images/misc/person.png'
import type { Person } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
// 国际化
const { t } = useI18n()
@@ -17,7 +18,9 @@ const personProps = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 媒体详情
const personDetail = ref<Person>({} as Person)

View File

@@ -13,6 +13,8 @@ import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDi
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
import { usePWA } from '@/composables/usePWA'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
// 国际化
const { t } = useI18n()
@@ -23,7 +25,8 @@ const route = useRoute()
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
// 当前标签
const activeTab = ref('installed')
@@ -31,6 +34,81 @@ const activeTab = ref('installed')
// 获取插件标签页
const pluginTabs = computed(() => getPluginTabs())
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
// 注册动态标签页在setup顶层立即执行
registerHeaderTab({
items: pluginTabs.value,
modelValue: activeTab,
appendButtons: [
{
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: computed(() =>
installedFilter.value || hasUpdateFilter.value || enabledFilter.value ? 'primary' : 'gray',
),
class: 'settings-icon-button',
dataAttr: 'installed-filter-btn',
action: () => {
filterInstalledPluginDialog.value = true
},
show: computed(() => activeTab.value === 'installed'),
},
{
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: computed(() => (isFilterFormEmpty.value ? 'gray' : 'primary')),
class: 'settings-icon-button',
dataAttr: 'market-filter-btn',
action: () => {
filterMarketPluginDialog.value = true
},
show: computed(() => activeTab.value === 'market'),
},
{
icon: 'mdi-refresh',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
refreshMarket()
},
show: computed(() => activeTab.value === 'market'),
},
{
icon: 'mdi-store-cog',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
MarketSettingDialog.value = true
},
show: computed(() => activeTab.value === 'market'),
},
{
icon: 'mdi-folder-plus',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
showNewFolderDialog()
},
show: computed(() => activeTab.value === 'installed' && !currentFolder.value),
},
{
icon: 'mdi-arrow-left',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
backToMain()
},
show: computed(() => activeTab.value === 'installed' && !!currentFolder.value),
},
],
})
// 插件ID参数
const pluginId = ref(route.query.id)
@@ -603,7 +681,7 @@ function pluginIcon(item: Plugin) {
if (pluginIconLoaded.value[item.id || '0'] === false) return noImage
// 如果是网络图片则使用代理后返回
if (item?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}&cache=true`
return `./plugin_icon/${item?.plugin_icon}`
}
@@ -796,6 +874,7 @@ function loadMarketMore({ done }: { done: any }) {
}
// 组件挂载后
onMounted(async () => {
await loadPluginOrderConfig()
await loadPluginFolders() // 加载文件夹配置
@@ -1213,173 +1292,118 @@ function onDragStartPlugin(evt: any) {
<template>
<div>
<VHeaderTab :items="pluginTabs" v-model="activeTab">
<template #append>
<VMenu
v-if="activeTab === 'installed'"
v-model="filterInstalledPluginDialog"
width="20rem"
:close-on-content-click="false"
scrim
>
<template #activator="{ props }">
<VBtn
icon="mdi-filter-multiple-outline"
variant="text"
:color="installedFilter || hasUpdateFilter || enabledFilter ? '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('plugin.filterPlugins') }}
</VCardTitle>
<VDialogCloseBtn @click="filterInstalledPluginDialog = false" />
</VCardItem>
<VCardText>
<!-- 过滤弹窗 -->
<Teleport to="body" v-if="filterInstalledPluginDialog">
<VMenu
v-model="filterInstalledPluginDialog"
width="20rem"
:close-on-content-click="false"
:activator="'[data-menu-activator=installed-filter-btn]'"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
{{ t('plugin.filterPlugins') }}
</VCardTitle>
<VDialogCloseBtn @click="filterInstalledPluginDialog = false" />
</VCardItem>
<VCardText>
<VRow>
<VCol cols="12">
<VCombobox
v-model="installedFilter"
:items="installedPluginNames"
:label="t('plugin.name')"
density="comfortable"
clearable
/>
</VCol>
<VCol cols="6">
<VSwitch v-model="enabledFilter" :label="t('plugin.running')" />
</VCol>
<VCol cols="6">
<VSwitch v-model="hasUpdateFilter" :label="t('plugin.hasNewVersion')" />
</VCol>
</VRow>
</VCardText>
</VCard>
</VMenu>
</Teleport>
<Teleport to="body" v-if="filterMarketPluginDialog">
<VMenu
v-model="filterMarketPluginDialog"
width="25rem"
:close-on-content-click="false"
:activator="'[data-menu-activator=market-filter-btn]'"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
{{ t('plugin.filterPlugins') }}
</VCardTitle>
<VDialogCloseBtn @click="filterMarketPluginDialog = false" />
</VCardItem>
<VCardText>
<!-- 过滤表单 -->
<div v-if="isAppMarketLoaded">
<VRow>
<VCol cols="12">
<VCombobox
v-model="installedFilter"
:items="installedPluginNames"
:label="t('plugin.name')"
<VCol cols="6">
<VTextField v-model="filterForm.name" density="comfortable" :label="t('plugin.name')" clearable />
</VCol>
<VCol v-if="authorFilterOptions.length > 0" cols="6">
<VSelect
v-model="filterForm.author"
:items="authorFilterOptions"
density="comfortable"
chips
:label="t('plugin.author')"
multiple
clearable
/>
</VCol>
<VCol cols="6">
<VSwitch v-model="enabledFilter" :label="t('plugin.running')" />
<VCol v-if="labelFilterOptions.length > 0" cols="6">
<VSelect
v-model="filterForm.label"
:items="labelFilterOptions"
density="comfortable"
chips
:label="t('plugin.label')"
multiple
clearable
/>
</VCol>
<VCol cols="6">
<VSwitch v-model="hasUpdateFilter" :label="t('plugin.hasNewVersion')" />
<VCol v-if="repoFilterOptions.length > 0" cols="6">
<VSelect
v-model="filterForm.repo"
:items="repoFilterOptions"
density="comfortable"
chips
:label="t('plugin.repository')"
multiple
clearable
/>
</VCol>
<VCol v-if="sortOptions.length > 0" cols="6">
<VSelect
v-model="activeSort"
:items="sortOptions"
density="comfortable"
:label="t('plugin.sortTitle')"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
</VMenu>
<VMenu
v-if="activeTab === 'market'"
v-model="filterMarketPluginDialog"
width="25rem"
:close-on-content-click="false"
scrim
>
<template #activator="{ props }">
<VBtn
icon="mdi-filter-multiple-outline"
variant="text"
:color="isFilterFormEmpty ? 'gray' : 'primary'"
size="default"
class="settings-icon-button"
v-bind="props"
/>
</template>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
{{ t('plugin.filterPlugins') }}
</VCardTitle>
<VDialogCloseBtn @click="filterMarketPluginDialog = false" />
</VCardItem>
<VCardText>
<!-- 过滤表单 -->
<div v-if="isAppMarketLoaded">
<VRow>
<VCol cols="6">
<VTextField v-model="filterForm.name" density="comfortable" :label="t('plugin.name')" clearable />
</VCol>
<VCol v-if="authorFilterOptions.length > 0" cols="6">
<VSelect
v-model="filterForm.author"
:items="authorFilterOptions"
density="comfortable"
chips
:label="t('plugin.author')"
multiple
clearable
/>
</VCol>
<VCol v-if="labelFilterOptions.length > 0" cols="6">
<VSelect
v-model="filterForm.label"
:items="labelFilterOptions"
density="comfortable"
chips
:label="t('plugin.label')"
multiple
clearable
/>
</VCol>
<VCol v-if="repoFilterOptions.length > 0" cols="6">
<VSelect
v-model="filterForm.repo"
:items="repoFilterOptions"
density="comfortable"
chips
:label="t('plugin.repository')"
multiple
clearable
/>
</VCol>
<VCol v-if="sortOptions.length > 0" cols="6">
<VSelect
v-model="activeSort"
:items="sortOptions"
density="comfortable"
:label="t('plugin.sortTitle')"
/>
</VCol>
</VRow>
</div>
</VCardText>
</VCard>
</VMenu>
<VBtn
v-if="activeTab === 'market'"
icon="mdi-refresh"
variant="text"
color="gray"
size="default"
class="settings-icon-button"
:loading="isMarketRefreshing"
@click="refreshMarket"
/>
<VBtn
v-if="activeTab === 'market'"
icon="mdi-store-cog"
variant="text"
color="gray"
size="default"
class="settings-icon-button"
@click="MarketSettingDialog = true"
/>
<VBtn
v-if="activeTab === 'installed' && !currentFolder"
icon="mdi-folder-plus"
variant="text"
color="gray"
size="default"
class="settings-icon-button"
@click="showNewFolderDialog"
/>
<VBtn
v-if="activeTab === 'installed' && currentFolder"
icon="mdi-arrow-left"
variant="text"
color="gray"
size="default"
class="settings-icon-button"
@click="backToMain"
/>
</template>
</VHeaderTab>
</div>
</VCardText>
</VCard>
</VMenu>
</Teleport>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition px-2" :touch="false">
<VWindow v-model="activeTab" class="disable-tab-transition px-2" :touch="false">
<!-- 我的插件 -->
<VWindowItem value="installed">
<transition name="fade-slide" appear>
@@ -1504,18 +1528,20 @@ function onDragStartPlugin(evt: any) {
<div v-if="isRefreshed">
<!-- 插件搜索图标 -->
<VFab
v-if="!appMode"
icon="mdi-magnify"
color="info"
location="bottom"
size="x-large"
fixed
app
appear
@click="SearchDialog = true"
:class="{ 'mb-12': appMode }"
/>
<Teleport to="body">
<VFab
v-if="!appMode"
icon="mdi-magnify"
color="info"
location="bottom"
size="x-large"
fixed
app
appear
@click="SearchDialog = true"
:class="{ 'mb-12': appMode }"
/>
</Teleport>
</div>
<!-- 插件市场设置窗口 -->
<PluginMarketSettingDialog
@@ -1622,7 +1648,3 @@ function onDragStartPlugin(evt: any) {
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
// 样式已移至 PluginMixedSortCard 组件
</style>

View File

@@ -11,13 +11,15 @@ import router from '@/router'
import { useDisplay } from 'vuetify'
import { formatFileSize } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
// i18n
const { t } = useI18n()
// APP
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
// 提示框
const $toast = useToast()
@@ -213,7 +215,7 @@ const TransferDict: { [key: string]: string } = {
}
const tableStyle = computed(() => {
return appMode
return appMode.value
? 'height: calc(100vh - 15rem - env(safe-area-inset-bottom) - 7rem)'
: 'height: calc(100vh - 15rem - env(safe-area-inset-bottom)'
})
@@ -698,29 +700,31 @@ onMounted(() => {
</VCard>
<!-- 底部操作按钮 -->
<div v-if="isRefreshed && selected.length > 0">
<VFab
icon="mdi-trash-can-outline"
color="error"
location="bottom"
size="x-large"
fixed
app
appear
@click="removeHistoryBatch"
:class="appMode ? 'mb-28' : 'mb-16'"
/>
<VFab
:class="appMode ? 'mb-44' : 'mb-32'"
icon="mdi-redo-variant"
location="bottom"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
</div>
<Teleport to="body">
<div v-if="isRefreshed && selected.length > 0">
<VFab
icon="mdi-trash-can-outline"
color="error"
location="bottom"
size="x-large"
fixed
app
appear
@click="removeHistoryBatch"
:class="appMode ? 'mb-28' : 'mb-16'"
/>
<VFab
:class="appMode ? 'mb-44' : 'mb-32'"
icon="mdi-redo-variant"
location="bottom"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
</div>
</Teleport>
<!-- 底部弹窗 -->
<VBottomSheet v-model="deleteConfirmDialog" inset>
<VCard class="text-center">

View File

@@ -3,18 +3,21 @@ import { useToast } from 'vue-toastification'
import api from '@/api'
import type { TorrentCacheData, TorrentCacheItem } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { formatFileSize, formatDateDifference } from '@core/utils/formatters'
import { useConfirm } from '@/composables/useConfirm'
import { useGlobalSettingsStore } from '@/stores'
import { usePWA } from '@/composables/usePWA'
// 国际化
const { t } = useI18n()
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// PWA模式检测
const { appMode } = usePWA()
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 确认框
const createConfirm = useConfirm()

View File

@@ -9,6 +9,7 @@ import DirectoryCard from '@/components/cards/DirectoryCard.vue'
import StorageCard from '@/components/cards/StorageCard.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { storageAttributes } from '@/api/constants'
const { t } = useI18n()
@@ -33,6 +34,17 @@ const sourceItems = [
{ 'title': '豆瓣', 'value': 'douban' },
]
// 存储选项(排除已添加的)
const storageOptions = computed(() => {
const existingTypes = storages.value.map(storage => storage.type)
return storageAttributes
.filter(item => !existingTypes.includes(item.type))
.map(item => ({
title: t(`storage.${item.type}`),
value: item.type,
}))
})
// 系统设置
const SystemSettings = ref<any>({
Basic: {
@@ -156,12 +168,32 @@ async function loadMediaCategories() {
}
// 添加存储
function addStorage() {
function addStorage(storageType = 'custom') {
let name: string
let type: string
if (storageType === 'custom') {
// 自定义存储需要数字序号
name = `${t(`storage.${storageType}`)} ${storages.value.length + 1}`
while (storages.value.some(item => item.name === name)) {
const num = parseInt(name.match(/\d+$/)?.[0] || '1') + 1
name = `${t(`storage.${storageType}`)} ${num}`
}
type = `custom${storages.value.length + 1}`
} else {
// 预定义存储类型直接使用类型名称
name = t(`storage.${storageType}`)
type = storageType
}
storages.value.push({
name: `${t('storage.custom')} ${storages.value.length + 1}`,
type: 'custom',
name: name,
type: type,
config: {},
})
// 保存存储
saveStorages()
}
// 移除存储
@@ -172,14 +204,6 @@ function removeStorage(storage: StorageConf) {
}
}
// 更新存储
async function updatedStorage(storage: StorageConf) {
const index = storages.value.indexOf(storage)
if (index > -1) {
storages.value[index] = storage
}
}
// 保存设置
async function saveSystemSettings(value: any) {
try {
@@ -218,7 +242,7 @@ onMounted(() => {
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
>
<template #item="{ element }">
<StorageCard :storage="element" @close="removeStorage(element)" @done="updatedStorage" />
<StorageCard :storage="element" @close="removeStorage(element)" @done="loadStorages" />
</template>
</draggable>
</VCardText>
@@ -228,8 +252,18 @@ onMounted(() => {
<VBtn type="submit" class="me-2" @click="saveStorages" prepend-icon="mdi-content-save">
{{ t('common.save') }}
</VBtn>
<VBtn color="success" variant="tonal" @click="addStorage">
<VBtn color="success" variant="tonal">
<VIcon icon="mdi-plus" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem v-for="item in storageOptions" :key="item.value" @click="addStorage(item.value)">
<VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem>
<VListItem @click="addStorage('custom')">
<VListItemTitle>{{ t('storage.custom') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</div>
</VForm>

View File

@@ -8,13 +8,15 @@ import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
import { useDisplay } from 'vuetify'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
// 国际化
const { t } = useI18n()
// APP
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
// 站点列表
const siteList = ref<Site[]>([])
@@ -22,6 +24,9 @@ const siteList = ref<Site[]>([])
// 站点数据列表
const userDataList = ref<SiteUserData[]>([])
// 站点统计数据列表
const siteStatsList = ref<{ [domain: string]: any }>({})
// 是否刷新过
const isRefreshed = ref(false)
@@ -31,6 +36,56 @@ const loading = ref(false)
// 新增站点对话框
const siteAddDialog = ref(false)
// 筛选相关
const filterMenu = ref(false)
const filterOption = ref('all') // all, active, inactive, connected, slow, failed, unknown
// 筛选选项
const filterOptions = computed(() => [
{ value: 'all', label: t('common.all'), icon: 'mdi-format-list-bulleted' },
{ value: 'active', label: t('common.active'), icon: 'mdi-check-circle', color: 'success' },
{ value: 'inactive', label: t('common.inactive'), icon: 'mdi-stop-circle', color: 'error' },
{ value: 'connected', label: t('site.connectionNormal'), icon: 'mdi-wifi', color: 'success' },
{ value: 'slow', label: t('site.connectionSlow'), icon: 'mdi-wifi-strength-2', color: 'warning' },
{ value: 'failed', label: t('site.connectionFailed'), icon: 'mdi-wifi-off', color: 'error' },
{ value: 'unknown', label: t('site.connectionUnknown'), icon: 'mdi-help-circle', color: 'secondary' },
])
// 筛选后的站点列表
const filteredSiteList = computed(() => {
if (filterOption.value === 'all') {
return siteList.value
}
return siteList.value.filter(site => {
if (filterOption.value === 'active') {
return site.is_active
} else if (filterOption.value === 'inactive') {
return !site.is_active
} else if (['connected', 'slow', 'failed', 'unknown'].includes(filterOption.value)) {
const connectionStatus = getConnectionStatus(site.domain)
return connectionStatus === filterOption.value
}
return true
})
})
// 用于拖拽排序的列表
const draggableSiteList = computed({
get() {
return filterOption.value === 'all' ? siteList.value : filteredSiteList.value
},
set(value) {
if (filterOption.value === 'all') {
siteList.value = value
}
},
})
// 当前筛选选项的显示信息
const currentFilter = computed(() => {
return filterOptions.value.find(option => option.value === filterOption.value)
})
// 获取站点列表数据
async function fetchData() {
try {
@@ -38,6 +93,8 @@ async function fetchData() {
siteList.value = await api.get('site/')
loading.value = false
isRefreshed.value = true
// 获取站点列表后,获取统计数据
await fetchSiteStats()
} catch (error) {
console.error(error)
}
@@ -52,13 +109,57 @@ async function fetchUserData() {
}
}
// 获取站点统计数据
async function fetchSiteStats() {
try {
// 使用批量接口一次性获取所有站点统计数据
const response = await api.get('site/statistic')
const stats = response.data || response
// 将数组转换为以domain为键的对象
const statsMap: { [domain: string]: any } = {}
if (Array.isArray(stats)) {
stats.forEach((stat: any) => {
if (stat.domain) {
statsMap[stat.domain] = stat
}
})
}
siteStatsList.value = statsMap
} catch (error) {
console.error('Failed to fetch site statistics:', error)
siteStatsList.value = {}
}
}
// 根据站点统计数据判断连接状态
function getConnectionStatus(domain: string) {
const stats = siteStatsList.value[domain]
if (!stats || Object.keys(stats).length === 0) {
return 'unknown'
}
if (stats.lst_state === 1) {
return 'failed'
} else if (stats.lst_state === 0) {
if (!stats.seconds) return 'unknown'
if (stats.seconds >= 5) return 'slow'
return 'connected'
}
return 'unknown'
}
// 保存站点排序
async function savaSitesPriority() {
// 只在显示全部站点时允许排序
if (filterOption.value !== 'all') {
return
}
// 重新排序
const priorities = siteList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
const priorities = draggableSiteList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
try {
const result: { [key: string]: any } = await api.post('site/priorities', priorities)
if (result.success) {
if (!result.success) {
fetchData()
}
} catch (error) {
@@ -71,12 +172,39 @@ function getUserData(domain: string) {
return userDataList.value.find(userData => userData.domain === domain)
}
// 根据站点域名获取统计数据
function getSiteStats(domain: string) {
return siteStatsList.value[domain] || {}
}
// 处理站点统计数据刷新请求
async function handleRefreshStats(domain?: string) {
if (domain) {
// 刷新特定站点的统计数据
try {
const stats = await api.get(`site/statistic/${domain}`)
siteStatsList.value[domain] = stats
} catch (error) {
console.error(`Failed to refresh stats for ${domain}:`, error)
}
} else {
// 刷新所有站点统计数据
await fetchSiteStats()
}
}
// 更新站点事件时
function onSiteSave() {
siteAddDialog.value = false
fetchData()
}
// 选择筛选选项
function selectFilter(value: string) {
filterOption.value = value
filterMenu.value = false
}
// 加载时获取数据
onBeforeMount(() => {
fetchData()
@@ -101,41 +229,92 @@ useDynamicButton({
<template>
<div class="card-list-container">
<!-- 页面标题 -->
<VPageContentTitle :title="t('navItems.siteManager')" />
<!-- 页面标题和筛选按钮 -->
<div class="d-flex justify-space-between align-center mb-4">
<VPageContentTitle :title="t('navItems.siteManager')" class="mb-0" />
<!-- 筛选按钮 -->
<VMenu v-model="filterMenu" offset-y :close-on-content-click="false" location="bottom end">
<template #activator="{ props }">
<VBtn
v-bind="props"
:icon="display.smAndDown.value"
:variant="filterOption === 'all' ? 'text' : 'tonal'"
:color="currentFilter?.color"
>
<VIcon :icon="currentFilter?.icon || 'mdi-filter'" />
<span v-if="!display.smAndDown.value" class="ml-2">
{{ currentFilter?.label }}
</span>
<VIcon v-if="!display.smAndDown.value" icon="mdi-chevron-down" class="ml-1" />
</VBtn>
</template>
<!-- 筛选菜单 -->
<VCard min-width="200">
<VList class="px-2">
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
<VListItem
v-for="option in filterOptions"
:key="option.value"
:active="filterOption === option.value"
@click="selectFilter(option.value)"
>
<template #prepend>
<VIcon :icon="option.icon" :color="option.color" />
</template>
<VListItemTitle>{{ option.label }}</VListItemTitle>
<template #append>
<VIcon v-if="filterOption === option.value" icon="mdi-check" color="primary" />
</template>
</VListItem>
</VList>
</VCard>
</VMenu>
</div>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<draggable
v-if="siteList.length > 0"
v-model="siteList"
v-if="draggableSiteList.length > 0"
v-model="draggableSiteList"
@end="savaSitesPriority"
handle=".cursor-move"
item-key="id"
tag="div"
:component-data="{ 'class': 'grid gap-4 grid-site-card px-2' }"
:disabled="filterOption !== 'all'"
>
<template #item="{ element }">
<SiteCard :site="element" :data="getUserData(element.domain)" @remove="fetchData" @update="fetchData" />
<SiteCard
:site="element"
:data="getUserData(element.domain)"
:stats="getSiteStats(element.domain)"
@remove="fetchData"
@update="fetchData"
@refresh-stats="handleRefreshStats"
/>
</template>
</draggable>
</div>
<NoDataFound
v-if="siteList.length === 0 && isRefreshed"
v-if="draggableSiteList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('site.noSites')"
:error-description="t('site.sitesWillBeShownHere')"
:error-title="filterOption === 'all' ? t('site.noSites') : t('common.noMatchingData')"
:error-description="filterOption === 'all' ? t('site.sitesWillBeShownHere') : t('common.tryChangingFilters')"
/>
<!-- 新增站点按钮 -->
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-web-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="siteAddDialog = true"
:class="{ 'mb-12': appMode }"
/>
<Teleport to="body">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-web-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="siteAddDialog = true"
:class="{ 'mb-12': appMode }"
/>
</Teleport>
<!-- 新增站点弹窗 -->
<SiteAddEditDialog
v-if="siteAddDialog"

View File

@@ -213,9 +213,9 @@ onActivated(() => {
.v-application .fc {
--fc-today-bg-color: rgba(var(--v-theme-on-surface), 0.04);
--fc-border-color: rgba(var(--v-border-color), var(--v-border-opacity));
--fc-neutral-bg-color: rgb(var(--v-theme-background));
--fc-neutral-bg-color: rgb(var(--v-theme-background), 0.3);
--fc-list-event-hover-bg-color: rgba(var(--v-theme-on-surface), 0.02);
--fc-page-bg-color: rgb(var(--v-theme-surface));
--fc-page-bg-color: rgb(var(--v-theme-background), 0.3);
--fc-event-border-color: currentcolor;
}
@@ -232,6 +232,16 @@ onActivated(() => {
padding: 0;
}
.v-application .fc .fc-toolbar-title {
display: inline-block;
overflow: hidden;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.25rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.v-application .fc .fc-col-header-cell-cushion {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.875rem;
@@ -309,6 +319,22 @@ onActivated(() => {
row-gap: 0.5rem;
}
.v-application .fc .fc-button-primary {
border: none;
background-color: transparent;
color: var(--v-theme-on-surface);
outline: none;
&:hover {
background-color: transparent;
color: rgb(var(--v-theme-primary));
}
}
.v-application .fc .fc-toolbar-chunk .fc-button-group {
align-items: center;
}
.v-application .fc .fc-toolbar-chunk {
display: flex;
align-items: center;
@@ -322,10 +348,6 @@ onActivated(() => {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary:focus {
box-shadow: none !important;
}
.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group {
border: 0.0625rem solid rgba(var(--v-theme-primary), var(--v-overlay-scrim-opacity));
border-radius: 0.375rem;
@@ -349,16 +371,6 @@ onActivated(() => {
color: rgb(var(--v-theme-primary));
}
.v-application .fc .fc-toolbar-title {
display: inline-block;
overflow: hidden;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.25rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.v-application .fc .fc-scrollgrid-section th {
border-inline: 0;
}
@@ -424,10 +436,6 @@ onActivated(() => {
font-weight: 500;
}
.v-application .fc .fc-toolbar-chunk .fc-button-group {
align-items: center;
}
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button .fc-icon {
vertical-align: bottom;
}
@@ -483,18 +491,6 @@ onActivated(() => {
background-color: transparent;
}
.v-application .fc .fc-button-primary {
border: none;
background-color: transparent;
color: var(--v-theme-on-surface);
outline: none;
}
.v-application .fc .fc-button-primary:hover {
background-color: transparent;
color: rgb(var(--v-theme-primary));
}
@media (width <= 776px) {
.fc-daygrid-event-harness {
display: flex;

View File

@@ -9,13 +9,15 @@ import { useUserStore } from '@/stores'
import { useDisplay } from 'vuetify'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
// 国际化
const { t } = useI18n()
// APP
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
// 用户 Store
const userStore = useUserStore()
@@ -181,20 +183,22 @@ useDynamicButton({
:error-description="keyword ? t('subscribe.noFilterData') : t('subscribe.noSubscribeData')"
/>
<!-- 底部操作按钮 -->
<div v-if="isRefreshed">
<VFab
v-if="userStore.superUser && !appMode"
icon="mdi-history"
color="info"
location="bottom"
:class="{ 'mb-12': appMode }"
size="x-large"
fixed
app
appear
@click="historyDialog = true"
/>
</div>
<Teleport to="body">
<div v-if="isRefreshed">
<VFab
v-if="userStore.superUser && !appMode"
icon="mdi-history"
color="info"
location="bottom"
:class="{ 'mb-12': appMode }"
size="x-large"
fixed
app
appear
@click="historyDialog = true"
/>
</div>
</Teleport>
<!-- 历史记录弹窗 -->
<SubscribeHistoryDialog
v-if="historyDialog"

View File

@@ -67,6 +67,8 @@ async function loadMessages({ done }: { done: any }) {
if (currData.value.length > 0) {
// 取最后一条时间为存量消息最新时间
lastTime.value = currData.value[currData.value.length - 1].reg_time ?? ''
// 倒序
currData.value.reverse()
// 合并数据
messages.value = [...currData.value, ...messages.value]
if (page.value === 1) {
@@ -119,7 +121,7 @@ onBeforeUnmount(() => {
:mode="!isLoaded ? 'intersect' : 'manual'"
side="start"
:items="messages"
class="overflow-visible message-scroll h-full"
class="overflow-auto h-full"
@load="loadMessages"
:load-more-text="t('message.loadMore') + ' ...'"
>
@@ -141,9 +143,3 @@ onBeforeUnmount(() => {
</div>
</VInfiniteScroll>
</template>
<style scoped>
.message-scroll {
overflow-y: auto !important;
}
</style>

View File

@@ -49,6 +49,7 @@ const sortTitles: Record<string, string> = {
site: t('torrent.sortSite'),
size: t('torrent.sortSize'),
seeder: t('torrent.sortSeeder'),
publishTime: t('torrent.sortPublishTime'),
}
// 过滤项映射
@@ -275,6 +276,9 @@ function filterData() {
} else if (sortField.value === 'seeder') {
// 按做种数排序(降序)
return (Number(b.torrent_info.seeders) || 0) - (Number(a.torrent_info.seeders) || 0)
} else if (sortField.value === 'publishTime') {
// 按发布时间排序(降序,最新的在前)
return new Date(b.torrent_info.pubdate || 0).getTime() - new Date(a.torrent_info.pubdate || 0).getTime()
}
} else {
if (sortField.value === 'site') {
@@ -286,6 +290,9 @@ function filterData() {
} else if (sortField.value === 'seeder') {
// 按做种数排序(降序)
return (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)
} else if (sortField.value === 'publishTime') {
// 按发布时间排序(升序,最旧的在前)
return new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
}
}

View File

@@ -50,6 +50,7 @@ const sortTitles: Record<string, string> = {
site: t('torrent.sortSite'),
size: t('torrent.sortSize'),
seeder: t('torrent.sortSeeder'),
publishTime: t('torrent.sortPublishTime'),
}
// 统一存储过滤选项
@@ -264,6 +265,11 @@ function filterData() {
filteredData = filteredData.sort((a, b) => b.torrent_info.size - a.torrent_info.size)
} else if (sortField.value === 'seeder') {
filteredData = filteredData.sort((a, b) => b.torrent_info.seeders - a.torrent_info.seeders)
} else if (sortField.value === 'publishTime') {
// 按发布时间排序(降序,最新的在前)
filteredData = filteredData.sort(
(a, b) => new Date(b.torrent_info.pubdate || 0).getTime() - new Date(a.torrent_info.pubdate || 0).getTime(),
)
}
} else {
if (sortField.value === 'default') {
@@ -276,6 +282,11 @@ function filterData() {
filteredData = filteredData.sort((a, b) => a.torrent_info.size - b.torrent_info.size)
} else if (sortField.value === 'seeder') {
filteredData = filteredData.sort((a, b) => a.torrent_info.seeders - b.torrent_info.seeders)
} else if (sortField.value === 'publishTime') {
// 按发布时间排序(升序,最旧的在前)
filteredData = filteredData.sort(
(a, b) => new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime(),
)
}
}

View File

@@ -4,16 +4,15 @@ import type { User } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import UserCard from '@/components/cards/UserCard.vue'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
import { useDisplay } from 'vuetify'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
// 国际化
const { t } = useI18n()
// APP
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
// 是否刷新过
const isRefreshed = ref(false)
@@ -96,17 +95,19 @@ useDynamicButton({
</div>
<!-- 新增用户按钮 -->
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-account-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="openAddUserDialog"
:class="{ 'mb-12': appMode }"
/>
<Teleport to="body">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-account-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="openAddUserDialog"
:class="{ 'mb-12': appMode }"
/>
</Teleport>
<!-- 用户添加弹窗 -->
<UserAddEditDialog

View File

@@ -1,19 +1,19 @@
<script setup lang="ts">
import api from '@/api'
import { Workflow } from '@/api/types'
import { useDisplay } from 'vuetify'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
// 国际化
const { t } = useI18n()
// APP
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
// 是否刷新
const isRefreshed = ref(false)
@@ -72,17 +72,19 @@ useDynamicButton({
</div>
<!-- 新增按钮 -->
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-plus"
location="bottom"
size="x-large"
fixed
app
appear
:class="{ 'mb-12': appMode }"
@click="addDialog = true"
/>
<Teleport to="body">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-plus"
location="bottom"
size="x-large"
fixed
app
appear
:class="{ 'mb-12': appMode }"
@click="addDialog = true"
/>
</Teleport>
<!-- 新增对话框 -->
<WorkflowAddEditDialog v-if="addDialog" v-model="addDialog" @close="addDialog = false" @save="addDone" />
</template>

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