Compare commits

...

94 Commits

Author SHA1 Message Date
jxxghp
b1289f6177 实现订阅批量管理功能 2025-08-23 21:20:09 +08:00
jxxghp
64b7ba48c8 fix ios 2025-08-23 20:39:40 +08:00
jxxghp
f093053ea4 优化对话框状态管理 2025-08-23 19:32:23 +08:00
jxxghp
9faa0ded59 为对话框组件添加防止滚动穿透的样式 2025-08-23 19:14:12 +08:00
jxxghp
0f7dafeb23 控制合集搜索项的显示 2025-08-23 19:03:50 +08:00
jxxghp
472d1960d9 重构对话框组件,将所有 DialogWrapper 替换为 VDialog,并更新缓存版本至 v1.1.0 2025-08-23 18:55:34 +08:00
jxxghp
6e50acf106 更新 service-worker.ts 2025-08-23 10:22:31 +08:00
jxxghp
a3fb4b1534 更新 package.json 版本号至 2.7.5 2025-08-23 08:56:14 +08:00
jxxghp
382cae32a2 fix site import dialog 2025-08-23 08:47:16 +08:00
jxxghp
0aa4851f8e Merge pull request #380 from jxxghp/cursor/implement-site-batch-import-and-export-2694 2025-08-23 07:32:40 +08:00
Cursor Agent
65271e6d13 Remove package-lock.json from version control
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-22 23:13:47 +00:00
Cursor Agent
671cf8d588 Refactor site import/export feature with improved toast notifications
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-22 23:12:01 +00:00
Cursor Agent
afc7c81028 Add site batch import/export functionality with preview and validation
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-22 23:09:03 +00:00
jxxghp
c330aee560 增强消息视图的SSE连接管理 2025-08-21 09:22:57 +08:00
jxxghp
eafe63c886 更新 package.json 2025-08-20 10:34:29 +08:00
jxxghp
53206d05b8 更新 service-worker.ts 2025-08-20 10:34:10 +08:00
jxxghp
af085d457e 更新 PluginCardListView.vue 2025-08-20 10:33:52 +08:00
jxxghp
fb36033939 修复数据库类型判断 2025-08-19 13:18:02 +08:00
jxxghp
584e7672df 更新版本号至2.7.3 2025-08-19 13:08:23 +08:00
jxxghp
d4f7a5a1c0 fix https://github.com/jxxghp/MoviePilot/issues/4769 2025-08-17 11:38:17 +08:00
jxxghp
2a9ea81ad4 feat: 优化SSE连接延迟,添加初始化状态提示 2025-08-17 08:39:02 +08:00
jxxghp
276948dd68 feat: 修复成功率计算和统计总览功能 2025-08-12 15:28:58 +08:00
jxxghp
990c5583f2 移除不必要的 TMDB 图片域名选项 2025-08-12 14:27:01 +08:00
jxxghp
644f1b5640 Merge pull request #377 from Sowevo/v2 2025-08-12 06:52:26 +08:00
sowevo
5261fbe870 🚨 0 2025-08-12 05:44:27 +08:00
sowevo
e4f2d85e2b 🚨 多余参数 2025-08-12 05:18:23 +08:00
sowevo
8e3ccdc24a feat: 透明倒影 2025-08-12 05:09:58 +08:00
sowevo
cd6d93affd feat: 透明背景 2025-08-12 04:32:51 +08:00
sowevo
6096ab0c9b feat: 调整间距 2025-08-12 04:31:19 +08:00
sowevo
0a87bb1db1 canvas固定宽和高 2025-08-12 04:14:13 +08:00
jxxghp
a19042c655 在设置中添加浏览器仿真选项 2025-08-11 21:35:20 +08:00
jxxghp
a889687a6a 更新 package.json 2025-08-10 18:16:09 +08:00
jxxghp
e1cdc715aa 更新 GitHub Actions 配置,启用最新版本标记功能 2025-08-06 16:37:09 +08:00
jxxghp
a82b3a0a29 优化消息处理逻辑 2025-08-05 15:47:46 +08:00
jxxghp
d93a71f0be 更新 TorrentRowListView.vue 2025-08-03 11:56:32 +08:00
jxxghp
899dc765bc 更新 TorrentCardListView.vue 2025-08-03 11:55:53 +08:00
jxxghp
449490e52d 更新 SiteStatisticsDialog.vue 2025-08-02 14:53:49 +08:00
jxxghp
5541d7974e 更新 SiteStatisticsDialog.vue 2025-08-02 14:34:59 +08:00
jxxghp
ae3eb36183 添加站点耗时统计信息展示 2025-08-02 14:20:17 +08:00
jxxghp
d57e9a397c 优化样式以支持动态颜色显示。 2025-08-02 11:12:27 +08:00
jxxghp
9d4fd16d81 优化透明主题下的模糊度和透明度设置 2025-07-29 11:49:59 +08:00
jxxghp
3b16e7a123 优化透明主题的模糊度和透明度设置 2025-07-29 09:46:23 +08:00
jxxghp
1c4a2176e9 实现透明主题的透明度和模糊度设置功能 2025-07-29 08:20:16 +08:00
jxxghp
62f9243714 更新 service-worker.ts 2025-07-29 07:05:17 +08:00
jxxghp
03bd23d314 更新文件系统资源检查的相关提示信息 2025-07-26 23:11:29 +08:00
jxxghp
27497d1812 更新 SiteAddEditDialog.vue 2025-07-26 08:34:22 +08:00
jxxghp
f36c1bd2b5 整合主题管理器,优化主题切换逻辑 2025-07-25 13:39:47 +08:00
jxxghp
cf72b2cdb9 更新加载动画的样式和逻辑。 2025-07-23 20:33:02 +08:00
jxxghp
44f6950fea Merge pull request #376 from wumode/fix_recommend
fix: 修复推荐页面外部推荐源URL参数拼接问题
2025-07-23 20:24:32 +08:00
wumode
308ddfedea fix: 修复推荐页面外部推荐源URL参数拼接问题 2025-07-23 20:12:36 +08:00
jxxghp
ac7c330e2f 优化工作流卡片和对话框中的事件类型显示逻辑 2025-07-23 15:33:43 +08:00
jxxghp
1bde3492da 更新 package.json 2025-07-23 12:01:03 +08:00
jxxghp
f884518df3 优化工作流任务卡片的状态显示 2025-07-23 11:52:54 +08:00
jxxghp
1f7f9ce9db 新增工作流触发类型和事件类型支持 2025-07-22 20:58:55 +08:00
jxxghp
58acde2292 优化支持站点的显示逻辑 2025-07-21 12:49:55 +08:00
jxxghp
4e0fe2f449 更新 AccountSettingAbout.vue 2025-07-21 12:38:37 +08:00
jxxghp
536793ab25 新增支持站点折叠功能,并更新相关国际化文本 2025-07-21 11:53:29 +08:00
jxxghp
23a48e07a2 优化订阅列表视图的状态筛选逻辑 2025-07-21 09:57:10 +08:00
jxxghp
1e55557154 优化订阅列表视图的状态筛选逻辑 2025-07-21 09:53:38 +08:00
jxxghp
752231086d 新增订阅功能的状态筛选选项 2025-07-21 09:38:59 +08:00
jxxghp
6f315a408a 移除站点链接的 href 属性 2025-07-20 15:40:58 +08:00
jxxghp
6fa4caa85e fix https://github.com/jxxghp/MoviePilot/issues/4635 2025-07-20 12:34:22 +08:00
jxxghp
1b36c1752f 优化消息弹窗的滚动逻辑 2025-07-20 08:39:33 +08:00
jxxghp
cd58498971 加载消息时按时间排序以确保最新消息在最后 2025-07-20 08:32:10 +08:00
jxxghp
1586137a5d 优化离线状态管理逻辑 2025-07-20 08:25:20 +08:00
jxxghp
6cb8bf74df 在滚动锁定功能中添加事件传播停止,以增强用户体验 2025-07-19 17:45:43 +08:00
jxxghp
787802d0db 优化模块测试视图 2025-07-19 08:55:08 +08:00
jxxghp
b4ad39db12 优化全局滚动锁定功能 2025-07-18 16:39:25 +08:00
jxxghp
c13edbe017 更新 package.json 2025-07-18 11:07:29 +08:00
jxxghp
7546da4f90 新增订阅分享页面及相关搜索功能 2025-07-18 11:05:05 +08:00
jxxghp
76b9a8d9e7 新增支持站点查看功能 2025-07-17 20:46:46 +08:00
jxxghp
d6d52338e9 优化排名展示效果 2025-07-16 12:59:09 +08:00
jxxghp
caa67a0f49 新增订阅分享统计功能 2025-07-16 09:37:34 +08:00
jxxghp
6ddc3ea996 fix #375 2025-07-15 20:25:42 +08:00
jxxghp
7edbf7c724 更新用户资料和账户设置中的链接 2025-07-15 17:31:15 +08:00
jxxghp
4f233ca886 更新 package.json 版本号至 2.6.6 2025-07-15 14:54:49 +08:00
jxxghp
457831536a 移除AccountSettingSite.vue中的USER_AGENT字段 2025-07-14 12:30:54 +08:00
jxxghp
ccef0d87db 更新缓存版本至v1.0.3 2025-07-13 13:52:16 +08:00
jxxghp
584d290283 增强全局滚动锁定功能 2025-07-13 13:46:28 +08:00
jxxghp
2ab14fa33b fix 2025-07-13 13:35:25 +08:00
jxxghp
f0317e1d74 为明亮主题优化Footer组件的背景色透明度 2025-07-13 13:32:05 +08:00
jxxghp
17a206e0f4 更新 DownloadingCard.vue 2025-07-13 11:40:23 +08:00
jxxghp
8ea352cc2f 优化DownloadingCard组件 2025-07-13 11:31:26 +08:00
jxxghp
0f10920898 fix #374 2025-07-13 11:22:27 +08:00
jxxghp
eb098ca775 增强滚动锁定功能 2025-07-13 09:46:38 +08:00
jxxghp
e25caddfef 更新 package.json 2025-07-12 15:15:48 +08:00
jxxghp
c74cf6cf6e 移除构建Plex深度链接时的警告弹窗 2025-07-12 15:13:18 +08:00
jxxghp
ce2d04fa64 更新Plex深度链接构建逻辑 2025-07-12 15:04:36 +08:00
jxxghp
40a4e29c7e 重构深度链接功能 2025-07-12 14:57:03 +08:00
jxxghp
60385715e6 新增媒体服务器深度链接功能 2025-07-12 13:47:00 +08:00
jxxghp
3cce92e83d 优化媒体查询条件,增强响应式样式支持 2025-07-12 13:16:30 +08:00
jxxghp
602b0067d2 Merge pull request #373 from jtcymc/v2 2025-07-12 07:17:57 +08:00
shaw
51d07db99b refactor(dialog): 将日志输出级别从 log 改为 warn
- 在 SubscribeEditDialog.vue 和 SubscribeSeasonDialog.vue 组件中- 当 tmdbid 未设置或为空时,使用 console.warn替代 console.log
- 此修改提高了日志的可见性和严重性级别,以便更好地提醒开发者注意潜在问题
2025-07-12 00:01:55 +08:00
shaw
33d121fd64 fix(dialog): 修复剧集分组查询时 TMDBID 未设置或为空的问题
- 在 SubscribeEditDialog 和 SubscribeSeasonDialog 组件中添加了对 TMDBID 的空值检查
- 如果 TMDBID 未设置或为空,将不会执行剧集分组查询,避免出现错误
2025-07-11 23:57:01 +08:00
116 changed files with 5883 additions and 1378 deletions

View File

@@ -57,7 +57,7 @@ jobs:
name: ${{ env.frontend_version }}
draft: false
prerelease: false
make_latest: false
make_latest: true
files: |
dist.zip
env:

1
components.d.ts vendored
View File

@@ -10,7 +10,6 @@ declare module 'vue' {
export interface GlobalComponents {
ConfirmDialog: typeof import('./src/@core/components/ConfirmDialog.vue')['default']
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
DialogWrapper: typeof import('./src/@core/components/DialogWrapper.vue')['default']
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
LoadingBanner: typeof import('./src/@core/components/LoadingBanner.vue')['default']

View File

@@ -1,344 +1,430 @@
<!DOCTYPE html>
<html lang="zh-CN" style="
<html
lang="zh-CN"
style="
overflow: hidden auto;
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-top: env(safe-area-inset-top);
background: var(--initial-loader-bg, #fff);
">
"
>
<head>
<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"
/>
<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" />
<!-- 防止缩放和选择,提供原生应用体验 -->
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
<!-- 防止缩放和选择,提供原生应用体验 -->
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
<!-- 基础信息 -->
<meta name="description" content="MoviePilot - 智能影视媒体库管理工具" />
<meta name="author" content="MoviePilot" />
<meta name="keywords" content="MoviePilot,影视,媒体库,管理" />
<!-- 基础信息 -->
<meta name="description" content="MoviePilot - 智能影视媒体库管理工具" />
<meta name="author" content="MoviePilot" />
<meta name="keywords" content="MoviePilot,影视,媒体库,管理" />
<!-- 安全和隐私 -->
<meta name="Robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="no-referrer" />
<!-- 安全和隐私 -->
<meta name="Robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="no-referrer" />
<!-- PWA - 基础图标 -->
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/logo.png" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<!-- PWA - 基础图标 -->
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/logo.png" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
<!-- 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" />
<!-- 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" />
<!-- 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" />
<!-- iOS Safari 防止自动识别 -->
<meta name="apple-mobile-web-app-orientations" content="portrait" />
<!-- iOS Safari 防止自动识别 -->
<meta name="apple-mobile-web-app-orientations" content="portrait" />
<!-- Android Chrome PWA 优化 -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-title" content="MoviePilot" />
<!-- Android Chrome PWA 优化 -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-title" content="MoviePilot" />
<!-- Microsoft Windows PWA -->
<meta name="msapplication-TileColor" content="#0E1116" />
<meta name="msapplication-TileImage" content="/android-chrome-192x192.png" />
<meta name="msapplication-config" content="none" />
<meta name="msapplication-tap-highlight" content="no" />
<meta name="msapplication-navbutton-color" content="#0E1116" />
<!-- 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="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" />
<!-- 屏幕方向锁定 -->
<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" />
<!-- UC浏览器优化 -->
<meta name="browsermode" content="application" />
<meta name="wap-font-scale" content="no" />
<!-- 360浏览器优化 -->
<meta name="renderer" content="webkit" />
<!-- 360浏览器优化 -->
<meta name="renderer" content="webkit" />
<!-- 触摸优化 -->
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<!-- 触摸优化 -->
<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" />
<!-- 缓存控制 -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- DNS预解析和预连接 -->
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
<link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
<link rel="dns-prefetch" href="//image.tmdb.org" />
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<!-- DNS预解析和预连接 -->
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
<link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
<link rel="dns-prefetch" href="//image.tmdb.org" />
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<!-- 预加载关键资源 -->
<link rel="preload" href="/logo.png" as="image" />
<link rel="modulepreload" href="/src/main.ts" />
<!-- 预加载关键资源 -->
<link rel="preload" href="/logo.png" as="image" />
<link rel="modulepreload" href="/src/main.ts" />
<!-- 内联关键CSS -->
<style>
/* 关键路径CSS - 从loader.css内联 */
#loading-bg {
position: fixed;
z-index: 99999;
display: block;
background: var(--initial-loader-bg, #fff);
block-size: 100vh;
inline-size: 100vw;
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
.loading-logo {
position: absolute;
inset-block-start: 35%;
inset-inline-start: calc(50% - 5rem);
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
/* 添加logo完成动画 - 放大虚化效果 */
.loading-complete .loading-logo {
filter: blur(10px);
opacity: 0;
transform: scale(1.5);
}
/* 添加加载背景消失动画 - 放大虚化效果 */
.loading-complete {
filter: blur(15px);
opacity: 0;
transform: scale(1.2);
}
.loading {
position: absolute;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 55px;
inline-size: 55px;
inset-block-start: 80%;
inset-inline-start: calc(50% - 27.5px);
transition: opacity 0.6s ease;
}
/* 完成时隐藏加载动画 */
.loading-complete .loading {
opacity: 0;
}
.loading .effect-1,
.loading .effect-2,
.loading .effect-3 {
position: absolute;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 100%;
border-inline-start: 3px solid var(--initial-loader-color, #eee);
inline-size: 100%;
}
.loading .effect-1 {
animation: rotate 1s ease infinite;
}
.loading .effect-2 {
animation: rotate-opacity 1s ease infinite 0.1s;
}
.loading .effect-3 {
animation: rotate-opacity 1s ease infinite 0.2s;
}
.loading .effects {
transition: all 0.3s ease;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
<!-- 内联关键CSS -->
<style>
/* 关键路径CSS - 从loader.css内联 */
#loading-bg {
position: fixed;
z-index: 99999;
display: block;
background: var(--initial-loader-bg, #fff);
block-size: 100vh;
inline-size: 100vw;
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
100% {
transform: rotate(1turn);
}
}
@keyframes rotate-opacity {
0% {
opacity: 0.1;
transform: rotate(0deg);
.loading-logo {
position: absolute;
inset-block-start: 35%;
inset-inline-start: calc(50% - 5rem);
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
100% {
opacity: 1;
transform: rotate(1turn);
/* 添加logo完成动画 - 放大虚化效果 */
.loading-complete .loading-logo {
filter: blur(10px);
opacity: 0;
transform: scale(1.5);
}
}
</style>
<!-- 初始化脚本 -->
<script>
// 主题色彩初始化
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
if (loaderColor) document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
if (primaryColor) document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
/* 添加加载背景消失动画 - 放大虚化效果 */
.loading-complete {
filter: blur(15px);
opacity: 0;
transform: scale(1.2);
}
// 状态栏适配
if (window.navigator.standalone) {
document.documentElement.style.setProperty('--status-bar-height', '20px')
}
.loading {
position: absolute;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 55px;
inline-size: 55px;
inset-block-start: 80%;
inset-inline-start: calc(50% - 27.5px);
transition: opacity 0.6s ease;
}
// 安全区域适配
function updateSafeArea() {
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)')
/* 完成时隐藏加载动画 */
.loading-complete .loading {
opacity: 0;
}
if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)
if (safeAreaBottom) document.documentElement.style.setProperty('--safe-area-bottom', safeAreaBottom)
}
.loading .effect-1,
.loading .effect-2,
.loading .effect-3 {
position: absolute;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 100%;
border-inline-start: 3px solid var(--initial-loader-color, #eee);
inline-size: 100%;
}
updateSafeArea()
window.addEventListener('resize', updateSafeArea)
window.addEventListener('orientationchange', updateSafeArea)
</script>
</head>
.loading .effect-1 {
animation: rotate 1s ease infinite;
}
<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">
.loading .effect-2 {
animation: rotate-opacity 1s ease infinite 0.1s;
}
.loading .effect-3 {
animation: rotate-opacity 1s ease infinite 0.2s;
}
.loading .effects {
transition: all 0.3s ease;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(1turn);
}
}
@keyframes rotate-opacity {
0% {
opacity: 0.1;
transform: rotate(0deg);
}
100% {
opacity: 1;
transform: rotate(1turn);
}
}
</style>
<!-- 初始化脚本 -->
<script>
// 检测系统主题是否为深色模式
function checkPrefersColorSchemeIsDark() {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches
} catch (e) {
return false
}
}
// 主题色彩初始化
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
let primaryColor = localStorage.getItem('materio-initial-loader-color')
// 检查主题设置
const savedTheme = localStorage.getItem('theme')
const isAutoTheme = savedTheme === 'auto'
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
if (isAutoTheme || !loaderColor) {
loaderColor = checkPrefersColorSchemeIsDark() ? '#0E1116' : '#FFFFFF'
}
if (!primaryColor) {
primaryColor = '#9155FD'
}
// 应用主题色彩
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
// 状态栏适配
if (window.navigator.standalone) {
document.documentElement.style.setProperty('--status-bar-height', '20px')
}
// 安全区域适配
function updateSafeArea() {
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)',
)
if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)
if (safeAreaBottom) document.documentElement.style.setProperty('--safe-area-bottom', safeAreaBottom)
}
updateSafeArea()
window.addEventListener('resize', updateSafeArea)
window.addEventListener('orientationchange', updateSafeArea)
</script>
</head>
<body style="margin: 0; 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="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)">
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="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)" />
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
/>
</clipPath>
<g clip-path="url(#_clip5)">
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
<path
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
style="fill: url(#_Linear6)"
/>
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
style="fill: rgb(141, 81, 249)"
/>
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
style="fill: url(#_Radial7)"
/>
</g>
</g>
</g>
</g>
</g>
</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>
<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>
</div>
<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>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.6.4",
"version": "2.7.5",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -114,4 +114,4 @@
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
}
}

View File

@@ -137,7 +137,7 @@
<div class="status-badge">
<span class="status-dot"></span>
<span>离线模式</span>
<span>离线状态</span>
</div>
</div>
@@ -157,4 +157,4 @@
}
</script>
</body>
</html>
</html>

View File

@@ -59,7 +59,7 @@ function handleCancel() {
</script>
<template>
<DialogWrapper :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" :max-width="width">
<VDialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" :max-width="width">
<VCard>
<VCardItem>
<div class="d-flex align-center justify-start mt-3">
@@ -82,5 +82,5 @@ function handleCancel() {
</VCardActions>
<VDialogCloseBtn @click="handleCancel" />
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -1,70 +0,0 @@
<template>
<VDialog v-model="dialogModel" v-bind="$attrs" @update:model-value="handleDialogChange">
<slot />
</VDialog>
</template>
<script setup lang="ts">
import { computed, watch, onBeforeUnmount } from 'vue'
import { useScrollLockWithWatch } from '@/composables/useScrollLock'
// Props
interface Props {
modelValue?: boolean
// 滚动锁定配置
scrollLock?: boolean
preserveScrollPosition?: boolean
preventTouchScroll?: boolean
}
const props = withDefaults(defineProps<Props>(), {
scrollLock: true,
preserveScrollPosition: true,
preventTouchScroll: true,
})
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
// 计算属性
const dialogModel = computed({
get: () => props.modelValue || false,
set: (value: boolean) => emit('update:modelValue', value),
})
// 使用滚动锁定
const { isLocked, lockScroll, restoreScroll } = useScrollLockWithWatch(dialogModel, {
autoRestore: true,
preserveScrollPosition: props.preserveScrollPosition,
preventTouchScroll: props.preventTouchScroll,
})
// 处理弹窗状态变化
const handleDialogChange = (value: boolean) => {
emit('update:modelValue', value)
}
// 监听弹窗状态变化
watch(
dialogModel,
newValue => {
if (props.scrollLock) {
if (newValue) {
lockScroll()
} else {
restoreScroll()
}
}
},
{ immediate: true },
)
// 组件卸载时确保恢复滚动
onBeforeUnmount(() => {
if (isLocked.value) {
restoreScroll()
}
})
</script>

View File

@@ -46,10 +46,10 @@ $header: ".layout-navbar";
}
/* Ensure header styles are preserved when dialog is opened,
regardless of scroll state
but only if window was scrolled before dialog opened
*/
html.v-overlay-scroll-blocked &.window-scrolled.layout-navbar-fixed,
html.dialog-scroll-locked &.layout-navbar-fixed {
html.dialog-scroll-locked &.window-scrolled.layout-navbar-fixed {
#{$header} {
padding-inline: 1rem;

View File

@@ -24,17 +24,16 @@ code {
position: relative;
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
@media (width >= 1280px) {
@media (width >= 1280px) and (hover: hover) {
background: rgba(var(--v-theme-background), 1);
.v-theme--transparent & {
backdrop-filter: blur(5px);
background: rgba(var(--v-theme-background), 0.1) !important;
backdrop-filter: blur(var(--transparent-blur-light, 5px));
background: rgba(var(--v-theme-background), var(--transparent-opacity-light, 0.1)) !important;
}
}
@media (width < 1280px) {
@media (width < 1280px), (hover: none) {
background: transparent;
&::before {
@@ -61,8 +60,9 @@ code {
}
.v-theme--transparent & {
background: rgba(var(--v-theme-background), 0.3);
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
background: rgba(var(--v-theme-background), var(--transparent-opacity-heavy, 0.5));
}
}
}
}
}

View File

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

View File

@@ -84,3 +84,15 @@ export const isMobileDevice = (): boolean => {
return mobileRegex.test(userAgent) || hasTouchScreen || isMobileSize
}
// 检测是否为iOS设备
export const isIOSDevice = (): boolean => {
const userAgent = navigator.userAgent.toLowerCase()
return /iphone|ipad|ipod/.test(userAgent) && !(window as any).MSStream
}
// 检测是否为Android设备
export const isAndroidDevice = (): boolean => {
const userAgent = navigator.userAgent.toLowerCase()
return /android/.test(userAgent)
}

View File

@@ -17,11 +17,36 @@ export default defineComponent({
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
const scrollDistance = ref(window.scrollY)
const isDialogOpen = ref(false)
const wasScrolledBeforeDialog = ref(false)
// 监听弹窗状态变化
const checkDialogState = () => {
const wasDialogOpen = isDialogOpen.value
isDialogOpen.value =
document.documentElement.classList.contains('dialog-scroll-locked') ||
document.documentElement.classList.contains('v-overlay-scroll-blocked')
// 当弹窗刚打开时,记录当前的滚动状态
if (!wasDialogOpen && isDialogOpen.value) {
wasScrolledBeforeDialog.value = scrollDistance.value > 0
}
}
onMounted(() => {
window.addEventListener('scroll', () => {
scrollDistance.value = window.scrollY
})
// 初始检查弹窗状态
checkDialogState()
// 监听 DOM 变化以检测弹窗状态
const observer = new MutationObserver(checkDialogState)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
})
return () => {
@@ -88,9 +113,6 @@ export default defineComponent({
},
})
// 检查是否有弹窗打开通过CSS类名判断
const isDialogOpen = document.documentElement.classList.contains('dialog-scroll-locked')
return h(
'div',
{
@@ -99,7 +121,7 @@ export default defineComponent({
'layout-navbar-fixed',
mdAndDown.value && 'layout-overlay-nav',
route.meta.layoutWrapperClasses,
(scrollDistance.value || isDialogOpen) && 'window-scrolled',
(scrollDistance.value > 0 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
],
},
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],

View File

@@ -11,6 +11,7 @@ import { preloadImage } from './@core/utils/image'
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
import { themeManager } from '@/utils/themeManager'
// 生效主题
const { global: globalTheme } = useTheme()
@@ -212,6 +213,9 @@ onMounted(async () => {
// 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value)
// 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue)
// 监听主题变化
watch(
() => globalTheme.name.value,

View File

@@ -39,8 +39,9 @@ const globalOfflineStatus = useGlobalOfflineStatus()
// 添加响应拦截器
api.interceptors.response.use(
response => {
// 成功响应时,清除应用离线状态
// 成功响应时,清除应用离线状态并重置连续错误计数
globalOfflineStatus.setAppOffline(false)
globalOfflineStatus.resetConsecutiveErrors()
return response.data
},
error => {
@@ -57,7 +58,8 @@ api.interceptors.response.use(
if (error.code === 'ECONNABORTED') {
reason = 'Request timeout'
}
globalOfflineStatus.setAppOffline(true, reason)
// 记录网络错误,只有连续三次才会设置为离线模式
globalOfflineStatus.recordNetworkError(reason)
}
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {

View File

@@ -164,6 +164,10 @@ export interface WorkflowShare {
description?: string
// 定时器
timer?: string
// 触发类型timer-定时触发 event-事件触发 manual-手动触发
trigger_type?: string
// 事件类型当trigger_type为event时使用
event_type?: string
// 动作列表
actions?: any[]
// 动作流
@@ -986,6 +990,8 @@ export interface MediaServerPlayItem {
link?: string
// 播放百分比
percent?: number
// 媒体服务器类型
server_type?: string
}
// 媒体服务器媒体库
@@ -1006,6 +1012,8 @@ export interface MediaServerLibrary {
image_list?: string[]
// 链接
link?: string
// 媒体服务器类型
server_type?: string
}
// 消息通知
@@ -1324,6 +1332,10 @@ export interface Workflow {
description?: string
// 定时器
timer?: string
// 触发类型timer-定时触发 event-事件触发 manual-手动触发
trigger_type?: string
// 事件类型当trigger_type为event时使用
event_type?: string
// 状态
state?: string
// 当前执行动作
@@ -1387,3 +1399,13 @@ export interface TorrentCacheData {
// 缓存数据
data: TorrentCacheItem[]
}
// 订阅分享统计
export interface SubscribeShareStatistics {
// 分享人
share_user?: string
// 分享数量
share_count?: number
// 总复用人次
total_reuse_count?: number
}

View File

@@ -133,7 +133,7 @@ const instructions = computed(() => {
</Teleport>
<!-- 手动安装说明对话框 -->
<DialogWrapper v-model="showInstructions" max-width="500">
<VDialog v-model="showInstructions" max-width="500">
<VCard>
<VCardItem>
<VCardTitle class="d-flex align-center">
@@ -170,7 +170,7 @@ const instructions = computed(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style scoped>

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
@@ -16,8 +17,10 @@ function imageLoadHandler() {
}
// 跳转播放
function goPlay() {
if (props.media?.link) window.open(props.media?.link, '_blank')
async function goPlay() {
if (props.media?.link) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
}
}
// 计算图片地址

View File

@@ -87,6 +87,12 @@ function saveRuleInfo() {
emit('done')
}
// 验证规则ID输入
function validateRuleId() {
// 只允许英文和数字,不允许空格
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
}
// 按钮点击
function onClose() {
emit('close')
@@ -110,7 +116,7 @@ function onClose() {
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
</VCardText>
</VCard>
<DialogWrapper
<VDialog
v-if="ruleInfoDialog"
v-model="ruleInfoDialog"
scrollable
@@ -138,6 +144,7 @@ function onClose() {
persistent-hint
active
prepend-inner-icon="mdi-identifier"
@input="validateRuleId"
/>
</VCol>
<VCol cols="12" md="6">
@@ -215,6 +222,6 @@ function onClose() {
}}</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -147,7 +147,7 @@ const { stop: stopRefresh } = useConditionalDataRefresh(
loadDownloaderInfo,
shouldRefresh, // 响应式条件只有当allowRefresh为true且downloader启用时才运行
3000, // 3秒间隔
true // 立即执行一次
true, // 立即执行一次
)
onUnmounted(() => {
@@ -196,7 +196,7 @@ onUnmounted(() => {
</VCard>
</VHover>
<DialogWrapper
<VDialog
v-if="downloaderInfoDialog"
v-model="downloaderInfoDialog"
scrollable
@@ -383,6 +383,6 @@ onUnmounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

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

View File

@@ -223,7 +223,7 @@ function onClose() {
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
</VCardText>
</VCard>
<DialogWrapper
<VDialog
v-if="groupInfoDialog"
v-model="groupInfoDialog"
scrollable
@@ -308,7 +308,7 @@ function onClose() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"

View File

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

View File

@@ -47,10 +47,12 @@ function openTmdbPage(type: string, tmdbId: number) {
</div>
<div class="flex-grow">
<VCardItem class="pb-1">
<VCardTitle class="text-center text-md-left">
<div class="text-center text-md-left text-h6 font-weight-bold line-clamp-2 overflow-hidden text-ellipsis">
{{ context?.media_info?.title || context?.meta_info?.name }}
{{ context?.meta_info?.season_episode }}
</VCardTitle>
<span v-if="context?.meta_info?.season_episode" class="text-sm text-medium-emphasis align-top">
{{ context?.meta_info?.season_episode }}
</span>
</div>
<VCardSubtitle class="text-center text-md-left">
{{ context?.media_info?.year || context?.meta_info?.year }}
</VCardSubtitle>

View File

@@ -204,7 +204,7 @@ onMounted(() => {
</VCardText>
</VCard>
<DialogWrapper
<VDialog
v-if="mediaServerInfoDialog"
v-model="mediaServerInfoDialog"
scrollable
@@ -506,6 +506,6 @@ onMounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -141,7 +141,7 @@ function onClose() {
</VCardText>
</VCard>
<DialogWrapper
<VDialog
v-if="notificationInfoDialog"
v-model="notificationInfoDialog"
scrollable
@@ -476,6 +476,6 @@ function onClose() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -106,7 +106,9 @@ const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}&cache=true`
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}`
})
@@ -267,15 +269,15 @@ const dropdownItems = ref([
<!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<DialogWrapper v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
</VCard>
</DialogWrapper>
</VDialog>
<!-- 插件详情-->
<DialogWrapper v-if="detailDialog" v-model="detailDialog" max-width="30rem">
<VDialog v-if="detailDialog" v-model="detailDialog" max-width="30rem">
<VCard>
<VDialogCloseBtn @click="detailDialog = false" />
<VCardText>
@@ -335,6 +337,6 @@ const dropdownItems = ref([
</VCol>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -547,7 +547,7 @@ watch(
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<DialogWrapper v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
@@ -562,10 +562,10 @@ watch(
</VBtn>
</VCardItem>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 实时日志弹窗 -->
<DialogWrapper
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
@@ -591,10 +591,10 @@ watch(
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 插件分身对话框 -->
<DialogWrapper
<VDialog
v-if="pluginCloneDialog"
v-model="pluginCloneDialog"
width="600"
@@ -700,7 +700,7 @@ watch(
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -350,7 +350,7 @@ const dropdownItems = ref([
</VHover>
<!-- 重命名对话框 -->
<DialogWrapper v-if="renameDialog" v-model="renameDialog" max-width="400">
<VDialog v-if="renameDialog" v-model="renameDialog" max-width="400">
<VCard>
<VCardItem>
<template #prepend>
@@ -374,10 +374,10 @@ const dropdownItems = ref([
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 设置对话框 -->
<DialogWrapper
<VDialog
v-if="settingDialog"
v-model="settingDialog"
max-width="600"
@@ -480,7 +480,7 @@ const dropdownItems = ref([
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import type { PropType } from 'vue'
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
@@ -31,8 +32,10 @@ const getImgUrl = computed(() => {
})
// 跳转播放
function goPlay(isHovering: boolean | null = false) {
if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
async function goPlay(isHovering: boolean | null = false) {
if (props.media?.link && isHovering) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
}
}
</script>

View File

@@ -220,7 +220,7 @@ function onClose() {
@close="smbConfigDialog = false"
@done="handleDone"
/>
<DialogWrapper
<VDialog
v-if="customConfigDialog"
v-model="customConfigDialog"
scrollable
@@ -263,6 +263,6 @@ function onClose() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -21,6 +21,14 @@ const { t } = useI18n()
// 输入参数
const props = defineProps({
media: Object as PropType<Subscribe>,
batchMode: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
})
// 从 provide 中获取全局设置
@@ -29,7 +37,7 @@ const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
const emit = defineEmits(['remove', 'save', 'select'])
// 确认框
const createConfirm = useConfirm()
@@ -297,6 +305,17 @@ function onSubscribeEditRemove() {
subscribeEditDialog.value = false
emit('remove')
}
// 处理卡片点击事件
function handleCardClick() {
if (props.batchMode) {
// 批量模式下触发选择事件
emit('select')
} else {
// 非批量模式下打开编辑弹窗
editSubscribeDialog()
}
}
</script>
<template>
@@ -308,6 +327,7 @@ function onSubscribeEditRemove() {
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
}"
>
<VCard
@@ -319,8 +339,8 @@ function onSubscribeEditRemove() {
}"
rounded="0"
min-height="150"
@click="editSubscribeDialog"
:ripple="false"
@click="handleCardClick"
:ripple="!props.batchMode"
>
<div class="me-n3 absolute top-1 right-4">
<IconBtn>

View File

@@ -278,7 +278,7 @@ onMounted(() => {
</VCard>
<!-- 更多来源对话框 -->
<DialogWrapper v-model="showMoreTorrents" max-width="25rem" location="center">
<VDialog v-model="showMoreTorrents" max-width="25rem" location="center">
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<span>其他来源</span>
@@ -361,7 +361,7 @@ onMounted(() => {
</VList>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<AddDownloadDialog
v-if="addDownloadDialog"
@@ -418,7 +418,7 @@ onMounted(() => {
}
.chip-web-source {
background-color: #8000FF;
background-color: #8000ff;
color: white;
}

View File

@@ -121,12 +121,22 @@ onMounted(() => {
</div>
<template v-slot:prepend>
<div class="d-flex flex-column align-center pr-3">
<VImg v-if="siteIcon" :src="siteIcon" :alt="torrent?.site_name" class="rounded mb-1" width="32" height="32" />
<VAvatar v-else size="24" class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold">
<div class="d-flex flex-column align-center pr-3" :title="torrent?.site_name">
<VImg
v-if="siteIcon"
:src="siteIcon"
:alt="torrent?.site_name"
class="rounded mb-1 site-icon"
width="32"
height="32"
/>
<VAvatar
v-else
size="32"
class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold site-icon"
>
{{ torrent?.site_name?.substring(0, 1) }}
</VAvatar>
<div class="font-weight-bold text-body-2 text-center d-none d-sm-block">{{ torrent?.site_name }}</div>
</div>
</template>
@@ -332,4 +342,12 @@ onMounted(() => {
background-color: #9c27b0;
color: white;
}
.site-icon {
transition: transform 0.2s ease;
}
.site-icon:hover {
transform: scale(1.1);
}
</style>

View File

@@ -6,6 +6,10 @@ import ForkWorkflowDialog from '../dialog/ForkWorkflowDialog.vue'
// 输入参数
const props = defineProps({
workflow: Object as PropType<WorkflowShare>,
eventTypes: {
type: Array as PropType<Array<{ title: string; value: string }>>,
default: () => [],
},
})
// 定义删除事件
@@ -135,6 +139,7 @@ function doDelete() {
v-if="forkWorkflowDialog"
v-model="forkWorkflowDialog"
:workflow="props.workflow"
:event-types="props.eventTypes"
@close="forkWorkflowDialog = false"
@fork="finishForkWorkflow"
@delete="doDelete"

View File

@@ -16,6 +16,10 @@ const props = defineProps({
required: true,
type: Object as PropType<Workflow>,
},
eventTypes: {
type: Array as PropType<Array<{ title: string; value: string }>>,
default: () => [],
},
})
// 定义事件
@@ -39,6 +43,12 @@ const shareDialog = ref(false)
// 加载中
const loading = ref(false)
// 根据事件类型值获取显示文本
const getEventTypeText = (eventTypeValue: string) => {
const eventType = props.eventTypes.find(item => item.value === eventTypeValue)
return eventType ? eventType.title : eventTypeValue
}
// 编辑任务
function handleEdit(item: Workflow) {
editDialog.value = true
@@ -165,11 +175,36 @@ async function handleReset(item: Workflow) {
// 计算状态颜色
const resolveStatusVariant = (status: string | undefined) => {
if (status === 'S') return { color: 'success', text: t('workflow.task.status.success') }
else if (status === 'R') return { color: 'primary', text: t('workflow.task.status.running') }
else if (status === 'F') return { color: 'error', text: t('workflow.task.status.failed') }
else if (status === 'P') return { color: 'secondary', text: t('workflow.task.status.paused') }
else return { color: 'info', text: t('workflow.task.status.waiting') }
if (status === 'S')
return {
color: 'success',
bgColor: 'linear-gradient(to bottom right, rgba(76, 175, 80, 0.9), rgba(76, 175, 80, 0.7))',
text: t('workflow.task.status.success'),
}
else if (status === 'R')
return {
color: 'primary',
bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',
text: t('workflow.task.status.running'),
}
else if (status === 'F')
return {
color: 'error',
bgColor: 'linear-gradient(to bottom right, rgba(244, 67, 54, 0.9), rgba(244, 67, 54, 0.7))',
text: t('workflow.task.status.failed'),
}
else if (status === 'P')
return {
color: 'warning',
bgColor: 'linear-gradient(to bottom right, rgba(255, 152, 0, 0.9), rgba(255, 152, 0, 0.7))',
text: t('workflow.task.status.paused'),
}
else
return {
color: 'info',
bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',
text: t('workflow.task.status.waiting'),
}
}
// 计算当前动作占比
@@ -190,11 +225,9 @@ const resolveProgress = (item: Workflow) => {
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VCardItem
class="px-2"
:class="{
'py-0': workflow?.description,
'py-2': !workflow?.description,
[`bg-${resolveStatusVariant(workflow?.state).color}`]: true,
class="px-2 py-2"
:style="{
background: resolveStatusVariant(workflow?.state).bgColor,
}"
>
<template #prepend>
@@ -209,9 +242,8 @@ const resolveProgress = (item: Workflow) => {
</VAvatar>
</template>
<VCardTitle class="text-white text-lg">
{{ workflow?.name }}
<span :title="workflow?.description">{{ workflow?.name }}</span>
</VCardTitle>
<VCardSubtitle class="text-white">{{ workflow?.description }}</VCardSubtitle>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
@@ -272,15 +304,28 @@ const resolveProgress = (item: Workflow) => {
</VCardItem>
<VDivider />
<VCardText class="pa-3">
<div class="d-flex flex-column gap-y-2">
<div class="d-flex flex-column gap-y-3">
<div class="d-flex flex-wrap gap-x-3">
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.timer') }}</div>
<h5 class="text-lg">{{ workflow?.timer }}</h5>
<div class="mb-1">{{ t('workflow.task.info.trigger') }}</div>
<h5>
<span v-if="workflow?.trigger_type === 'timer' || !workflow?.trigger_type">
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
{{ workflow?.timer }}
</span>
<span v-else-if="workflow?.trigger_type === 'event'">
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
{{ getEventTypeText(workflow?.event_type || '') }}
</span>
<span v-else-if="workflow?.trigger_type === 'manual'">
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
{{ t('workflow.task.info.manualTrigger') }}
</span>
</h5>
</div>
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.status') }}</div>
<h5 class="text-lg" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
<h5 :class="`text-${resolveStatusVariant(workflow?.state).color}`">
{{ resolveStatusVariant(workflow?.state).text }}
</h5>
</div>
@@ -289,14 +334,14 @@ const resolveProgress = (item: Workflow) => {
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.actionCount') }}</div>
<div>
<VAvatar size="28" color="primary" variant="tonal">
<span class="text-sm">{{ workflow?.actions?.length }}</span>
<VAvatar size="24" color="primary" variant="tonal">
<span class="text-xs">{{ workflow?.actions?.length }}</span>
</VAvatar>
</div>
</div>
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.runCount') }}</div>
<h5 class="text-lg">{{ workflow?.run_count }}</h5>
<h5>{{ workflow?.run_count }}</h5>
</div>
</div>
<div class="d-flex flex-wrap gap-x-3">

View File

@@ -132,7 +132,7 @@ onMounted(() => {
})
</script>
<template>
<DialogWrapper max-width="35rem" scrollable>
<VDialog max-width="35rem" scrollable>
<VCard>
<VCardItem class="py-2">
<template #prepend>
@@ -209,5 +209,5 @@ onMounted(() => {
</VBtn>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -70,7 +70,7 @@ async function savaAlistConfig() {
</script>
<template>
<DialogWrapper width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
@@ -143,5 +143,5 @@ async function savaAlistConfig() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -110,7 +110,7 @@ onUnmounted(() => {
</script>
<template>
<DialogWrapper width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
@@ -148,5 +148,5 @@ onUnmounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -170,9 +170,8 @@ onMounted(() => {
})
</script>
<template>
<DialogWrapper max-width="40rem" scrollable>
<VDialog max-width="40rem" scrollable>
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
@@ -285,6 +284,7 @@ onMounted(() => {
</div>
</VCol>
</VCardText>
<VDialogCloseBtn @click="emit('close')" />
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -13,6 +13,10 @@ const { t } = useI18n()
// 输入参数
const props = defineProps({
workflow: Object as PropType<WorkflowShare>,
eventTypes: {
type: Array as PropType<Array<{ title: string; value: string }>>,
default: () => [],
},
})
// 定义事件
@@ -32,6 +36,12 @@ const processing = ref(false)
// 删除中
const deleting = ref(false)
// 根据事件类型值获取显示文本
const getEventTypeText = (eventTypeValue: string) => {
const eventType = props.eventTypes.find(item => item.value === eventTypeValue)
return eventType ? eventType.title : eventTypeValue
}
// 流程图相关
const { nodes, edges } = useVueFlow()
@@ -146,7 +156,7 @@ async function doDelete() {
}
</script>
<template>
<DialogWrapper max-width="40rem" scrollable>
<VDialog max-width="40rem" scrollable>
<VCard>
<VCardText>
<VCol>
@@ -190,10 +200,23 @@ async function doDelete() {
<span class="text-body-1"> {{ props.workflow?.share_user }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0" v-if="props.workflow?.timer">
<VListItem class="ps-0" v-if="props.workflow?.trigger_type || props.workflow?.timer">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('workflow.timer') }}</span>
<span class="text-body-1"> {{ props.workflow?.timer }}</span>
<span class="font-weight-medium">{{ t('workflow.trigger') }}</span>
<span class="text-body-1">
<span v-if="props.workflow?.trigger_type === 'timer' || !props.workflow?.trigger_type">
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
{{ props.workflow?.timer }}
</span>
<span v-else-if="props.workflow?.trigger_type === 'event'">
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
{{ getEventTypeText(props.workflow?.event_type || '') }}
</span>
<span v-else-if="props.workflow?.trigger_type === 'manual'">
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
{{ t('workflow.manualTrigger') }}
</span>
</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0" v-if="parsedWorkflow?.actions">
@@ -243,7 +266,7 @@ async function doDelete() {
</VCardText>
<VDialogCloseBtn @click="emit('close')" />
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style lang="scss">

View File

@@ -24,7 +24,7 @@ function handleImport() {
</script>
<template>
<DialogWrapper width="40rem" scrollable max-height="85vh">
<VDialog width="40rem" scrollable max-height="85vh">
<VCard>
<VCardItem>
<template #prepend>
@@ -43,5 +43,5 @@ function handleImport() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -15,12 +15,12 @@ defineProps({
const emit = defineEmits(['close'])
</script>
<template>
<DialogWrapper max-width="50rem">
<VDialog max-width="50rem">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<MediaInfoCard :context="context" />
</VCardItem>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -148,7 +148,7 @@ onBeforeMount(async () => {
})
</script>
<template>
<DialogWrapper scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
<VDialogCloseBtn @click="emit('close')" />
@@ -187,5 +187,5 @@ onBeforeMount(async () => {
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</DialogWrapper>
</VDialog>
</template>

View File

@@ -124,7 +124,7 @@ onMounted(() => {
})
</script>
<template>
<DialogWrapper scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
<VDialogCloseBtn @click="emit('close')" />
@@ -160,5 +160,5 @@ onMounted(() => {
/>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -63,7 +63,7 @@ onMounted(() => {
</script>
<template>
<DialogWrapper width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
@@ -89,5 +89,5 @@ onMounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -10,12 +10,12 @@ const props = defineProps({
</script>
<template>
<!-- Progress Dialog -->
<DialogWrapper :scrim="false" width="25rem">
<VDialog :scrim="false" width="25rem">
<VCard elevation="3" color="primary">
<VCardText class="text-center">
{{ props.text || t('dialog.progress.processing') }}
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -57,7 +57,7 @@ async function handleReset() {
</script>
<template>
<DialogWrapper width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
@@ -99,5 +99,5 @@ async function handleReset() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -205,7 +205,7 @@ const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`,
handleProgressMessage,
'reorganize-progress',
progressActive
progressActive,
)
// 使用SSE监听加载进度
@@ -269,7 +269,7 @@ onUnmounted(() => {
</script>
<template>
<DialogWrapper scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend> <VIcon icon="mdi-folder-move" class="me-2" /> </template>
@@ -487,7 +487,7 @@ onUnmounted(() => {
<!-- 手动整理进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- TMDB ID搜索框 -->
<DialogWrapper v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
<MediaIdSelector
v-if="mediaSource === 'themoviedb'"
v-model="transferForm.tmdbid"
@@ -500,6 +500,6 @@ onUnmounted(() => {
@close="mediaSelectorDialog = false"
:type="mediaSource"
/>
</DialogWrapper>
</DialogWrapper>
</VDialog>
</VDialog>
</template>

View File

@@ -3,7 +3,7 @@ import api from '@/api'
import type { Site, Plugin, Subscribe } from '@/api/types'
import { getNavMenus, getSettingTabs } from '@/router/i18n-menu'
import { NavMenu } from '@/@layouts/types'
import { useUserStore } from '@/stores'
import { useUserStore, useGlobalSettingsStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
@@ -26,6 +26,10 @@ const router = useRouter()
// 用户 Store
const userStore = useUserStore()
// 全局设置 Store
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 超级用户
const superUser = userStore.superUser
@@ -63,6 +67,11 @@ const hasManagePermission = computed(() => {
)
})
// 是否显示合集搜索项当SEARCH_SOURCE包含themoviedb时显示
const showCollectionSearch = computed(() => {
return globalSettings.SEARCH_SOURCE?.includes('themoviedb') || false
})
// 所有订阅数据
const SubscribeItems = ref<Subscribe[]>([])
@@ -298,6 +307,19 @@ function searchHistory() {
emit('close')
}
// 跳转到订阅分享页面
function searchSubscribeShares() {
if (!searchWord.value) return
saveRecentSearches(searchWord.value)
router.push({
path: '/subscribe-share',
query: {
keyword: searchWord.value,
},
})
emit('close')
}
// 跳转插件页面
function showPlugin(pluginId: string) {
router.push({
@@ -357,7 +379,7 @@ onMounted(() => {
})
</script>
<template>
<DialogWrapper v-model="dialog" max-width="42rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog v-model="dialog" max-width="42rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard class="search-dialog">
<!-- 搜索输入框 -->
<VCardItem class="pa-4 pa-sm-5 search-box-container">
@@ -422,7 +444,7 @@ onMounted(() => {
</template>
</VHover>
<VHover>
<VHover v-if="showCollectionSearch">
<template #default="hover">
<VListItem
density="comfortable"
@@ -484,6 +506,37 @@ onMounted(() => {
</template>
</VHover>
<VHover v-if="hasSubscribePermission">
<template #default="hover">
<VListItem
density="comfortable"
link
rounded="xl"
v-bind="hover.props"
@click="searchSubscribeShares"
class="search-option mx-2 mx-sm-4 my-1"
>
<template #prepend>
<div class="option-icon-wrapper d-flex align-center justify-center">
<VIcon
icon="mdi-share-variant"
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
size="small"
/>
</div>
</template>
<VListItemTitle class="font-weight-medium">{{ t('subscribe.searchShares') }}</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.subscribeShareSearch') }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
</template>
</VListItem>
</template>
</VHover>
<VHover v-if="hasManagePermission">
<template #default="hover">
<VListItem
@@ -741,7 +794,7 @@ onMounted(() => {
</div>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 站点选择对话框 -->
<SearchSiteDialog

View File

@@ -56,7 +56,7 @@ const filteredSites = computed(() => {
</script>
<template>
<!-- Site Selection Dialog -->
<DialogWrapper max-width="40rem" fullscreen-mobile>
<VDialog max-width="40rem" fullscreen-mobile>
<VCard class="site-dialog">
<VCardItem>
<template #prepend>
@@ -169,7 +169,7 @@ const filteredSites = computed(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style scoped>
.site-checkbox-wrapper {

View File

@@ -147,7 +147,7 @@ onMounted(async () => {
</script>
<template>
<DialogWrapper scrollable :close-on-back="false" eager max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable :close-on-back="false" eager max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem :class="props.oper === 'add' ? 'py-3' : 'py-2'">
<template #prepend>
@@ -203,7 +203,7 @@ onMounted(async () => {
prepend-inner-icon="mdi-rss"
/>
</VCol>
<VCol cols="12" md="3">
<VCol cols="6" md="3">
<VTextField
v-model="siteForm.timeout"
:label="t('site.fields.timeout')"
@@ -350,5 +350,5 @@ onMounted(async () => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -71,7 +71,7 @@ async function updateSiteCookie() {
}
</script>
<template>
<DialogWrapper max-width="30rem" scrollable>
<VDialog max-width="30rem" scrollable>
<!-- Dialog Content -->
<VCard :title="t('dialog.siteCookieUpdate.title')">
<VDialogCloseBtn @click="emit('close')" />
@@ -114,5 +114,5 @@ async function updateSiteCookie() {
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</DialogWrapper>
</VDialog>
</template>

View File

@@ -0,0 +1,423 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import type { Site } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 提示框
const $toast = useToast()
// 注册事件
const emit = defineEmits(['update:modelValue', 'import-success'])
// 界面阶段枚举
enum ImportStage {
SELECT_FILE = 'select_file', // 选择文件阶段
PREVIEW_FILE = 'preview_file', // 文件预览阶段
IMPORTING = 'importing', // 正在导入阶段
IMPORT_COMPLETE = 'import_complete', // 导入完成阶段
}
// 当前阶段
const currentStage = ref<ImportStage>(ImportStage.SELECT_FILE)
// 是否拖拽中
const isDragging = ref(false)
// 导入的文件数据
const importData = ref<Site[]>([])
// 导入进度
const importProgress = ref(0)
// 预览数据
const previewData = ref<Site[]>([])
// 选中的文件
const selectedFile = ref<File | null>(null)
// 导入错误信息
const importErrors = ref<Array<{ site: Site; error: string }>>([])
// 导入成功的站点
const importSuccesses = ref<Site[]>([])
// 是否显示错误详情
const showErrorDetails = ref(false)
// 处理拖拽事件
function handleDragOver(event: DragEvent) {
event.preventDefault()
isDragging.value = true
}
function handleDragLeave(event: DragEvent) {
event.preventDefault()
isDragging.value = false
}
async function handleDrop(event: DragEvent) {
event.preventDefault()
isDragging.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) {
const file = files[0]
if (file.type === 'application/json' || file.name.endsWith('.json')) {
selectedFile.value = file
await processFile(file)
} else {
$toast.error(t('site.messages.invalidFileType'))
}
}
}
// 处理文件
async function processFile(file: File) {
try {
const text = await file.text()
const data = JSON.parse(text)
if (Array.isArray(data)) {
importData.value = data
previewData.value = data.slice(0, 5) // 只显示前5个站点作为预览
currentStage.value = ImportStage.PREVIEW_FILE
} else {
$toast.error(t('site.messages.invalidFileFormat'))
}
} catch (error) {
console.error('Parse file error:', error)
$toast.error(t('site.messages.parseFileError'))
}
}
// 验证站点数据
function validateSiteData(site: any): boolean {
const requiredFields = ['name', 'domain', 'url']
return requiredFields.every(field => site[field])
}
// 批量导入站点
async function importSites() {
if (importData.value.length === 0) {
$toast.error(t('site.messages.noDataToImport'))
return
}
// 验证数据
const validSites = importData.value.filter(validateSiteData)
if (validSites.length === 0) {
$toast.error(t('site.messages.noValidData'))
return
}
if (validSites.length !== importData.value.length) {
$toast.warning(t('site.messages.someInvalidData', { valid: validSites.length, total: importData.value.length }))
}
// 进入导入阶段
currentStage.value = ImportStage.IMPORTING
startNProgress()
importProgress.value = 0
try {
let successCount = 0
let failCount = 0
importErrors.value = [] // 清空之前的错误信息
importSuccesses.value = [] // 清空之前的成功信息
for (let i = 0; i < validSites.length; i++) {
const site = validSites[i]
try {
// 移除id字段避免冲突
const { id, ...siteData } = site
const result: { success: boolean; message?: string } = await api.post('site/', siteData)
if (result.success) {
// 记录成功的站点
successCount++
importSuccesses.value.push(site)
} else {
failCount++
// 记录失败信息
importErrors.value.push({
site,
error: result.message || t('site.messages.importFailed'),
})
}
} catch (error) {
console.error(`Import site ${site.name} failed:`, error)
failCount++
// 记录错误信息
importErrors.value.push({
site,
error: error instanceof Error ? error.message : t('site.messages.importFailed'),
})
}
// 更新进度
importProgress.value = Math.round(((i + 1) / validSites.length) * 100)
}
// 进入完成阶段
currentStage.value = ImportStage.IMPORT_COMPLETE
// 显示导入结果
if (failCount === 0 && successCount > 0) {
// 全部成功,直接关闭对话框
$toast.success(t('site.messages.importSuccess', { count: successCount }))
closeDialog(true)
} else if (successCount === 0 && failCount > 0) {
// 全部失败的情况
$toast.error(t('site.messages.importAllFailed', { count: failCount }))
showErrorDetails.value = true
} else {
// 部分成功部分失败的情况
$toast.error(t('site.messages.importPartialFailed', { success: successCount, failed: failCount }))
showErrorDetails.value = true
}
} catch (error) {
console.error('Import sites failed:', error)
$toast.error(t('site.messages.importFailed'))
// 出错时回到预览阶段
currentStage.value = ImportStage.PREVIEW_FILE
} finally {
doneNProgress()
}
}
// 重置到文件选择阶段
function resetToFileSelection() {
currentStage.value = ImportStage.SELECT_FILE
importData.value = []
previewData.value = []
importProgress.value = 0
isDragging.value = false
selectedFile.value = null
importErrors.value = []
importSuccesses.value = []
showErrorDetails.value = false
}
// 关闭对话框
function closeDialog(success: boolean = false) {
if (success) {
emit('import-success')
}
emit('update:modelValue', false)
}
// 监听文件选择
watch(selectedFile, async newFile => {
if (newFile) {
await processFile(newFile)
}
})
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-upload" class="me-2" />
</template>
<VCardTitle>{{ t('site.actions.import') }}</VCardTitle>
<VCardSubtitle>{{ t('site.hints.import') }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="closeDialog" />
<VDivider />
<VCardText>
<!-- 阶段1选择文件阶段 -->
<div v-if="currentStage === ImportStage.SELECT_FILE" class="upload-area">
<div
class="upload-zone"
:class="{ 'dragging': isDragging }"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<VFileInput
v-model="selectedFile"
accept=".json"
:label="t('site.fields.selectFile')"
:hint="t('site.hints.selectFile')"
persistent-hint
prepend-icon="mdi-file-upload"
/>
<div class="text-center mt-4">
<VIcon icon="mdi-cloud-upload" size="48" color="primary" />
<p class="text-body-1 mt-2">{{ t('site.hints.dragDropFile') }}</p>
<p class="text-caption text-medium-emphasis">{{ t('site.hints.supportedFormat') }}</p>
</div>
</div>
</div>
<!-- 阶段2文件预览阶段 -->
<div v-if="currentStage === ImportStage.PREVIEW_FILE" class="preview-area">
<VAlert
type="info"
variant="tonal"
class="mb-4"
:text="t('site.messages.previewData', { count: importData.length })"
/>
<!-- 预览列表 -->
<VCard variant="outlined" class="mb-4">
<VCardTitle class="text-subtitle-1">
{{ t('site.preview.title') }} ({{
t('site.preview.showing', { count: previewData.length, total: importData.length })
}})
</VCardTitle>
<VCardText>
<VList>
<VListItem
v-for="(site, index) in previewData"
:key="index"
:class="{ 'border-error': !validateSiteData(site) }"
>
<template #prepend>
<VIcon
:icon="validateSiteData(site) ? 'mdi-check-circle' : 'mdi-alert-circle'"
:color="validateSiteData(site) ? 'success' : 'error'"
/>
</template>
<VListItemTitle>{{ site.name || t('site.preview.unnamed') }}</VListItemTitle>
<VListItemSubtitle>{{ site.url || t('site.preview.noUrl') }}</VListItemSubtitle>
<template #append>
<VChip v-if="!validateSiteData(site)" size="small" color="error" variant="tonal">
{{ t('site.preview.invalid') }}
</VChip>
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
<!-- 操作按钮 -->
<div class="d-flex justify-end gap-2">
<VBtn variant="text" @click="resetToFileSelection">
{{ t('common.reset') }}
</VBtn>
<VBtn color="primary" @click="importSites" :disabled="importData.length === 0">
{{ t('site.actions.startImport') }}
</VBtn>
</div>
</div>
<!-- 阶段3正在导入阶段 -->
<div v-if="currentStage === ImportStage.IMPORTING" class="importing-area">
<VAlert
type="info"
variant="tonal"
class="mb-4"
:text="t('site.messages.importing', { progress: importProgress })"
/>
<!-- 导入进度 -->
<VCard variant="outlined" class="mb-4">
<VCardTitle class="text-subtitle-1">
{{ t('site.messages.importing', { progress: importProgress }) }}
</VCardTitle>
<VCardText>
<VProgressLinear v-model="importProgress" color="primary" height="8" rounded class="mb-2" />
<p class="text-caption text-center">{{ importProgress }}%</p>
</VCardText>
</VCard>
</div>
<!-- 阶段4导入完成阶段 -->
<div v-if="currentStage === ImportStage.IMPORT_COMPLETE" class="result-area">
<!-- 成功导入的站点 -->
<div v-if="importSuccesses.length > 0" class="success-sites mb-4">
<VAlert
type="success"
variant="tonal"
class="mb-4"
:text="t('site.messages.importSuccess', { count: importSuccesses.length })"
/>
</div>
<!-- 错误详情 -->
<div v-if="showErrorDetails && importErrors.length > 0" class="error-details">
<VAlert
type="error"
variant="tonal"
class="mb-4"
:text="t('site.messages.importErrors', { count: importErrors.length })"
/>
<VCard variant="outlined" class="mb-4">
<VCardTitle class="text-subtitle-1 d-flex align-center justify-space-between">
{{ t('site.errors.title') }}
</VCardTitle>
<!-- 错误信息详情 -->
<VExpansionPanels class="mt-4">
<VExpansionPanel v-for="(error, index) in importErrors" :key="index">
<VExpansionPanelTitle>
{{ error.site.name || t('site.preview.unnamed') }} - {{ t('site.errors.details') }}
</VExpansionPanelTitle>
<VExpansionPanelText>
<VAlert type="error" variant="text" :text="error.error" class="mb-0" />
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</VCard>
</div>
<!-- 操作按钮 -->
<div class="d-flex justify-end gap-2">
<VBtn variant="text" @click="resetToFileSelection">
{{ t('common.reset') }}
</VBtn>
<VBtn color="primary" @click="closeDialog(false)">
{{ t('common.close') }}
</VBtn>
</div>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.upload-area {
padding: 2rem;
}
.upload-zone {
padding: 2rem;
border: 2px dashed #ccc;
border-radius: 8px;
text-align: center;
transition: all 0.3s ease;
}
.upload-zone.dragging {
border-color: rgb(var(--v-theme-primary));
background-color: rgba(var(--v-theme-primary), 0.05);
}
.error-details {
margin-block: 1rem;
margin-inline: 0;
}
.error-details .v-expansion-panels {
background: transparent;
}
.border-success {
border-inline-start: 4px solid rgb(var(--v-theme-success));
}
.border-error {
border-inline-start: 4px solid rgb(var(--v-theme-error));
}
</style>

View File

@@ -130,7 +130,7 @@ onMounted(() => {
})
</script>
<template>
<DialogWrapper scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
<VCard>
<!-- Toolbar -->
<div>
@@ -281,7 +281,7 @@ onMounted(() => {
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</DialogWrapper>
</VDialog>
</template>
<style lang="scss" scoped>

View File

@@ -0,0 +1,478 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import api from '@/api'
import type { Site, SiteStatistic } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
sites: {
type: Array as PropType<Site[]>,
default: () => [],
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue'])
// 站点统计数据
const siteStats = ref<SiteStatistic[]>([])
// 是否加载中
const loading = ref(false)
// 当前选中的站点
const selectedSite = ref<Site | null>(null)
// 耗时记录详情弹窗
const detailDialog = ref(false)
// 获取站点统计数据
async function fetchSiteStats() {
try {
loading.value = true
const response = await api.get('site/statistic')
siteStats.value = Array.isArray(response) ? response : response.data || []
loading.value = false
} catch (error) {
console.error('Failed to fetch site statistics:', error)
loading.value = false
}
}
// 根据站点域名获取统计数据
function getSiteStats(domain: string): SiteStatistic | undefined {
return siteStats.value.find(stat => stat.domain === domain)
}
// 获取站点连接状态
function getConnectionStatus(stats: SiteStatistic | undefined): string {
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'
}
// 获取状态颜色
function getStatusColor(status: string): string {
switch (status) {
case 'connected':
return 'success'
case 'slow':
return 'warning'
case 'failed':
return 'error'
default:
return 'secondary'
}
}
// 获取状态图标
function getStatusIcon(status: string): string {
switch (status) {
case 'connected':
return 'mdi-wifi'
case 'slow':
return 'mdi-wifi-strength-2'
case 'failed':
return 'mdi-wifi-off'
default:
return 'mdi-help-circle'
}
}
// 获取状态文本
function getStatusText(status: string): string {
switch (status) {
case 'connected':
return t('site.connectionNormal')
case 'slow':
return t('site.connectionSlow')
case 'failed':
return t('site.connectionFailed')
default:
return t('site.connectionUnknown')
}
}
// 获取耗时颜色
function getTimeColor(seconds: number | undefined): string {
if (!seconds) return 'secondary'
if (seconds < 2) return 'success'
if (seconds < 5) return 'warning'
return 'error'
}
// 获取成功率(与列表/概览口径一致)
function getSuccessRate(stats: SiteStatistic | undefined): string {
if (!stats) return '-'
const success = Number(stats.success ?? 0)
const fail = Number(stats.fail ?? 0)
const total = success + fail
if (total <= 0) return '-'
return String(Math.round((success / total) * 100))
}
// 解析耗时记录
function parseTimeRecords(note: any): Array<{ time: string; duration: number }> {
if (!note) return []
try {
// note可能是字符串或对象如果是字符串则解析
const records = typeof note === 'string' ? JSON.parse(note) : note
if (typeof records === 'object' && records !== null) {
const result = Object.entries(records)
.map(([time, duration]) => ({
time,
duration: Number(duration) || 0,
}))
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
.slice(0, 10) // 只显示最近10条记录
return result
}
} catch (error) {
console.error('Failed to parse time records:', error)
}
return []
}
// 查看详情
function viewDetail(site: Site) {
selectedSite.value = site
detailDialog.value = true
}
// 关闭弹窗
function closeDialog() {
emit('update:modelValue', false)
}
// 计算属性:按平均耗时排序的站点列表
const sortedSites = computed(() => {
return props.sites
.map(site => {
const stats = getSiteStats(site.domain)
return {
site,
stats,
status: getConnectionStatus(stats),
avgTime: stats?.seconds || 0,
}
})
.sort((a, b) => {
// 先按状态排序connected > slow > failed > unknown
const statusOrder = { connected: 0, slow: 1, failed: 2, unknown: 3 }
const statusDiff =
statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder]
if (statusDiff !== 0) return statusDiff
// 再按平均耗时排序
return a.avgTime - b.avgTime
})
})
// 统计总览(与列表口径一致)
const overviewCounts = computed(() => {
const items = sortedSites.value
const total = items.length
const connected = items.filter(i => i.status === 'connected').length
const slow = items.filter(i => i.status === 'slow').length
const failed = items.filter(i => i.status === 'failed').length
const unknown = total - connected - slow - failed
return { total, connected, slow, failed, unknown }
})
onMounted(() => {
fetchSiteStats()
})
</script>
<template>
<VDialog max-width="50rem" :fullscreen="display.smAndDown.value" scrollable>
<VCard>
<!-- 标题栏 -->
<VCardItem>
<VDialogCloseBtn @click="closeDialog" />
<template #prepend>
<VIcon icon="mdi-chart-line" class="me-2" />
</template>
<VCardTitle>
{{ t('site.statistics') }}
</VCardTitle>
</VCardItem>
<VDivider />
<!-- 内容区域 -->
<VCardText class="pa-0">
<LoadingBanner v-if="loading" class="my-8" />
<div v-else class="site-statistics-content">
<!-- 统计概览 -->
<div class="statistics-overview pa-4">
<div class="d-flex flex-wrap gap-4">
<div class="stat-card">
<div class="stat-number">{{ overviewCounts.total }}</div>
<div class="stat-label">{{ t('site.totalSites') }}</div>
</div>
<div class="stat-card">
<div class="stat-number success--text">{{ overviewCounts.connected }}</div>
<div class="stat-label">{{ t('site.normalSites') }}</div>
</div>
<div class="stat-card">
<div class="stat-number warning--text">{{ overviewCounts.slow }}</div>
<div class="stat-label">{{ t('site.slowSites') }}</div>
</div>
<div class="stat-card">
<div class="stat-number error--text">{{ overviewCounts.failed }}</div>
<div class="stat-label">{{ t('site.failedSites') }}</div>
</div>
</div>
</div>
<!-- 站点列表 -->
<div class="sites-list">
<div
v-for="item in sortedSites"
:key="item.site.id"
class="site-item pa-4 border-b"
:class="`border-${getStatusColor(item.status)}`"
>
<div class="d-flex align-center justify-space-between">
<!-- 左侧站点信息 -->
<div class="d-flex align-center flex-1 min-w-0">
<!-- 状态指示器 -->
<div class="status-indicator me-3" :class="getStatusColor(item.status)">
<VIcon :icon="getStatusIcon(item.status)" size="20" />
</div>
<!-- 站点名称和状态 -->
<div class="flex-1 min-w-0">
<div class="d-flex align-center">
<h4 class="text-h6 mb-1 truncate">{{ item.site.name }}</h4>
<VChip :color="getStatusColor(item.status)" size="small" class="ml-2" variant="tonal">
{{ getStatusText(item.status) }}
</VChip>
</div>
<div class="text-caption text-medium-emphasis">{{ item.site.domain }}</div>
</div>
</div>
<!-- 右侧统计信息 -->
<div class="d-flex align-center gap-4">
<!-- 平均耗时 -->
<div class="text-center">
<div class="text-h6 font-weight-bold" :class="`text-${getTimeColor(item.stats?.seconds)}`">
{{ item.stats?.seconds || '-' }}s
</div>
<div class="text-caption text-medium-emphasis">{{ t('site.averageTime') }}</div>
</div>
<!-- 成功率 -->
<div class="text-center">
<div class="text-h6 font-weight-bold">{{ getSuccessRate(item.stats) }}%</div>
<div class="text-caption text-medium-emphasis">{{ t('site.successRate') }}</div>
</div>
<!-- 详情按钮 -->
<VBtn icon variant="text" size="small" @click="viewDetail(item.site)">
<VIcon icon="mdi-information-outline" />
</VBtn>
</div>
</div>
</div>
</div>
</div>
</VCardText>
</VCard>
<!-- 详情弹窗 -->
<VDialog v-model="detailDialog" :max-width="display.mdAndUp.value ? 600 : '95%'" scrollable>
<VCard v-if="selectedSite">
<VCardItem class="py-3">
<template #prepend>
<VIcon icon="mdi-information-outline" class="me-2" />
</template>
<VCardTitle> {{ selectedSite.name }} - {{ t('site.timeRecords') }} </VCardTitle>
<VDialogCloseBtn @click="detailDialog = false" />
</VCardItem>
<VDivider />
<VCardText>
<div v-if="getSiteStats(selectedSite.domain)">
<div class="mb-4">
<h5 class="text-h6 mb-2">{{ t('site.statistics') }}</h5>
<div class="d-flex flex-wrap gap-4">
<div class="stat-item">
<span class="stat-label">{{ t('site.successCount') }}:</span>
<span class="stat-value success--text">
{{ getSiteStats(selectedSite.domain)?.success || 0 }}
</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('site.failCount') }}:</span>
<span class="stat-value error--text">
{{ getSiteStats(selectedSite.domain)?.fail || 0 }}
</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('site.averageTime') }}:</span>
<span class="stat-value" :class="`text-${getTimeColor(getSiteStats(selectedSite.domain)?.seconds)}`">
{{ getSiteStats(selectedSite.domain)?.seconds || '-' }}s
</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('site.lastAccess') }}:</span>
<span class="stat-value">
{{ getSiteStats(selectedSite.domain)?.lst_mod_date || '-' }}
</span>
</div>
</div>
</div>
<div>
<h5 class="text-h6 mb-2">{{ t('site.recentTimeRecords') }}</h5>
<div class="time-records">
<div
v-for="(record, index) in parseTimeRecords(getSiteStats(selectedSite.domain)?.note)"
:key="index"
class="time-record-item pa-3 border rounded mb-2"
:class="`border-${getTimeColor(record.duration)}`"
>
<div class="d-flex justify-space-between align-center">
<div>
<div class="text-body-2 font-weight-medium">{{ record.time }}</div>
<div class="text-caption text-medium-emphasis">{{ t('site.accessTime') }}</div>
</div>
<div class="text-end">
<div class="text-h6 font-weight-bold" :class="`text-${getTimeColor(record.duration)}`">
{{ record.duration }}s
</div>
<div class="text-caption text-medium-emphasis">{{ t('site.responseTime') }}</div>
</div>
</div>
</div>
<div
v-if="parseTimeRecords(getSiteStats(selectedSite.domain)?.note).length === 0"
class="text-center pa-4"
>
<VIcon icon="mdi-information-outline" size="48" color="secondary" class="mb-2" />
<div class="text-body-1 text-medium-emphasis">{{ t('site.noTimeRecords') }}</div>
</div>
</div>
</div>
</div>
</VCardText>
</VCard>
</VDialog>
</VDialog>
</template>
<style scoped>
.statistics-overview {
background: linear-gradient(135deg, var(--v-theme-surface) 0%, var(--v-theme-surface-variant) 100%);
border-block-end: 1px solid var(--v-border-color);
}
.stat-card {
padding: 16px;
border: 1px solid var(--v-border-color);
border-radius: 8px;
background: var(--v-theme-surface);
min-inline-size: 100px;
text-align: center;
}
.stat-number {
font-size: 24px;
font-weight: bold;
line-height: 1;
margin-block-end: 4px;
}
.stat-label {
color: var(--v-theme-on-surface-variant);
font-size: 12px;
}
.sites-list {
background: var(--v-theme-surface);
}
.site-item {
transition: background-color 0.2s ease;
}
.site-item:hover {
background: var(--v-theme-surface-variant);
}
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--v-theme-surface-variant);
block-size: 40px;
inline-size: 40px;
}
.status-indicator.success {
background: rgba(var(--v-theme-success), 0.1);
color: rgb(var(--v-theme-success));
}
.status-indicator.warning {
background: rgba(var(--v-theme-warning), 0.1);
color: rgb(var(--v-theme-warning));
}
.status-indicator.error {
background: rgba(var(--v-theme-error), 0.1);
color: rgb(var(--v-theme-error));
}
.status-indicator.secondary {
background: rgba(var(--v-theme-secondary), 0.1);
color: rgb(var(--v-theme-secondary));
}
.stat-item {
display: flex;
align-items: center;
gap: 8px;
}
.stat-item .stat-label {
color: var(--v-theme-on-surface-variant);
font-weight: 500;
}
.stat-value {
font-weight: bold;
}
.time-records {
max-block-size: 300px;
overflow-y: auto;
}
.time-record-item {
transition: all 0.2s ease;
}
</style>

View File

@@ -287,11 +287,11 @@ onBeforeMount(() => {
</script>
<template>
<DialogWrapper scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle
>{{ t('dialog.siteUserData.title') }} - {{ props.site?.name }}
<VCardTitle>
{{ t('dialog.siteUserData.title') }} - {{ props.site?.name }}
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh" /></IconBtn>
</VCardTitle>
<VDialogCloseBtn @click="emit('close')" />
@@ -484,5 +484,5 @@ onBeforeMount(() => {
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="t('dialog.siteUserData.refreshing')" />
</DialogWrapper>
</VDialog>
</template>

View File

@@ -50,7 +50,7 @@ async function saveSmbConfig() {
</script>
<template>
<DialogWrapper width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
@@ -127,5 +127,5 @@ async function saveSmbConfig() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -99,6 +99,10 @@ function episodeGroupItemProps(item: { title: string; subtitle: string }) {
// 查询所有剧集组
async function getEpisodeGroups() {
if (!subscribeForm.value.tmdbid) {
console.warn('tmdbid is not set or is empty')
return
}
try {
episodeGroups.value = await api.get(`media/groups/${subscribeForm.value.tmdbid}`)
} catch (error) {
@@ -280,10 +284,10 @@ onMounted(() => {
</script>
<template>
<DialogWrapper scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<VDialogCloseBtn @click="emit('close')" />
<VDialogCloseBtn @click="emit('close')" />
<template #prepend>
<VIcon icon="mdi-clipboard-list-outline" class="me-2" />
</template>
@@ -539,5 +543,5 @@ onMounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -85,7 +85,7 @@ onBeforeMount(() => {
})
</script>
<template>
<DialogWrapper scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="my-2">
<VDialogCloseBtn @click="emit('close')" />
@@ -206,7 +206,7 @@ onBeforeMount(() => {
</div>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style lang="scss" scoped>

View File

@@ -146,7 +146,7 @@ function getMediaTypeText(type: string | undefined) {
</script>
<template>
<DialogWrapper scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%">
<VCardItem>
<VCardTitle>{{ t('dialog.subscribeHistory.title', { type: getMediaTypeText(props.type) }) }}</VCardTitle>
@@ -220,5 +220,5 @@ function getMediaTypeText(type: string | undefined) {
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</DialogWrapper>
</VDialog>
</template>

View File

@@ -81,6 +81,10 @@ function getMediaId() {
// 查询所有剧集组
async function getEpisodeGroups() {
if (!props.media?.tmdb_id) {
console.warn('tmdbid is not set or is empty')
return
}
try {
episodeGroups.value = await api.get(`media/groups/${props.media?.tmdb_id}`)
} catch (error) {

View File

@@ -55,7 +55,7 @@ const $toast = useToast()
</script>
<template>
<DialogWrapper scrollable max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend>
@@ -112,5 +112,5 @@ const $toast = useToast()
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -0,0 +1,477 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import api from '@/api'
import type { SubscribeShareStatistics } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay, useTheme } from 'vuetify'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 主题
const theme = useTheme()
// 定义事件
const emit = defineEmits(['close'])
// 统计数据
const statistics = ref<SubscribeShareStatistics[]>([])
// 是否加载中
const loading = ref(false)
// 获取统计数据
async function fetchStatistics() {
try {
loading.value = true
const data: SubscribeShareStatistics[] = await api.get('subscribe/share/statistics')
statistics.value = data
} catch (error) {
console.error('获取分享统计数据失败:', error)
} finally {
loading.value = false
}
}
// 计算排名
const rankedStatistics = computed(() => {
return statistics.value
.sort((a, b) => (b.total_reuse_count || 0) - (a.total_reuse_count || 0))
.map((item, index) => ({
...item,
rank: index + 1,
}))
})
// 获取排名样式
function getRankStyle(rank: number) {
if (rank === 1) {
return {
background: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
color: '#fff',
fontWeight: 'bold',
}
} else if (rank === 2) {
return {
background: 'linear-gradient(135deg, #CD7F32 0%, #B8860B 100%)',
color: '#fff',
fontWeight: 'bold',
}
} else if (rank === 3) {
return {
background: 'linear-gradient(135deg, #C0C0C0 0%, #A0A0A0 100%)',
color: '#fff',
fontWeight: 'bold',
}
}
return {}
}
// 获取前三名文字颜色
function getPodiumTextColor() {
return theme.global.current.value.dark ? '#fff' : '#000'
}
// 获取前三名统计背景样式
function getPodiumStatStyle() {
const isDark = theme.global.current.value.dark
return {
border: `1px solid ${isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'}`,
background: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
}
}
// 获取前三名区域背景样式
function getPodiumAreaBackgroundStyle() {
const isDark = theme.global.current.value.dark
return {
background: isDark
? 'linear-gradient(135deg, rgba(255, 215, 0, 0.25) 0%, rgba(255, 69, 0, 0.2) 25%, rgba(255, 20, 147, 0.15) 50%, rgba(138, 43, 226, 0.1) 75%, rgba(0, 191, 255, 0.08) 100%), linear-gradient(to bottom, transparent 0%, transparent 70%, rgba(255, 215, 0, 0.1) 85%, transparent 100%)'
: 'linear-gradient(135deg, rgba(255, 215, 0, 0.2) 0%, rgba(255, 69, 0, 0.15) 25%, rgba(255, 20, 147, 0.12) 50%, rgba(138, 43, 226, 0.08) 75%, rgba(0, 191, 255, 0.05) 100%), linear-gradient(to bottom, transparent 0%, transparent 70%, rgba(255, 215, 0, 0.08) 85%, transparent 100%)',
border: 'none',
borderRadius: '0',
padding: '32px 24px 48px 24px',
margin: '0 -24px 0 -',
boxShadow: isDark
? '0 16px 48px rgba(255, 215, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)'
: '0 16px 48px rgba(255, 215, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.3)',
position: 'relative' as const,
overflow: 'hidden',
}
}
// 获取排名图标
function getRankIcon(rank: number) {
if (rank === 1) return 'mdi-trophy'
if (rank === 2) return 'mdi-medal-outline'
if (rank === 3) return 'mdi-medal'
return ''
}
// 组件挂载时获取数据
onMounted(() => {
fetchStatistics()
})
</script>
<template>
<VDialog scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-chart-line" class="me-2" />
</template>
<VCardTitle>{{ t('subscribe.shareStatistics') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText class="pa-0">
<LoadingBanner v-if="loading" class="mt-4" />
<div v-else-if="rankedStatistics.length === 0" class="text-center py-8">
<VIcon icon="mdi-chart-line" size="64" color="grey" class="mb-4" />
<div class="text-h6 text-grey">{{ t('subscribe.noStatisticsData') }}</div>
</div>
<div v-else>
<!-- 前三名特殊展示 -->
<div class="podium-area" :style="getPodiumAreaBackgroundStyle()">
<!-- 装饰性背景元素 -->
<div class="podium-decoration">
<div class="decoration-circle decoration-1"></div>
<div class="decoration-circle decoration-2"></div>
<div class="decoration-circle decoration-3"></div>
</div>
<div class="text-h6 mb-4 text-center podium-title">{{ t('subscribe.ranking') }}</div>
<!-- 大屏幕横向排列 -->
<div class="d-none d-md-flex justify-center align-center gap-4 flex-wrap">
<!-- 第二名 -->
<div v-if="rankedStatistics[1]" class="text-center">
<div class="rank-circle mb-2" :style="getRankStyle(2)">
<VIcon :icon="getRankIcon(2)" size="24" />
</div>
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[1].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-2 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[1].share_count || 0
}}</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[1].total_reuse_count || 0
}}</span>
</div>
</div>
</div>
<!-- 第一名 -->
<div v-if="rankedStatistics[0]" class="text-center">
<div class="rank-circle mb-2 first-place" :style="getRankStyle(1)">
<VIcon :icon="getRankIcon(1)" size="32" />
</div>
<div class="text-h5 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[0].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-3 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[0].share_count || 0
}}</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[0].total_reuse_count || 0
}}</span>
</div>
</div>
</div>
<!-- 第三名 -->
<div v-if="rankedStatistics[2]" class="text-center">
<div class="rank-circle mb-2" :style="getRankStyle(3)">
<VIcon :icon="getRankIcon(3)" size="24" />
</div>
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[2].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-2 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[2].share_count || 0
}}</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[2].total_reuse_count || 0
}}</span>
</div>
</div>
</div>
</div>
<!-- 小屏幕垂直排列 -->
<div class="d-flex d-md-none flex-column align-center gap-4">
<!-- 第一名 -->
<div v-if="rankedStatistics[0]" class="text-center">
<div class="rank-circle mb-2 first-place" :style="getRankStyle(1)">
<VIcon :icon="getRankIcon(1)" size="32" />
</div>
<div class="text-h5 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[0].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-3 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">{{ rankedStatistics[0].share_count || 0 }}</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[0].total_reuse_count || 0
}}</span>
</div>
</div>
</div>
<!-- 第二名 -->
<div v-if="rankedStatistics[1]" class="text-center">
<div class="rank-circle mb-2" :style="getRankStyle(2)">
<VIcon :icon="getRankIcon(2)" size="24" />
</div>
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[1].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-2 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">{{ rankedStatistics[1].share_count || 0 }}</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[1].total_reuse_count || 0
}}</span>
</div>
</div>
</div>
<!-- 第三名 -->
<div v-if="rankedStatistics[2]" class="text-center">
<div class="rank-circle mb-2" :style="getRankStyle(3)">
<VIcon :icon="getRankIcon(3)" size="24" />
</div>
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[2].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-2 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[2].share_count || 0 }}
</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[2].total_reuse_count || 0 }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 完整排行榜 -->
<VList class="bg-transparent px-3">
<VListItem
v-for="item in rankedStatistics.filter(item => item.rank > 3)"
:key="item.share_user"
class="mb-2 rounded-lg"
>
<VListItemTitle class="font-weight-bold text-h6 mb-1">
{{ item.share_user || '未知' }}
</VListItemTitle>
<VListItemSubtitle class="d-flex align-center gap-3 mt-1">
<div class="stat-badge share-badge">
<VIcon icon="mdi-share-outline" size="14" color="primary" class="mr-1" />
<span class="text-primary font-weight-bold">{{ item.share_count || 0 }}</span>
<span class="text-grey text-caption ml-1">{{ t('subscribe.shareCount') }}</span>
</div>
<div class="stat-badge reuse-badge">
<VIcon icon="mdi-fire" size="14" color="warning" class="mr-1" />
<span class="text-warning font-weight-bold">{{ item.total_reuse_count || 0 }}</span>
<span class="text-grey text-caption ml-1">{{ t('subscribe.totalReuseCount') }}</span>
</div>
</VListItemSubtitle>
<template #append>
<div class="text-right">
<div
class="text-h6 font-weight-bold"
:style="{ color: item.rank <= 3 ? 'var(--v-primary-base)' : 'inherit' }"
>
#{{ item.rank }}
</div>
</div>
</template>
</VListItem>
</VList>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.rank-circle {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
block-size: 60px;
inline-size: 60px;
margin-block: 0;
margin-inline: auto;
}
.first-place {
block-size: 80px;
box-shadow: 0 4px 12px rgba(255, 215, 0, 30%);
inline-size: 80px;
}
.rank-badge {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
block-size: 32px;
inline-size: 32px;
}
.stat-badge {
display: flex;
align-items: center;
border: 1px solid rgba(var(--v-theme-outline), 0.2);
border-radius: 6px;
background: rgba(var(--v-theme-surface), 0.8);
padding-block: 4px;
padding-inline: 8px;
transition: all 0.2s ease;
}
.share-badge {
border-inline-start: 3px solid rgb(var(--v-theme-primary));
}
.reuse-badge {
border-inline-start: 3px solid rgb(var(--v-theme-warning));
}
.podium-stat {
border-radius: 6px;
backdrop-filter: blur(4px);
padding-block: 4px;
padding-inline: 8px;
transition: all 0.2s ease;
}
.podium-stat:hover {
transform: scale(1.05);
}
/* 前三名区域样式 */
.podium-area {
position: relative;
z-index: 1;
}
.podium-title {
position: relative;
z-index: 2;
color: #fff !important;
font-weight: bold;
text-shadow: 0 2px 4px rgba(0, 0, 0, 30%);
}
/* 装饰性元素 */
.podium-decoration {
position: absolute;
z-index: 0;
inset: 0;
pointer-events: none;
}
.decoration-circle {
position: absolute;
border-radius: 50%;
animation: float 6s ease-in-out infinite;
background: radial-gradient(circle, rgba(255, 255, 255, 10%) 0%, transparent 70%);
}
.decoration-1 {
animation-delay: 0s;
block-size: 80px;
inline-size: 80px;
inset-block-start: 10%;
inset-inline-start: 10%;
}
.decoration-2 {
animation-delay: 2s;
block-size: 60px;
inline-size: 60px;
inset-block-start: 20%;
inset-inline-end: 15%;
}
.decoration-3 {
animation-delay: 4s;
block-size: 40px;
inline-size: 40px;
inset-block-end: 20%;
inset-inline-start: 20%;
}
@keyframes float {
0%,
100% {
opacity: 0.6;
transform: translateY(0) rotate(0deg);
}
50% {
opacity: 1;
transform: translateY(-10px) rotate(180deg);
}
}
/* 增强前三名文字效果 */
.podium-area .text-h6,
.podium-area .text-h5 {
font-weight: bold;
text-shadow: 0 2px 4px rgba(0, 0, 0, 30%);
}
.podium-area .rank-circle {
border: 2px solid rgba(255, 255, 255, 20%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 30%);
}
.podium-area .first-place {
border: 3px solid rgba(255, 215, 0, 50%);
box-shadow: 0 12px 32px rgba(255, 215, 0, 40%);
}
</style>

View File

@@ -127,7 +127,7 @@ const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`,
handleProgressMessage,
'transfer-queue-progress',
progressActive
progressActive,
)
// 使用SSE监听加载进度
@@ -154,7 +154,7 @@ onUnmounted(() => {
</script>
<template>
<DialogWrapper scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%">
<VCardItem>
<VCardTitle>{{ t('dialog.transferQueue.title') }}</VCardTitle>
@@ -166,6 +166,7 @@ onUnmounted(() => {
:value="progressValue"
color="primary"
indeterminate
:height="2"
/>
<VCardItem v-if="dataList.length > 0 && progressValue > 0" class="text-center pt-2">
<span class="text-sm">{{ progressText }}</span>
@@ -202,5 +203,5 @@ onUnmounted(() => {
</VWindow>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -115,7 +115,7 @@ onUnmounted(() => {
</script>
<template>
<DialogWrapper width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
@@ -147,5 +147,5 @@ onUnmounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -366,7 +366,7 @@ onMounted(() => {
</script>
<template>
<DialogWrapper scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem :class="props.oper === 'add' ? 'py-3' : 'py-2'">
<template #prepend>
@@ -619,5 +619,5 @@ onMounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -4,7 +4,6 @@ import api from '@/api'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
@@ -134,7 +133,7 @@ onMounted(async () => {
</script>
<template>
<DialogWrapper width="40rem" scrollable>
<VDialog width="40rem" scrollable>
<VCard>
<VCardItem>
<VCardTitle>
@@ -179,5 +178,5 @@ onMounted(async () => {
</VBtn>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -197,7 +197,7 @@ const isMacOS = computed(() => {
</script>
<template>
<DialogWrapper scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
<VCard class="workflow-dialog">
<!-- Toolbar -->
<VToolbar color="primary" density="comfortable">
@@ -256,7 +256,7 @@ const isMacOS = computed(() => {
@close="importCodeDialog = false"
@save="saveCodeString"
/>
</DialogWrapper>
</VDialog>
</template>
<style lang="scss">

View File

@@ -33,20 +33,95 @@ const workflowForm = ref<Workflow>(
name: undefined,
timer: undefined,
description: undefined,
trigger_type: 'timer',
event_type: undefined,
state: 'P',
run_count: 0,
},
)
// 监听props变化处理存量数据
watch(
() => props.workflow,
newWorkflow => {
if (newWorkflow) {
// 如果trigger_type为空默认为timer
if (!newWorkflow.trigger_type) {
newWorkflow.trigger_type = 'timer'
}
workflowForm.value = { ...newWorkflow }
}
},
{ immediate: true },
)
// 事件类型列表
const eventTypes = ref<Array<{ title: string; value: string }>>([])
// 触发类型选项
const triggerTypeOptions = computed(() => [
{
title: t('dialog.workflowAddEdit.triggerTypeTimer'),
value: 'timer',
prependIcon: 'mdi-clock-outline',
},
{
title: t('dialog.workflowAddEdit.triggerTypeEvent'),
value: 'event',
prependIcon: 'mdi-calendar-check',
},
{
title: t('dialog.workflowAddEdit.triggerTypeManual'),
value: 'manual',
prependIcon: 'mdi-hand-pointing-up',
},
])
// 加载事件类型列表
async function loadEventTypes() {
try {
eventTypes.value = await api.get('workflow/event_types')
} catch (error) {
console.error('Failed to load event types:', error)
}
}
// 监听触发类型变化
watch(
() => workflowForm.value.trigger_type,
newType => {
if (newType !== 'event') {
workflowForm.value.event_type = undefined
}
},
)
// 提示框
const $toast = useToast()
// 调用API 新增任务
async function addWorkflow() {
if (!workflowForm.value.name || !workflowForm.value.timer) {
if (!workflowForm.value.name) {
$toast.error(t('dialog.workflowAddEdit.nameRequired'))
return
}
if (!workflowForm.value.trigger_type) {
$toast.error(t('dialog.workflowAddEdit.triggerRequired'))
return
}
// 根据触发类型验证必填字段
if (workflowForm.value.trigger_type === 'timer' && !workflowForm.value.timer) {
$toast.error(t('dialog.workflowAddEdit.timerRequired'))
return
}
if (workflowForm.value.trigger_type === 'event' && !workflowForm.value.event_type) {
$toast.error(t('dialog.workflowAddEdit.eventTypeRequired'))
return
}
startNProgress()
try {
const result: { [key: string]: string } = await api.post('workflow/', workflowForm.value)
@@ -64,10 +139,27 @@ async function addWorkflow() {
// 调用API 编辑任务
async function editWorkflow() {
if (!workflowForm.value.name || !workflowForm.value.timer) {
if (!workflowForm.value.name) {
$toast.error(t('dialog.workflowAddEdit.nameRequired'))
return
}
if (!workflowForm.value.trigger_type) {
$toast.error(t('dialog.workflowAddEdit.triggerRequired'))
return
}
// 根据触发类型验证必填字段
if (workflowForm.value.trigger_type === 'timer' && !workflowForm.value.timer) {
$toast.error(t('dialog.workflowAddEdit.timerRequired'))
return
}
if (workflowForm.value.trigger_type === 'event' && !workflowForm.value.event_type) {
$toast.error(t('dialog.workflowAddEdit.eventTypeRequired'))
return
}
startNProgress()
try {
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
@@ -82,10 +174,15 @@ async function editWorkflow() {
}
doneNProgress()
}
// 组件挂载时加载事件类型
onMounted(() => {
loadEventTypes()
})
</script>
<template>
<DialogWrapper scrollable :close-on-back="false" eager max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable :close-on-back="false" eager max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<template #prepend>
@@ -109,6 +206,25 @@ async function editWorkflow() {
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="workflowForm.trigger_type"
:label="t('dialog.workflowAddEdit.triggerType')"
:items="triggerTypeOptions"
item-title="title"
item-value="value"
:rules="[requiredValidator]"
prepend-inner-icon="mdi-run"
>
<template #item="{ item, props: itemProps }">
<VListItem v-bind="itemProps">
<template #prepend>
<VIcon :icon="item.raw.prependIcon" />
</template>
</VListItem>
</template>
</VSelect>
</VCol>
<VCol v-if="workflowForm.trigger_type === 'timer'" cols="12">
<VCronField
v-model="workflowForm.timer"
:label="t('dialog.workflowAddEdit.schedule')"
@@ -119,6 +235,19 @@ async function editWorkflow() {
prepend-inner-icon="mdi-clock-outline"
/>
</VCol>
<VCol v-if="workflowForm.trigger_type === 'event'" cols="12">
<VSelect
v-model="workflowForm.event_type"
:label="t('dialog.workflowAddEdit.eventType')"
:items="eventTypes"
item-title="title"
item-value="value"
:rules="[requiredValidator]"
persistent-hint
:hint="t('dialog.workflowAddEdit.eventTypePlaceholder')"
prepend-inner-icon="mdi-calendar-check"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="workflowForm.description"
@@ -140,5 +269,5 @@ async function editWorkflow() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -68,7 +68,7 @@ const $toast = useToast()
</script>
<template>
<DialogWrapper scrollable max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend>
@@ -132,5 +132,5 @@ const $toast = useToast()
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -749,7 +749,7 @@ onMounted(() => {
</VCardText>
</VCard>
<!-- 重命名弹窗 -->
<DialogWrapper v-if="renamePopper" v-model="renamePopper" max-width="35rem">
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="35rem">
<VCard>
<VCardItem>
<template #prepend>
@@ -783,7 +783,7 @@ onMounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 文件整理弹窗 -->
<ReorganizeDialog
v-if="transferPopper"

View File

@@ -166,7 +166,7 @@ const sortIcon = computed(() => {
<VIcon icon="mdi-arrow-up-bold-outline" />
</IconBtn>
<!-- 新建文件夹 -->
<DialogWrapper v-model="newFolderPopper" max-width="35rem">
<VDialog v-model="newFolderPopper" max-width="35rem">
<template #activator="{ props }">
<IconBtn>
<VIcon v-bind="props" icon="mdi-folder-plus-outline" />
@@ -191,6 +191,6 @@ const sortIcon = computed(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</VToolbar>
</template>

View File

@@ -22,22 +22,38 @@ export function useBackgroundOptimization() {
backgroundCloseDelay?: number
reconnectDelay?: number
maxReconnectAttempts?: number
connectDelay?: number // 新增:连接延迟
},
) => {
const manager = sseManagerSingleton.getManager(url, options)
const isConnected = ref(false)
onMounted(() => {
manager.addMessageListener(listenerId, messageHandler)
// 延迟建立连接,确保组件完全挂载
const connectDelay = options?.connectDelay || 100
setTimeout(() => {
try {
manager.addMessageListener(listenerId, event => {
messageHandler(event)
isConnected.value = true
})
} catch (error) {
console.error('SSE连接建立失败:', error)
}
}, connectDelay)
})
onUnmounted(() => {
manager.removeMessageListener(listenerId)
isConnected.value = false
})
return {
manager,
readyState: () => manager.readyState,
close: () => manager.removeMessageListener(listenerId),
isConnected,
forceReconnect: () => manager.forceReconnect(),
}
}

View File

@@ -4,6 +4,8 @@ import { useOnline } from '@vueuse/core'
// 全局状态
const isAppOffline = ref(false)
const appOfflineReason = ref('')
const consecutiveNetworkErrors = ref(0)
const MAX_CONSECUTIVE_ERRORS = 3
// 全局离线状态管理
export function useGlobalOfflineStatus() {
@@ -19,6 +21,26 @@ export function useGlobalOfflineStatus() {
const setAppOffline = (offline: boolean, reason?: string) => {
isAppOffline.value = offline
appOfflineReason.value = reason || ''
// 如果设置为在线状态,重置连续错误计数
if (!offline) {
consecutiveNetworkErrors.value = 0
}
}
// 记录网络错误
const recordNetworkError = (reason?: string) => {
consecutiveNetworkErrors.value++
// 只有连续出现三次网络错误时才设置为离线模式
if (consecutiveNetworkErrors.value >= MAX_CONSECUTIVE_ERRORS) {
setAppOffline(true, reason || `连续${MAX_CONSECUTIVE_ERRORS}次网络错误`)
}
}
// 重置连续错误计数
const resetConsecutiveErrors = () => {
consecutiveNetworkErrors.value = 0
}
// 获取离线消息
@@ -37,7 +59,10 @@ export function useGlobalOfflineStatus() {
isOffline,
canPerformNetworkAction,
setAppOffline,
recordNetworkError,
resetConsecutiveErrors,
getOfflineMessage,
consecutiveNetworkErrors: computed(() => consecutiveNetworkErrors.value),
}
}

View File

@@ -1,269 +0,0 @@
import { ref, watch, onBeforeUnmount, readonly } from 'vue'
// 滚动锁定配置
export interface ScrollLockOptions {
// 是否在组件卸载时自动恢复滚动
autoRestore?: boolean
// 是否保存和恢复滚动位置
preserveScrollPosition?: boolean
// 是否阻止触摸事件穿透
preventTouchScroll?: boolean
// 自定义锁定时的样式
lockStyles?: {
overflow?: string
position?: string
width?: string
}
}
// 默认配置
const DEFAULT_OPTIONS: Required<ScrollLockOptions> = {
autoRestore: true,
preserveScrollPosition: true,
preventTouchScroll: 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 originalHtmlStyles = 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,
}
// 保存 html 样式
originalHtmlStyles.value = {
overflow: document.documentElement.style.overflow,
}
}
// 阻止触摸滚动事件
const preventTouchScroll = (event: TouchEvent) => {
if (isLocked.value && config.preventTouchScroll) {
// 检查触摸事件的目标元素是否在弹窗内
const target = event.target as Element
if (target) {
// 检查目标元素是否在弹窗、菜单或其他覆盖层内
const isInDialog = target.closest(
'.v-dialog, .v-menu, .v-bottom-sheet, .v-snackbar, [role="dialog"], .v-overlay__content',
)
// 检查目标元素是否是可滚动的内容区域
const isScrollableContent = target.closest(
'.v-card-text, .v-list, .v-table__wrapper, .v-data-table__wrapper, .v-sheet, .v-card__content, .v-data-table, .v-table',
)
// 检查目标元素是否在可滚动的容器内
const scrollableContainer = target.closest('[style*="overflow"], [class*="overflow"]')
const isInScrollableContainer =
scrollableContainer &&
(scrollableContainer.scrollHeight > scrollableContainer.clientHeight ||
getComputedStyle(scrollableContainer).overflow !== 'hidden')
// 如果不在弹窗内且不是可滚动内容且不在可滚动容器内,则阻止滚动
if (!isInDialog && !isScrollableContent && !isInScrollableContainer) {
event.preventDefault()
}
} else {
// 如果无法确定目标元素,则阻止滚动以确保安全
event.preventDefault()
}
}
}
// 锁定滚动
const lockScroll = () => {
if (isLocked.value) return
// 保存当前状态
saveScrollPosition()
saveOriginalStyles()
// 应用锁定样式到 body
document.body.style.overflow = config.lockStyles.overflow || 'hidden'
document.body.style.position = config.lockStyles.position || 'fixed'
document.body.style.width = config.lockStyles.width || '100%'
// 应用锁定样式到 documentElement
document.documentElement.style.overflow = config.lockStyles.overflow || 'hidden'
// 添加 CSS 类名
document.documentElement.classList.add('v-overlay-scroll-blocked')
// 如果需要保持滚动位置设置top偏移
if (config.preserveScrollPosition) {
document.body.style.top = `-${savedScrollPosition.value}px`
}
// 保持navbar的滚动状态 - 添加一个CSS变量来记录滚动位置
if (savedScrollPosition.value > 0) {
document.documentElement.style.setProperty('--saved-scroll-y', `${savedScrollPosition.value}px`)
document.documentElement.classList.add('dialog-scroll-locked')
}
// 添加触摸事件监听器
if (config.preventTouchScroll) {
document.addEventListener('touchmove', preventTouchScroll, { passive: false })
}
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 || ''
// 移除 CSS 类名
document.documentElement.classList.remove('v-overlay-scroll-blocked')
document.documentElement.classList.remove('dialog-scroll-locked')
// 移除CSS变量
document.documentElement.style.removeProperty('--saved-scroll-y')
// 移除触摸事件监听器
if (config.preventTouchScroll) {
document.removeEventListener('touchmove', preventTouchScroll)
}
// 恢复滚动位置
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,
}
}
// 全局弹窗检测和管理
export function useGlobalDialogScrollLock() {
const activeDialogs = ref<Set<string>>(new Set())
const registerDialog = (dialogId: string) => {
activeDialogs.value.add(dialogId)
if (activeDialogs.value.size === 1) {
// 第一个弹窗时锁定滚动
lockGlobalScroll()
}
}
const unregisterDialog = (dialogId: string) => {
activeDialogs.value.delete(dialogId)
if (activeDialogs.value.size === 0) {
// 没有弹窗时恢复滚动
unlockGlobalScroll()
}
}
const lockGlobalScroll = () => {
document.body.style.overflow = 'hidden'
document.documentElement.classList.add('v-overlay-scroll-blocked')
}
const unlockGlobalScroll = () => {
document.body.style.overflow = ''
document.documentElement.classList.remove('v-overlay-scroll-blocked')
}
return {
activeDialogs: readonly(activeDialogs),
registerDialog,
unregisterDialog,
lockGlobalScroll,
unlockGlobalScroll,
}
}

View File

@@ -18,7 +18,6 @@ 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'
@@ -163,9 +162,6 @@ const handleServiceWorkerMessage = (event: MessageEvent) => {
}
}
// 使用滚动锁定 composable自动监听showPluginQuickAccess的变化
useScrollLockWithWatch(showPluginQuickAccess)
// 检查是否可以使用下拉手势
const canUsePullGesture = () => {
// 检查是否在dashboard页面

View File

@@ -248,10 +248,16 @@ const showDynamicButton = computed(() => {
position: relative;
overflow: hidden;
backdrop-filter: blur(24px);
background-color: rgba(var(--v-theme-surface), 0.3);
background-color: rgba(var(--v-theme-surface), 0.6);
pointer-events: auto;
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
// 透明主题下的特殊样式
.v-theme--transparent & {
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
}
&.shift-left {
transform: translateX(0);
}

View File

@@ -141,7 +141,9 @@ function getPluginIcon(plugin: Plugin): string {
// 如果是网络图片则使用代理后返回
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 `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
plugin?.plugin_icon,
)}&cache=true`
return `./plugin_icon/${plugin?.plugin_icon}`
}
@@ -233,6 +235,12 @@ function handleTouchStart(event: TouchEvent) {
const target = event.target as HTMLElement
startedFromBottomArea.value = !!target.closest('.bottom-drag-area')
// 如果触摸发生在插件网格内,不处理拖拽关闭
if (target.closest('.plugin-grid')) {
startedFromBottomArea.value = false
return
}
startY.value = touch.clientY
lastY.value = touch.clientY
lastTime.value = Date.now()
@@ -253,6 +261,12 @@ function handleTouchMove(event: TouchEvent) {
// 只有从 bottom-drag-area 开始的触摸才处理上滑关闭
if (!startedFromBottomArea.value) return
// 检查当前触摸是否在插件网格内,如果是则不处理拖拽关闭
const target = event.target as HTMLElement
if (target.closest('.plugin-grid')) {
return
}
const currentY = touch.clientY
const currentTime = Date.now()
const deltaY = startY.value - currentY // 向上为正值
@@ -561,6 +575,7 @@ function handleBackdropClick(event: MouseEvent) {
flex: 1;
flex-direction: column;
gap: 16px;
max-block-size: calc(100vh - 200px); // 确保有最大高度限制
min-block-size: 0;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none; // IE/Edge
@@ -571,6 +586,7 @@ function handleBackdropClick(event: MouseEvent) {
// 隐藏滚动条
scrollbar-width: none; // Firefox
touch-action: pan-y;
will-change: scroll-position;
&::-webkit-scrollbar {
display: none; // WebKit 浏览器

View File

@@ -50,6 +50,9 @@ const sendButtonDisabled = ref(false)
// 消息对话框引用
const messageDialogRef = ref<any>(null)
// 消息视图引用
const messageViewRef = ref<any>(null)
// 滚动容器引用
const messageContentRef = ref<any>()
@@ -111,6 +114,16 @@ async function openMessageDialog() {
setTimeout(async () => {
await clearAppBadge()
}, 500)
// 延迟滚动到底部,确保弹窗完全打开
setTimeout(() => {
forceScrollToEnd()
}, 600)
// 等待对话框打开后恢复SSE连接
nextTick(() => {
if (messageViewRef.value && typeof messageViewRef.value.resumeSSE === 'function') {
messageViewRef.value.resumeSSE()
}
})
}
// 智能滚动到底部(只有用户在底部附近时才滚动)
@@ -118,6 +131,7 @@ function scrollMessageToEnd() {
// 使用更长的延迟确保DOM已更新
setTimeout(() => {
try {
// 查找消息弹窗的滚动容器
const cardText = document.querySelector('.v-dialog .v-card-text')
if (cardText) {
const { scrollTop, scrollHeight, clientHeight } = cardText
@@ -138,6 +152,7 @@ function scrollMessageToEnd() {
function forceScrollToEnd() {
setTimeout(() => {
try {
// 查找消息弹窗的滚动容器
const cardText = document.querySelector('.v-dialog .v-card-text')
if (cardText) {
cardText.scrollTop = cardText.scrollHeight
@@ -178,8 +193,15 @@ defineExpose({
openMessageDialog: openMessageDialogFromExternal,
})
// 监听消息对话框状态变化
watch(messageDialog, newValue => {
if (!newValue && messageViewRef.value && typeof messageViewRef.value.pauseSSE === 'function') {
// 对话框关闭时暂停SSE连接
messageViewRef.value.pauseSSE()
}
})
onMounted(() => {
forceScrollToEnd() // 初始化时强制滚动到底部
const shortcut = getQueryValue('shortcut')
if (shortcut) {
const found = shortcuts.find(item => item.dialog === shortcut)
@@ -243,7 +265,7 @@ onMounted(() => {
</VCard>
</VMenu>
<!-- 名称测试弹窗 -->
<DialogWrapper
<VDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
max-width="45rem"
@@ -263,9 +285,9 @@ onMounted(() => {
<NameTestView />
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 网络测试弹窗 -->
<DialogWrapper
<VDialog
v-if="netTestDialog"
v-model="netTestDialog"
max-width="35rem"
@@ -285,9 +307,9 @@ onMounted(() => {
<NetTestView />
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 实时日志弹窗 -->
<DialogWrapper
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
@@ -313,9 +335,9 @@ onMounted(() => {
<LoggingView logfile="moviepilot.log" />
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 过滤规则弹窗 -->
<DialogWrapper
<VDialog
v-if="ruleTestDialog"
v-model="ruleTestDialog"
max-width="35rem"
@@ -335,9 +357,9 @@ onMounted(() => {
<RuleTestView />
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 系统健康检查弹窗 -->
<DialogWrapper
<VDialog
v-if="systemTestDialog"
v-model="systemTestDialog"
max-width="35rem"
@@ -353,13 +375,13 @@ onMounted(() => {
<VDialogCloseBtn @click="systemTestDialog = false" />
</VCardItem>
<VDivider />
<VCardText>
<VCardText class="pa-0">
<ModuleTestView />
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 消息中心弹窗 -->
<DialogWrapper
<VDialog
v-if="messageDialog"
v-model="messageDialog"
max-width="50rem"
@@ -402,5 +424,5 @@ onMounted(() => {
</div>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -14,6 +14,7 @@ import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
import { saveLocalTheme } from '@/@core/utils/theme'
import type { ThemeSwitcherTheme } from '@layouts/types'
import { useConfirm } from '@/composables/useConfirm'
import { themeManager } from '@/utils/themeManager'
// 认证 Store
const authStore = useAuthStore()
@@ -45,6 +46,33 @@ const showLanguageMenu = ref(false)
// 自定义CSS
const customCSS = ref('')
// 透明度相关
const transparencyOpacity = ref(parseFloat(localStorage.getItem('transparency-opacity') || '0.3'))
const transparencyBlur = ref(parseFloat(localStorage.getItem('transparency-blur') || '10'))
const transparencyLevel = ref(localStorage.getItem('transparency-level') || 'medium')
const isTransparentTheme = computed(() => currentThemeName.value === 'transparent')
const showTransparencyDialog = ref(false)
// 预设值配置
const transparencyPresets = {
low: { opacity: 0.1, blur: 5 },
medium: { opacity: 0.3, blur: 10 },
high: { opacity: 0.6, blur: 15 },
}
// 判断当前值是否匹配预设值
const currentPresetLevel = computed(() => {
for (const [level, preset] of Object.entries(transparencyPresets)) {
if (
Math.abs(transparencyOpacity.value - preset.opacity) < 0.01 &&
Math.abs(transparencyBlur.value - preset.blur) < 0.1
) {
return level
}
}
return null
})
// 重启轮询控制标识
const restartPollingId = ref<number | null>(null)
const isRestarting = ref(false)
@@ -226,22 +254,35 @@ const themes: ThemeSwitcherTheme[] = [
const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
// 更新主题
function updateTheme() {
async function updateTheme() {
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
// 设置Vuetify主题
globalTheme.name.value = theme
// 统一处理主题切换 - 主题管理器会自动处理CSS加载和错误
await themeManager.setTheme(currentThemeName.value)
// 保存原始主题设置,而不是计算后的值
savedTheme.value = currentThemeName.value
// 保存主题到本地
saveLocalTheme(currentThemeName.value, globalTheme)
// 刷新页面
location.reload()
}
// 切换主题
function changeTheme(theme: string) {
async function changeTheme(theme: string) {
currentThemeName.value = theme
showThemeMenu.value = false
// 立即更新主题(不再刷新页面)
await updateTheme()
// 如果是透明主题,应用透明度设置
if (theme === 'transparent') {
applyTransparencySettings()
}
// 保存主题到服务端
try {
api.post('/user/config/Layout', {
@@ -285,15 +326,87 @@ async function saveCustomCSS() {
}
}
// 应用透明度设置
function applyTransparencySettings() {
const root = document.documentElement
// 设置CSS变量
root.style.setProperty('--transparent-opacity', transparencyOpacity.value.toString())
root.style.setProperty('--transparent-opacity-light', (transparencyOpacity.value * 0.67).toString())
root.style.setProperty('--transparent-opacity-heavy', (transparencyOpacity.value * 1.67).toString())
root.style.setProperty('--transparent-blur', `${transparencyBlur.value}px`)
root.style.setProperty('--transparent-blur-light', `${transparencyBlur.value * 0.6}px`)
root.style.setProperty('--transparent-blur-heavy', `${transparencyBlur.value * 1.6}px`)
// 保存到本地存储
localStorage.setItem('transparency-opacity', transparencyOpacity.value.toString())
localStorage.setItem('transparency-blur', transparencyBlur.value.toString())
}
// 调整透明度预设
function adjustTransparency(level: string) {
transparencyLevel.value = level
localStorage.setItem('transparency-level', level)
// 设置预设值
switch (level) {
case 'low':
transparencyOpacity.value = 0.1
transparencyBlur.value = 5
break
case 'medium':
transparencyOpacity.value = 0.3
transparencyBlur.value = 10
break
case 'high':
transparencyOpacity.value = 0.6
transparencyBlur.value = 15
break
}
applyTransparencySettings()
}
// 透明度变化处理
function onOpacityChange() {
applyTransparencySettings()
// 清除预设级别,因为用户手动调整了
transparencyLevel.value = ''
}
// 模糊度变化处理
function onBlurChange() {
applyTransparencySettings()
// 清除预设级别,因为用户手动调整了
transparencyLevel.value = ''
}
// 重置透明度设置
function resetTransparencySettings() {
transparencyOpacity.value = 0.3
transparencyBlur.value = 10
transparencyLevel.value = 'medium'
applyTransparencySettings()
}
// 监听主题变化
watch(
() => currentThemeName.value,
() => updateTheme(),
async () => {
await updateTheme()
// 如果切换到透明主题,应用透明度设置
if (currentThemeName.value === 'transparent') {
applyTransparencySettings()
}
},
)
// 监听系统主题变化
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', async () => {
await updateTheme()
})
} catch (e) {
console.error(t('theme.deviceNotSupport'))
}
@@ -338,6 +451,11 @@ const getThemeIcon = computed(() => {
onMounted(() => {
getCustomCSS()
// 初始化透明度设置
if (isTransparentTheme.value) {
applyTransparencySettings()
}
})
// 组件卸载时清理轮询
@@ -443,6 +561,20 @@ onUnmounted(() => {
</template>
<VListItemTitle>{{ t('theme.custom') }}</VListItemTitle>
</VListItem>
<!-- 透明度调整 - 仅在透明主题下显示 -->
<template v-if="isTransparentTheme">
<VDivider class="my-2" />
<VListItem @click="showTransparencyDialog = true">
<template #prepend>
<VIcon icon="mdi-opacity" />
</template>
<VListItemTitle>{{ t('theme.transparencyAdjust') }}</VListItemTitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
</template>
</VList>
</VMenu>
@@ -481,7 +613,7 @@ onUnmounted(() => {
</VMenu>
<!-- 👉 FAQ -->
<VListItem href="https://wiki.movie-pilot.org" target="_blank" class="mb-1 rounded-lg" hover>
<VListItem href="https://movie-pilot.org" target="_blank" class="mb-1 rounded-lg" hover>
<template #prepend>
<VIcon icon="mdi-help-circle-outline" />
</template>
@@ -518,7 +650,7 @@ onUnmounted(() => {
<!-- 用户认证对话框 -->
<UserAuthDialog v-if="siteAuthDialog" v-model="siteAuthDialog" @done="siteAuthDone" @close="siteAuthDialog = false" />
<!-- 自定义 CSS -->
<DialogWrapper v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
@@ -539,7 +671,99 @@ onUnmounted(() => {
</VBtn>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 透明度调整对话框 -->
<VDialog v-if="showTransparencyDialog" v-model="showTransparencyDialog" max-width="30rem">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-opacity" class="me-2" />
{{ t('theme.transparencyAdjust') }}
</VCardTitle>
<VDialogCloseBtn @click="showTransparencyDialog = false" />
</VCardItem>
<VDivider />
<VCardText>
<div class="space-y-6">
<!-- 透明度滑动条 -->
<div>
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2">{{ t('theme.transparencyOpacity') }}</span>
<span class="text-caption">{{ Math.round(transparencyOpacity * 100) }}%</span>
</div>
<VSlider
v-model="transparencyOpacity"
:min="0"
:max="1"
:step="0.01"
color="primary"
@update:model-value="onOpacityChange"
/>
</div>
<!-- 模糊度滑动条 -->
<div>
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2">{{ t('theme.transparencyBlur') }}</span>
<span class="text-caption">{{ transparencyBlur }}px</span>
</div>
<VSlider
v-model="transparencyBlur"
:min="0"
:max="30"
:step="1"
color="primary"
@update:model-value="onBlurChange"
/>
</div>
<!-- 预设按钮 -->
<div>
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>
<VBtnGroup density="compact" variant="outlined" class="w-full">
<VBtn
size="small"
:color="currentPresetLevel === 'low' ? 'primary' : undefined"
@click="adjustTransparency('low')"
class="flex-1"
>
{{ t('theme.transparencyLow') }}
</VBtn>
<VBtn
size="small"
:color="currentPresetLevel === 'medium' ? 'primary' : undefined"
@click="adjustTransparency('medium')"
class="flex-1"
>
{{ t('theme.transparencyMedium') }}
</VBtn>
<VBtn
size="small"
:color="currentPresetLevel === 'high' ? 'primary' : undefined"
@click="adjustTransparency('high')"
class="flex-1"
>
{{ t('theme.transparencyHigh') }}
</VBtn>
</VBtnGroup>
</div>
</div>
</VCardText>
<VDivider />
<VCardText class="text-center">
<VBtn @click="resetTransparencySettings" variant="outlined" class="me-2">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
{{ t('theme.transparencyReset') }}
</VBtn>
<VBtn @click="showTransparencyDialog = false" color="primary">
{{ t('common.confirm') }}
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>

View File

@@ -41,6 +41,7 @@ export default {
details: 'Details',
files: 'Files',
share: 'Share',
subscribe: 'Subscribe',
unsubscribe: 'Unsubscribe',
media: 'Media',
unknown: 'Unknown',
@@ -60,6 +61,8 @@ export default {
networkOffline: 'Network Offline',
serviceAvailable: 'Service Available',
serviceUnavailable: 'Service Unavailable',
status: 'Status',
preset: 'Preset',
},
mediaType: {
movie: 'Movie',
@@ -125,7 +128,15 @@ export default {
auto: 'Follow System',
transparent: 'Transparent',
purple: 'Purple',
custom: 'Custom Theme',
custom: 'Custom Style',
transparency: 'Transparency',
transparencyAdjust: 'Transparency Adjustment',
transparencyOpacity: 'Opacity',
transparencyBlur: 'Blur',
transparencyReset: 'Reset',
transparencyLow: 'Low Transparency',
transparencyMedium: 'Medium Transparency',
transparencyHigh: 'High Transparency',
customCssSaveSuccess: 'Custom CSS saved successfully, please refresh the page to take effect!',
customCssSaveFailed: 'Failed to save custom CSS to server',
deviceNotSupport: 'Current device does not support monitoring system theme changes',
@@ -144,9 +155,9 @@ 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',
offline: 'Application Offline',
offlineMessage: 'Network connection lost, some features may be limited',
online: 'Online Mode',
online: 'Application Online',
onlineMessage: 'Network connection restored',
},
pwa: {
@@ -521,12 +532,14 @@ export default {
waiting: 'Waiting',
},
info: {
trigger: 'Trigger',
timer: 'Timer',
status: 'Status',
actionCount: 'Action Count',
runCount: 'Run Count',
progress: 'Progress',
error: 'Error Message',
manualTrigger: 'Manual',
},
},
scanFile: {
@@ -673,7 +686,9 @@ export default {
searchShares: 'Search Workflow Shares',
noShareData: 'No shared workflows',
sharer: 'Sharer',
trigger: 'Trigger',
timer: 'Timer',
manualTrigger: 'Manual Trigger',
actionCount: 'Action Count',
normalFork: 'Fork Workflow',
cancelShare: 'Cancel Share',
@@ -740,6 +755,8 @@ export default {
searchResource: 'Search Resource',
subscribe: 'Subscribe',
playOnline: 'Play Online',
playInApp: 'Play in App',
playInWeb: 'Play in Web',
},
search: {
byTitle: 'Title',
@@ -768,6 +785,14 @@ export default {
title: 'Error!',
noMediaInfo: 'No media information recognized.',
},
server: {
plex: 'Plex',
jellyfin: 'Jellyfin',
emby: 'Emby',
appLaunchFailed: 'App launch failed, redirecting to web version',
appNotInstalled: 'App not detected, redirecting to web version',
downloadApp: 'Download App',
},
},
subscribe: {
normalSub: 'Subscribe',
@@ -797,6 +822,34 @@ export default {
'After reset, {name} will be restored to its initial state, downloaded records will be cleared, and unimported content will be downloaded again. Are you sure?',
resetSuccess: '{name} has been reset successfully!',
resetFailed: '{name} reset failed: {message}',
shareStatistics: 'Share Statistics',
shareCount: 'Shares',
totalReuseCount: 'Total Reuse Count',
ranking: 'Ranking',
noStatisticsData: 'No share statistics data available',
bestVersion: 'Version Upgrading',
completed: 'Completed',
subscribing: 'Subscribing',
notStarted: 'Not Started',
pending: 'Pending',
paused: 'Paused',
selectedCount: 'Selected {count}/{total} items',
noSelectedItems: 'Please select subscriptions to operate',
batchEnable: 'Batch Enable',
batchPause: 'Batch Pause',
batchDelete: 'Batch Delete',
batchEnableConfirm: 'Are you sure you want to enable {count} selected subscriptions?',
batchPauseConfirm: 'Are you sure you want to pause {count} selected subscriptions?',
batchDeleteConfirm: 'Are you sure you want to delete {count} selected subscriptions? This action cannot be undone!',
batchEnableSuccess: 'Successfully enabled {count} subscriptions',
batchPauseSuccess: 'Successfully paused {count} subscriptions',
batchDeleteSuccess: 'Successfully deleted {count} subscriptions',
batchEnableFailed: 'Failed to enable {count} subscriptions',
batchPauseFailed: 'Failed to pause {count} subscriptions',
batchDeleteFailed: 'Failed to delete {count} subscriptions',
batchEnableError: 'Batch enable operation failed',
batchPauseError: 'Batch pause operation failed',
batchDeleteError: 'Batch delete operation failed',
},
recommend: {
all: 'All',
@@ -971,6 +1024,7 @@ export default {
limitSeconds: 'Access Interval (seconds)',
useProxy: 'Use Proxy',
browserSimulation: 'Browser Simulation',
selectFile: 'Select File',
},
hints: {
url: 'Format: http://www.example.com/',
@@ -988,19 +1042,48 @@ export default {
limitSeconds: 'Minimum interval between each access',
useProxy: 'Use proxy server to access this site',
browserSimulation: 'Use browser simulation for authentic site access',
import: 'Batch import site data, supports JSON format files',
selectFile: 'Select JSON file',
dragDropFile: 'Drag and drop file here or click to select file',
supportedFormat: 'Supports JSON format site configuration files',
},
actions: {
add: 'Add Site',
edit: 'Edit Site',
import: 'Import',
export: 'Export',
startImport: 'Start Import',
},
messages: {
addSuccess: 'Site added successfully',
addFailed: 'Failed to add site',
updateSuccess: 'Updated successfully',
updateFailed: 'Update failed',
exportSuccess: 'Sites exported successfully',
exportFailed: 'Failed to export sites',
importSuccess: 'Successfully imported {count} sites',
importFailed: 'Failed to import sites',
importPartialFailed: 'Import completed, {success} successful, {failed} failed',
importAllFailed: 'Import failed, all {count} sites failed to import',
noDataToImport: 'No data to import',
noValidData: 'No valid data',
someInvalidData: 'Some data is invalid, valid data: {valid}/{total}',
invalidFileType: 'Unsupported file type, please select a JSON file',
invalidFileFormat: 'Invalid file format, please check file content',
parseFileError: 'Failed to parse file, please check file format',
previewData: 'Preview data ({count} sites)',
importing: 'Importing... ({progress}%)',
importErrors: 'Import encountered {count} errors',
},
errors: {
loadDownloader: 'Failed to load downloader settings',
title: 'Import Error Details',
failed: 'Import Failed',
details: 'Error Details',
},
results: {
successTitle: 'Successfully Imported Sites',
success: 'Import Success',
},
testConnectivity: 'Test Connectivity',
testing: 'Testing ...',
@@ -1017,6 +1100,28 @@ export default {
deleteSite: 'Delete Site',
updateCookie: 'Update Cookie',
viewUserData: 'View User Data',
statistics: 'Statistics',
totalSites: 'Total Sites',
normalSites: 'Normal Sites',
slowSites: 'Slow Sites',
failedSites: 'Failed Sites',
averageTime: 'Average Time',
successRate: 'Success Rate',
successCount: 'Success Count',
failCount: 'Fail Count',
lastAccess: 'Last Access',
timeRecords: 'Time Records',
recentTimeRecords: 'Recent Time Records',
accessTime: 'Access Time',
responseTime: 'Response Time',
noTimeRecords: 'No Time Records',
preview: {
title: 'Preview Sites',
showing: 'Showing {count}/{total}',
unnamed: 'Unnamed Site',
noUrl: 'No Site URL',
invalid: 'Invalid Data',
},
},
message: {
loadMore: 'Load More',
@@ -1028,11 +1133,17 @@ export default {
program: 'Program',
content: 'Content',
refreshing: 'Refreshing',
initializing: 'Initializing',
},
moduleTest: {
normal: 'Normal',
disabled: 'Disabled',
error: 'Error',
checking: 'Checking...',
complete: 'Check Complete',
preparing: 'Preparing...',
totalModules: 'Total Modules',
recheck: 'Recheck',
},
nameTest: {
recognize: 'Recognize',
@@ -1067,6 +1178,7 @@ export default {
dataDir: 'Data Directory',
timezone: 'Timezone',
latest: 'Latest',
supportingSites: 'Supporting Sites',
support: 'Support',
documentation: 'Documentation',
feedback: 'Feedback',
@@ -1077,6 +1189,8 @@ export default {
viewChangelog: 'View Changelog',
changelog: 'Changelog',
dataDirectory: '/moviepilot',
expand: 'Expand',
collapse: 'Collapse',
},
system: {
custom: 'Custom',
@@ -1148,7 +1262,7 @@ export default {
workflowStatisticShareHint: 'Share workflow statistics to popular workflows for other MP users to reference',
bigMemoryMode: 'Large Memory Mode',
bigMemoryModeHint: 'Use more memory to cache data and improve system performance',
dbWalEnable: 'WAL Mode',
dbWalEnable: 'Sqlite WAL Mode',
dbWalEnableHint:
'Can improve read/write concurrency performance, but may increase the risk of data loss in exceptional cases, requires restart to take effect',
tmdbApiDomain: 'TMDB API Service Address',
@@ -1309,7 +1423,12 @@ export default {
syncBlacklistHint: 'CookieCloud sync domain blacklist, multiple domains separated by commas',
userAgent: 'Browser User-Agent',
userAgentHint: 'User-Agent of the browser with CookieCloud plugin',
browserEmulation: 'Browser Emulation',
browserEmulationHint: 'Choose how to emulate browser when accessing sites (Playwright or FlareSolverr)',
flaresolverrUrl: 'FlareSolverr URL',
flaresolverrUrlHint: 'Required when using FlareSolverr, e.g. http://127.0.0.1:8191',
siteDataRefresh: 'Site Data Refresh',
siteOptions: 'Site Options',
siteDataRefreshInterval: 'Site Data Refresh Interval',
siteDataRefreshIntervalHint: 'Time interval for refreshing site user upload/download data',
readSiteMessage: 'Read Site Messages',
@@ -1551,8 +1670,9 @@ export default {
bestVersionRuleGroupHint: 'Filter version upgrade subscriptions based on selected filter rule groups',
timedSearch: 'Subscription Scheduled Search',
timedSearchHint: 'Search all sites every 24 hours to supplement resources that may be missed by subscription',
checkLocalMedia: 'Check Local Media Library',
checkLocalMediaHint: 'Check if resources exist on storage disk to avoid duplicate downloads',
checkLocalMedia: 'Check File System Resources',
checkLocalMediaHint:
'Scan the storage directory for existing resource files to avoid duplicate downloads; regardless of whether it is enabled, the media server will be checked',
modes: {
auto: 'Auto',
rss: 'Site RSS',
@@ -1717,6 +1837,7 @@ export default {
collectionSearch: 'Related series works',
actorSearch: 'Related actors, directors, etc.',
historySearch: 'Related history records',
subscribeShareSearch: 'Related subscription shares',
siteResources: 'Site Resources',
searchInSites: 'Search for torrent resources in sites',
relatedResources: 'Related Resources',
@@ -1771,7 +1892,8 @@ export default {
shareSuccess: '{name} shared successfully!',
shareFailed: '{name} share failed: {message}!',
securityWarning: 'Security Warning',
securityWarningMessage: 'Before sharing, please ensure the workflow does not contain sensitive information such as PassKey in RSS links to avoid information leakage.',
securityWarningMessage:
'Before sharing, please ensure the workflow does not contain sensitive information such as PassKey in RSS links to avoid information leakage.',
},
u115Auth: {
loginTitle: '115 Cloud Login',
@@ -1834,10 +1956,19 @@ export default {
desc: 'Description',
descPlaceholder: 'Workflow description',
enabled: 'Enabled',
triggerType: 'Trigger Type',
triggerTypeTimer: 'Timer Trigger',
triggerTypeEvent: 'Event Trigger',
triggerTypeManual: 'Manual Trigger',
schedule: 'Schedule',
cronExpr: 'Cron Expression',
cronExprDesc: 'Cron expression for workflow scheduling',
eventType: 'Event Type',
eventTypePlaceholder: 'Please select event type',
nameRequired: 'Please fill in complete information!',
triggerRequired: 'Please select trigger type!',
timerRequired: 'Please fill in timer expression!',
eventTypeRequired: 'Please select event type!',
addSuccess: 'Task created successfully, please edit the workflow!',
addFailed: 'Failed to create task: {message}',
editSuccess: 'Task modified successfully!',

View File

@@ -41,6 +41,7 @@ export default {
details: '详情',
files: '文件',
share: '分享',
subscribe: '订阅',
unsubscribe: '取消订阅',
media: '媒体',
unknown: '未知',
@@ -60,6 +61,8 @@ export default {
networkOffline: '网络离线',
serviceAvailable: '服务可用',
serviceUnavailable: '服务不可用',
status: '状态',
preset: '预设',
},
mediaType: {
movie: '电影',
@@ -125,7 +128,15 @@ export default {
auto: '跟随系统',
transparent: '透明',
purple: '幻紫',
custom: '自定义主题',
custom: '附加样式',
transparency: '透明度',
transparencyAdjust: '透明度调整',
transparencyOpacity: '透明度',
transparencyBlur: '模糊度',
transparencyReset: '重置',
transparencyLow: '低透明度',
transparencyMedium: '中等透明度',
transparencyHigh: '高透明度',
customCssSaveSuccess: '自定义CSS保存成功请刷新页面生效',
customCssSaveFailed: '保存自定义CSS到服务端失败',
deviceNotSupport: '当前设备不支持监听系统主题变化',
@@ -144,9 +155,9 @@ export default {
restartTip: '重启后,您将被注销并需要重新登录。',
restartTimeout: '重启超时,系统可能需要更长时间恢复,请稍后手动刷新页面',
restartFailed: '重启失败,请检查系统状态',
offline: '离线模式',
offline: '应用已离线',
offlineMessage: '网络连接已断开,部分功能可能受限',
online: '在线模式',
online: '应用在线',
onlineMessage: '网络连接已恢复',
},
pwa: {
@@ -518,12 +529,14 @@ export default {
waiting: '等待',
},
info: {
trigger: '触发方式',
timer: '定时',
status: '状态',
actionCount: '动作数',
runCount: '已执行次数',
progress: '进度',
error: '错误信息',
manualTrigger: '手动',
},
},
scanFile: {
@@ -670,7 +683,9 @@ export default {
searchShares: '搜索工作流分享',
noShareData: '暂无分享的工作流',
sharer: '分享人',
trigger: '触发方式',
timer: '定时器',
manualTrigger: '手动触发',
actionCount: '动作数量',
normalFork: '复用工作流',
cancelShare: '取消分享',
@@ -737,6 +752,8 @@ export default {
searchResource: '搜索资源',
subscribe: '订阅',
playOnline: '在线播放',
playInApp: 'APP播放',
playInWeb: '网页播放',
},
search: {
byTitle: '标题',
@@ -765,6 +782,14 @@ export default {
title: '出错啦!',
noMediaInfo: '未识别到媒体信息。',
},
server: {
plex: 'Plex',
jellyfin: 'Jellyfin',
emby: 'Emby',
appLaunchFailed: 'APP启动失败正在跳转到网页版',
appNotInstalled: '未检测到APP正在跳转到网页版',
downloadApp: '下载APP',
},
},
subscribe: {
normalSub: '订阅',
@@ -793,6 +818,34 @@ export default {
resetConfirm: '重置后 {name} 将恢复初始状态,已下载记录将被清除,未入库的内容将会重新下载,是否确认?',
resetSuccess: '{name} 重置成功!',
resetFailed: '{name} 重置失败:{message}',
shareStatistics: '分享统计',
shareCount: '个分享',
totalReuseCount: '次复用',
ranking: '排名',
noStatisticsData: '暂无分享统计数据',
bestVersion: '洗版中',
completed: '订阅完成',
subscribing: '订阅中',
notStarted: '未开始',
pending: '待定',
paused: '暂停',
selectedCount: '已选择 {count}/{total} 项',
noSelectedItems: '请先选择要操作的订阅',
batchEnable: '批量启用',
batchPause: '批量暂停',
batchDelete: '批量删除',
batchEnableConfirm: '确定要启用选中的 {count} 个订阅吗?',
batchPauseConfirm: '确定要暂停选中的 {count} 个订阅吗?',
batchDeleteConfirm: '确定要删除选中的 {count} 个订阅吗?此操作不可恢复!',
batchEnableSuccess: '成功启用 {count} 个订阅',
batchPauseSuccess: '成功暂停 {count} 个订阅',
batchDeleteSuccess: '成功删除 {count} 个订阅',
batchEnableFailed: '启用失败 {count} 个订阅',
batchPauseFailed: '暂停失败 {count} 个订阅',
batchDeleteFailed: '删除失败 {count} 个订阅',
batchEnableError: '批量启用操作失败',
batchPauseError: '批量暂停操作失败',
batchDeleteError: '批量删除操作失败',
},
recommend: {
all: '全部',
@@ -967,6 +1020,7 @@ export default {
limitSeconds: '访问间隔(秒)',
useProxy: '使用代理访问',
browserSimulation: '浏览器仿真',
selectFile: '选择文件',
},
hints: {
url: '格式http://www.example.com/',
@@ -984,19 +1038,48 @@ export default {
limitSeconds: '每次访问需要间隔的最小时间',
useProxy: '使用代理服务器访问该站点',
browserSimulation: '使用浏览器模拟真实访问该站点',
import: '批量导入站点数据支持JSON格式文件',
selectFile: '选择JSON文件',
dragDropFile: '拖拽文件到此处或点击选择文件',
supportedFormat: '支持JSON格式的站点配置文件',
},
actions: {
add: '新增站点',
edit: '编辑站点',
import: '导入',
export: '导出',
startImport: '开始导入',
},
messages: {
addSuccess: '新增站点成功',
addFailed: '新增站点失败',
updateSuccess: '更新成功',
updateFailed: '更新失败',
exportSuccess: '站点导出成功',
exportFailed: '站点导出失败',
importSuccess: '成功导入 {count} 个站点',
importFailed: '站点导入失败',
importPartialFailed: '导入完成,成功 {success} 个,失败 {failed} 个',
importAllFailed: '导入失败,{count} 个站点全部导入失败',
noDataToImport: '没有数据可导入',
noValidData: '没有有效的数据',
someInvalidData: '部分数据无效,有效数据 {valid}/{total} 个',
invalidFileType: '不支持的文件类型请选择JSON文件',
invalidFileFormat: '文件格式无效,请检查文件内容',
parseFileError: '文件解析失败,请检查文件格式',
previewData: '预览数据 ({count} 个站点)',
importing: '正在导入... ({progress}%)',
importErrors: '导入过程中出现 {count} 个错误',
},
errors: {
loadDownloader: '加载下载器设置失败',
title: '导入错误详情',
failed: '导入失败',
details: '错误详情',
},
results: {
successTitle: '成功导入的站点',
success: '导入成功',
},
testConnectivity: '测试连通性',
testing: '测试中 ...',
@@ -1013,6 +1096,28 @@ export default {
deleteSite: '删除站点',
updateCookie: '更新Cookie',
viewUserData: '查看用户数据',
statistics: '统计信息',
totalSites: '总站点数',
normalSites: '正常站点',
slowSites: '缓慢站点',
failedSites: '失败站点',
averageTime: '平均耗时',
successRate: '成功率',
successCount: '成功次数',
failCount: '失败次数',
lastAccess: '最后访问',
timeRecords: '耗时记录',
recentTimeRecords: '最近耗时记录',
accessTime: '访问时间',
responseTime: '响应时间',
noTimeRecords: '暂无耗时记录',
preview: {
title: '预览站点',
showing: '显示 {count}/{total}',
unnamed: '未命名站点',
noUrl: '无站点地址',
invalid: '数据无效',
},
},
message: {
loadMore: '加载更多',
@@ -1024,11 +1129,17 @@ export default {
program: '程序',
content: '内容',
refreshing: '正在刷新',
initializing: '正在初始化',
},
moduleTest: {
normal: '正常',
disabled: '未启用',
error: '错误',
checking: '正在检查...',
complete: '检查完成',
preparing: '准备检查...',
totalModules: '总模块数',
recheck: '重新检查',
},
nameTest: {
recognize: '识别',
@@ -1063,6 +1174,7 @@ export default {
dataDir: '数据目录',
timezone: '时区',
latest: '最新',
supportingSites: '支持站点',
support: '支援',
documentation: '文档',
feedback: '问题反馈',
@@ -1073,6 +1185,8 @@ export default {
viewChangelog: '查看变更日志',
changelog: '变更日志',
dataDirectory: '/moviepilot',
expand: '展开',
collapse: '收起',
},
system: {
custom: '自定义',
@@ -1143,7 +1257,7 @@ export default {
workflowStatisticShareHint: '分享工作流统计数据到热门工作流供其他MPer参考',
bigMemoryMode: '大内存模式',
bigMemoryModeHint: '使用更大的内存缓存数据,提升系统性能',
dbWalEnable: 'WAL模式',
dbWalEnable: '数据库WAL模式',
dbWalEnableHint: '可提升读写并发性能,但可能在异常情况下增加数据丢失风险,更改后需重启生效',
tmdbApiDomain: 'TMDB API服务地址',
tmdbApiDomainPlaceholder: 'api.themoviedb.org',
@@ -1297,6 +1411,11 @@ export default {
userAgent: '浏览器User-Agent',
userAgentHint: 'CookieCloud插件所在的浏览器的User-Agent',
siteDataRefresh: '站点数据刷新',
siteOptions: '站点选项',
browserEmulation: '浏览器仿真',
browserEmulationHint: '站点访问仿真方式,支持 Playwright 或 FlareSolverr',
flaresolverrUrl: 'FlareSolverr 服务地址',
flaresolverrUrlHint: '当仿真方式为 FlareSolverr 时生效例如http://127.0.0.1:8191',
siteDataRefreshInterval: '站点数据刷新间隔',
siteDataRefreshIntervalHint: '刷新站点用户上传下载等数据的时间间隔',
readSiteMessage: '阅读站点消息',
@@ -1530,8 +1649,8 @@ export default {
bestVersionRuleGroupHint: '按选定的过滤规则组对洗版订阅进行过滤',
timedSearch: '订阅定时搜索',
timedSearchHint: '每隔24小时全站搜索以补全订阅可能漏掉的资源',
checkLocalMedia: '检查本地媒体库资源',
checkLocalMediaHint: '检查存储盘是否存在资源,以避免重复下载',
checkLocalMedia: '检查文件系统资源',
checkLocalMediaHint: '扫描存储目录中是否存在相应资源文件,以避免重复下载;不管是否开启都会检查媒体服务器',
modes: {
auto: '自动',
rss: '站点RSS',
@@ -1696,6 +1815,7 @@ export default {
collectionSearch: '相关的系列作品',
actorSearch: '相关的演员、导演等',
historySearch: '相关的历史记录',
subscribeShareSearch: '相关的订阅分享',
siteResources: '站点资源',
searchInSites: '在站点中搜索种子资源',
relatedResources: '相关资源',
@@ -1810,10 +1930,19 @@ export default {
desc: '描述',
descPlaceholder: '工作流描述',
enabled: '启用',
triggerType: '触发类型',
triggerTypeTimer: '定时触发',
triggerTypeEvent: '事件触发',
triggerTypeManual: '手动触发',
schedule: '定时执行',
cronExpr: 'Cron表达式',
cronExprDesc: '工作流定时执行的cron表达式',
eventType: '事件类型',
eventTypePlaceholder: '请选择事件类型',
nameRequired: '请填写完整信息!',
triggerRequired: '请选择触发类型!',
timerRequired: '请填写定时表达式!',
eventTypeRequired: '请选择事件类型!',
addSuccess: '创建任务成功,请编辑流程!',
addFailed: '创建任务失败:{message}',
editSuccess: '修改任务成功!',

View File

@@ -41,6 +41,7 @@ export default {
details: '詳情',
files: '文件',
share: '分享',
subscribe: '訂閱',
unsubscribe: '取消訂閱',
media: '媒體',
unknown: '未知',
@@ -60,6 +61,8 @@ export default {
networkOffline: '網絡離線',
serviceAvailable: '服務可用',
serviceUnavailable: '服務不可用',
status: '狀態',
preset: '預設',
},
mediaType: {
movie: '電影',
@@ -125,7 +128,15 @@ export default {
auto: '跟隨系統',
transparent: '透明',
purple: '幻紫',
custom: '自定義主題',
custom: '附加樣式',
transparency: '透明度',
transparencyAdjust: '透明度調整',
transparencyOpacity: '透明度',
transparencyBlur: '模糊度',
transparencyReset: '重置',
transparencyLow: '低透明度',
transparencyMedium: '中等透明度',
transparencyHigh: '高透明度',
customCssSaveSuccess: '自定義CSS保存成功請刷新頁面生效',
customCssSaveFailed: '保存自定義CSS到服務端失敗',
deviceNotSupport: '當前設備不支持監聽系統主題變化',
@@ -145,9 +156,9 @@ export default {
restartTip: '重啟後,您將被註銷並需要重新登錄。',
restartTimeout: '重啟超時,系統可能需要更長時間恢復,請稍後手動刷新頁面',
restartFailed: '重啟失敗,請檢查系統狀態',
offline: '離線模式',
offline: '應用已離線',
offlineMessage: '網絡連接已斷開,部分功能可能受限',
online: '在線模式',
online: '應用在線',
onlineMessage: '網絡連接已恢復',
},
pwa: {
@@ -516,12 +527,14 @@ export default {
waiting: '等待中',
},
info: {
trigger: '觸發方式',
timer: '定時器',
status: '狀態',
actionCount: '動作數量',
runCount: '執行次數',
progress: '進度',
error: '錯誤訊息',
manualTrigger: '手動',
},
},
scanFile: {
@@ -668,7 +681,9 @@ export default {
searchShares: '搜索工作流分享',
noShareData: '暫無分享的工作流',
sharer: '分享人',
trigger: '觸發方式',
timer: '定時器',
manualTrigger: '手動觸發',
actionCount: '動作數量',
normalFork: '復用工作流',
cancelShare: '取消分享',
@@ -735,6 +750,8 @@ export default {
searchResource: '搜索資源',
subscribe: '訂閱',
playOnline: '線上播放',
playInApp: 'APP播放',
playInWeb: '網頁播放',
},
search: {
byTitle: '標題',
@@ -763,6 +780,14 @@ export default {
title: '出錯啦!',
noMediaInfo: '未識別到媒體信息。',
},
server: {
plex: 'Plex',
jellyfin: 'Jellyfin',
emby: 'Emby',
appLaunchFailed: 'APP啟動失敗正在跳轉到網頁版',
appNotInstalled: '未檢測到APP正在跳轉到網頁版',
downloadApp: '下載APP',
},
},
subscribe: {
normalSub: '訂閱',
@@ -791,6 +816,34 @@ export default {
resetConfirm: '重置後 {name} 將恢復初始狀態,已下載記錄將被清除,未入庫的內容將會重新下載,是否確認?',
resetSuccess: '{name} 重置成功!',
resetFailed: '{name} 重置失敗:{message}',
shareStatistics: '分享統計',
shareCount: '個分享',
totalReuseCount: '次複用',
ranking: '排名',
noStatisticsData: '暫無分享統計數據',
bestVersion: '洗版中',
completed: '訂閱完成',
subscribing: '訂閱中',
notStarted: '未開始',
pending: '待定',
paused: '暫停',
selectedCount: '已選擇 {count}/{total} 項',
noSelectedItems: '請先選擇要操作的訂閱',
batchEnable: '批量啟用',
batchPause: '批量暫停',
batchDelete: '批量刪除',
batchEnableConfirm: '確定要啟用選中的 {count} 個訂閱嗎?',
batchPauseConfirm: '確定要暫停選中的 {count} 個訂閱嗎?',
batchDeleteConfirm: '確定要刪除選中的 {count} 個訂閱嗎?此操作不可恢復!',
batchEnableSuccess: '成功啟用 {count} 個訂閱',
batchPauseSuccess: '成功暫停 {count} 個訂閱',
batchDeleteSuccess: '成功刪除 {count} 個訂閱',
batchEnableFailed: '啟用失敗 {count} 個訂閱',
batchPauseFailed: '暫停失敗 {count} 個訂閱',
batchDeleteFailed: '刪除失敗 {count} 個訂閱',
batchEnableError: '批量啟用操作失敗',
batchPauseError: '批量暫停操作失敗',
batchDeleteError: '批量刪除操作失敗',
},
recommend: {
all: '全部',
@@ -966,6 +1019,7 @@ export default {
limitSeconds: '訪問間隔(秒)',
useProxy: '使用代理訪問',
browserSimulation: '瀏覽器仿真',
selectFile: '選擇文件',
},
hints: {
url: '格式http://www.example.com/',
@@ -983,19 +1037,48 @@ export default {
limitSeconds: '每次訪問需要間隔的最小時間',
useProxy: '使用代理服務器訪問該站點',
browserSimulation: '使用瀏覽器模擬真實訪問該站點',
import: '批量導入站點數據支持JSON格式文件',
selectFile: '選擇JSON文件',
dragDropFile: '拖拽文件到此處或點擊選擇文件',
supportedFormat: '支持JSON格式的站點配置文件',
},
actions: {
add: '新增站點',
edit: '編輯站點',
import: '導入',
export: '導出',
startImport: '開始導入',
},
messages: {
addSuccess: '新增站點成功',
addFailed: '新增站點失敗',
updateSuccess: '更新成功',
updateFailed: '更新失敗',
exportSuccess: '站點導出成功',
exportFailed: '站點導出失敗',
importSuccess: '成功導入 {count} 個站點',
importFailed: '站點導入失敗',
importPartialFailed: '導入完成,成功 {success} 個,失敗 {failed} 個',
importAllFailed: '導入失敗,{count} 個站點全部導入失敗',
noDataToImport: '沒有數據可導入',
noValidData: '沒有有效的數據',
someInvalidData: '部分數據無效,有效數據 {valid}/{total} 個',
invalidFileType: '不支持的文件類型請選擇JSON文件',
invalidFileFormat: '文件格式無效,請檢查文件內容',
parseFileError: '文件解析失敗,請檢查文件格式',
previewData: '預覽數據 ({count} 個站點)',
importing: '正在導入... ({progress}%)',
importErrors: '導入過程中出現 {count} 個錯誤',
},
errors: {
loadDownloader: '加載下載器設置失敗',
title: '導入錯誤詳情',
failed: '導入失敗',
details: '錯誤詳情',
},
results: {
successTitle: '成功導入的站點',
success: '導入成功',
},
testConnectivity: '測試連通性',
testing: '測試中 ...',
@@ -1012,6 +1095,28 @@ export default {
deleteSite: '刪除站點',
updateCookie: '更新Cookie',
viewUserData: '查看用戶數據',
statistics: '統計信息',
totalSites: '總站點數',
normalSites: '正常站點',
slowSites: '緩慢站點',
failedSites: '失敗站點',
averageTime: '平均耗時',
successRate: '成功率',
successCount: '成功次數',
failCount: '失敗次數',
lastAccess: '最後訪問',
timeRecords: '耗時記錄',
recentTimeRecords: '最近耗時記錄',
accessTime: '訪問時間',
responseTime: '響應時間',
noTimeRecords: '暫無耗時記錄',
preview: {
title: '預覽站點',
showing: '顯示 {count}/{total}',
unnamed: '未命名站點',
noUrl: '無站點地址',
invalid: '數據無效',
},
},
message: {
loadMore: '加載更多',
@@ -1023,11 +1128,17 @@ export default {
program: '程序',
content: '內容',
refreshing: '正在刷新',
initializing: '正在初始化',
},
moduleTest: {
normal: '正常',
disabled: '未啟用',
error: '錯誤',
checking: '正在檢查...',
complete: '檢查完成',
preparing: '準備檢查...',
totalModules: '總模組數',
recheck: '重新檢查',
},
nameTest: {
recognize: '識別',
@@ -1063,6 +1174,7 @@ export default {
timezone: '時區',
latest: '最新',
support: '支援',
supportingSites: '支持站點',
documentation: '文檔',
feedback: '問題反饋',
channel: '發布頻道',
@@ -1072,6 +1184,8 @@ export default {
viewChangelog: '查看變更日誌',
changelog: '變更日誌',
dataDirectory: '/moviepilot',
expand: '展開',
collapse: '收起',
},
system: {
custom: '自定義',
@@ -1142,7 +1256,7 @@ export default {
workflowStatisticShareHint: '分享工作流統計數據到熱門工作流供其他MPer參考',
bigMemoryMode: '大內存模式',
bigMemoryModeHint: '使用更大的內存緩存數據,提升系統性能',
dbWalEnable: 'WAL模式',
dbWalEnable: '數據庫WAL模式',
dbWalEnableHint: '可提升讀寫併發性能,但可能在異常情況下增加數據丟失風險,更改後需重啟生效',
tmdbApiDomain: 'TMDB API服務地址',
tmdbApiDomainPlaceholder: 'api.themoviedb.org',
@@ -1296,6 +1410,11 @@ export default {
userAgent: '瀏覽器User-Agent',
userAgentHint: 'CookieCloud插件所在的瀏覽器的User-Agent',
siteDataRefresh: '站點數據刷新',
siteOptions: '站點選項',
browserEmulation: '瀏覽器仿真',
browserEmulationHint: '站點訪問仿真方式,支援 Playwright 或 FlareSolverr',
flaresolverrUrl: 'FlareSolverr 服務地址',
flaresolverrUrlHint: '當仿真方式為 FlareSolverr 時生效例如http://127.0.0.1:8191',
siteDataRefreshInterval: '站點數據刷新間隔',
siteDataRefreshIntervalHint: '刷新站點用戶上傳下載等數據的時間間隔',
readSiteMessage: '閱讀站點消息',
@@ -1528,8 +1647,8 @@ export default {
bestVersionRuleGroupHint: '按選定的過濾規則組對洗版訂閱進行過濾',
timedSearch: '訂閱定時搜索',
timedSearchHint: '每隔24小時全站搜索以補全訂閱可能漏掉的資源',
checkLocalMedia: '檢查本地媒體庫資源',
checkLocalMediaHint: '檢查存儲盤是否存在資源,以避免重複下載',
checkLocalMedia: '檢查文件系統資源',
checkLocalMediaHint: '掃描存儲目錄中是否存在相應資源文件,以避免重複下載;不管是否開啟都會檢查媒體伺服器',
modes: {
auto: '自動',
rss: '站點RSS',
@@ -1695,6 +1814,7 @@ export default {
collectionSearch: '相關的系列作品',
actorSearch: '相關的演員、導演等',
historySearch: '相關的歷史記錄',
subscribeShareSearch: '相關的訂閱分享',
siteResources: '站點資源',
searchInSites: '在站點中搜索種子資源',
relatedResources: '相關資源',
@@ -1809,10 +1929,19 @@ export default {
desc: '描述',
descPlaceholder: '工作流描述',
enabled: '啟用',
triggerType: '觸發類型',
triggerTypeTimer: '定時觸發',
triggerTypeEvent: '事件觸發',
triggerTypeManual: '手動觸發',
schedule: '定時執行',
cronExpr: 'Cron表達式',
cronExprDesc: '工作流定時執行的cron表達式',
eventType: '事件類型',
eventTypePlaceholder: '請選擇事件類型',
nameRequired: '請填寫完整資訊!',
triggerRequired: '請選擇觸發類型!',
timerRequired: '請填寫定時表達式!',
eventTypeRequired: '請選擇事件類型!',
addSuccess: '建立任務成功,請編輯流程!',
addFailed: '建立任務失敗:{message}',
editSuccess: '修改任務成功!',

View File

@@ -10,6 +10,7 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { VCardActions } from 'vuetify/components'
import { usePWA } from '@/composables/usePWA'
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
// 国际化
const { t } = useI18n()
@@ -161,6 +162,18 @@ const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
// 弹窗
const dialog = ref(false)
// 为每个项目生成随机颜色
const itemColors = ref<{ [key: string]: string }>({})
// 初始化颜色
function initializeColors() {
initializeItemColors(dashboardConfigs.value, item => buildPluginDashboardId(item.id, item.key))
dashboardConfigs.value.forEach(item => {
const itemId = buildPluginDashboardId(item.id, item.key)
itemColors.value[itemId] = getItemColor(itemId)
})
}
// 使用动态按钮钩子
useDynamicButton({
icon: 'mdi-view-dashboard-edit',
@@ -286,6 +299,11 @@ async function getPluginDashboard(id: string, key: string) {
dashboardConfigs.value[index] = res
} else {
dashboardConfigs.value.push(res)
// 为新增的插件仪表板生成颜色
const pluginDashboardId = buildPluginDashboardId(id, key)
if (!itemColors.value[pluginDashboardId]) {
itemColors.value[pluginDashboardId] = getItemColor(pluginDashboardId)
}
// 排序
sortDashboardConfigs()
}
@@ -322,6 +340,7 @@ function dragOrderEnd() {
onBeforeMount(async () => {
await loadDashboardConfig()
initializeColors()
getPluginDashboardMeta()
})
@@ -370,7 +389,7 @@ onDeactivated(() => {
</Teleport>
<!-- 弹窗根据配置生成选项 -->
<DialogWrapper v-if="dialog" v-model="dialog" max-width="35rem" :fullscreen="!display.mdAndUp.value" scrollable>
<VDialog v-if="dialog" v-model="dialog" max-width="35rem" :fullscreen="!display.mdAndUp.value" scrollable>
<VCard>
<VCardItem>
<VCardTitle>
@@ -390,6 +409,7 @@ onDeactivated(() => {
:class="{
'enabled': enableConfig[buildPluginDashboardId(item.id, item.key)],
}"
:style="{ '--item-color': itemColors[buildPluginDashboardId(item.id, item.key)] }"
@click="
enableConfig[buildPluginDashboardId(item.id, item.key)] =
!enableConfig[buildPluginDashboardId(item.id, item.key)]
@@ -423,7 +443,7 @@ onDeactivated(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style lang="scss" scoped>
.settings-card-header {
@@ -444,8 +464,11 @@ onDeactivated(() => {
}
.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;
}
@@ -462,7 +485,7 @@ onDeactivated(() => {
&::before {
position: absolute;
background-color: transparent;
background-color: var(--item-color, #4caf50);
block-size: 100%;
content: '';
inline-size: 4px;
@@ -472,16 +495,15 @@ onDeactivated(() => {
}
&:hover {
border-color: rgba(var(--v-theme-on-surface), 0.15);
background-color: rgba(var(--v-theme-surface-variant), 0.6);
transform: translateY(-2px);
}
&.enabled {
border-color: rgba(var(--v-theme-primary), 0.5);
background-color: rgba(var(--v-theme-primary), 0.05);
border-color: rgba(var(--v-theme-primary), 0.3);
background-color: rgba(var(--v-theme-primary), 0.1);
.setting-label {
color: rgb(var(--v-theme-primary));
color: rgba(var(--v-theme-primary), 0.9);
font-weight: 500;
}
}
@@ -490,9 +512,16 @@ onDeactivated(() => {
.setting-item-inner {
display: flex;
align-items: center;
gap: 8px;
}
.setting-check {
margin-inline-end: 8px;
flex-shrink: 0;
}
@media (width <= 600px) {
.settings-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -10,6 +10,7 @@ import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
const display = useDisplay()
@@ -44,6 +45,17 @@ const extraDiscoverSources = ref<DiscoverSource[]>([])
// 排序对话框
const orderConfigDialog = ref(false)
// 为每个项目生成随机颜色
const itemColors = ref<{ [key: string]: string }>({})
// 初始化颜色
function initializeColors() {
initializeItemColors(discoverTabs.value, item => item.mediaid_prefix)
discoverTabs.value.forEach(item => {
itemColors.value[item.mediaid_prefix] = getItemColor(item.mediaid_prefix)
})
}
// 初始化发现标签
function initDiscoverTabs() {
const tabs = getDiscoverTabs()
@@ -70,6 +82,10 @@ async function loadExtraDiscoverSources() {
continue
}
discoverTabs.value.push(source)
// 为新增的数据源生成颜色
if (!itemColors.value[source.mediaid_prefix]) {
itemColors.value[source.mediaid_prefix] = getItemColor(source.mediaid_prefix)
}
}
} catch (error) {
console.log(error)
@@ -145,6 +161,7 @@ registerHeaderTab({
onBeforeMount(async () => {
initDiscoverTabs()
initializeColors()
await loadOrderConfig()
await loadExtraDiscoverSources()
sortSubscribeOrder()
@@ -199,7 +216,7 @@ onActivated(async () => {
</VWindowItem>
</VWindow>
<!-- 弹窗根据配置生成选项 -->
<DialogWrapper
<VDialog
v-if="orderConfigDialog"
v-model="orderConfigDialog"
max-width="35rem"
@@ -225,9 +242,14 @@ onActivated(async () => {
:component-data="{ 'class': 'settings-grid' }"
>
<template #item="{ element }">
<VCard variant="text" class="setting-item enabled">
<div class="setting-item-inner cursor-move text-center">
<VCard
variant="text"
class="setting-item enabled"
:style="{ '--item-color': itemColors[element.mediaid_prefix] }"
>
<div class="setting-item-inner">
<span class="setting-label">{{ element.name }}</span>
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
</div>
</VCard>
</template>
@@ -243,7 +265,7 @@ onActivated(async () => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 快速滚动到顶部按钮 -->
<Teleport to="body" v-if="route.path === '/discover'">
<VScrollToTopBtn />
@@ -269,8 +291,11 @@ onActivated(async () => {
}
.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;
}
@@ -287,8 +312,7 @@ onActivated(async () => {
&::before {
position: absolute;
background-color: transparent;
background-color: rgb(var(--v-theme-primary));
background-color: var(--item-color, #4caf50);
block-size: 100%;
content: '';
inline-size: 4px;
@@ -298,16 +322,15 @@ onActivated(async () => {
}
&:hover {
border-color: rgba(var(--v-theme-on-surface), 0.15);
background-color: rgba(var(--v-theme-surface-variant), 0.6);
transform: translateY(-2px);
}
&.enabled {
border-color: rgba(var(--v-theme-primary), 0.5);
background-color: rgba(var(--v-theme-primary), 0.05);
border-color: rgba(var(--v-theme-primary), 0.3);
background-color: rgba(var(--v-theme-primary), 0.1);
.setting-label {
color: rgb(var(--v-theme-primary));
color: rgba(var(--v-theme-primary), 0.9);
font-weight: 500;
}
}
@@ -316,9 +339,22 @@ onActivated(async () => {
.setting-item-inner {
display: flex;
align-items: center;
gap: 8px;
}
.setting-check {
margin-inline-end: 8px;
flex-shrink: 0;
}
.drag-icon {
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.5);
cursor: move;
}
@media (width <= 600px) {
.settings-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -5,6 +5,7 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
const display = useDisplay()
@@ -114,6 +115,17 @@ const enableConfig = ref<{ [key: string]: boolean }>({
...Object.fromEntries(viewList.map(item => [item.title, true])),
})
// 为每个项目生成随机颜色
const itemColors = ref<{ [key: string]: string }>({})
// 初始化颜色
function initializeColors() {
initializeItemColors(viewList, item => item.title)
viewList.forEach(item => {
itemColors.value[item.title] = getItemColor(item.title)
})
}
// 弹窗
const dialog = ref(false)
@@ -127,9 +139,11 @@ async function loadExtraRecommendSources() {
if (extraRecommendSources.value.length > 0) {
extraRecommendSources.value.map(source => {
if (!viewList.some(item => item.apipath === source.api_path)) {
const querySeparator = source.api_path.includes('?') ? '&' : '?'
const linkUrl = `/browse/${source.api_path}${querySeparator}title=${encodeURIComponent(source.name)}`
viewList.push({
apipath: source.api_path,
linkurl: `/browse/${source.api_path}&title=${source.name}`,
linkurl: linkUrl,
title: source.name,
type: source.type,
})
@@ -219,10 +233,17 @@ registerHeaderTab({
onBeforeMount(async () => {
await loadConfig()
initializeColors()
})
onMounted(async () => {
await loadExtraRecommendSources()
// 为新增的数据源也生成颜色
extraRecommendSources.value.forEach(source => {
if (!itemColors.value[source.name]) {
itemColors.value[source.name] = getItemColor(source.name)
}
})
})
onActivated(async () => {
@@ -248,13 +269,7 @@ onActivated(async () => {
</div>
<!-- 设置面板 -->
<DialogWrapper
v-model="dialog"
width="35rem"
class="settings-dialog"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VDialog v-model="dialog" width="35rem" class="settings-dialog" scrollable :fullscreen="!display.mdAndUp.value">
<VCard class="settings-card">
<VCardItem class="settings-card-header">
<VCardTitle>
@@ -273,8 +288,8 @@ onActivated(async () => {
class="setting-item"
:class="{
'enabled': enableConfig[item.title],
[item.type]: true,
}"
:style="{ '--item-color': itemColors[item.title] }"
@click="enableConfig[item.title] = !enableConfig[item.title]"
>
<div class="setting-item-inner">
@@ -306,7 +321,7 @@ onActivated(async () => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 快速滚动到顶部按钮 -->
<Teleport to="body" v-if="route.path === '/recommend'">
@@ -392,7 +407,7 @@ onActivated(async () => {
&::before {
position: absolute;
background-color: transparent;
background-color: var(--item-color, #4caf50);
block-size: 100%;
content: '';
inline-size: 4px;
@@ -401,19 +416,6 @@ onActivated(async () => {
transition: background-color 0.3s ease;
}
&.电影::before {
background-color: #4caf50;
} // Green
&.电视剧::before {
background-color: #2196f3;
} // Blue
&.动漫::before {
background-color: #ff9800;
} // Orange
&.排行榜::before {
background-color: #9c27b0;
} // Purple
&.enabled {
border-color: rgba(var(--v-theme-primary), 0.3);
background-color: rgba(var(--v-theme-primary), 0.1);
@@ -450,7 +452,7 @@ onActivated(async () => {
@media (width <= 600px) {
.settings-grid {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
// 从路由参数中获取搜索关键字
const route = useRoute()
const keyword = route.query.keyword as string
</script>
<template>
<div>
<SubscribeShareView :keyword="keyword" />
<!-- 滚动到顶部按钮 -->
<Teleport to="body" v-if="route.path === '/subscribe-share'">
<VScrollToTopBtn />
</Teleport>
</div>
</template>

View File

@@ -3,6 +3,7 @@ import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
@@ -36,9 +37,15 @@ const filterSubscribeDialog = ref(false)
// 搜索订阅分享弹窗
const searchShareDialog = ref(false)
// 订阅分享统计弹窗
const shareStatisticsDialog = ref(false)
// 订阅过滤词
const subscribeFilter = ref('')
// 订阅状态筛选
const subscribeStatusFilter = ref<string | null>(null)
// 分享搜索词
const shareKeyword = ref('')
@@ -48,6 +55,41 @@ const searchShares = () => {
shareViewKey.value++
}
// 筛选选项
const filterOptions = computed(() => {
const baseOptions = [
{ value: 'all', label: t('common.all'), icon: 'mdi-format-list-bulleted' },
{ value: 'best_version', label: t('subscribe.bestVersion'), icon: 'mdi-refresh', color: 'warning' },
]
// 电影只显示基本选项和状态选项
if (subType === '电影') {
return [
...baseOptions,
{ value: 'pending', label: t('subscribe.pending'), icon: 'mdi-help-circle', color: 'secondary' },
{ value: 'paused', label: t('subscribe.paused'), icon: 'mdi-pause-circle', color: 'error' },
]
}
// 电视剧显示所有选项
return [
...baseOptions,
{ value: 'not_started', label: t('subscribe.notStarted'), icon: 'mdi-clock-outline', color: 'secondary' },
{ value: 'subscribing', label: t('subscribe.subscribing'), icon: 'mdi-download', color: 'info' },
{ value: 'pending', label: t('subscribe.pending'), icon: 'mdi-help-circle', color: 'secondary' },
{ value: 'paused', label: t('subscribe.paused'), icon: 'mdi-pause-circle', color: 'error' },
{ value: 'completed', label: t('subscribe.completed'), icon: 'mdi-check-circle', color: 'success' },
]
})
// 计算筛选按钮颜色
const filterButtonColor = computed(() => {
if (subscribeFilter.value || (subscribeStatusFilter.value && subscribeStatusFilter.value !== 'all')) {
return 'primary'
}
return 'gray'
})
// VMenu activator选择器
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
@@ -63,7 +105,7 @@ registerHeaderTab({
{
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: computed(() => (subscribeFilter.value ? 'primary' : 'gray')),
color: filterButtonColor,
class: 'settings-icon-button',
dataAttr: 'filter-btn',
action: () => {
@@ -71,6 +113,29 @@ registerHeaderTab({
},
show: computed(() => activeTab.value === 'mysub'),
},
{
icon: 'mdi-checkbox-multiple-marked-outline',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
// 触发批量管理模式
const event = new CustomEvent('toggle-batch-mode')
window.dispatchEvent(event)
},
show: computed(() => activeTab.value === 'mysub'),
},
{
icon: 'mdi-chart-line',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
dataAttr: 'statistics-btn',
action: () => {
shareStatisticsDialog.value = true
},
show: computed(() => activeTab.value === 'share'),
},
{
icon: 'mdi-movie-search-outline',
variant: 'text',
@@ -110,7 +175,12 @@ onMounted(() => {
<VWindowItem value="mysub">
<transition name="fade-slide" appear>
<div>
<SubscribeListView :type="subType" :subid="subId" :keyword="subscribeFilter" />
<SubscribeListView
:type="subType"
:subid="subId"
:keyword="subscribeFilter"
:status-filter="subscribeStatusFilter ?? ''"
/>
</div>
</transition>
</VWindowItem>
@@ -134,7 +204,7 @@ onMounted(() => {
<Teleport to="body" v-if="filterSubscribeDialog">
<VMenu
v-model="filterSubscribeDialog"
width="20rem"
width="25rem"
:close-on-content-click="false"
:activator="filterActivator"
location="bottom end"
@@ -148,7 +218,25 @@ onMounted(() => {
<VDialogCloseBtn @click="filterSubscribeDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="subscribeFilter" :label="t('subscribe.name')" clearable density="comfortable" />
<VRow>
<!-- 名称筛选 -->
<VCol cols="6">
<VTextField v-model="subscribeFilter" :label="t('subscribe.name')" clearable density="comfortable" />
</VCol>
<!-- 状态筛选 -->
<VCol cols="6">
<VSelect
v-model="subscribeStatusFilter"
:items="filterOptions"
item-title="label"
item-value="value"
:label="t('common.status')"
density="comfortable"
clearable
/>
</VCol>
</VRow>
</VCardText>
</VCard>
</VMenu>
@@ -191,6 +279,13 @@ onMounted(() => {
@save="subscribeEditDialog = false"
@close="subscribeEditDialog = false"
/>
<!-- 订阅分享统计弹窗 -->
<SubscribeShareStatisticsDialog
v-if="shareStatisticsDialog"
v-model="shareStatisticsDialog"
@close="shareStatisticsDialog = false"
/>
</div>
</template>

View File

@@ -69,6 +69,14 @@ const router = createRouter({
subType: '电视剧',
},
},
{
path: '/subscribe-share',
component: () => import('../pages/subscribe-share.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: '/workflow',
component: () => import('../pages/workflow.vue'),

View File

@@ -6,7 +6,7 @@ declare let self: ServiceWorkerGlobalScope & {
}
// 缓存版本控制
const CACHE_VERSION = 'v1.0.2'
const CACHE_VERSION = 'v1.0.8'
const CACHE_NAMES = {
appShell: `app-shell-${CACHE_VERSION}`,
static: `static-resources-${CACHE_VERSION}`,

View File

@@ -1,14 +1,13 @@
// Write your overrides
// 公共样式 - 所有主题都需要
@tailwind base;
@tailwind components;
@tailwind utilities;
html.v-overlay-scroll-blocked {
position: fixed;
position: relative;
--v-body-scroll-y: 0px !important;
@supports (-webkit-touch-callout: none) {
html.v-overlay-scroll-blocked {
position: inherit;
}
}
body {
@@ -30,6 +29,7 @@ body {
}
}
// 进度条样式
#nprogress .bar {
background: rgb(var(--v-theme-primary)) !important;
inset-block-start: env(safe-area-inset-top) !important;
@@ -38,15 +38,17 @@ body {
#nprogress .peg {
box-shadow: 0 0 10px rgb(var(--v-theme-primary)), 0 0 5px rgb(var(--v-theme-primary)) !important;
inline-size: 5px;
transform: rotate(0deg) translate(0, 0);
transform: rotate(0deg) translate(0, 0);
}
// 卡片高度匹配
.match-height.v-row {
.v-card {
block-size: 100%;
}
}
// Toast通知样式
.Vue-Toastification__container {
z-index: 2500;
margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
@@ -64,11 +66,12 @@ body {
}
}
// 对话框样式
.v-dialog > .v-overlay__content > .v-card > .v-card-item {
padding: 16px;
}
/* router view transition fade-slide */
// 路由过渡动画
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.6s;
@@ -84,99 +87,13 @@ body {
transform: translateY(45px);
}
// 网格布局样式
.grid-info-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
.text-moviepilot {
background-clip: text;
background-image: linear-gradient(to bottom right,var(--tw-gradient-stops));
color: transparent;
--tw-gradient-from: #818cf8;
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
--tw-gradient-to: #c084fc;
}
.slider-header {
position: relative;
display: flex;
}
.slider-title {
display: inline-flex;
align-items: center;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.75rem;
}
@media (width >= 640px){
.slider-title {
overflow: hidden;
font-size: 1.5rem;
line-height: 2.25rem;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// 美化滚动条
::-webkit-scrollbar {
block-size: 4px;
inline-size: 4px;
opacity: 0;
transition: opacity 0.3s;
}
::-webkit-scrollbar-thumb {
border-radius: 2px;
background: rgb(var(--v-theme-perfect-scrollbar-thumb));
box-shadow: inset 0 0 10px rgba(0,0,0,20%);
@media(hover){
&:hover{
background: #a1a1a1;
}
}
}
// 当鼠标悬停在可滚动元素上时显示滚动条
*:hover::-webkit-scrollbar {
opacity: 1;
}
// 当元素正在滚动时显示滚动条
*:active::-webkit-scrollbar {
opacity: 1;
}
.v-alert--variant-elevated, .v-alert--variant-flat {
background: rgb(var(--v-table-header-background));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.backdrop-blur {
--tw-backdrop-blur: blur(8px)!important;
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;
}
.v-toolbar{
background: rgb(var(--v-table-header-background));
}
.v-divider {
border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity));
opacity:0.75;
}
.apexcharts-title-text {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
}
.grid-site-card {
.grid-site-card {
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
padding-block-end: 1rem;
}
@@ -233,6 +150,98 @@ body {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
}
// 文本样式
.text-moviepilot {
background-clip: text;
background-image: linear-gradient(to bottom right,var(--tw-gradient-stops));
color: transparent;
--tw-gradient-from: #818cf8;
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
--tw-gradient-to: #c084fc;
}
.text-shadow {
text-shadow: 1px 1px #777;
}
// 滑块标题样式
.slider-header {
position: relative;
display: flex;
}
.slider-title {
display: inline-flex;
align-items: center;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.75rem;
}
@media (width >= 640px){
.slider-title {
overflow: hidden;
font-size: 1.5rem;
line-height: 2.25rem;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// 滚动条样式
::-webkit-scrollbar {
block-size: 4px;
inline-size: 4px;
opacity: 0;
transition: opacity 0.3s;
}
::-webkit-scrollbar-thumb {
border-radius: 2px;
background: rgb(var(--v-theme-perfect-scrollbar-thumb));
box-shadow: inset 0 0 10px rgba(0,0,0,20%);
@media(hover){
&:hover{
background: #a1a1a1;
}
}
}
*:hover::-webkit-scrollbar {
opacity: 1;
}
*:active::-webkit-scrollbar {
opacity: 1;
}
// 组件样式
.v-alert--variant-elevated, .v-alert--variant-flat {
background: rgb(var(--v-table-header-background));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.backdrop-blur {
--tw-backdrop-blur: blur(8px)!important;
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;
}
.v-toolbar{
background: rgb(var(--v-table-header-background));
}
.v-divider {
border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity));
opacity:0.75;
}
.apexcharts-title-text {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
}
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
@@ -241,10 +250,6 @@ body {
padding-block-end: env(safe-area-inset-bottom);
}
.text-shadow {
text-shadow: 1px 1px #777;
}
.card-cover-blurred::before {
position: absolute;
backdrop-filter: blur(2px);
@@ -253,6 +258,7 @@ body {
inset: 0;
}
// 弹出层样式
.v-overlay__content .v-list{
backdrop-filter: blur(6px);
background-color: rgb(var(--v-theme-surface), 0.9) !important;
@@ -308,121 +314,10 @@ body {
min-inline-size: auto;
}
.v-infinite-scroll__side {
padding: 0;
}
.v-menu .v-overlay__content {
box-shadow: none !important;
}
// 透明主题下的弹出窗口样式
html[data-theme="transparent"] {
// 先将所有全局组件定义放在前面避免CSS优先级问题
.v-application, .v-layout, .v-main, .layout-page-content {
background: transparent;
}
// 侧边导航栏
.layout-vertical-nav {
backdrop-filter: blur(16px);
background-color: rgba(var(--v-theme-surface), 0.2);
border-inline-end: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
// 列表
.v-list {
backdrop-filter: blur(10px);
background-color: rgba(var(--v-theme-surface), 0.3);
}
// 卡片
.v-card:not(.no-blur) {
backdrop-filter: blur(10px);
background-color: rgba(var(--v-theme-surface), 0.3);
.v-list {
backdrop-filter: none;
background-color: transparent;
}
}
// 工具栏
.v-toolbar {
backdrop-filter: blur(10px);
background-color: rgba(var(--v-theme-surface), 0.3);
}
// 表格
.v-table {
border-radius: 0;
background-color: rgba(var(--v-theme-surface), 0);
.v-table__wrapper > table > thead {
background-color: rgba(var(--v-theme-surface), 0.3);
}
}
// 页脚
.v-footer {
backdrop-filter: blur(10px);
background-color: rgba(var(--v-theme-surface), 0.3);
}
// Sheet
.v-sheet {
backdrop-filter: blur(10px);
background-color: rgba(var(--v-theme-surface), 0.3);
}
// 页面容器
.layout-content-wrapper {
background: transparent;
}
// 无内容区域的背景设为透明
.page-content-container {
background: transparent;
}
// 对话框和菜单蒙层样式
.v-overlay__scrim {
background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity));
}
// 折叠面板
.v-expansion-panel {
backdrop-filter: blur(10px);
background-color: rgba(var(--v-theme-surface), 0.3);
}
// 加载占位
.v-skeleton-loader {
background-color: rgba(var(--v-theme-surface), 0.3);
}
// 输入框和搜索框
.v-field {
background-color: rgba(var(--v-theme-surface), 0);
}
.v-overlay__content {
border-radius: 12px !important;
backdrop-filter: blur(10px) !important;
.v-list {
backdrop-filter: blur(10px);
background-color: rgb(var(--v-theme-surface), 0.5) !important;
}
.v-card:not(.bg-primary) {
backdrop-filter: blur(10px);
background-color: rgb(var(--v-theme-surface), 0.5) !important;
}
.v-table__wrapper table thead {
background-color: rgba(var(--v-theme-surface), 0.3);
}
}
}
}

View File

@@ -5,7 +5,7 @@
@use '@core/scss/index' as template;
@use '@layouts/styles/index' as layouts;
@use 'vuetify/styles' as vuetify;
@use '@styles/custom' as custom;
@use '@styles/common' as common;
/* 第三方库纯CSS样式 */
@import 'vue-toastification/dist/index.css';

View File

@@ -0,0 +1,118 @@
// 透明主题专用样式
html[data-theme="transparent"] {
// 定义透明度变量
--transparent-opacity: 0.3;
--transparent-opacity-light: 0.2;
--transparent-opacity-heavy: 0.5;
--transparent-blur: 10px;
--transparent-blur-light: 6px;
--transparent-blur-heavy: 16px;
// 应用、布局、主内容区域
.v-application, .v-layout, .v-main, .layout-page-content {
background: transparent;
}
// 侧边导航栏
.layout-vertical-nav {
backdrop-filter: blur(var(--transparent-blur-heavy));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-light));
border-inline-end: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
// 列表
.v-list {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
// 卡片
.v-card:not(.no-blur) {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
.v-list {
backdrop-filter: none;
background-color: transparent;
}
}
// 工具栏
.v-toolbar {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
// 表格
.v-table {
border-radius: 0;
background-color: rgba(var(--v-theme-surface), 0);
.v-table__wrapper > table > thead {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
}
// 页脚
.v-footer {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
// Sheet
.v-sheet {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
// 页面容器
.layout-content-wrapper {
background: transparent;
}
// 无内容区域的背景设为透明
.page-content-container {
background: transparent;
}
// 对话框和菜单蒙层样式
.v-overlay__scrim {
background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity));
}
// 折叠面板
.v-expansion-panel {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
// 加载占位
.v-skeleton-loader {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
// 输入框和搜索框
.v-field {
background-color: rgba(var(--v-theme-surface), 0);
}
// 弹出层内容
.v-overlay__content {
border-radius: 12px !important;
backdrop-filter: blur(var(--transparent-blur)) !important;
.v-card:not(.bg-primary) {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)) !important;
}
.v-list {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)) !important;
}
.v-table__wrapper table thead {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
}
}

720
src/utils/appDeepLink.ts Normal file
View File

@@ -0,0 +1,720 @@
/**
* 通用APP深度链接工具类
* 支持媒体服务器Plex、Jellyfin、Emby和豆瓣的APP跳转和网页跳转
*
* 深度链接格式参考:
* - Plex: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456
* - Emby: https://emby.media/support/articles/Deep-Linking.html
* - Jellyfin: https://jellyfin.org/docs/general/administration/deep-linking
* - 豆瓣: 官方搜索格式
*/
import { isMobileDevice, isIOSDevice, isAndroidDevice } from '@/@core/utils'
// APP类型
export type AppType = 'plex' | 'jellyfin' | 'emby' | 'trimemedia' | 'douban'
// 深度链接配置
interface DeepLinkConfig {
appScheme: string
webUrl: string
timeout: number
}
// 各APP的深度链接配置
const DEEP_LINK_CONFIGS: Record<AppType, DeepLinkConfig> = {
plex: {
appScheme: 'plex://',
webUrl: 'https://app.plex.tv',
timeout: 2000,
},
jellyfin: {
appScheme: 'jellyfin://',
webUrl: 'https://jellyfin.org',
timeout: 2000,
},
emby: {
appScheme: 'emby://',
webUrl: 'https://emby.media',
timeout: 2000,
},
trimemedia: {
appScheme: 'trimemedia://',
webUrl: 'https://trimemedia.com',
timeout: 2000,
},
douban: {
appScheme: 'douban://',
webUrl: 'https://movie.douban.com',
timeout: 2000,
},
}
// 豆瓣APP跳转参数
interface DoubanAppParams {
doubanId: string
mediaType?: string
title?: string
year?: string
fallbackUrl?: string
}
/**
* 尝试跳转到APP如果失败则跳转到网页
* @param appType APP类型
* @param params 跳转参数
*/
export async function openApp(appType: AppType, params: string | DoubanAppParams, fallbackUrl?: string): Promise<void> {
// 如果不是移动设备,直接使用网页链接
if (!isMobileDevice()) {
const webUrl = getWebUrl(appType, params, fallbackUrl)
window.open(webUrl, '_blank')
return
}
const config = DEEP_LINK_CONFIGS[appType]
if (!config) {
console.warn(`不支持的APP类型: ${appType}`)
const webUrl = getWebUrl(appType, params, fallbackUrl)
window.open(webUrl, '_blank')
return
}
// 构建APP深度链接
const appUrl = buildDeepLinkUrl(appType, params)
console.log(`构建${appType}深度链接:`, {
params,
deepLinkUrl: appUrl,
})
// 尝试跳转到APP
try {
await attemptAppLaunch(appUrl, config.timeout)
} catch (error) {
console.log(`${appType} APP跳转失败使用网页链接: ${error}`)
// APP跳转失败使用网页链接
const webUrl = getWebUrl(appType, params, fallbackUrl)
window.open(webUrl, '_blank')
}
}
/**
* 获取网页链接
* @param appType APP类型
* @param params 参数
* @param fallbackUrl 备用链接
*/
function getWebUrl(appType: AppType, params: string | DoubanAppParams, fallbackUrl?: string): string {
if (fallbackUrl) return fallbackUrl
const config = DEEP_LINK_CONFIGS[appType]
switch (appType) {
case 'douban':
const doubanParams = params as DoubanAppParams
return `${config.webUrl}/subject/${doubanParams.doubanId}`
default:
return typeof params === 'string' ? params : config.webUrl
}
}
/**
* 构建深度链接URL
* @param appType APP类型
* @param params 参数
*/
function buildDeepLinkUrl(appType: AppType, params: string | DoubanAppParams): string {
switch (appType) {
case 'plex':
return buildPlexDeepLink(params as string)
case 'jellyfin':
return buildJellyfinDeepLink(params as string)
case 'emby':
return buildEmbyDeepLink(params as string)
case 'trimemedia':
return buildTrimemediaDeepLink(params as string)
case 'douban':
return buildDoubanDeepLink(params as DoubanAppParams)
default:
return typeof params === 'string' ? params : ''
}
}
/**
* 构建Plex深度链接
* 参考: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456
*
* 后台API返回格式
* - 媒体库: web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={token}
* - 媒体项: web/index.html#!/server/{machineIdentifier}/details?key={item_id}&X-Plex-Token={token}
*
* Plex官方APP URL格式
* plex://play/?metadataKey=/library/metadata/$SOME_ID&server=$SERVER_ID
* 例如: plex://play/?metadataKey=/library/metadata/123&server=456
*
* @param playUrl 播放链接
*/
function buildPlexDeepLink(playUrl: string): string {
try {
const url = new URL(playUrl)
// 提取媒体ID、机器标识符、库ID等
let mediaId: string | null = null
let machineIdentifier: string | null = null
let libraryKey: string | null = null
let librarySectionId: string | null = null
let plexToken: string | null = null
// 提取X-Plex-Token
const tokenMatch = playUrl.match(/X-Plex-Token=([^&]+)/)
if (tokenMatch) {
plexToken = tokenMatch[1]
console.log('提取Plex Token:', { plexToken })
}
// 格式1: 后台API返回的媒体库格式
// web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={token}
const mediaLibraryMatch = playUrl.match(/\/media\/([^\/]+)\/com\.plexapp\.plugins\.library\?source=([^&]+)/)
if (mediaLibraryMatch) {
machineIdentifier = mediaLibraryMatch[1]
libraryKey = mediaLibraryMatch[2]
console.log('Plex后台API媒体库格式匹配:', { machineIdentifier, libraryKey })
// 从library.key中提取section ID
// library.key格式通常是: library://video-section/1 或类似格式
const sectionMatch = libraryKey.match(/section\/(\d+)/)
if (sectionMatch) {
librarySectionId = sectionMatch[1]
console.log('从library.key提取section ID:', { librarySectionId })
}
}
// 格式2: 后台API返回的媒体项格式
// web/index.html#!/server/{machineIdentifier}/details?key={item_id}&X-Plex-Token={token}
const serverDetailsMatch = playUrl.match(/\/server\/([^\/]+)\/details\?key=([^&]+)/)
if (serverDetailsMatch) {
machineIdentifier = serverDetailsMatch[1]
const keyValue = serverDetailsMatch[2]
console.log('Plex后台API媒体项格式匹配:', { machineIdentifier, keyValue })
// 从key中提取媒体ID
// key格式可能是: /library/metadata/1668 或直接是 1668
const metadataMatch = keyValue.match(/\/library\/metadata\/(\d+)/)
if (metadataMatch) {
mediaId = metadataMatch[1]
console.log('从key提取媒体ID:', { mediaId })
} else if (/^\d+$/.test(keyValue)) {
// 如果key本身就是数字直接使用
mediaId = keyValue
console.log('key本身就是媒体ID:', { mediaId })
}
}
// 构建深度链接 - 使用新的官方格式
if (mediaId && machineIdentifier) {
// plex://play/?metadataKey=/library/metadata/$SOME_ID&server=$SERVER_ID
let deepLink = `plex://play/?metadataKey=/library/metadata/${mediaId}&server=${machineIdentifier}`
if (plexToken) {
deepLink += `&X-Plex-Token=${plexToken}`
}
console.log('Plex深度链接构建成功:', {
originalUrl: playUrl,
machineIdentifier,
libraryKey,
librarySectionId,
mediaId,
plexToken,
deepLink,
})
return deepLink
}
// 如果有媒体ID但没有机器标识符尝试使用旧的格式作为降级
if (mediaId) {
let deepLink = `plex://library/metadata/${mediaId}`
if (plexToken) {
deepLink += `?X-Plex-Token=${plexToken}`
}
console.log('Plex深度链接构建成功(降级格式):', {
originalUrl: playUrl,
mediaId,
plexToken,
deepLink,
})
return deepLink
}
// 如果有库ID尝试使用库ID
if (librarySectionId) {
// http://[PMS_IP_Address]:32400/library/sections/29/all?X-Plex-Token=YourTokenGoesHere
let libraryLink = `plex://library/sections/${librarySectionId}/all`
if (plexToken) {
libraryLink += `?X-Plex-Token=${plexToken}`
}
console.log('Plex库深度链接构建成功:', {
originalUrl: playUrl,
librarySectionId,
plexToken,
libraryLink,
})
return libraryLink
}
// 如果无法提取媒体ID尝试使用机器标识符
if (machineIdentifier) {
// http://[PMS_IP_Address]:32400/library/sections?X-Plex-Token=YourTokenGoesHere
let fallbackLink = `plex://library/sections`
if (plexToken) {
fallbackLink += `?X-Plex-Token=${plexToken}`
}
console.log('Plex深度链接构建失败使用机器标识符:', {
originalUrl: playUrl,
machineIdentifier,
plexToken,
fallbackLink,
})
return fallbackLink
}
// 最后的降级方案
console.log('Plex深度链接构建失败使用原始URL:', {
originalUrl: playUrl,
})
return `plex://${playUrl}`
} catch (error) {
console.warn('构建Plex深度链接失败:', error)
return `plex://${playUrl}`
}
}
/**
* 构建Jellyfin深度链接
* 参考: https://jellyfin.org/docs/general/administration/deep-linking
* @param playUrl 播放链接
*/
function buildJellyfinDeepLink(playUrl: string): string {
try {
const url = new URL(playUrl)
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
// 提取媒体ID、库ID、serverId
let mediaId: string | null = null
let libraryId: string | null = null
let serverId: string | null = null
// 格式1: /details?id={item_id}&serverId={serverid}
const detailsMatch = playUrl.match(/\/details\?id=([^&]+)&serverId=([^&]+)/)
if (detailsMatch) {
mediaId = detailsMatch[1]
serverId = detailsMatch[2]
}
// 格式2: /movies.html?topParentId={libraryId}
const moviesMatch = playUrl.match(/\/movies\.html\?topParentId=([^&]+)/)
if (moviesMatch) {
libraryId = moviesMatch[1]
}
// 格式3: /tv.html?topParentId={libraryId}
const tvMatch = playUrl.match(/\/tv\.html\?topParentId=([^&]+)/)
if (tvMatch) {
libraryId = tvMatch[1]
}
// 格式4: /library.html?topParentId={libraryId}
const libMatch = playUrl.match(/\/library\.html\?topParentId=([^&]+)/)
if (libMatch) {
libraryId = libMatch[1]
}
// 兼容原有格式:?id=xxx
if (!mediaId) {
const idMatch = playUrl.match(/[?&]id=([^&]+)/)
if (idMatch) {
mediaId = idMatch[1]
}
}
// 兼容原有格式:/items/xxx
if (!mediaId) {
const itemsMatch = playUrl.match(/\/items\/([^\/\?]+)/)
if (itemsMatch) {
mediaId = itemsMatch[1]
}
}
// 构建深度链接
if (mediaId) {
let deepLink = `jellyfin://${serverAddress}/item/${mediaId}`
if (serverId) {
deepLink += `?serverId=${serverId}`
}
console.log('Jellyfin深度链接构建成功:', {
originalUrl: playUrl,
serverAddress,
mediaId,
serverId,
deepLink,
})
return deepLink
}
if (libraryId) {
const deepLink = `jellyfin://${serverAddress}/library/${libraryId}`
console.log('Jellyfin库深度链接构建成功:', {
originalUrl: playUrl,
serverAddress,
libraryId,
deepLink,
})
return deepLink
}
// 如果无法提取ID尝试直接使用服务器地址
const fallbackLink = `jellyfin://${serverAddress}`
console.log('Jellyfin深度链接构建失败使用服务器地址:', {
originalUrl: playUrl,
serverAddress,
fallbackLink,
})
return fallbackLink
} catch (error) {
console.warn('构建Jellyfin深度链接失败:', error)
return `jellyfin://${playUrl}`
}
}
/**
* 构建Emby深度链接
* 参考: https://emby.media/support/articles/Deep-Linking.html
* iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}
* Android格式: emby://{服务器地址}/item/{媒体ID}
* @param playUrl 播放链接
*/
function buildEmbyDeepLink(playUrl: string): string {
try {
const url = new URL(playUrl)
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
// 尝试多种格式提取媒体ID
let mediaId: string | null = null
let serverId: string | null = null
// 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式)
const itemHashMatch = playUrl.match(/\/item\?id=([^&]+)/)
if (itemHashMatch) {
mediaId = itemHashMatch[1]
// 提取serverId
const serverIdMatch = playUrl.match(/serverId=([^&]+)/)
if (serverIdMatch) {
serverId = serverIdMatch[1]
}
}
// 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式)
const videosHashMatch = playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/)
if (videosHashMatch) {
// 对于videos格式我们使用parentId作为媒体ID
mediaId = videosHashMatch[2]
serverId = videosHashMatch[1]
}
// 格式3: ?id=xxx (通用格式)
if (!mediaId) {
const idMatch = playUrl.match(/[?&]id=([^&]+)/)
if (idMatch) {
mediaId = idMatch[1]
}
}
// 格式4: /itemdetails.html?id=xxx
if (!mediaId) {
const itemMatch = playUrl.match(/\/itemdetails\.html\?id=([^&]+)/)
if (itemMatch) {
mediaId = itemMatch[1]
}
}
// 格式5: /items/xxx
if (!mediaId) {
const itemsMatch = playUrl.match(/\/items\/([^\/\?]+)/)
if (itemsMatch) {
mediaId = itemsMatch[1]
}
}
// 格式6: /item/xxx (路径格式)
if (!mediaId) {
const itemPathMatch = playUrl.match(/\/item\/([^\/\?]+)/)
if (itemPathMatch) {
mediaId = itemPathMatch[1]
}
}
if (mediaId) {
let deepLink: string
// 根据设备类型使用不同的深度链接格式
if (isIOSDevice()) {
// iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}
if (serverId) {
deepLink = `emby://items?serverId=${serverId}&itemId=${mediaId}`
} else {
// 如果没有serverId尝试使用服务器地址作为serverId
deepLink = `emby://items?serverId=${serverAddress}&itemId=${mediaId}`
}
} else {
// Android格式: emby://{服务器地址}/item/{媒体ID}
deepLink = `emby://${serverAddress}/item/${mediaId}`
if (serverId) {
deepLink += `?serverId=${serverId}`
}
}
console.log('Emby深度链接构建成功:', {
originalUrl: playUrl,
serverAddress,
mediaId,
serverId,
deviceType: isIOSDevice() ? 'iOS' : 'Android',
deepLink,
})
return deepLink
}
// 如果无法提取媒体ID尝试直接使用服务器地址
// 这会打开Emby APP的主界面
const fallbackLink = `emby://${serverAddress}`
console.log('Emby深度链接构建失败使用服务器地址:', {
originalUrl: playUrl,
serverAddress,
fallbackLink,
})
return fallbackLink
} catch (error) {
console.warn('构建Emby深度链接失败:', error)
return playUrl
}
}
/**
* 构建Trimemedia深度链接
* @param playUrl 播放链接
*/
function buildTrimemediaDeepLink(playUrl: string): string {
try {
const url = new URL(playUrl)
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
// 提取媒体ID
let mediaId: string | null = null
// 尝试从URL路径中提取媒体ID
const pathMatch = playUrl.match(/\/item\/([^\/\?]+)/)
if (pathMatch) {
mediaId = pathMatch[1]
}
// 尝试从查询参数中提取媒体ID
if (!mediaId) {
const idMatch = playUrl.match(/[?&]id=([^&]+)/)
if (idMatch) {
mediaId = idMatch[1]
}
}
// 构建深度链接
if (mediaId) {
const deepLink = `trimemedia://${serverAddress}/item/${mediaId}`
console.log('Trimemedia深度链接构建成功:', {
originalUrl: playUrl,
serverAddress,
mediaId,
deepLink,
})
return deepLink
}
// 如果无法提取媒体ID尝试直接使用服务器地址
const fallbackLink = `trimemedia://${serverAddress}`
console.log('Trimemedia深度链接构建失败使用服务器地址:', {
originalUrl: playUrl,
serverAddress,
fallbackLink,
})
return fallbackLink
} catch (error) {
console.warn('构建Trimemedia深度链接失败:', error)
return playUrl
}
}
/**
* 构建豆瓣深度链接
* 使用豆瓣App官方支持的搜索格式
* @param params 豆瓣参数
*/
function buildDoubanDeepLink(params: DoubanAppParams): string {
const { title, year } = params
// 使用豆瓣App官方支持的搜索格式
// 格式douban:///search?q={query}
const searchQuery = `${title || ''} ${year || ''}`.trim()
const deepLink = `douban:///search?q=${encodeURIComponent(searchQuery)}`
console.log('豆瓣深度链接构建成功:', {
params,
searchQuery,
deepLink,
})
return deepLink
}
/**
* 尝试启动APP
* @param appUrl APP深度链接
* @param timeout 超时时间
*/
async function attemptAppLaunch(appUrl: string, timeout: number): Promise<void> {
return new Promise((resolve, reject) => {
// 创建一个隐藏的iframe来尝试启动APP
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = appUrl
// 设置超时
const timeoutId = setTimeout(() => {
document.body.removeChild(iframe)
reject(new Error('APP启动超时'))
}, timeout)
// 监听页面可见性变化如果用户切换到APP说明启动成功
const handleVisibilityChange = () => {
if (document.hidden) {
clearTimeout(timeoutId)
document.removeEventListener('visibilitychange', handleVisibilityChange)
document.body.removeChild(iframe)
resolve()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// 添加到页面并尝试启动
document.body.appendChild(iframe)
// 对于iOS还需要尝试window.location
if (isIOSDevice()) {
try {
window.location.href = appUrl
} catch (error) {
console.log('iOS window.location跳转失败:', error)
}
}
})
}
/**
* 根据播放链接自动检测媒体服务器类型并跳转
* @param playUrl 播放链接
* @param fallbackUrl 备用网页链接
* @param serverType 媒体服务器类型(可选,优先使用此参数)
*/
export async function openMediaServerWithAutoDetect(
playUrl: string,
fallbackUrl?: string,
serverType?: string,
): Promise<void> {
let detectedServerType: AppType | null = null
// 优先使用传入的 serverType 参数
if (serverType) {
const type = serverType.toLowerCase()
if (type === 'plex' || type === 'jellyfin' || type === 'emby' || type === 'trimemedia') {
detectedServerType = type as AppType
}
}
// 如果没有传入 serverType 或类型不支持则从URL中检测
if (!detectedServerType) {
const url = playUrl.toLowerCase()
if (url.includes('plex') || url.includes('plex.tv')) {
detectedServerType = 'plex'
} else if (url.includes('jellyfin')) {
detectedServerType = 'jellyfin'
} else if (url.includes('emby')) {
detectedServerType = 'emby'
}
}
if (detectedServerType) {
await openApp(detectedServerType, playUrl, fallbackUrl)
} else {
// 无法检测到服务器类型,直接使用网页链接
window.open(fallbackUrl || playUrl, '_blank')
}
}
/**
* 打开豆瓣APP
* @param doubanId 豆瓣ID
* @param mediaType 媒体类型(电影/电视剧)
* @param title 媒体标题
* @param year 媒体年份
* @param fallbackUrl 备用网页链接
*/
export async function openDoubanApp(
doubanId: string,
mediaType?: string,
title?: string,
year?: string,
fallbackUrl?: string,
): Promise<void> {
const params: DoubanAppParams = {
doubanId,
mediaType,
title,
year,
fallbackUrl,
}
await openApp('douban', params, fallbackUrl)
}
/**
* 获取APP的下载链接
* @param appType APP类型
*/
export function getAppDownloadUrl(appType: AppType): string {
switch (appType) {
case 'plex':
return 'https://www.plex.tv/apps/'
case 'jellyfin':
return 'https://jellyfin.org/downloads/'
case 'emby':
return 'https://emby.media/download.html'
case 'trimemedia':
return 'https://trimemedia.com/download'
case 'douban':
return 'https://www.douban.com/doubanapp/'
default:
return ''
}
}
/**
* 检查是否安装了特定的APP
* 注意由于浏览器安全限制无法直接检测APP是否安装
* 这个方法主要用于提示用户
*/
export function checkAppInstalled(appType: AppType): boolean {
// 由于浏览器安全限制无法直接检测APP是否安装
// 这里可以根据用户代理或其他信息进行推测
// 目前返回false让系统总是尝试跳转
return false
}

137
src/utils/colorUtils.ts Normal file
View File

@@ -0,0 +1,137 @@
// 预定义的颜色数组,包含更多丰富的颜色选项
const COLORS = [
// 基础颜色
'#4caf50', // 绿色
'#2196f3', // 蓝色
'#ff9800', // 橙色
'#9c27b0', // 紫色
'#f44336', // 红色
'#00bcd4', // 青色
'#8bc34a', // 浅绿色
'#ff5722', // 深橙色
'#3f51b5', // 靛蓝色
'#009688', // 青绿色
'#e91e63', // 粉红色
'#673ab7', // 深紫色
'#ffc107', // 琥珀色
'#795548', // 棕色
'#607d8b', // 蓝灰色
// 扩展颜色
'#ff4081', // 深粉红色
'#00e676', // 浅绿色
'#ff6f00', // 深橙色
'#4fc3f7', // 浅蓝色
'#ba68c8', // 浅紫色
'#81c784', // 浅绿色
'#ffb74d', // 浅橙色
'#64b5f6', // 浅蓝色
'#f06292', // 浅粉红色
'#4db6ac', // 浅青绿色
'#aed581', // 浅绿色
'#ffd54f', // 浅黄色
'#7986cb', // 浅靛蓝色
'#4dd0e1', // 浅青色
'#ff8a65', // 浅红色
'#9575cd', // 浅紫色
'#4fc3f7', // 天蓝色
'#ffcc02', // 金黄色
'#7cb342', // 浅绿色
'#42a5f5', // 蓝色
'#ab47bc', // 紫色
'#26a69a', // 青绿色
'#66bb6a', // 绿色
'#ff7043', // 深橙色
'#29b6f6', // 浅蓝色
'#7e57c2', // 紫色
'#26c6da', // 青色
'#9ccc65', // 浅绿色
'#ffb300', // 琥珀色
'#8d6e63', // 棕色
'#78909c', // 蓝灰色
'#ef5350', // 红色
'#ec407a', // 粉红色
'#ab47bc', // 紫色
'#42a5f5', // 蓝色
'#7cb342', // 绿色
'#ffa726', // 橙色
'#26c6da', // 青色
'#d4e157', // 浅绿色
'#ffca28', // 黄色
'#9fa8da', // 浅靛蓝色
'#80cbc4', // 浅青绿色
'#c5e1a5', // 浅绿色
'#ffe082', // 浅黄色
'#b39ddb', // 浅紫色
'#90caf9', // 浅蓝色
'#a5d6a7', // 浅绿色
'#ffcc80', // 浅橙色
'#b2dfdb', // 浅青绿色
'#f8bbd9', // 浅粉红色
'#c8e6c9', // 浅绿色
'#fff9c4', // 浅黄色
'#d1c4e9', // 浅紫色
'#bbdefb', // 浅蓝色
'#c8e6c9', // 浅绿色
'#ffecb3', // 浅琥珀色
'#d7ccc8', // 浅棕色
'#cfd8dc', // 浅蓝灰色
]
// 颜色缓存,确保同一项目总是获得相同颜色
const colorCache = new Map<string, string>()
/**
* 生成随机颜色
* @returns 随机颜色值
*/
export function generateRandomColor(): string {
return COLORS[Math.floor(Math.random() * COLORS.length)]
}
/**
* 为指定项目获取或生成颜色
* @param itemKey 项目的唯一标识
* @returns 颜色值
*/
export function getItemColor(itemKey: string): string {
if (!colorCache.has(itemKey)) {
colorCache.set(itemKey, generateRandomColor())
}
return colorCache.get(itemKey)!
}
/**
* 初始化项目颜色
* @param items 项目数组
* @param keyExtractor 从项目中提取唯一键的函数
*/
export function initializeItemColors<T>(items: T[], keyExtractor: (item: T) => string): void {
items.forEach(item => {
const key = keyExtractor(item)
getItemColor(key) // 这会自动缓存颜色
})
}
/**
* 清除颜色缓存
*/
export function clearColorCache(): void {
colorCache.clear()
}
/**
* 获取所有预定义颜色
* @returns 颜色数组
*/
export function getAllColors(): string[] {
return [...COLORS]
}
/**
* 获取颜色总数
* @returns 颜色数量
*/
export function getColorCount(): number {
return COLORS.length
}

View File

@@ -14,6 +14,8 @@ export class SSEManager {
reconnectDelay: number
maxReconnectAttempts: number
}
private reconnectAttempts = 0
private isConnecting = false
constructor(url: string, options: Partial<typeof SSEManager.prototype.options> = {}) {
this.url = url
@@ -21,7 +23,7 @@ export class SSEManager {
backgroundCloseDelay: 5000, // 5秒后关闭后台连接
reconnectDelay: 3000, // 3秒后重连
maxReconnectAttempts: 3,
...options
...options,
}
this.setupVisibilityListener()
@@ -44,15 +46,14 @@ export class SSEManager {
private handleBackground() {
this.isBackground = true
// 延迟关闭SSE连接避免频繁切换
if (this.backgroundCloseTimer) {
clearTimeout(this.backgroundCloseTimer)
}
this.backgroundCloseTimer = window.setTimeout(() => {
if (this.isBackground && this.eventSource) {
console.log('SSE: 后台关闭连接')
this.eventSource.close()
this.eventSource = null
}
@@ -61,51 +62,62 @@ export class SSEManager {
private handleForeground() {
this.isBackground = false
// 清除后台关闭定时器
if (this.backgroundCloseTimer) {
clearTimeout(this.backgroundCloseTimer)
this.backgroundCloseTimer = null
}
// 立即重新建立连接
if (!this.eventSource || this.eventSource.readyState === EventSource.CLOSED) {
console.log('SSE: 前台恢复连接')
// 只有在有活跃监听器时才重新建立连接
if (this.listeners.size > 0 && (!this.eventSource || this.eventSource.readyState === EventSource.CLOSED)) {
this.reconnectSSE()
}
}
private reconnectSSE(attemptCount = 0) {
if (attemptCount >= this.options.maxReconnectAttempts) {
console.warn('SSE: 达到最大重连次数')
return
}
if (this.isConnecting) {
return
}
// 如果没有活跃的监听器,不进行重连
if (this.listeners.size === 0) {
return
}
this.isConnecting = true
this.reconnectAttempts = attemptCount
try {
this.eventSource = new EventSource(this.url)
this.eventSource.onopen = () => {
console.log('SSE: 连接已建立')
this.isConnecting = false
this.reconnectAttempts = 0
}
this.eventSource.onerror = (error) => {
console.error('SSE: 连接错误', error)
this.eventSource.onerror = error => {
this.isConnecting = false
if (this.eventSource?.readyState === EventSource.CLOSED) {
// 连接已关闭,尝试重连
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.reconnectTimer = window.setTimeout(() => {
if (!this.isBackground) {
this.reconnectSSE(attemptCount + 1)
if (!this.isBackground && this.listeners.size > 0) {
this.reconnectSSE(this.reconnectAttempts + 1)
}
}, this.options.reconnectDelay)
}
}
this.eventSource.onmessage = (event) => {
this.eventSource.onmessage = event => {
// 分发消息给所有监听器
this.listeners.forEach(listener => {
try {
@@ -115,9 +127,19 @@ export class SSEManager {
}
})
}
} catch (error) {
console.error('SSE: 创建连接失败', error)
this.isConnecting = false
// 连接创建失败,尝试重连
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.reconnectTimer = window.setTimeout(() => {
if (!this.isBackground && this.listeners.size > 0) {
this.reconnectSSE(this.reconnectAttempts + 1)
}
}, this.options.reconnectDelay)
}
}
@@ -126,9 +148,9 @@ export class SSEManager {
*/
addMessageListener(id: string, listener: (event: MessageEvent) => void) {
this.listeners.set(id, listener)
// 如果还没有连接,现在建立连接
if (!this.eventSource && !this.isBackground) {
// 如果还没有连接且不在后台,现在建立连接
if (!this.eventSource && !this.isBackground && !this.isConnecting) {
this.reconnectSSE()
}
}
@@ -138,7 +160,7 @@ export class SSEManager {
*/
removeMessageListener(id: string) {
this.listeners.delete(id)
// 如果没有监听器了,关闭连接
if (this.listeners.size === 0) {
this.close()
@@ -153,18 +175,20 @@ export class SSEManager {
this.eventSource.close()
this.eventSource = null
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.backgroundCloseTimer) {
clearTimeout(this.backgroundCloseTimer)
this.backgroundCloseTimer = null
}
this.listeners.clear()
this.isConnecting = false
this.reconnectAttempts = 0
}
/**
@@ -180,6 +204,37 @@ export class SSEManager {
get connectionUrl(): string {
return this.url
}
/**
* 强制重新连接
*/
forceReconnect() {
this.close()
if (!this.isBackground && this.listeners.size > 0) {
this.reconnectSSE()
}
}
/**
* 检查是否有活跃的监听器
*/
get hasActiveListeners(): boolean {
return this.listeners.size > 0
}
/**
* 获取当前重连次数
*/
get currentReconnectAttempts(): number {
return this.reconnectAttempts
}
/**
* 检查是否达到最大重连次数
*/
get hasReachedMaxAttempts(): boolean {
return this.reconnectAttempts >= this.options.maxReconnectAttempts
}
}
/**
@@ -218,4 +273,4 @@ class SSEManagerSingleton {
}
}
export const sseManagerSingleton = new SSEManagerSingleton()
export const sseManagerSingleton = new SSEManagerSingleton()

212
src/utils/themeManager.ts Normal file
View File

@@ -0,0 +1,212 @@
// 主题管理器 - 动态加载主题CSS
export interface ThemeConfig {
name: string
cssPath: string
isLoaded: boolean
}
class ThemeManager {
private themes: Map<string, ThemeConfig> = new Map()
private currentTheme: string = 'default'
private loadedLinks: Map<string, HTMLLinkElement> = new Map()
constructor() {
// 注册所有可用主题
this.registerTheme('default', '')
this.registerTheme('light', '')
this.registerTheme('dark', '')
this.registerTheme('purple', '')
this.registerTheme('auto', '')
// 只有透明主题有特定的CSS文件
this.registerTheme('transparent', './src/styles/themes/transparent.css')
}
/**
* 注册主题
*/
registerTheme(name: string, cssPath: string): void {
this.themes.set(name, {
name,
cssPath,
isLoaded: false,
})
}
/**
* 获取当前主题
*/
getCurrentTheme(): string {
return this.currentTheme
}
/**
* 设置主题
*/
async setTheme(themeName: string): Promise<void> {
if (!this.themes.has(themeName)) {
console.warn(`Theme "${themeName}" not found`)
return
}
const theme = this.themes.get(themeName)!
// 清理其他主题的CSS除了当前要设置的主题
this.unloadOtherThemes()
// 如果主题有CSS文件则加载CSS
if (theme.cssPath) {
try {
await this.loadThemeCSS(themeName, theme.cssPath)
} catch (error) {
console.error(`Failed to load CSS for theme "${themeName}":`, error)
// 即使CSS加载失败也继续应用主题使用默认样式
}
}
// 应用主题无论是否有CSS文件
this.applyTheme(themeName)
}
/**
* 加载主题CSS文件
*/
private async loadThemeCSS(themeName: string, cssPath: string): Promise<void> {
// 如果已经加载过,直接返回
if (this.loadedLinks.has(themeName)) {
return
}
try {
// 动态导入CSS模块
if (themeName === 'transparent') {
await import('@/styles/themes/transparent.scss')
this.themes.get(themeName)!.isLoaded = true
return
}
// 对于其他主题使用传统的link方式
const link = document.createElement('link')
link.rel = 'stylesheet'
link.type = 'text/css'
link.href = cssPath
link.id = `theme-${themeName}`
// 等待CSS加载完成
await new Promise<void>((resolve, reject) => {
link.onload = () => {
this.loadedLinks.set(themeName, link)
this.themes.get(themeName)!.isLoaded = true
resolve()
}
link.onerror = () => {
reject(new Error(`Failed to load theme CSS: ${cssPath}`))
}
})
// 添加到head
document.head.appendChild(link)
} catch (error) {
console.error(`Error loading theme "${themeName}":`, error)
throw error
}
}
/**
* 应用主题到DOM
*/
private applyTheme(themeName: string): void {
// 移除之前的主题属性
document.documentElement.removeAttribute('data-theme')
// 设置新主题除了default主题
if (themeName !== 'default') {
document.documentElement.setAttribute('data-theme', themeName)
}
this.currentTheme = themeName
// 触发主题变更事件
this.dispatchThemeChangeEvent(themeName)
}
/**
* 卸载主题CSS
*/
unloadTheme(themeName: string): void {
const theme = this.themes.get(themeName)
if (!theme) return
// 对于动态导入的CSS我们无法直接卸载但可以标记为未加载
if (themeName === 'transparent') {
theme.isLoaded = false
return
}
// 对于传统link方式加载的CSS
const link = this.loadedLinks.get(themeName)
if (link) {
link.remove()
this.loadedLinks.delete(themeName)
theme.isLoaded = false
}
}
/**
* 卸载所有主题CSS除了当前主题
*/
unloadOtherThemes(): void {
for (const [themeName] of this.themes) {
if (themeName !== this.currentTheme && this.themes.get(themeName)?.isLoaded) {
this.unloadTheme(themeName)
}
}
}
/**
* 获取已注册的主题列表
*/
getAvailableThemes(): string[] {
return Array.from(this.themes.keys())
}
/**
* 检查主题是否已加载
*/
isThemeLoaded(themeName: string): boolean {
return this.themes.get(themeName)?.isLoaded || false
}
/**
* 触发主题变更事件
*/
private dispatchThemeChangeEvent(themeName: string): void {
const event = new CustomEvent('themechange', {
detail: { theme: themeName },
})
document.dispatchEvent(event)
}
/**
* 监听主题变更事件
*/
onThemeChange(callback: (theme: string) => void): void {
document.addEventListener('themechange', (event: any) => {
callback(event.detail.theme)
})
}
/**
* 移除主题变更监听器
*/
offThemeChange(callback: (theme: string) => void): void {
document.removeEventListener('themechange', (event: any) => {
callback(event.detail.theme)
})
}
}
// 创建单例实例
export const themeManager = new ThemeManager()
// 导出类型
export type { ThemeManager }

View File

@@ -16,6 +16,7 @@ import { useTheme } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { hasPermission } from '@/utils/permission'
import { useGlobalSettingsStore } from '@/stores'
import { openMediaServerWithAutoDetect, openDoubanApp } from '@/utils/appDeepLink'
// 国际化
const { t } = useI18n()
@@ -353,6 +354,18 @@ function getDoubanLink() {
return `https://movie.douban.com/subject/${mediaDetail.value.douban_id}`
}
// 处理豆瓣链接点击
async function handleDoubanClick() {
if (mediaDetail.value.douban_id) {
await openDoubanApp(
mediaDetail.value.douban_id,
mediaDetail.value.type,
mediaDetail.value.title,
mediaDetail.value.year,
)
}
}
// 拼装IMDB地址
function getImdbLink() {
return `https://www.imdb.com/title/${mediaDetail.value.imdb_id}`
@@ -475,10 +488,8 @@ async function handlePlay() {
try {
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
if (result?.success) {
// 打开链接地址
setTimeout(() => {
window.open(result.data.url, '_blank')
}, 100)
// 使用深度链接工具优先跳转到APP失败后跳转到网页
await openMediaServerWithAutoDetect(result.data.url, undefined, result.data.server_type)
} else {
$toast.error(`获取播放链接失败:${result.message}`)
}
@@ -608,7 +619,7 @@ onBeforeMount(() => {
<VListItem @click="clickSearch('title')">
<VListItemTitle>{{ t('media.search.byTitle') }}</VListItemTitle>
</VListItem>
<VListItem @click="clickSearch('imdb')">
<VListItem @click="clickSearch('imdbid')">
<VListItemTitle>{{ t('media.search.byImdb') }}</VListItemTitle>
</VListItem>
</VList>
@@ -669,19 +680,14 @@ onBeforeMount(() => {
<span class="ms-1">TheMovieDb</span>
</div>
</a>
<a
v-if="mediaDetail.douban_id"
class="mb-2 mr-2 inline-flex last:mr-0"
:href="getDoubanLink()"
target="_blank"
>
<div v-if="mediaDetail.douban_id" class="mb-2 mr-2 inline-flex last:mr-0" @click="handleDoubanClick">
<div
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
>
<VIcon icon="mdi-link" />
<span class="ms-1">豆瓣</span>
</div>
</a>
</div>
<a v-if="mediaDetail.imdb_id" class="mb-2 mr-2 inline-flex last:mr-0" :href="getImdbLink()" target="_blank">
<div
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"

View File

@@ -215,10 +215,7 @@ const defaultColor = '#2196F3'
// 计算过滤表单是否全部为空
const isFilterFormEmpty = computed(() => {
return (
filterForm.name === '' &&
filterForm.author.length === 0 &&
filterForm.label.length === 0 &&
filterForm.repo.length === 0
!filterForm.name && filterForm.author.length === 0 && filterForm.label.length === 0 && filterForm.repo.length === 0
)
})
@@ -1552,7 +1549,7 @@ function onDragStartPlugin(evt: any) {
/>
<!-- 插件搜索窗口 -->
<DialogWrapper
<VDialog
v-if="SearchDialog"
v-model="SearchDialog"
scrollable
@@ -1611,20 +1608,20 @@ function onDragStartPlugin(evt: any) {
</VVirtualScroll>
</VList>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 安装插件进度框 -->
<DialogWrapper v-if="progressDialog" v-model="progressDialog" :scrim="false" width="25rem">
<VDialog v-if="progressDialog" v-model="progressDialog" :scrim="false" width="25rem">
<VCard color="primary">
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear indeterminate color="white" class="mb-0 mt-1" />
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 新建文件夹对话框 -->
<DialogWrapper v-if="newFolderDialog" v-model="newFolderDialog" max-width="400">
<VDialog v-if="newFolderDialog" v-model="newFolderDialog" max-width="400">
<VCard>
<VDialogCloseBtn @click="newFolderDialog = false" />
<VCardItem>
@@ -1646,5 +1643,5 @@ function onDragStartPlugin(evt: any) {
}}</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -12,6 +12,38 @@ const systemEnv = ref<any>({})
// 所有Release
const allRelease = ref<any>([])
// 支持站点
const supportingSites = ref<any>({})
// 支持站点折叠状态
const sitesExpanded = ref(false)
// 去重后的支持站点
const uniqueSupportingSites = computed(() => {
const sitesMap = new Map()
Object.entries(supportingSites.value).forEach(([domain, site]: [string, any]) => {
if (!sitesMap.has(site.name)) {
sitesMap.set(site.name, {
name: site.name,
urls: [{ domain, url: site.url }],
})
} else {
sitesMap.get(site.name).urls.push({ domain, url: site.url })
}
})
return Array.from(sitesMap.values())
})
// 显示的支持站点折叠时只显示前5个
const displayedSites = computed(() => {
if (sitesExpanded.value) {
return uniqueSupportingSites.value
}
return uniqueSupportingSites.value.slice(0, 5)
})
// 变更日志对话框
const releaseDialog = ref(false)
@@ -56,6 +88,20 @@ async function queryAllRelease() {
}
}
// 查询支持站点
async function querySupportingSites() {
try {
supportingSites.value = await api.get('site/supporting')
} catch (error) {
console.log(error)
}
}
// 切换站点列表展开状态
function toggleSitesExpanded() {
sitesExpanded.value = !sitesExpanded.value
}
// 计算发布时间
function releaseTime(releaseDate: string) {
// 上一次更新时间
@@ -65,6 +111,7 @@ function releaseTime(releaseDate: string) {
onMounted(() => {
querySystemEnv()
queryAllRelease()
querySupportingSites()
})
</script>
@@ -156,6 +203,36 @@ onMounted(() => {
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 mt-1 ms-1">
<VChip v-for="site in displayedSites" :key="site.name" variant="outlined" size="small">
<span class="truncate max-w-32">{{ site.name }}</span>
</VChip>
<VChip
v-if="!sitesExpanded && uniqueSupportingSites.length > 5"
variant="tonal"
size="small"
@click="toggleSitesExpanded"
>
<span> {{ uniqueSupportingSites.length }}+ ...</span>
</VChip>
<VChip
v-if="sitesExpanded && uniqueSupportingSites.length > 5"
variant="tonal"
size="small"
@click="toggleSitesExpanded"
>
<span>< {{ t('setting.about.collapse') }}</span>
</VChip>
</div>
</div>
</dd>
</div>
</div>
</dl>
</div>
</div>
@@ -171,12 +248,12 @@ onMounted(() => {
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow undefined">
<a
href="https://wiki.movie-pilot.org"
href="https://movie-pilot.org"
target="_blank"
rel="noreferrer"
class="text-indigo-500 transition duration-300 hover:underline"
>
https://wiki.movie-pilot.org
https://movie-pilot.org
</a>
</span>
</dd>
@@ -261,7 +338,7 @@ onMounted(() => {
</div>
</div>
</div>
<DialogWrapper v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard>
<VCardItem>
<VDialogCloseBtn @click="releaseDialog = false" />
@@ -269,7 +346,7 @@ onMounted(() => {
</VCardItem>
<VCardText v-html="releaseDialogBody" />
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style type="scss" scoped>

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