mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-09 08:12:40 +08:00
Compare commits
196 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01835c0ac5 | ||
|
|
e5749bd6ef | ||
|
|
689e58737b | ||
|
|
38da061cf1 | ||
|
|
e79940e52e | ||
|
|
88dd6068b6 | ||
|
|
7dd10f9c96 | ||
|
|
94aaf83107 | ||
|
|
e84fc5f424 | ||
|
|
f342b08179 | ||
|
|
0fcad02f3b | ||
|
|
43d2406ee9 | ||
|
|
78e2d05730 | ||
|
|
425bf808ed | ||
|
|
6d2916dc9f | ||
|
|
2281e4224b | ||
|
|
95282f9883 | ||
|
|
b470f182c9 | ||
|
|
0bba1068af | ||
|
|
947a7d8296 | ||
|
|
bd36cbf888 | ||
|
|
d8fa47bff7 | ||
|
|
1132beea5e | ||
|
|
2e3314e6c3 | ||
|
|
daa8f857f8 | ||
|
|
6d14271fe8 | ||
|
|
9284d48f67 | ||
|
|
c5d1c5a468 | ||
|
|
b98512789f | ||
|
|
6b8ed8d527 | ||
|
|
ec4500dcef | ||
|
|
288e63ce68 | ||
|
|
b3885584bb | ||
|
|
968b24be1e | ||
|
|
5a23c1783a | ||
|
|
ddeeb5a7c3 | ||
|
|
0b9bbcc7b8 | ||
|
|
022c8b4515 | ||
|
|
be04991928 | ||
|
|
34770567a5 | ||
|
|
6154fc2157 | ||
|
|
e77dcdd3d4 | ||
|
|
58a3532c1b | ||
|
|
116a5eeb43 | ||
|
|
decd50cb40 | ||
|
|
355563244c | ||
|
|
51aad628b5 | ||
|
|
7dd7a2cf34 | ||
|
|
4c0ff7c7f2 | ||
|
|
8aba3cbe00 | ||
|
|
e21c3ec507 | ||
|
|
fdbb0b2ca8 | ||
|
|
180195ab7d | ||
|
|
8add4e6b46 | ||
|
|
3d622d2efe | ||
|
|
bb7ed7b963 | ||
|
|
d541ea41ad | ||
|
|
7c7ebc9eb7 | ||
|
|
22275c3b12 | ||
|
|
8744a34e8e | ||
|
|
e98836fd0e | ||
|
|
feb62196a2 | ||
|
|
9fd29a2958 | ||
|
|
546c82ca40 | ||
|
|
f132dc38f4 | ||
|
|
58c70b8ca6 | ||
|
|
147f55eefe | ||
|
|
229b7b0c12 | ||
|
|
4b7b5ff8a4 | ||
|
|
4906bde746 | ||
|
|
a87a1a8988 | ||
|
|
e05f45e681 | ||
|
|
b4acacea81 | ||
|
|
fa9645b05b | ||
|
|
1ed4052814 | ||
|
|
7dc814461f | ||
|
|
9154ec0e8c | ||
|
|
3a2ea60583 | ||
|
|
b36bff3a1e | ||
|
|
b3d8cbf280 | ||
|
|
38fb02d112 | ||
|
|
2597f893cd | ||
|
|
ebdd036654 | ||
|
|
5032f0e6a9 | ||
|
|
ad963d718d | ||
|
|
69d314bce3 | ||
|
|
4a7425a947 | ||
|
|
c172ac0d5c | ||
|
|
01a66493a8 | ||
|
|
188f8b3faa | ||
|
|
ebcf5fad71 | ||
|
|
d1a656db82 | ||
|
|
4f6a11fd7c | ||
|
|
1d09a946bb | ||
|
|
6c4eb7edbd | ||
|
|
4f9f669ac6 | ||
|
|
f9e0e78473 | ||
|
|
b004facfca | ||
|
|
fb6ee2910f | ||
|
|
3fedc9b730 | ||
|
|
b260427312 | ||
|
|
dd1447e93c | ||
|
|
dbcc213562 | ||
|
|
1c019cd5c8 | ||
|
|
e37bde77a1 | ||
|
|
57bf0d2021 | ||
|
|
88b00f7069 | ||
|
|
7b08cbb2f7 | ||
|
|
97c0ec184d | ||
|
|
d18c845088 | ||
|
|
a64d97774d | ||
|
|
2ddc51aa4f | ||
|
|
28afe2a922 | ||
|
|
c2e97bf191 | ||
|
|
c922752a1f | ||
|
|
08f36a74ca | ||
|
|
d7809dd00c | ||
|
|
27582004da | ||
|
|
3d6a176cde | ||
|
|
4a2073a038 | ||
|
|
c8a65ecbe4 | ||
|
|
3750d5cba0 | ||
|
|
55b383780e | ||
|
|
6aec0ddf88 | ||
|
|
7c8e94d1df | ||
|
|
5ecbf626c8 | ||
|
|
584f580e3b | ||
|
|
280de47dac | ||
|
|
c7c05f5897 | ||
|
|
bb86180582 | ||
|
|
aff228edd3 | ||
|
|
f65ae6d703 | ||
|
|
0fccc06883 | ||
|
|
8652966645 | ||
|
|
6d84eb9f09 | ||
|
|
1a3dccac29 | ||
|
|
fa8de34fc5 | ||
|
|
10cfd6be80 | ||
|
|
a390b36e7c | ||
|
|
d6b5994e22 | ||
|
|
08611a97e7 | ||
|
|
35bbb44ce3 | ||
|
|
8ff879661a | ||
|
|
a8f01f099d | ||
|
|
040ab1096b | ||
|
|
0cbdf24315 | ||
|
|
164ea79bd1 | ||
|
|
97f3435bb3 | ||
|
|
63b108ff6b | ||
|
|
b0880cb369 | ||
|
|
5f70ee8e18 | ||
|
|
4c64f7a2c3 | ||
|
|
262927e459 | ||
|
|
b16c566004 | ||
|
|
1af82dbee6 | ||
|
|
2e9a5a4e13 | ||
|
|
b455f603dc | ||
|
|
37c0c3e339 | ||
|
|
b6cb341082 | ||
|
|
1af1a06700 | ||
|
|
79e4ecfdbe | ||
|
|
1585271e37 | ||
|
|
c240b171e4 | ||
|
|
9c405e90ac | ||
|
|
3ec3212ca5 | ||
|
|
b1289f6177 | ||
|
|
64b7ba48c8 | ||
|
|
f093053ea4 | ||
|
|
9faa0ded59 | ||
|
|
0f7dafeb23 | ||
|
|
472d1960d9 | ||
|
|
6e50acf106 | ||
|
|
a3fb4b1534 | ||
|
|
382cae32a2 | ||
|
|
0aa4851f8e | ||
|
|
65271e6d13 | ||
|
|
671cf8d588 | ||
|
|
afc7c81028 | ||
|
|
c330aee560 | ||
|
|
eafe63c886 | ||
|
|
53206d05b8 | ||
|
|
af085d457e | ||
|
|
fb36033939 | ||
|
|
584e7672df | ||
|
|
d4f7a5a1c0 | ||
|
|
2a9ea81ad4 | ||
|
|
276948dd68 | ||
|
|
990c5583f2 | ||
|
|
644f1b5640 | ||
|
|
5261fbe870 | ||
|
|
e4f2d85e2b | ||
|
|
8e3ccdc24a | ||
|
|
cd6d93affd | ||
|
|
6096ab0c9b | ||
|
|
0a87bb1db1 | ||
|
|
a19042c655 |
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -245,13 +245,21 @@ const props = defineProps({
|
||||
|
||||
<template>
|
||||
<div class="dashboard-widget">
|
||||
<!-- 仪表板内容 -->
|
||||
<v-card>
|
||||
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- 组件内容 -->
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-hover>
|
||||
<!-- 仪表板内容 -->
|
||||
<template #default="{ isHovering, props: hoverProps }">
|
||||
<v-card v-bind="hoverProps">
|
||||
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- 组件内容 -->
|
||||
</v-card-text>
|
||||
<!-- 只在悬停时显示拖拽图标 -->
|
||||
<div v-show="isHovering" class="absolute right-5 top-5">
|
||||
<v-icon class="cursor-move">mdi-drag</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-hover>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
717
index.html
717
index.html
@@ -1,430 +1,363 @@
|
||||
<!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));
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
"
|
||||
>
|
||||
<head>
|
||||
<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" />
|
||||
<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, interactive-widget=resizes-content" />
|
||||
|
||||
<!-- 基础信息 -->
|
||||
<meta name="description" content="MoviePilot - 智能影视媒体库管理工具" />
|
||||
<meta name="author" content="MoviePilot" />
|
||||
<meta name="keywords" content="MoviePilot,影视,媒体库,管理" />
|
||||
<!-- 防止缩放和选择,提供原生应用体验 -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||
|
||||
<!-- 安全和隐私 -->
|
||||
<meta name="Robots" content="noindex,nofollow,noarchive" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<!-- 基础信息 -->
|
||||
<meta name="description" content="MoviePilot - 智能影视媒体库管理工具" />
|
||||
<meta name="author" content="MoviePilot" />
|
||||
<meta name="keywords" content="MoviePilot,影视,媒体库,管理" />
|
||||
|
||||
<!-- 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" />
|
||||
<!-- 安全和隐私 -->
|
||||
<meta name="Robots" content="noindex,nofollow,noarchive" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
||||
<!-- 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" />
|
||||
<!-- 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 全屏模式 -->
|
||||
<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 PWA 优化 -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
|
||||
|
||||
<!-- iOS Safari 防止自动识别 -->
|
||||
<meta name="apple-mobile-web-app-orientations" content="portrait" />
|
||||
<!-- 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" />
|
||||
|
||||
<!-- 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" />
|
||||
<!-- iOS Safari 防止自动识别 -->
|
||||
<meta name="apple-mobile-web-app-orientations" content="portrait" />
|
||||
|
||||
<!-- 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" />
|
||||
<!-- 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" />
|
||||
|
||||
<!-- 主题色彩 - 适配深色和浅色模式 -->
|
||||
<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" />
|
||||
<!-- 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="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="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" />
|
||||
|
||||
<!-- UC浏览器优化 -->
|
||||
<meta name="browsermode" content="application" />
|
||||
<meta name="wap-font-scale" content="no" />
|
||||
<!-- 屏幕方向锁定 -->
|
||||
<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" />
|
||||
|
||||
<!-- 360浏览器优化 -->
|
||||
<meta name="renderer" content="webkit" />
|
||||
<!-- UC浏览器优化 -->
|
||||
<meta name="browsermode" content="application" />
|
||||
<meta name="wap-font-scale" content="no" />
|
||||
|
||||
<!-- 触摸优化 -->
|
||||
<meta name="HandheldFriendly" content="True" />
|
||||
<meta name="MobileOptimized" content="320" />
|
||||
<!-- 360浏览器优化 -->
|
||||
<meta name="renderer" content="webkit" />
|
||||
|
||||
<!-- 缓存控制 -->
|
||||
<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 name="HandheldFriendly" content="True" />
|
||||
<meta name="MobileOptimized" content="320" />
|
||||
|
||||
<!-- 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 />
|
||||
<!-- 缓存控制 -->
|
||||
<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" />
|
||||
|
||||
<!-- 预加载关键资源 -->
|
||||
<link rel="preload" href="/logo.png" as="image" />
|
||||
<link rel="modulepreload" href="/src/main.ts" />
|
||||
<!-- 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 />
|
||||
|
||||
<!-- 内联关键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;
|
||||
<style>
|
||||
#app {
|
||||
block-size: 100%;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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% {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-opacity {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
/* 添加logo完成动画 - 放大虚化效果 */
|
||||
.loading-complete .loading-logo {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加加载背景消失动画 - 放大虚化效果 */
|
||||
.loading-complete {
|
||||
filter: blur(15px);
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
/* 超时通知样式 */
|
||||
#loading-timeout {
|
||||
position: absolute;
|
||||
z-index: 2500;
|
||||
display: none;
|
||||
inset-block-end: 20px;
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#timeout-btn {
|
||||
color: var(--initial-loader-color, #9155FD);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
margin-inline-start: 8px;
|
||||
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 检测系统主题是否为深色模式
|
||||
function checkPrefersColorSchemeIsDark() {
|
||||
try {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
// 主题色彩初始化
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
|
||||
// 检查主题设置
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
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)
|
||||
|
||||
// 清除缓存处理逻辑
|
||||
window.clearAndReload = async function() {
|
||||
try {
|
||||
// 1. 清除所有缓存
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)))
|
||||
console.log('[VersionChecker] 已清除所有缓存')
|
||||
}
|
||||
// 2. 注销 Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map(registration => registration.unregister()))
|
||||
console.log('[VersionChecker] 已注销所有 Service Worker')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VersionChecker] 清除缓存时出错:', e)
|
||||
} finally {
|
||||
// 3. 重载页面
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
window.location.replace(url.pathname + url.search + url.hash)
|
||||
}
|
||||
};
|
||||
|
||||
/* 完成时隐藏加载动画 */
|
||||
.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);
|
||||
setTimeout(function() {
|
||||
const timeoutEl = document.getElementById('loading-timeout');
|
||||
if (timeoutEl) {
|
||||
// 适配多语言
|
||||
const lang = navigator.language || 'zh-CN';
|
||||
const messages = {
|
||||
'zh-CN': {
|
||||
text: '页面加载似乎遇到了阻碍,请尝试',
|
||||
btn: '清除缓存'
|
||||
},
|
||||
'zh-TW': {
|
||||
text: '頁面載入似乎遇到了阻礙,請嘗試',
|
||||
btn: '清除快取'
|
||||
},
|
||||
'en-US': {
|
||||
text: 'Page loading seems to be blocked, please try',
|
||||
btn: 'Clear Cache'
|
||||
}
|
||||
};
|
||||
|
||||
// 默认匹配前缀,如 en-GB 匹配 en-US 的逻辑
|
||||
let msg = messages['zh-CN'];
|
||||
if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) {
|
||||
msg = messages['zh-TW'];
|
||||
} else if (lang.startsWith('en')) {
|
||||
msg = messages['en-US'];
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
const textNode = document.createTextNode(msg.text + ' ');
|
||||
const btnLink = document.createElement('a');
|
||||
btnLink.href = 'javascript:void(0)';
|
||||
btnLink.id = 'timeout-btn';
|
||||
btnLink.onclick = window.clearAndReload;
|
||||
btnLink.textContent = msg.btn;
|
||||
|
||||
timeoutEl.innerHTML = '';
|
||||
timeoutEl.appendChild(textNode);
|
||||
timeoutEl.appendChild(btnLink);
|
||||
timeoutEl.style.display = 'block';
|
||||
}
|
||||
}, 15000); // 15秒后显示超时提示
|
||||
</script>
|
||||
</head>
|
||||
|
||||
@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="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
|
||||
style="fill: url(#_Linear1)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
|
||||
style="fill: url(#_Linear2)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
|
||||
style="fill: rgb(141, 81, 249)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
|
||||
style="fill: url(#_Linear3)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
|
||||
style="fill: rgb(165, 118, 255)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
|
||||
style="fill: url(#_Linear4)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
|
||||
<path
|
||||
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
|
||||
style="fill: rgb(141, 81, 249)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
|
||||
style="fill: rgb(104, 0, 197)"
|
||||
/>
|
||||
<clipPath id="_clip5">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
|
||||
/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip5)">
|
||||
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
|
||||
<path
|
||||
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
|
||||
style="fill: url(#_Linear6)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
|
||||
style="fill: rgb(141, 81, 249)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
|
||||
style="fill: url(#_Radial7)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="_Linear1"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear2"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear3"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear4"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear6"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
id="_Radial7"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160px" height="160px" />
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
<!-- 超时提示 - 默认隐藏 -->
|
||||
<div id="loading-timeout"></div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.7.0",
|
||||
"version": "2.9.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
@@ -27,6 +27,7 @@
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@fullcalendar/vue3": "^6.1.15",
|
||||
"@iconify/utils": "^2.2.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.2",
|
||||
@@ -40,8 +41,10 @@
|
||||
"ace-builds": "^1.37.4",
|
||||
"apexcharts": "^4.0.0",
|
||||
"axios": "^1.7.9",
|
||||
"body-scroll-lock": "^3.1.5",
|
||||
"colorthief": "^2.6.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-http-proxy": "^2.1.1",
|
||||
@@ -72,6 +75,7 @@
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
"@originjs/vite-plugin-federation": "^1.4.1",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^20.1.4",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 102 KiB |
53
public/logo.svg
Normal file
53
public/logo.svg
Normal file
@@ -0,0 +1,53 @@
|
||||
<svg width="3em" height="3em" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||
<rect x="0" y="0" width="192" height="192" style="fill:none;"/>
|
||||
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
|
||||
<path d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z" style="fill:url(#_Linear1);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z" style="fill:url(#_Linear2);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z" style="fill:rgb(141,81,249);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z" style="fill:url(#_Linear3);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z" style="fill:rgb(165,118,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z" style="fill:url(#_Linear4);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
|
||||
<path d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z" style="fill:rgb(141,81,249);"/>
|
||||
</g>
|
||||
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
|
||||
<path d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z" style="fill:rgb(104,0,197);"/>
|
||||
<clipPath id="_clip5">
|
||||
<path d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip5)">
|
||||
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
|
||||
<path d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z" style="fill:url(#_Linear6);"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z" style="fill:rgb(141,81,249);"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z" style="fill:url(#_Radial7);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)"><stop offset="0" style="stop-color:rgb(141,81,249);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(116,50,223);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)"><stop offset="0" style="stop-color:rgb(141,81,249);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(116,50,223);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)"><stop offset="0" style="stop-color:rgb(211,187,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(211,187,255);stop-opacity:0"/></linearGradient>
|
||||
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)"><stop offset="0" style="stop-color:rgb(211,187,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(211,187,255);stop-opacity:0"/></linearGradient>
|
||||
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)"><stop offset="0" style="stop-color:rgb(116,50,223);stop-opacity:1"/><stop offset="0.51" style="stop-color:rgb(110,38,217);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(91,0,197);stop-opacity:1"/></linearGradient>
|
||||
<radialGradient id="_Radial7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)"><stop offset="0" style="stop-color:rgb(211,187,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(211,187,255);stop-opacity:0"/></radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
1
shims.d.ts
vendored
1
shims.d.ts
vendored
@@ -12,3 +12,4 @@ declare module 'vue-prism-component' {
|
||||
export default component
|
||||
}
|
||||
declare module 'vue-shepherd';
|
||||
declare module 'colorthief';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -46,10 +46,9 @@ $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.v-overlay-scroll-blocked &.window-scrolled.layout-navbar-fixed {
|
||||
|
||||
#{$header} {
|
||||
padding-inline: 1rem;
|
||||
|
||||
@@ -45,7 +45,7 @@ code {
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease-in-out;
|
||||
transition: padding 0.3s ease-in-out;
|
||||
|
||||
.v-theme--light & {
|
||||
background: rgba(var(--v-theme-surface), 0.6);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use "sass:map";
|
||||
@use "vuetify/lib/styles/settings" as vuetify_settings;
|
||||
@use "vuetify/lib/styles/settings/_index.sass" as vuetify_settings;
|
||||
@use "@styles/variables/_vuetify.scss" as vuetify;
|
||||
|
||||
@mixin themed($property, $light-value, $dark-value) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.auth-wrapper {
|
||||
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
min-block-size: 100%;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
.auth-footer-mask {
|
||||
|
||||
@@ -23,6 +23,13 @@ export function kFormatter(num: number) {
|
||||
: Math.abs(num).toFixed(0).replace(regex, ',')
|
||||
}
|
||||
|
||||
// 格式化下载量显示,超过1000显示为x.xk格式
|
||||
export function formatDownloadCount(num: number): string {
|
||||
if (!num || num < 1000) return num?.toLocaleString() || '0'
|
||||
|
||||
return `${(num / 1000).toFixed(1)}k`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format and return date in Humanize format
|
||||
* Intl docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format
|
||||
|
||||
@@ -35,6 +35,19 @@ export function urlBase64ToUint8Array(base64String: string) {
|
||||
return outputArray
|
||||
}
|
||||
|
||||
// Uint8Array 转 Base64URL
|
||||
export function bufferToBase64Url(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '')
|
||||
}
|
||||
|
||||
// Base64URL 转 Uint8Array
|
||||
export function base64UrlToUint8Array(base64Url: string): Uint8Array {
|
||||
return Uint8Array.from(atob(base64Url.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
}
|
||||
|
||||
// 判断是否为PWA
|
||||
export const isPWA = async (): Promise<boolean> => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
||||
@@ -17,11 +17,34 @@ export default defineComponent({
|
||||
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
|
||||
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
const isDialogOpen = ref(false)
|
||||
const wasScrolledBeforeDialog = ref(false)
|
||||
|
||||
// 监听弹窗状态变化
|
||||
const checkDialogState = () => {
|
||||
const wasDialogOpen = isDialogOpen.value
|
||||
isDialogOpen.value = document.documentElement.classList.contains('v-overlay-scroll-blocked')
|
||||
|
||||
// 当弹窗刚打开时,记录当前的滚动状态
|
||||
if (!wasDialogOpen && isDialogOpen.value) {
|
||||
wasScrolledBeforeDialog.value = scrollDistance.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
})
|
||||
|
||||
// 初始检查弹窗状态
|
||||
checkDialogState()
|
||||
|
||||
// 监听 DOM 变化以检测弹窗状态
|
||||
const observer = new MutationObserver(checkDialogState)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -88,9 +111,6 @@ export default defineComponent({
|
||||
},
|
||||
})
|
||||
|
||||
// 检查是否有弹窗打开(通过CSS类名判断)
|
||||
const isDialogOpen = document.documentElement.classList.contains('dialog-scroll-locked')
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
@@ -99,7 +119,7 @@ export default defineComponent({
|
||||
'layout-navbar-fixed',
|
||||
mdAndDown.value && 'layout-overlay-nav',
|
||||
route.meta.layoutWrapperClasses,
|
||||
(scrollDistance.value || isDialogOpen) && 'window-scrolled',
|
||||
(scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
html {
|
||||
background: rgb(var(--v-theme-background));
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
overflow-y: overlay;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -40,8 +40,8 @@ body,
|
||||
|
||||
// TODO: Use grid gutter variable here;
|
||||
padding-block: 1.5rem;
|
||||
padding-inline: 0.5rem;
|
||||
padding-block-start: calc(env(safe-area-inset-top) + 4.5rem);
|
||||
padding-inline: 0.5rem;
|
||||
|
||||
// display: flex;display
|
||||
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom))
|
||||
min-block-size: 100%;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ValidationRule } from 'vuetify/types/services/validation'
|
||||
type ValidationRule = (value: any) => string | boolean
|
||||
|
||||
// 必输校验
|
||||
export const requiredValidator: ValidationRule = (value: any) => {
|
||||
|
||||
44
src/App.vue
44
src/App.vue
@@ -15,7 +15,7 @@ import { themeManager } from '@/utils/themeManager'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
@@ -38,6 +38,9 @@ const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -45,6 +48,33 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
if (heartbeatInterval) {
|
||||
stopHeartbeat()
|
||||
}
|
||||
|
||||
// 开始心跳任务
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
try {
|
||||
if (isLogin.value) {
|
||||
await api.get('dashboard/cpu')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatInterval) {
|
||||
window.clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// 配置 ApexCharts 全局选项
|
||||
function configureApexCharts() {
|
||||
if (typeof window !== 'undefined' && window.Apex) {
|
||||
@@ -207,6 +237,14 @@ async function loadBackgroundImages(retryCount = 0) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 移除URL中的时间戳参数
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.has('_t')) {
|
||||
url.searchParams.delete('_t')
|
||||
const newUrl = url.pathname + url.search + url.hash
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
}
|
||||
|
||||
// 配置 ApexCharts
|
||||
configureApexCharts()
|
||||
|
||||
@@ -234,11 +272,15 @@ onMounted(async () => {
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
})
|
||||
// 启动心跳
|
||||
startHeartbeat()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清除背景轮换定时器
|
||||
removeBackgroundTimer('background-rotation')
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -314,6 +314,8 @@ export interface MediaInfo {
|
||||
production_countries?: any[]
|
||||
// 语种
|
||||
spoken_languages?: string[]
|
||||
// 数字/实体发行日期
|
||||
release_dates?: MediaRelease[]
|
||||
// 状态
|
||||
status?: string
|
||||
// 标签
|
||||
@@ -368,6 +370,18 @@ export interface TmdbSeason {
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
// 发行信息
|
||||
export interface MediaRelease {
|
||||
// 发行日期
|
||||
date: string
|
||||
// 发行地区
|
||||
iso_code: string
|
||||
// 备注
|
||||
note?: string
|
||||
// 发行类型
|
||||
type: number
|
||||
}
|
||||
|
||||
// TMDB集信息
|
||||
export interface TmdbEpisode {
|
||||
// 上映日期
|
||||
@@ -520,7 +534,7 @@ export interface SiteUserData {
|
||||
// 用户名
|
||||
username?: string
|
||||
// 用户ID
|
||||
userid?: number
|
||||
userid?: string
|
||||
// 用户等级
|
||||
user_level?: string
|
||||
// 加入时间
|
||||
@@ -992,6 +1006,8 @@ export interface MediaServerPlayItem {
|
||||
percent?: number
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
// 图片是否需要Cookies
|
||||
use_cookies?: boolean
|
||||
}
|
||||
|
||||
// 媒体服务器媒体库
|
||||
@@ -1014,6 +1030,8 @@ export interface MediaServerLibrary {
|
||||
link?: string
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
// 图片是否需要Cookies
|
||||
use_cookies?: boolean
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
@@ -1066,6 +1084,8 @@ export interface DownloaderConf {
|
||||
config: { [key: string]: any }
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
// 路径映射
|
||||
path_mapping?: Array<[storagePath: string, downloadPath: string]>
|
||||
}
|
||||
|
||||
// 通知配置
|
||||
|
||||
BIN
src/assets/images/logos/discord.png
Normal file
BIN
src/assets/images/logos/discord.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -4,6 +4,12 @@ import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
|
||||
// LocalStorage keys
|
||||
const SORT_KEY = 'fileBrowser.sort'
|
||||
const SHOW_TREE_KEY = 'fileBrowser.showDirTree'
|
||||
const NAV_WIDTH_KEY = 'fileBrowser.navigatorWidth'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -11,7 +17,7 @@ const props = defineProps({
|
||||
tree: Boolean,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
axiosconfig: Object,
|
||||
@@ -119,22 +125,33 @@ const fileIcons = {
|
||||
|
||||
// 加载次数
|
||||
const loading = ref(0)
|
||||
// 当前存储
|
||||
const activeStorage = ref('local')
|
||||
|
||||
// 刷新
|
||||
const refreshPending = ref(false)
|
||||
// 排序
|
||||
const sort = ref('name')
|
||||
// 排序 - 从localStorage恢复
|
||||
const sort = ref(localStorage.getItem(SORT_KEY) || 'name')
|
||||
|
||||
// 是否显示目录树
|
||||
const showDirTree = ref(false)
|
||||
// 是否显示目录树 - 从localStorage恢复
|
||||
const showDirTree = ref(localStorage.getItem(SHOW_TREE_KEY) === 'true')
|
||||
|
||||
// 拖动分隔条相关
|
||||
const navigatorWidth = ref(280) // 初始宽度
|
||||
// 拖动分隔条相关 - 从localStorage恢复宽度
|
||||
const navigatorWidth = ref(parseInt(localStorage.getItem(NAV_WIDTH_KEY) || '280'))
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartWidth = ref(0)
|
||||
|
||||
watch(sort, (val) => {
|
||||
localStorage.setItem(SORT_KEY, val)
|
||||
})
|
||||
|
||||
watch(showDirTree, (val) => {
|
||||
localStorage.setItem(SHOW_TREE_KEY, String(val))
|
||||
})
|
||||
|
||||
watch(navigatorWidth, (val) => {
|
||||
localStorage.setItem(NAV_WIDTH_KEY, String(val))
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const storagesArray = computed(() => {
|
||||
return props.storages?.map(item => ({
|
||||
@@ -144,15 +161,15 @@ const storagesArray = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
// 方法
|
||||
function loadingChanged(loading: number) {
|
||||
if (loading) loading++
|
||||
else if (loading > 0) loading--
|
||||
function loadingChanged(isLoading: number) {
|
||||
if (isLoading) loading.value++
|
||||
else if (loading.value > 0) loading.value--
|
||||
}
|
||||
|
||||
// 存储切换
|
||||
async function storageChanged(storage: string) {
|
||||
activeStorage.value = storage
|
||||
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
|
||||
}
|
||||
|
||||
@@ -235,12 +252,12 @@ function stopDrag() {
|
||||
|
||||
<template>
|
||||
<div class="mx-auto" :loading="loading > 0">
|
||||
<div v-if="activeStorage && item">
|
||||
<div v-if="item">
|
||||
<FileToolbar
|
||||
:sort="sort"
|
||||
:item="item"
|
||||
:itemstack="itemstack"
|
||||
:storages="storagesArray"
|
||||
:storage="activeStorage"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
@storagechanged="storageChanged"
|
||||
@@ -251,7 +268,7 @@ function stopDrag() {
|
||||
<div class="flex">
|
||||
<FileNavigator
|
||||
v-if="showDirTree"
|
||||
:storage="activeStorage"
|
||||
:storage="item.storage"
|
||||
:currentPath="item.path"
|
||||
:items="fileListItems"
|
||||
:endpoints="endpoints"
|
||||
@@ -266,7 +283,6 @@ function stopDrag() {
|
||||
</div>
|
||||
<FileList
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,7 +26,12 @@ async function goPlay() {
|
||||
// 计算图片地址
|
||||
const getImgUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const use_cookies = props.media?.use_cookies
|
||||
if (use_cookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return url
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -116,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
|
||||
@@ -222,6 +222,6 @@ function onClose() {
|
||||
}}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,12 +4,10 @@ import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import qbittorrent_image from '@images/logos/qbittorrent.png'
|
||||
import transmission_image from '@images/logos/transmission.png'
|
||||
import custom_image from '@images/logos/downloader.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { downloaderDict } from '@/api/constants'
|
||||
import { downloaderDict, storageAttributes } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
|
||||
@@ -54,6 +52,54 @@ const download_rate = ref(0)
|
||||
// 下载器详情弹窗
|
||||
const downloaderInfoDialog = ref(false)
|
||||
|
||||
// 表单
|
||||
const downloaderForm = ref()
|
||||
|
||||
// 路径前缀选项
|
||||
const prefixOptions = computed(() => {
|
||||
return storageAttributes.map(item => ({
|
||||
title: t(`storage.${item.type}`),
|
||||
value: item.type,
|
||||
}))
|
||||
})
|
||||
|
||||
function getStorageType(path: string) {
|
||||
if (!path) return 'local'
|
||||
// 查找匹配的存储类型
|
||||
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
|
||||
return storage?.type || 'local'
|
||||
}
|
||||
|
||||
function storage2Prefix(storage: string) {
|
||||
return storage === 'local' ? '' : storage + ':'
|
||||
}
|
||||
|
||||
// 获取存储路径前后缀
|
||||
function parseStoragePath(path: string): [prefix: string, suffix: string] {
|
||||
if (!path) return ['', '']
|
||||
const storage = getStorageType(path)
|
||||
const prefix = storage2Prefix(storage)
|
||||
return [prefix, path.slice(prefix.length)]
|
||||
}
|
||||
|
||||
// 更新存储路径前缀
|
||||
function updateStoragePrefix(row: PathMappingRow, storage: string) {
|
||||
const [, currentSuffix] = parseStoragePath(row.storage)
|
||||
const prefix = storage2Prefix(storage)
|
||||
row.storage = prefix + currentSuffix
|
||||
}
|
||||
|
||||
// 更新存储路径后缀
|
||||
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
|
||||
const [currentPrefix] = parseStoragePath(row.storage)
|
||||
row.storage = currentPrefix + suffix
|
||||
}
|
||||
|
||||
const pathValidationRules = [
|
||||
(v: string) => !!v || t('downloader.pathMappingRequired'),
|
||||
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
|
||||
]
|
||||
|
||||
// 下载器详情
|
||||
const downloaderInfo = ref<DownloaderConf>({
|
||||
name: '',
|
||||
@@ -61,8 +107,24 @@ const downloaderInfo = ref<DownloaderConf>({
|
||||
default: false,
|
||||
enabled: false,
|
||||
config: {},
|
||||
path_mapping: [],
|
||||
})
|
||||
|
||||
// 路径映射行定义
|
||||
interface PathMappingRow {
|
||||
id: string
|
||||
storage: string
|
||||
download: string
|
||||
}
|
||||
|
||||
// 路径映射行数据
|
||||
const pathMappingRows = ref<PathMappingRow[]>([])
|
||||
|
||||
// 生成随机ID
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
// 下载器是否应该刷新数据的计算属性
|
||||
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
|
||||
|
||||
@@ -94,11 +156,24 @@ async function loadDownloaderInfo() {
|
||||
function openDownloaderInfoDialog() {
|
||||
// 深复制
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
// 初始化路径映射行数据
|
||||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||||
id: generateId(),
|
||||
storage: item[0],
|
||||
download: item[1],
|
||||
}))
|
||||
downloaderInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveDownloaderInfo() {
|
||||
async function saveDownloaderInfo() {
|
||||
// 表单校验
|
||||
const { valid } = await downloaderForm.value?.validate()
|
||||
if (!valid) return
|
||||
|
||||
// 同步路径映射数据
|
||||
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
|
||||
|
||||
// 为空不保存,跳出警告框
|
||||
if (!downloaderInfo.value.name) {
|
||||
$toast.error(t('downloader.nameRequired'))
|
||||
@@ -128,14 +203,28 @@ function saveDownloaderInfo() {
|
||||
const getIcon = computed(() => {
|
||||
switch (props.downloader.type) {
|
||||
case 'qbittorrent':
|
||||
return qbittorrent_image
|
||||
return getLogoUrl('qbittorrent')
|
||||
case 'transmission':
|
||||
return transmission_image
|
||||
return getLogoUrl('transmission')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('downloader')
|
||||
}
|
||||
})
|
||||
|
||||
// 添加路径映射
|
||||
function addPathMapping() {
|
||||
pathMappingRows.value.push({
|
||||
id: generateId(),
|
||||
storage: '',
|
||||
download: '',
|
||||
})
|
||||
}
|
||||
|
||||
// 移除路径映射
|
||||
function removePathMapping(index: number) {
|
||||
pathMappingRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
emit('close')
|
||||
@@ -147,13 +236,14 @@ const { stop: stopRefresh } = useConditionalDataRefresh(
|
||||
loadDownloaderInfo,
|
||||
shouldRefresh, // 响应式条件:只有当allowRefresh为true且downloader启用时才运行
|
||||
3000, // 3秒间隔
|
||||
true // 立即执行一次
|
||||
true, // 立即执行一次
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VHover v-slot="hover">
|
||||
@@ -196,7 +286,7 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
</VHover>
|
||||
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="downloaderInfoDialog"
|
||||
v-model="downloaderInfoDialog"
|
||||
scrollable
|
||||
@@ -214,7 +304,7 @@ onUnmounted(() => {
|
||||
<VDialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VForm ref="downloaderForm">
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
|
||||
@@ -375,6 +465,89 @@ onUnmounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VDivider class="my-2">
|
||||
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
|
||||
</VDivider>
|
||||
|
||||
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
|
||||
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
|
||||
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
|
||||
</div>
|
||||
|
||||
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="my-2">
|
||||
<VCardText class="pa-3">
|
||||
<VRow align="center" no-gutters>
|
||||
<VCol cols="12" class="mb-2">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
|
||||
</div>
|
||||
<VRow no-gutters>
|
||||
<VCol cols="12" sm="4" class="pe-2">
|
||||
<VSelect
|
||||
:model-value="getStorageType(row.storage)"
|
||||
:items="prefixOptions"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="v => updateStoragePrefix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="8">
|
||||
<VTextField
|
||||
:model-value="parseStoragePath(row.storage)[1]"
|
||||
:placeholder="'/path/to/storage'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
@update:model-value="v => updateStorageSuffix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="mb-1">
|
||||
<div class="d-flex align-center justify-center my-1">
|
||||
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
|
||||
</div>
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
|
||||
</div>
|
||||
<VTextField
|
||||
v-model="row.download"
|
||||
:placeholder="'/path/to/download'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="d-flex justify-end pt-1">
|
||||
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
|
||||
<VIcon icon="mdi-delete-outline" />
|
||||
</IconBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
@click="addPathMapping"
|
||||
class="mt-1"
|
||||
size="small"
|
||||
>
|
||||
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
@@ -383,6 +556,6 @@ onUnmounted(() => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MediaServerLibrary } from '@/api/types'
|
||||
import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import trimemedia from '@images/logos/trimemedia.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
@@ -40,7 +40,7 @@ function getDefaultImage() {
|
||||
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 if (props.media?.server_type === 'trimemedia') return getLogoUrl('trimemedia')
|
||||
else return plex
|
||||
}
|
||||
|
||||
@@ -52,40 +52,46 @@ async function goPlay() {
|
||||
}
|
||||
|
||||
// 生成图片代理路径
|
||||
function getImgUrl(url: string) {
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url) return getDefaultImage()
|
||||
else return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
if (use_cookies) {
|
||||
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return imgurl
|
||||
}
|
||||
|
||||
// 根据多张图片生成媒体库封面
|
||||
async function drawImages(imageList: string[]) {
|
||||
async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 图片
|
||||
const IMAGES = imageList
|
||||
if (IMAGES.length === 0) return getDefaultImage()
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
for (let i = 0; i < IMAGES.length; i++)
|
||||
for (let i = 0; i < IMAGES.length; i++) {
|
||||
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
|
||||
if (use_cookies) {
|
||||
IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
}
|
||||
|
||||
// canvas
|
||||
const canvas = canvasRef.value
|
||||
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) {
|
||||
@@ -104,36 +110,27 @@ 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)
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(0, canvas.height)
|
||||
ctx.scale(1, -1)
|
||||
ctx.drawImage(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
img.width,
|
||||
img.height,
|
||||
x,
|
||||
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
|
||||
POSTER_WIDTH,
|
||||
REFLECTION_HEIGHT,
|
||||
)
|
||||
ctx.drawImage(img, 0, 0, img.width, img.height, x, 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()
|
||||
}
|
||||
@@ -148,8 +145,8 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.media?.image_list && props.media?.image_list.length > 0)
|
||||
imgUrl.value = await drawImages(props.media?.image_list || [])
|
||||
else imgUrl.value = getImgUrl(props.media?.image || '')
|
||||
imgUrl.value = await drawImages(props.media?.image_list || [], props.media?.use_cookies)
|
||||
else imgUrl.value = getImgUrl(props.media?.image || '', props.media?.use_cookies)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -166,7 +163,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">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import tmdbImage from '@images/logos/tmdb.png'
|
||||
import doubanImage from '@images/logos/douban-black.png'
|
||||
import bangumiImage from '@images/logos/bangumi.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
@@ -64,9 +62,9 @@ const seasonsSelected = ref<MediaSeason[]>([])
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
themoviedb: tmdbImage,
|
||||
douban: doubanImage,
|
||||
bangumi: bangumiImage,
|
||||
themoviedb: getLogoUrl('tmdb'),
|
||||
douban: getLogoUrl('douban-black'),
|
||||
bangumi: getLogoUrl('bangumi'),
|
||||
}
|
||||
|
||||
// 绑定MediaCard元素
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import emby_image from '@images/logos/emby.png'
|
||||
import jellyfin_image from '@images/logos/jellyfin.png'
|
||||
import plex_image from '@images/logos/plex.png'
|
||||
import trimemedia_image from '@images/logos/trimemedia.png'
|
||||
import custom_image from '@images/logos/mediaserver.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -109,15 +105,15 @@ function saveMediaServerInfo() {
|
||||
const getIcon = computed(() => {
|
||||
switch (props.mediaserver.type) {
|
||||
case 'emby':
|
||||
return emby_image
|
||||
return getLogoUrl('emby')
|
||||
case 'jellyfin':
|
||||
return jellyfin_image
|
||||
return getLogoUrl('jellyfin')
|
||||
case 'trimemedia':
|
||||
return trimemedia_image
|
||||
return getLogoUrl('trimemedia')
|
||||
case 'plex':
|
||||
return plex_image
|
||||
return getLogoUrl('plex')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('mediaserver')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -204,7 +200,7 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="mediaServerInfoDialog"
|
||||
v-model="mediaServerInfoDialog"
|
||||
scrollable
|
||||
@@ -262,6 +258,16 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
@@ -506,6 +512,6 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { NotificationConf } from '@/api/types'
|
||||
import wechat_image from '@images/logos/wechat.png'
|
||||
import telegram_image from '@images/logos/telegram.webp'
|
||||
import vocechat_image from '@images/logos/vocechat.png'
|
||||
import synologychat_image from '@images/logos/synologychat.png'
|
||||
import slack_image from '@images/logos/slack.webp'
|
||||
import chrome_image from '@images/logos/chrome.png'
|
||||
import custom_image from '@images/logos/notification.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -55,6 +49,7 @@ const notificationTypeNames: { [key: string]: string } = {
|
||||
vocechat: t('notification.vocechat.name'),
|
||||
synologychat: t('notification.synologychat.name'),
|
||||
slack: t('notification.slack.name'),
|
||||
discord: t('notification.discord.name'),
|
||||
webpush: t('notification.webpush.name'),
|
||||
custom: t('setting.notification.custom'),
|
||||
}
|
||||
@@ -99,19 +94,21 @@ function saveNotificationInfo() {
|
||||
const getIcon = computed(() => {
|
||||
switch (props.notification.type) {
|
||||
case 'wechat':
|
||||
return wechat_image
|
||||
return getLogoUrl('wechat')
|
||||
case 'telegram':
|
||||
return telegram_image
|
||||
return getLogoUrl('telegram')
|
||||
case 'vocechat':
|
||||
return vocechat_image
|
||||
return getLogoUrl('vocechat')
|
||||
case 'synologychat':
|
||||
return synologychat_image
|
||||
return getLogoUrl('synologychat')
|
||||
case 'slack':
|
||||
return slack_image
|
||||
return getLogoUrl('slack')
|
||||
case 'discord':
|
||||
return getLogoUrl('discord')
|
||||
case 'webpush':
|
||||
return chrome_image
|
||||
return getLogoUrl('chrome')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('notification')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -141,7 +138,7 @@ function onClose() {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="notificationInfoDialog"
|
||||
v-model="notificationInfoDialog"
|
||||
scrollable
|
||||
@@ -356,6 +353,47 @@ function onClose() {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'discord'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.DISCORD_BOT_TOKEN"
|
||||
:label="t('notification.discord.botToken')"
|
||||
:hint="t('notification.discord.botTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.DISCORD_GUILD_ID"
|
||||
:label="t('notification.discord.guildId')"
|
||||
:placeholder="t('notification.discord.guildIdPlaceholder')"
|
||||
:hint="t('notification.discord.guildIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.DISCORD_CHANNEL_ID"
|
||||
:label="t('notification.discord.channelId')"
|
||||
:placeholder="t('notification.discord.channelIdPlaceholder')"
|
||||
:hint="t('notification.discord.channelIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound-box"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'synologychat'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
@@ -476,6 +514,6 @@ function onClose() {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useToast } from 'vue-toastification'
|
||||
import VersionHistory from '../misc/VersionHistory.vue'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -103,10 +104,12 @@ async function installPlugin() {
|
||||
|
||||
// 计算图标路径
|
||||
const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
// 如果是网络图片则使用代理后返回
|
||||
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}`
|
||||
})
|
||||
@@ -242,7 +245,7 @@ const dropdownItems = ref([
|
||||
</div>
|
||||
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
@@ -267,15 +270,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>
|
||||
@@ -325,7 +328,7 @@ const dropdownItems = ref([
|
||||
}}</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />{{
|
||||
t('plugin.totalDownloads', { count: props.count?.toLocaleString() })
|
||||
t('plugin.totalDownloads', { count: formatDownloadCount(props.count) })
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,6 +338,6 @@ const dropdownItems = ref([
|
||||
</VCol>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@core/utils'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
|
||||
@@ -167,7 +168,7 @@ async function showPluginConfig() {
|
||||
|
||||
// 计算图标路径
|
||||
const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
@@ -492,7 +493,7 @@ watch(
|
||||
</div>
|
||||
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
@@ -547,7 +548,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 +563,10 @@ watch(
|
||||
</VBtn>
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
|
||||
<!-- 实时日志弹窗 -->
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
@@ -591,10 +592,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 +701,7 @@ watch(
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -28,7 +28,12 @@ function getChipColor(type: string) {
|
||||
const getImgUrl = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
const image = props.media?.image || ''
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const use_cookies = props.media?.use_cookies
|
||||
if (use_cookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return url
|
||||
})
|
||||
|
||||
// 跳转播放
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import noImage from '@images/logos/site.webp'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
|
||||
@@ -62,7 +62,7 @@ async function getSiteIcon() {
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
|
||||
if (!siteIcon.value) {
|
||||
siteIcon.value = noImage
|
||||
siteIcon.value = getLogoUrl('site')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
414
src/components/dialog/AboutDialog.vue
Normal file
414
src/components/dialog/AboutDialog.vue
Normal file
@@ -0,0 +1,414 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { clearCachesAndServiceWorker, reloadWithTimestamp } from '@/composables/useVersionChecker'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// APP版本
|
||||
const appVersion = __APP_VERSION__
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 显示器
|
||||
const display = useDisplay()
|
||||
|
||||
// 系统环境变量
|
||||
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)
|
||||
|
||||
// 最新版本
|
||||
const latestRelease = ref('')
|
||||
|
||||
// 变更日志对话框标题
|
||||
const releaseDialogTitle = ref('')
|
||||
|
||||
// 变更日志对话框内容
|
||||
const releaseDialogBody = ref('')
|
||||
|
||||
// 打开日志对话框
|
||||
function showReleaseDialog(title: string, body: string) {
|
||||
releaseDialogTitle.value = title
|
||||
releaseDialogBody.value = body.replaceAll('\r\n', '<br />')
|
||||
releaseDialog.value = true
|
||||
}
|
||||
|
||||
// 查询系统环境变量
|
||||
async function querySystemEnv() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env')
|
||||
|
||||
systemEnv.value = result.data
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有Release
|
||||
async function queryAllRelease() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/versions')
|
||||
|
||||
allRelease.value = result.data ?? []
|
||||
|
||||
// 最新版本
|
||||
if (allRelease.value.length > 0) latestRelease.value = allRelease.value[0].tag_name
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询支持站点
|
||||
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) {
|
||||
// 上一次更新时间
|
||||
return formatDateDifference(releaseDate)
|
||||
}
|
||||
|
||||
// 强制清除缓存
|
||||
async function clearCache() {
|
||||
await clearCachesAndServiceWorker()
|
||||
// 刷新页面,添加时间戳参数以强制更新
|
||||
reloadWithTimestamp()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
querySystemEnv()
|
||||
queryAllRelease()
|
||||
querySupportingSites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-information" class="me-2" />
|
||||
{{ t('setting.about.title') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="px-3">
|
||||
<div class="section">
|
||||
<div class="section border-gray-800">
|
||||
<dl>
|
||||
<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.softwareVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.VERSION }}</code>
|
||||
<a
|
||||
v-if="latestRelease === systemEnv.VERSION"
|
||||
href="https://github.com/jxxghp/MoviePilot/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 ml-2 !cursor-pointer transition hover:bg-green-400"
|
||||
>
|
||||
{{ t('setting.about.latest') }}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="systemEnv.FRONTEND_VERSION">
|
||||
<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.frontendVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.FRONTEND_VERSION }}</code>
|
||||
</span>
|
||||
</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.browserVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ appVersion }}</code>
|
||||
<VBtn
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="ms-2"
|
||||
@click="clearCache"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" size="14" />
|
||||
</template>
|
||||
{{ t('setting.about.clearCache') }}
|
||||
</VBtn>
|
||||
</span>
|
||||
</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.authVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.AUTH_VERSION }}</code>
|
||||
</span>
|
||||
</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.indexerVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.INDEXER_VERSION }}</code>
|
||||
</span>
|
||||
</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.configDir') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<code>{{ systemEnv.CONFIG_DIR }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</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.dataDir') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all"
|
||||
><code>{{ t('setting.about.dataDirectory') }}</code></span
|
||||
>
|
||||
</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.timezone') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<code>{{ systemEnv.TZ }}</code>
|
||||
</span>
|
||||
</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>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">{{ t('setting.about.support') }}</h3>
|
||||
</div>
|
||||
<div class="section border-t border-gray-800">
|
||||
<dl>
|
||||
<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.documentation') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://movie-pilot.org"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://movie-pilot.org
|
||||
</a>
|
||||
</span>
|
||||
</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.feedback') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://github.com/jxxghp/MoviePilot/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://github.com/jxxghp/MoviePilot/issues/new/choose
|
||||
</a>
|
||||
</span>
|
||||
</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.channel') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://t.me/moviepilot_channel"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://t.me/moviepilot_channel
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">{{ t('setting.about.versions') }}</h3>
|
||||
<div class="section space-y-3">
|
||||
<div>
|
||||
<div
|
||||
v-for="release in allRelease"
|
||||
:key="release.tag_name"
|
||||
class="mb-3 flex w-full flex-col space-y-3 rounded-md px-4 py-2 ring-1 ring-gray-400 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||
>
|
||||
<div class="flex w-full flex-grow items-center justify-start space-x-2 truncate sm:justify-start">
|
||||
<span class="truncate text-lg font-bold">
|
||||
<span class="mr-2 whitespace-nowrap text-xs font-normal">{{
|
||||
releaseTime(release.published_at)
|
||||
}}</span>
|
||||
{{ release.tag_name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="release.tag_name === latestRelease"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-green-500 bg-opacity-80 border border-green-500 !text-green-100"
|
||||
>
|
||||
{{ t('setting.about.latestVersion') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="release.tag_name === systemEnv.VERSION"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100"
|
||||
>
|
||||
{{ t('setting.about.currentVersion') }}
|
||||
</span>
|
||||
</div>
|
||||
<VBtn @click.stop="showReleaseDialog(release.tag_name, release.body)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-text-box-outline" />
|
||||
</template>
|
||||
{{ t('setting.about.viewChangelog') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VCardTitle>{{ releaseDialogTitle }} {{ t('setting.about.changelog') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText v-html="releaseDialogBody" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style type="scss" scoped>
|
||||
.heading {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 2rem;
|
||||
|
||||
--tw-text-opacity: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-block: 0.5rem 2.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -6,10 +6,20 @@ import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } fr
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
@@ -38,6 +48,18 @@ const directories = ref<TransferDirectoryConf[]>([])
|
||||
// 是否正在加载
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
|
||||
// 计算按钮图标
|
||||
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
|
||||
|
||||
@@ -56,9 +78,21 @@ async function loadDirectories() {
|
||||
}
|
||||
}
|
||||
|
||||
function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
}
|
||||
|
||||
// 获取保存目录
|
||||
const targetDirectories = computed(() => {
|
||||
const downloadDirectories = directories.value.map(item => item.download_path)
|
||||
const downloadDirectories = directories.value
|
||||
.map(item => convertToUri(item))
|
||||
.filter((item): item is string => item !== undefined)
|
||||
return [...new Set(downloadDirectories)]
|
||||
})
|
||||
|
||||
@@ -96,6 +130,14 @@ async function addDownload() {
|
||||
payload.media_in = props.media
|
||||
}
|
||||
|
||||
// 添加媒体ID辅助识别
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
}
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
|
||||
result = await api.post(endpoint, payload)
|
||||
@@ -132,7 +174,7 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<DialogWrapper max-width="35rem" scrollable>
|
||||
<VDialog max-width="35rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
@@ -202,6 +244,56 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow class="px-5 mt-2">
|
||||
<VCol cols="12">
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click="showAdvancedOptions = !showAdvancedOptions"
|
||||
>
|
||||
{{
|
||||
showAdvancedOptions
|
||||
? t('dialog.addDownload.hideAdvancedOptions')
|
||||
: t('dialog.addDownload.showAdvancedOptions')
|
||||
}}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
size="small"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
size="small"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardText class="text-center">
|
||||
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
|
||||
@@ -209,5 +301,15 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
<!-- 媒体ID选择器 -->
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -170,7 +170,7 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<DialogWrapper max-width="40rem" scrollable>
|
||||
<VDialog max-width="40rem" scrollable>
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VCol>
|
||||
@@ -286,5 +286,5 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -156,7 +156,7 @@ async function doDelete() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<DialogWrapper max-width="40rem" scrollable>
|
||||
<VDialog max-width="40rem" scrollable>
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VCol>
|
||||
@@ -266,7 +266,7 @@ async function doDelete() {
|
||||
</VCardText>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -113,7 +122,7 @@ function loadRecentSearches() {
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
getNavMenus().forEach(
|
||||
getNavMenus(t).forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
@@ -125,7 +134,7 @@ function getMenus(): NavMenu[] {
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
getSettingTabs().forEach(
|
||||
getSettingTabs(t).forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
@@ -370,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">
|
||||
@@ -435,7 +444,7 @@ onMounted(() => {
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<VHover>
|
||||
<VHover v-if="showCollectionSearch">
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
@@ -785,7 +794,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
|
||||
<!-- 站点选择对话框 -->
|
||||
<SearchSiteDialog
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -350,5 +350,5 @@ onMounted(async () => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
423
src/components/dialog/SiteImportDialog.vue
Normal file
423
src/components/dialog/SiteImportDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -117,6 +117,16 @@ function getTimeColor(seconds: number | undefined): string {
|
||||
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 []
|
||||
@@ -178,13 +188,24 @@ const sortedSites = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 统计总览(与列表口径一致)
|
||||
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>
|
||||
<DialogWrapper max-width="50rem" :fullscreen="display.smAndDown.value" scrollable>
|
||||
<VDialog max-width="50rem" :fullscreen="display.smAndDown.value" scrollable>
|
||||
<VCard>
|
||||
<!-- 标题栏 -->
|
||||
<VCardItem>
|
||||
@@ -206,25 +227,19 @@ onMounted(() => {
|
||||
<div class="statistics-overview pa-4">
|
||||
<div class="d-flex flex-wrap gap-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ siteStats.length }}</div>
|
||||
<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">
|
||||
{{ siteStats.filter(s => s.lst_state === 0).length }}
|
||||
</div>
|
||||
<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">
|
||||
{{ siteStats.filter(s => s.lst_state === 0 && s.seconds && s.seconds >= 5).length }}
|
||||
</div>
|
||||
<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">
|
||||
{{ siteStats.filter(s => s.lst_state === 1).length }}
|
||||
</div>
|
||||
<div class="stat-number error--text">{{ overviewCounts.failed }}</div>
|
||||
<div class="stat-label">{{ t('site.failedSites') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,13 +285,7 @@ onMounted(() => {
|
||||
|
||||
<!-- 成功率 -->
|
||||
<div class="text-center">
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{
|
||||
item.stats?.success && item.stats?.fail
|
||||
? Math.round((item.stats.success / (item.stats.success + item.stats.fail)) * 100)
|
||||
: '-'
|
||||
}}%
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold">{{ getSuccessRate(item.stats) }}%</div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('site.successRate') }}</div>
|
||||
</div>
|
||||
|
||||
@@ -293,7 +302,7 @@ onMounted(() => {
|
||||
</VCard>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<DialogWrapper v-model="detailDialog" :max-width="display.mdAndUp.value ? 600 : '95%'" scrollable>
|
||||
<VDialog v-model="detailDialog" :max-width="display.mdAndUp.value ? 600 : '95%'" scrollable>
|
||||
<VCard v-if="selectedSite">
|
||||
<VCardItem class="py-3">
|
||||
<template #prepend>
|
||||
@@ -370,8 +379,8 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -287,7 +287,7 @@ 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>
|
||||
@@ -484,5 +484,5 @@ onBeforeMount(() => {
|
||||
</VCard>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="t('dialog.siteUserData.refreshing')" />
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -284,7 +284,7 @@ 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')" />
|
||||
@@ -543,5 +543,5 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -118,7 +118,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogWrapper scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
@@ -331,7 +331,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { FileItem, TransferQueue } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
@@ -18,11 +20,14 @@ const emit = defineEmits(['close'])
|
||||
// 数据列表
|
||||
const dataList = ref<TransferQueue[]>([])
|
||||
|
||||
// 整理进度文本
|
||||
const progressText = ref(t('dialog.transferQueue.processing'))
|
||||
// 整体进度相关 - 根据完成的文件计算
|
||||
const overallProgress = ref({
|
||||
value: 0,
|
||||
text: t('dialog.transferQueue.processing'),
|
||||
})
|
||||
|
||||
// 整理进度
|
||||
const progressValue = ref(0)
|
||||
// 文件进度映射
|
||||
const fileProgressMap = ref<Map<string, { enable: boolean; value: number }>>(new Map())
|
||||
|
||||
// 数据可刷新标志
|
||||
const refreshFlag = ref(false)
|
||||
@@ -33,6 +38,9 @@ const progressActive = ref(false)
|
||||
// 活动标签
|
||||
const activeTab = ref('')
|
||||
|
||||
// 定时器引用
|
||||
const queueTimer = ref<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 状态标签
|
||||
const stateDict: { [key: string]: string } = {
|
||||
'waiting': t('dialog.transferQueue.waitingState'),
|
||||
@@ -50,9 +58,18 @@ function getStateColor(state: string) {
|
||||
else return 'error'
|
||||
}
|
||||
|
||||
// 从dataList中提取所有的媒体信息
|
||||
// 从dataList中提取所有的媒体信息,合并相同title_year的记录
|
||||
const mediaList = computed(() => {
|
||||
return dataList.value.map(item => item.media)
|
||||
const mediaMap = new Map<string, any>()
|
||||
|
||||
dataList.value.forEach(item => {
|
||||
const titleYear = item.media.title_year || ''
|
||||
if (!mediaMap.has(titleYear)) {
|
||||
mediaMap.set(titleYear, item.media)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(mediaMap.values())
|
||||
})
|
||||
|
||||
// 按media计算总数和完成数,返回 x/x
|
||||
@@ -66,17 +83,49 @@ function getMediaCount(title_year: string) {
|
||||
return `${completed} / ${total}`
|
||||
}
|
||||
|
||||
// 根据媒体信息获取对应的整理任务
|
||||
// 根据媒体信息获取对应的整理任务,合并相同title_year的所有任务
|
||||
const activeTasks = computed(() => {
|
||||
return dataList.value.find(item => item.media.title_year === activeTab.value)?.tasks
|
||||
const tasks = dataList.value.filter(item => item.media.title_year === activeTab.value).flatMap(item => item.tasks)
|
||||
return tasks
|
||||
})
|
||||
|
||||
// 根据媒体title_year获取对应的任务列表
|
||||
function getTasksByMedia(title_year: string) {
|
||||
return dataList.value.filter(item => item.media.title_year === title_year).flatMap(item => item.tasks)
|
||||
}
|
||||
|
||||
// 计算整体进度
|
||||
const overallProgressComputed = computed(() => {
|
||||
if (dataList.value.length === 0) return 0
|
||||
|
||||
const allTasks = dataList.value.flatMap(item => item.tasks)
|
||||
const totalTasks = allTasks.length
|
||||
const completedTasks = allTasks.filter(task => task.state === 'completed').length
|
||||
|
||||
return totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0
|
||||
})
|
||||
|
||||
// 获取文件进度
|
||||
function getFileProgress(filePath: string) {
|
||||
return fileProgressMap.value.get(filePath) || { enable: false, value: 0 }
|
||||
}
|
||||
|
||||
// 调用API获取队列信息
|
||||
async function get_transfer_queue() {
|
||||
try {
|
||||
dataList.value = await api.get('transfer/queue')
|
||||
if (dataList.value.length > 0) {
|
||||
if (!activeTab.value || activeTasks.value?.length == 0) activeTab.value = dataList.value[0].media.title_year || ''
|
||||
|
||||
// 如果有数据且SSE未启动,则启动SSE监听
|
||||
if (!progressActive.value) {
|
||||
startLoadingProgress()
|
||||
}
|
||||
} else {
|
||||
// 如果没有数据,停止SSE监听
|
||||
if (progressActive.value) {
|
||||
stopLoadingProgress()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -93,85 +142,164 @@ async function remove_queue_task(fileitem: FileItem) {
|
||||
}
|
||||
}
|
||||
|
||||
// 进度SSE消息处理函数
|
||||
function handleProgressMessage(event: MessageEvent) {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
if (!progress.enable) {
|
||||
progressText.value = t('dialog.transferQueue.processing')
|
||||
progressValue.value = 0
|
||||
if (refreshFlag.value) {
|
||||
refreshFlag.value = false
|
||||
get_transfer_queue()
|
||||
}
|
||||
return
|
||||
}
|
||||
progressText.value = progress.text
|
||||
progressValue.value = progress.value
|
||||
if (progress.value >= 100 && refreshFlag.value) {
|
||||
refreshFlag.value = false
|
||||
get_transfer_queue()
|
||||
} else {
|
||||
if (progress.value > 0 && refreshFlag.value && progress.text?.includes('整理完成')) {
|
||||
refreshFlag.value = false
|
||||
get_transfer_queue()
|
||||
} else {
|
||||
refreshFlag.value = true
|
||||
// 文件进度SSE消息处理函数
|
||||
function createFileProgressHandler(filePath: string) {
|
||||
return function handleFileProgressMessage(event: MessageEvent) {
|
||||
try {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
fileProgressMap.value.set(filePath, {
|
||||
enable: progress.enable || false,
|
||||
value: progress.value || 0,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析文件进度消息失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的进度SSE连接
|
||||
const progressSSE = useProgressSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`,
|
||||
handleProgressMessage,
|
||||
'transfer-queue-progress',
|
||||
progressActive
|
||||
// 文件进度SSE连接映射
|
||||
const fileProgressSSEMap = ref<Map<string, any>>(new Map())
|
||||
|
||||
// 启动文件进度监听
|
||||
function startFileProgress(filePath: string) {
|
||||
if (fileProgressSSEMap.value.has(filePath)) {
|
||||
return // 已经存在连接
|
||||
}
|
||||
|
||||
// filePath计算md5
|
||||
const filePathMd5 = CryptoJS.MD5(filePath).toString()
|
||||
// 使用包含文件路径的唯一监听器ID
|
||||
const uniqueListenerId = `transfer-queue-file-progress-${filePathMd5}`
|
||||
const fileProgressUrl = `${import.meta.env.VITE_API_BASE_URL}system/progress/${filePathMd5}`
|
||||
|
||||
const fileProgressSSE = useProgressSSE(
|
||||
fileProgressUrl,
|
||||
createFileProgressHandler(filePath),
|
||||
uniqueListenerId,
|
||||
progressActive,
|
||||
)
|
||||
|
||||
fileProgressSSE.start()
|
||||
fileProgressSSEMap.value.set(filePath, fileProgressSSE)
|
||||
}
|
||||
|
||||
// 停止所有文件进度监听
|
||||
function stopAllFileProgress() {
|
||||
fileProgressSSEMap.value.forEach((sse, filePath) => {
|
||||
sse.stop()
|
||||
})
|
||||
fileProgressSSEMap.value.clear()
|
||||
fileProgressMap.value.clear()
|
||||
}
|
||||
|
||||
// 监听队列变化,自动管理文件进度SSE
|
||||
watch(
|
||||
dataList,
|
||||
newDataList => {
|
||||
// 获取当前正在运行的文件路径集合
|
||||
const currentRunningFiles = new Set<string>()
|
||||
newDataList.forEach(item => {
|
||||
item.tasks.forEach(task => {
|
||||
if (task.state === 'running') {
|
||||
currentRunningFiles.add(task.fileitem.path)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 获取当前已建立SSE连接的文件路径集合
|
||||
const currentSSEFiles = new Set(fileProgressSSEMap.value.keys())
|
||||
|
||||
// 停止不再需要的SSE连接
|
||||
currentSSEFiles.forEach(filePath => {
|
||||
if (!currentRunningFiles.has(filePath)) {
|
||||
const sse = fileProgressSSEMap.value.get(filePath)
|
||||
if (sse) {
|
||||
sse.stop()
|
||||
fileProgressSSEMap.value.delete(filePath)
|
||||
}
|
||||
// 清除对应的进度数据
|
||||
fileProgressMap.value.delete(filePath)
|
||||
}
|
||||
})
|
||||
|
||||
// 为新的运行中文件建立SSE连接
|
||||
currentRunningFiles.forEach(filePath => {
|
||||
if (!fileProgressSSEMap.value.has(filePath)) {
|
||||
startFileProgress(filePath)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
progressText.value = t('dialog.transferQueue.processing')
|
||||
overallProgress.value.text = t('dialog.transferQueue.processing')
|
||||
progressActive.value = true
|
||||
progressSSE.start()
|
||||
}
|
||||
|
||||
// 停止监听加载进度
|
||||
function stopLoadingProgress() {
|
||||
progressActive.value = false
|
||||
progressSSE.stop()
|
||||
// 只有在没有数据时才停止所有文件进度监听
|
||||
if (dataList.value.length === 0) {
|
||||
stopAllFileProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 启动定时获取队列
|
||||
function startQueueTimer() {
|
||||
// 清除可能存在的定时器
|
||||
if (queueTimer.value) {
|
||||
clearInterval(queueTimer.value)
|
||||
}
|
||||
|
||||
// 立即执行一次
|
||||
get_transfer_queue()
|
||||
|
||||
// 设置3秒定时器
|
||||
queueTimer.value = setInterval(() => {
|
||||
get_transfer_queue()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 停止定时获取队列
|
||||
function stopQueueTimer() {
|
||||
if (queueTimer.value) {
|
||||
clearInterval(queueTimer.value)
|
||||
queueTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
get_transfer_queue()
|
||||
startLoadingProgress()
|
||||
startQueueTimer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopQueueTimer()
|
||||
stopLoadingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogWrapper scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="mx-auto" width="100%">
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('dialog.transferQueue.title') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VProgressLinear
|
||||
v-if="dataList.length > 0 && progressValue > 0"
|
||||
:value="progressValue"
|
||||
color="primary"
|
||||
indeterminate
|
||||
/>
|
||||
<VCardItem v-if="dataList.length > 0 && progressValue > 0" class="text-center pt-2">
|
||||
<span class="text-sm">{{ progressText }}</span>
|
||||
</VCardItem>
|
||||
<VCardText v-if="dataList.length === 0" class="text-center"> {{ t('dialog.transferQueue.noTasks') }} </VCardText>
|
||||
<VCardText>
|
||||
|
||||
<!-- 整体进度显示 -->
|
||||
<VProgressLinear v-if="dataList.length > 0" :model-value="overallProgressComputed" color="primary" />
|
||||
<VDivider v-else />
|
||||
|
||||
<VCardText v-if="dataList.length === 0" class="text-center">
|
||||
{{ t('dialog.transferQueue.noTasks') }}
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="dataList.length > 0">
|
||||
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill" stacked>
|
||||
<VTab
|
||||
v-for="media in mediaList"
|
||||
@@ -185,16 +313,34 @@ onUnmounted(() => {
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindowItem v-for="media in mediaList" :value="media.title_year">
|
||||
<VList>
|
||||
<VListItem v-for="task in activeTasks">
|
||||
<VListItem v-for="task in getTasksByMedia(media.title_year || '')" :key="task.fileitem.path">
|
||||
<VListItemTitle>{{ task.fileitem.name }}</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
<VListItemSubtitle class="py-1">
|
||||
{{ t('dialog.transferQueue.sizeTitle') }}:{{ formatFileSize(task.fileitem.size || 0) }}
|
||||
<VChip size="small" :color="getStateColor(task.state)" class="ms-2">
|
||||
<VChip size="small" :color="getStateColor(task.state)" class="mx-2">
|
||||
{{ stateDict[task.state] }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
|
||||
<!-- 文件进度显示 -->
|
||||
<div v-if="task.state === 'running' && getFileProgress(task.fileitem.path).enable" class="mt-2">
|
||||
<VProgressLinear
|
||||
:model-value="getFileProgress(task.fileitem.path).value"
|
||||
color="success"
|
||||
class="mb-1"
|
||||
:height="3"
|
||||
/>
|
||||
<div class="text-xs text-medium-emphasis text-center">
|
||||
{{ getFileProgress(task.fileitem.path).value.toFixed(1) }}%
|
||||
</div>
|
||||
</div>
|
||||
<template #append>
|
||||
<IconBtn size="small" icon="mdi-cancel" @click="remove_queue_task(task.fileitem)" />
|
||||
<IconBtn
|
||||
size="small"
|
||||
icon="mdi-cancel"
|
||||
@click="remove_queue_task(task.fileitem)"
|
||||
:disabled="task.state === 'completed'"
|
||||
/>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
@@ -202,5 +348,5 @@ onUnmounted(() => {
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -182,7 +182,7 @@ onMounted(() => {
|
||||
</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>
|
||||
@@ -269,5 +269,5 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -26,10 +26,9 @@ const { appMode } = usePWA()
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
icons: Object,
|
||||
storage: String,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
refreshpending: Boolean,
|
||||
@@ -82,6 +81,9 @@ const items = ref<FileItem[]>([])
|
||||
// 过滤条件
|
||||
const filter = ref('')
|
||||
|
||||
// 是否忽略大小写
|
||||
const ignoreCase = ref(true)
|
||||
|
||||
// 重命名弹窗
|
||||
const renamePopper = ref(false)
|
||||
|
||||
@@ -112,12 +114,26 @@ const dropdownItems = ref<{ [key: string]: any }[]>([])
|
||||
// 进度是否激活
|
||||
const progressActive = ref(false)
|
||||
|
||||
// 通用过滤
|
||||
const getFilteredItems = (type: 'dir' | 'file') => {
|
||||
const filterValue = filter.value
|
||||
if (!filterValue) {
|
||||
return items.value.filter(item => item.type === type)
|
||||
}
|
||||
|
||||
if (ignoreCase.value) {
|
||||
const lowerCaseFilter = filterValue.toLowerCase()
|
||||
return items.value.filter(item => item.type === type && item.name.toLowerCase().includes(lowerCaseFilter))
|
||||
} else {
|
||||
return items.value.filter(item => item.type === type && item.name.includes(filterValue))
|
||||
}
|
||||
}
|
||||
|
||||
// 目录过滤
|
||||
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.name.includes(filter.value)))
|
||||
const dirs = computed(() => getFilteredItems('dir'))
|
||||
|
||||
// 文件过滤
|
||||
const files = computed(() => items.value.filter(item => item.type === 'file' && item.name.includes(filter.value)))
|
||||
|
||||
const files = computed(() => getFilteredItems('file'))
|
||||
// 是否文件
|
||||
const isFile = computed(() => inProps.item.type == 'file')
|
||||
|
||||
@@ -166,6 +182,8 @@ function changeSelectMode() {
|
||||
// 调API加载文件夹内的内容
|
||||
async function list_files() {
|
||||
loading.value = true
|
||||
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/');
|
||||
const prevURI = takeURISnapshot();
|
||||
emit('loading', true)
|
||||
|
||||
// 参数
|
||||
@@ -178,7 +196,12 @@ async function list_files() {
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
items.value = (await inProps.axios.request(config)) ?? []
|
||||
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
|
||||
// 如果当前路径已经变化,则放弃此次加载结果
|
||||
if (prevURI !== takeURISnapshot()) {
|
||||
return;
|
||||
}
|
||||
items.value = data
|
||||
emit('loading', false)
|
||||
loading.value = false
|
||||
|
||||
@@ -277,7 +300,7 @@ async function download(item: FileItem) {
|
||||
responseType: 'blob',
|
||||
}
|
||||
// 加载数据
|
||||
const result: Blob = await inProps.axios.request(config)
|
||||
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
|
||||
if (result) {
|
||||
const downloadUrl = URL.createObjectURL(result)
|
||||
window.open(downloadUrl, '_blank')
|
||||
@@ -295,7 +318,7 @@ async function getImgLink(item: FileItem) {
|
||||
responseType: 'blob',
|
||||
}
|
||||
// 加载二进制数据
|
||||
const result: Blob = await inProps.axios.request(config)
|
||||
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
|
||||
if (result) {
|
||||
// 创建图片地址
|
||||
currentImgLink.value = URL.createObjectURL(result)
|
||||
@@ -372,7 +395,7 @@ async function rename() {
|
||||
method: inProps.endpoints?.rename.method || 'post',
|
||||
data: currentItem.value,
|
||||
}
|
||||
const result: { [key: string]: any } = await inProps.axios?.request(config)
|
||||
const result: { [key: string]: any } = (await inProps.axios?.request<any, { [key: string]: any }>(config))
|
||||
if (!result.success) {
|
||||
$toast.error(result.message)
|
||||
}
|
||||
@@ -429,9 +452,9 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 监听item变化或者storage变化
|
||||
// 监听item变化
|
||||
watch(
|
||||
[() => inProps.item, () => inProps.storage],
|
||||
[() => inProps.item],
|
||||
async () => {
|
||||
// 清空列表
|
||||
items.value = []
|
||||
@@ -533,7 +556,7 @@ async function scrape(item: FileItem, confirm: boolean = true) {
|
||||
progressDialog.value = true
|
||||
progressText.value = t('file.scraping', { path: item.path })
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.storage}`, item)
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item)
|
||||
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
@@ -622,9 +645,11 @@ onMounted(() => {
|
||||
rounded
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="!isFile" @click="ignoreCase = !ignoreCase">
|
||||
<VIcon :color="ignoreCase ? 'primary' : 'error'" icon="mdi-format-letter-case" />
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
|
||||
<VIcon color="primary" v-else>mdi-select</VIcon>
|
||||
<VIcon color="primary" :icon="selectMode ? 'mdi-selection-remove' : 'mdi-select'" />
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
|
||||
<VIcon color="primary"> mdi-text-recognition </VIcon>
|
||||
@@ -749,7 +774,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,13 +808,13 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
<!-- 文件整理弹窗 -->
|
||||
<ReorganizeDialog
|
||||
v-if="transferPopper"
|
||||
v-model="transferPopper"
|
||||
:items="transferItems"
|
||||
:target_storage="inProps.storage"
|
||||
:target_storage="inProps.item.storage"
|
||||
@done="transferDone"
|
||||
@close="transferPopper = false"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { PropType } from 'vue'
|
||||
import type { FileItem } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
@@ -42,7 +42,7 @@ const availableHeight = computed(() => {
|
||||
const props = defineProps({
|
||||
storage: {
|
||||
type: String,
|
||||
default: 'local',
|
||||
required: true,
|
||||
},
|
||||
currentPath: {
|
||||
type: String,
|
||||
@@ -54,7 +54,7 @@ const props = defineProps({
|
||||
},
|
||||
endpoints: Object,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
@@ -131,7 +131,7 @@ async function loadSubdirectories(path: string) {
|
||||
data: fakeItem,
|
||||
}
|
||||
|
||||
const result = await props.axios?.request(config)
|
||||
const result = (await props.axios?.request(config))
|
||||
if (result && Array.isArray(result)) {
|
||||
// 过滤出目录项
|
||||
const dirs = result.filter(item => item.type === 'dir')
|
||||
@@ -223,7 +223,7 @@ watch(
|
||||
watch(
|
||||
() => props.items,
|
||||
newItems => {
|
||||
if (newItems && newItems.length > 0) {
|
||||
if (newItems) {
|
||||
// 过滤出目录项
|
||||
const dirs = newItems.filter(item => item.type === 'dir')
|
||||
|
||||
@@ -283,9 +283,6 @@ onMounted(async () => {
|
||||
await loadRootDirectories()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
updateHeight()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -309,7 +306,6 @@ onActivated(() => {
|
||||
<span>{{ t('file.rootDirectory') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载根目录 -->
|
||||
<div v-if="loading['/']" class="tree-loading">
|
||||
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -13,7 +13,6 @@ const display = useDisplay()
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
storages: Array as PropType<any[]>,
|
||||
storage: String,
|
||||
item: {
|
||||
type: Object as PropType<FileItem>,
|
||||
required: true,
|
||||
@@ -24,9 +23,13 @@ const inProps = defineProps({
|
||||
},
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
sort: {
|
||||
type: String,
|
||||
default: 'name',
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
@@ -38,15 +41,10 @@ const newFolderPopper = ref(false)
|
||||
// 新建文件名称
|
||||
const newFolderName = ref('')
|
||||
|
||||
// 排序方式
|
||||
const sort = ref('name')
|
||||
|
||||
// 调整排序方式
|
||||
function changeSort() {
|
||||
if (sort.value === 'name') sort.value = 'time'
|
||||
else sort.value = 'name'
|
||||
|
||||
emit('sortchanged', sort.value)
|
||||
const newSort = inProps.sort === 'name' ? 'time' : 'name'
|
||||
emit('sortchanged', newSort)
|
||||
}
|
||||
|
||||
// 计算PATH面包屑
|
||||
@@ -67,12 +65,12 @@ const pathSegments = computed(() => {
|
||||
|
||||
// 当前存储
|
||||
const storageObject = computed(() => {
|
||||
return inProps.storages?.find(item => item.value === inProps.storage)
|
||||
return inProps.storages?.find(item => item.value === inProps.item.storage)
|
||||
})
|
||||
|
||||
// 切换存储
|
||||
function changeStorage(code: string) {
|
||||
if (inProps.storage !== code) {
|
||||
if (inProps.item.storage!== code) {
|
||||
emit('storagechanged', code)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +111,7 @@ async function mkdir() {
|
||||
|
||||
// 计算排序图标
|
||||
const sortIcon = computed(() => {
|
||||
if (sort.value === 'time') return 'mdi-sort-clock-ascending-outline'
|
||||
if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline'
|
||||
else return 'mdi-sort-alphabetical-ascending'
|
||||
})
|
||||
</script>
|
||||
@@ -166,7 +164,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 +189,6 @@ const sortIcon = computed(() => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</VToolbar>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,6 @@ const props = defineProps({
|
||||
root: {
|
||||
type: String,
|
||||
default: '/',
|
||||
required: true,
|
||||
},
|
||||
storage: {
|
||||
type: String,
|
||||
|
||||
@@ -91,10 +91,6 @@ onUnmounted(() => {
|
||||
<!-- Vue 渲染模式 -->
|
||||
<div v-if="pluginRenderMode === 'vue'">
|
||||
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" :api="api" />
|
||||
<!-- Vue 模式下也可以显示拖拽句柄 -->
|
||||
<div class="absolute right-5 top-5">
|
||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VHover v-else-if="pluginRenderMode === 'vuetify'">
|
||||
|
||||
82
src/components/toast/VersionUpdateToast.vue
Normal file
82
src/components/toast/VersionUpdateToast.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="version-update-toast">
|
||||
<span class="message">{{ message }}</span>
|
||||
<button v-if="refreshText" class="refresh-button" @click="handleRefresh">
|
||||
{{ refreshText }}
|
||||
</button>
|
||||
<div v-else class="spinner"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 接收 props
|
||||
interface Props {
|
||||
message: string
|
||||
refreshText?: string
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (props.onRefresh) {
|
||||
props.onRefresh()
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.version-update-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
padding: 6px 16px;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.refresh-button:hover {
|
||||
background-color: #f5f5f5;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.refresh-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -22,22 +22,39 @@ export function useBackgroundOptimization() {
|
||||
backgroundCloseDelay?: number
|
||||
reconnectDelay?: number
|
||||
maxReconnectAttempts?: number
|
||||
connectDelay?: number // 新增:连接延迟
|
||||
},
|
||||
) => {
|
||||
const manager = sseManagerSingleton.getManager(url, options)
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +102,8 @@ export function useBackgroundOptimization() {
|
||||
delay: number = 3000,
|
||||
options?: Parameters<typeof useSSE>[3],
|
||||
) => {
|
||||
const manager = sseManagerSingleton.getManager(url, options)
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
@@ -117,7 +135,8 @@ export function useBackgroundOptimization() {
|
||||
listenerId: string,
|
||||
isActive: Ref<boolean>,
|
||||
) => {
|
||||
const manager = sseManagerSingleton.getManager(url, {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
|
||||
@@ -12,6 +12,16 @@ const globalPwaStatus = ref<{
|
||||
const globalLoading = ref(false)
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
// UI模式设置
|
||||
export type UIMode = 'auto' | 'desktop' | 'app'
|
||||
const uiMode = ref<UIMode>((localStorage.getItem('ui-mode') as UIMode) || 'auto')
|
||||
|
||||
// 设置UI模式
|
||||
function setUIMode(mode: UIMode) {
|
||||
uiMode.value = mode
|
||||
localStorage.setItem('ui-mode', mode)
|
||||
}
|
||||
|
||||
// 全局初始化函数
|
||||
async function initializePWAGlobally() {
|
||||
if (initPromise) return initPromise
|
||||
@@ -50,6 +60,8 @@ export function usePWA() {
|
||||
})
|
||||
|
||||
const appMode = computed(() => {
|
||||
if (uiMode.value === 'app') return true
|
||||
if (uiMode.value === 'desktop') return false
|
||||
return pwaMode.value && display.mdAndDown.value
|
||||
})
|
||||
|
||||
@@ -70,6 +82,8 @@ export function usePWA() {
|
||||
pwaMode,
|
||||
appMode,
|
||||
pwaStatus,
|
||||
uiMode,
|
||||
setUIMode,
|
||||
loading: globalLoading,
|
||||
initializePWA: initializePWAGlobally,
|
||||
}
|
||||
|
||||
@@ -236,16 +236,15 @@ export function usePullDownGesture(options: PullDownOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// PWA状态确定后,一次性决定是否添加事件监听器
|
||||
// 监听 appMode 变化动态添加/移除事件监听器
|
||||
onMounted(() => {
|
||||
// 等待PWA检测完成后添加事件监听器
|
||||
const stopWatcher = watch(
|
||||
watch(
|
||||
appMode,
|
||||
newValue => {
|
||||
if (newValue) {
|
||||
addEventListeners()
|
||||
// PWA状态确定后停止监听
|
||||
stopWatcher()
|
||||
} else {
|
||||
removeEventListeners()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
import { ref, watch, onBeforeUnmount, readonly } from 'vue'
|
||||
|
||||
/**
|
||||
* 滚动锁定 Composable
|
||||
*
|
||||
* 使用示例:
|
||||
*
|
||||
* // 基本用法
|
||||
* const { isLocked, lockScroll, restoreScroll } = useScrollLock()
|
||||
*
|
||||
* // 带配置的用法
|
||||
* const { isLocked, lockScroll, restoreScroll } = useScrollLock({
|
||||
* preventTouchScroll: true,
|
||||
* preserveScrollPosition: true,
|
||||
* allowScrollSelectors: ['.my-modal', '.scrollable-content'],
|
||||
* allowScrollContainerSelectors: ['.modal-content'],
|
||||
* customScrollCheck: (element) => {
|
||||
* // 自定义逻辑
|
||||
* return element.classList.contains('allow-scroll')
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // 自动监听版本
|
||||
* const { isLocked, lockScroll, restoreScroll } = useScrollLockWithWatch(
|
||||
* showModal, // 响应式布尔值
|
||||
* {
|
||||
* allowScrollSelectors: ['.modal-content'],
|
||||
* allowScrollContainerSelectors: ['.scrollable-area']
|
||||
* }
|
||||
* )
|
||||
*/
|
||||
|
||||
// 滚动锁定配置
|
||||
export interface ScrollLockOptions {
|
||||
// 是否在组件卸载时自动恢复滚动
|
||||
autoRestore?: boolean
|
||||
// 是否保存和恢复滚动位置
|
||||
preserveScrollPosition?: boolean
|
||||
// 是否阻止触摸事件穿透
|
||||
preventTouchScroll?: boolean
|
||||
// 自定义锁定时的样式
|
||||
lockStyles?: {
|
||||
overflow?: string
|
||||
position?: string
|
||||
width?: string
|
||||
}
|
||||
// 允许滚动的选择器列表(CSS选择器)
|
||||
// 例如:['.my-modal', '.scrollable-content']
|
||||
allowScrollSelectors?: string[]
|
||||
// 允许滚动的容器选择器列表(CSS选择器)
|
||||
// 这些容器内的可滚动元素将被允许滚动
|
||||
// 例如:['.modal-content', '.scroll-container']
|
||||
allowScrollContainerSelectors?: string[]
|
||||
// 自定义滚动检查函数
|
||||
// 返回 true 表示允许滚动,false 表示阻止滚动
|
||||
customScrollCheck?: (element: Element) => boolean
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_OPTIONS: Required<
|
||||
Omit<ScrollLockOptions, 'allowScrollSelectors' | 'allowScrollContainerSelectors' | 'customScrollCheck'>
|
||||
> = {
|
||||
autoRestore: true,
|
||||
preserveScrollPosition: true,
|
||||
preventTouchScroll: true,
|
||||
lockStyles: {
|
||||
overflow: 'hidden',
|
||||
position: 'fixed',
|
||||
width: '100%',
|
||||
},
|
||||
}
|
||||
|
||||
// 全局状态管理
|
||||
const globalLockCount = ref(0)
|
||||
const globalOriginalStyles = ref<{
|
||||
body: { [key: string]: string }
|
||||
documentElement: { [key: string]: string }
|
||||
html: { [key: string]: string }
|
||||
} | null>(null)
|
||||
const globalSavedScrollPosition = ref(0)
|
||||
const globalTouchEventListeners = new Set<(event: TouchEvent) => void>()
|
||||
|
||||
// 保存全局原始样式(只在第一次锁定时保存)
|
||||
const saveGlobalOriginalStyles = () => {
|
||||
if (globalOriginalStyles.value === null) {
|
||||
globalOriginalStyles.value = {
|
||||
body: {
|
||||
overflow: document.body.style.overflow,
|
||||
},
|
||||
documentElement: {
|
||||
overflow: document.documentElement.style.overflow,
|
||||
},
|
||||
html: {
|
||||
overflow: document.documentElement.style.overflow,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存全局滚动位置(只在第一次锁定时保存)
|
||||
const saveGlobalScrollPosition = () => {
|
||||
if (globalLockCount.value === 0) {
|
||||
globalSavedScrollPosition.value =
|
||||
window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
|
||||
}
|
||||
}
|
||||
|
||||
// 应用全局锁定样式
|
||||
const applyGlobalLockStyles = (config: any) => {
|
||||
if (globalLockCount.value === 1) {
|
||||
// 第一次锁定时应用样式
|
||||
document.body.style.overflow = config.lockStyles.overflow || 'hidden'
|
||||
document.documentElement.style.overflow = config.lockStyles.overflow || 'hidden'
|
||||
document.documentElement.classList.add('v-overlay-scroll-blocked')
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复全局样式(只在最后一个锁定时恢复)
|
||||
const restoreGlobalStyles = (config: any) => {
|
||||
if (globalLockCount.value === 0 && globalOriginalStyles.value) {
|
||||
// 最后一个锁定时恢复样式
|
||||
document.body.style.overflow = globalOriginalStyles.value.body.overflow || ''
|
||||
document.documentElement.style.overflow = globalOriginalStyles.value.documentElement.overflow || ''
|
||||
|
||||
// 移除 CSS 类名
|
||||
document.documentElement.classList.remove('v-overlay-scroll-blocked')
|
||||
|
||||
// 重置全局状态
|
||||
globalOriginalStyles.value = null
|
||||
globalSavedScrollPosition.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 添加全局触摸事件监听器
|
||||
const addGlobalTouchEventListener = (listener: (event: TouchEvent) => void) => {
|
||||
globalTouchEventListeners.add(listener)
|
||||
if (globalTouchEventListeners.size === 1) {
|
||||
// 第一次添加监听器时绑定到document
|
||||
document.addEventListener('touchmove', listener, { passive: false })
|
||||
}
|
||||
}
|
||||
|
||||
// 移除全局触摸事件监听器
|
||||
const removeGlobalTouchEventListener = (listener: (event: TouchEvent) => void) => {
|
||||
globalTouchEventListeners.delete(listener)
|
||||
if (globalTouchEventListeners.size === 0) {
|
||||
// 最后一个监听器被移除时解绑
|
||||
document.removeEventListener('touchmove', listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function useScrollLock(options: ScrollLockOptions = {}) {
|
||||
const config = {
|
||||
...DEFAULT_OPTIONS,
|
||||
allowScrollSelectors: options.allowScrollSelectors || [],
|
||||
allowScrollContainerSelectors: options.allowScrollContainerSelectors || [],
|
||||
customScrollCheck: options.customScrollCheck,
|
||||
...options,
|
||||
}
|
||||
|
||||
// 状态管理
|
||||
const isLocked = ref(false)
|
||||
const savedScrollPosition = ref(0)
|
||||
|
||||
// 保存当前滚动位置
|
||||
const saveScrollPosition = () => {
|
||||
if (config.preserveScrollPosition) {
|
||||
savedScrollPosition.value =
|
||||
window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
|
||||
}
|
||||
}
|
||||
|
||||
// 检查元素是否应该允许滚动
|
||||
const shouldAllowScroll = (element: Element): boolean => {
|
||||
// 1. 检查是否匹配允许滚动的选择器
|
||||
for (const selector of config.allowScrollSelectors) {
|
||||
if (element.matches(selector) || element.closest(selector)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否在允许滚动的容器内
|
||||
for (const selector of config.allowScrollContainerSelectors) {
|
||||
const container = element.closest(selector)
|
||||
if (container) {
|
||||
// 检查容器是否可滚动
|
||||
const style = getComputedStyle(container)
|
||||
const isScrollable =
|
||||
container.scrollHeight > container.clientHeight &&
|
||||
style.overflow !== 'hidden' &&
|
||||
(style.overflow === 'auto' ||
|
||||
style.overflow === 'scroll' ||
|
||||
style.overflowY === 'auto' ||
|
||||
style.overflowY === 'scroll')
|
||||
if (isScrollable) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查是否在弹窗、菜单或其他覆盖层内
|
||||
const isInDialog = element.closest(
|
||||
'.v-dialog, .v-menu, .v-bottom-sheet, .v-snackbar, [role="dialog"], .v-overlay__content',
|
||||
)
|
||||
|
||||
// 4. 检查是否是可滚动的内容区域
|
||||
const isScrollableContent = element.closest(
|
||||
'.v-card-text, .v-list, .v-table__wrapper, .v-data-table__wrapper, .v-sheet, .v-card__content, .v-data-table, .v-table',
|
||||
)
|
||||
|
||||
// 5. 检查是否在可滚动的容器内
|
||||
const scrollableContainer = element.closest('[style*="overflow"], [class*="overflow"]')
|
||||
const isInScrollableContainer =
|
||||
scrollableContainer &&
|
||||
(scrollableContainer.scrollHeight > scrollableContainer.clientHeight ||
|
||||
getComputedStyle(scrollableContainer).overflow !== 'hidden')
|
||||
|
||||
// 6. 使用自定义检查函数
|
||||
if (config.customScrollCheck && config.customScrollCheck(element)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果不在弹窗内且不是可滚动内容且不在可滚动容器内,则不允许滚动
|
||||
return !!(isInDialog || isScrollableContent || isInScrollableContainer)
|
||||
}
|
||||
|
||||
// 阻止触摸滚动事件
|
||||
const preventTouchScroll = (event: TouchEvent) => {
|
||||
if (isLocked.value && config.preventTouchScroll) {
|
||||
// 检查触摸事件的目标元素
|
||||
const target = event.target as Element
|
||||
if (target) {
|
||||
// 如果元素应该允许滚动,则不阻止事件
|
||||
if (shouldAllowScroll(target)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 否则阻止滚动
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
// 锁定滚动
|
||||
const lockScroll = () => {
|
||||
if (isLocked.value) return
|
||||
|
||||
// 增加全局锁定计数
|
||||
globalLockCount.value++
|
||||
|
||||
// 保存当前状态(只在第一次锁定时)
|
||||
if (globalLockCount.value === 1) {
|
||||
saveGlobalOriginalStyles()
|
||||
saveGlobalScrollPosition()
|
||||
}
|
||||
|
||||
// 应用锁定样式
|
||||
applyGlobalLockStyles(config)
|
||||
|
||||
// 添加触摸事件监听器
|
||||
if (config.preventTouchScroll) {
|
||||
addGlobalTouchEventListener(preventTouchScroll)
|
||||
}
|
||||
|
||||
isLocked.value = true
|
||||
}
|
||||
|
||||
// 恢复滚动
|
||||
const restoreScroll = () => {
|
||||
if (!isLocked.value) return
|
||||
|
||||
// 减少全局锁定计数
|
||||
globalLockCount.value--
|
||||
|
||||
// 移除触摸事件监听器
|
||||
if (config.preventTouchScroll) {
|
||||
removeGlobalTouchEventListener(preventTouchScroll)
|
||||
}
|
||||
|
||||
// 恢复样式(只在最后一个锁定时)
|
||||
restoreGlobalStyles(config)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
1233
src/composables/useSetupWizard.ts
Normal file
1233
src/composables/useSetupWizard.ts
Normal file
File diff suppressed because it is too large
Load Diff
174
src/composables/useVersionChecker.ts
Normal file
174
src/composables/useVersionChecker.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { ref, h } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { Workbox } from 'workbox-window'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'
|
||||
|
||||
// 全局状态
|
||||
const currentVersion = ref(__APP_VERSION__)
|
||||
let isUpdateToastShown = false
|
||||
let wb: Workbox | null = null
|
||||
|
||||
/**
|
||||
* 普通刷新页面
|
||||
*/
|
||||
export const reloadPage = (): void => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新页面并添加时间戳
|
||||
*/
|
||||
export const reloadWithTimestamp = (): void => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
window.location.replace(url.pathname + url.search + url.hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存和 Service Worker
|
||||
*/
|
||||
export const clearCachesAndServiceWorker = async (): Promise<void> => {
|
||||
try {
|
||||
// 1. 清除所有缓存
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)))
|
||||
console.log('[VersionChecker] 已清除所有缓存')
|
||||
}
|
||||
|
||||
// 2. 注销 Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map(registration => registration.unregister()))
|
||||
console.log('[VersionChecker] 已注销所有 Service Worker')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VersionChecker] 清除缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存并刷新
|
||||
*/
|
||||
const clearCacheAndReload = async (): Promise<void> => {
|
||||
await clearCachesAndServiceWorker()
|
||||
reloadWithTimestamp()
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本检查 Composable
|
||||
*
|
||||
* 功能:
|
||||
* - 使用 Workbox 监听 Service Worker 更新
|
||||
* - 检查浏览器版本与服务端版本是否一致
|
||||
* - 显示持久化更新通知
|
||||
*/
|
||||
export function useVersionChecker() {
|
||||
const toast = useToast()
|
||||
|
||||
/**
|
||||
* 显示版本更新通知
|
||||
* @param message 通知消息文本
|
||||
* @param refreshText 按钮文本,不传则不显示按钮
|
||||
* @param onRefresh 按钮点击事件
|
||||
*/
|
||||
const showUpdateNotification = (message: string, refreshText?: string, onRefresh?: () => void): void => {
|
||||
if (isUpdateToastShown) return
|
||||
isUpdateToastShown = true
|
||||
const component = h(VersionUpdateToast, {
|
||||
message,
|
||||
refreshText,
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
toast.info(component, {
|
||||
timeout: false, // 不自动消失
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
draggable: false,
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化 Workbox
|
||||
if (!wb && 'serviceWorker' in navigator) {
|
||||
wb = new Workbox('/service-worker.js')
|
||||
|
||||
// Service Worker 激活事件 (install -> activate)
|
||||
wb.addEventListener('activated', event => {
|
||||
// 只有在更新时才显示通知
|
||||
if (event.isUpdate) {
|
||||
console.log('[VersionChecker] Service Worker 更新已就绪,等待用户刷新')
|
||||
|
||||
showUpdateNotification(i18n.global.t('common.swUpdateReady'), i18n.global.t('common.refresh'), reloadPage)
|
||||
}
|
||||
})
|
||||
|
||||
// 注册 Service Worker
|
||||
wb.register()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查版本并在需要时显示更新通知
|
||||
* @param latestVersion 服务端返回的最新版本号
|
||||
*/
|
||||
const checkVersion = async (latestVersion: string): Promise<void> => {
|
||||
// 如果已经显示过通知,说明已经检查过了
|
||||
if (isUpdateToastShown) return
|
||||
|
||||
// 版本一致,无需操作
|
||||
if (latestVersion === currentVersion.value) {
|
||||
console.log('[VersionChecker] 版本号一致,无需操作')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[VersionChecker] 检测到版本不一致: ${currentVersion.value} -> ${latestVersion}`)
|
||||
|
||||
// 尝试触发 Service Worker 更新检查
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (registration) {
|
||||
console.log('[VersionChecker] 触发 Service Worker 更新检查...')
|
||||
|
||||
// 标记是否发现更新
|
||||
let updateFound = false
|
||||
const onUpdateFound = () => {
|
||||
updateFound = true
|
||||
}
|
||||
|
||||
// 监听 updatefound 事件
|
||||
registration.addEventListener('updatefound', onUpdateFound, { once: true })
|
||||
|
||||
// 等待检查完成
|
||||
await registration.update()
|
||||
|
||||
// 检查是否有更新正在进行
|
||||
// 如果发现更新,或者正在安装/等待中,则直接返回(交由 SW activated 事件处理)
|
||||
if (updateFound || registration.installing || registration.waiting) {
|
||||
console.log('[VersionChecker] Service Worker 更新中...')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[VersionChecker] SW 无更新,但版本号不一致,可能是缓存问题')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[VersionChecker] Service Worker 更新检查失败:', error)
|
||||
// 失败继续向下执行,显示通知
|
||||
}
|
||||
} else {
|
||||
console.log('[VersionChecker] 无 Service Worker, 直接显示通知')
|
||||
}
|
||||
|
||||
// 最终兜底:显示版本不一致通知(清除缓存)
|
||||
showUpdateNotification(
|
||||
i18n.global.t('common.versionMismatch'),
|
||||
i18n.global.t('common.clearCache'),
|
||||
clearCacheAndReload,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
checkVersion,
|
||||
}
|
||||
}
|
||||
@@ -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,17 +162,6 @@ const handleServiceWorkerMessage = (event: MessageEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用滚动锁定 composable(自动监听showPluginQuickAccess的变化)
|
||||
useScrollLockWithWatch(showPluginQuickAccess, {
|
||||
preventTouchScroll: true,
|
||||
preserveScrollPosition: true,
|
||||
autoRestore: true,
|
||||
// 允许快速访问面板内的滚动
|
||||
allowScrollSelectors: ['.plugin-quick-access'],
|
||||
// 允许快速访问面板内的可滚动容器
|
||||
allowScrollContainerSelectors: ['.plugin-grid'],
|
||||
})
|
||||
|
||||
// 检查是否可以使用下拉手势
|
||||
const canUsePullGesture = () => {
|
||||
// 检查是否在dashboard页面
|
||||
@@ -209,7 +197,7 @@ const {
|
||||
// 根据分类获取菜单列表
|
||||
const getMenuList = (header: string) => {
|
||||
// 使用国际化菜单
|
||||
const menus = getNavMenus()
|
||||
const menus = getNavMenus(t)
|
||||
const filteredMenus = filterMenusByPermission(menus, userPermissions.value)
|
||||
return filteredMenus.filter((item: NavMenu) => item.header === header)
|
||||
}
|
||||
@@ -398,7 +386,7 @@ onMounted(() => {
|
||||
|
||||
<!-- 👉 Footer -->
|
||||
<template #footer>
|
||||
<Footer />
|
||||
<Footer :show-nav="!showPluginQuickAccess" />
|
||||
</template>
|
||||
</VerticalNavLayout>
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ import { useUserStore } from '@/stores'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// 是否显示的输入参数
|
||||
defineProps({
|
||||
showNav: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const display = useDisplay()
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
@@ -41,7 +49,7 @@ const userPermissions = computed(() => {
|
||||
|
||||
// 获取导航菜单
|
||||
const navMenus = computed(() => {
|
||||
const allMenus = getNavMenus()
|
||||
const allMenus = getNavMenus(t)
|
||||
return filterMenusByPermission(allMenus, userPermissions.value)
|
||||
})
|
||||
|
||||
@@ -160,53 +168,59 @@ const showDynamicButton = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="appMode" to="body">
|
||||
<Teleport v-if="appMode && showNav" to="body">
|
||||
<div class="footer-nav-container">
|
||||
<VCard elevation="3" class="footer-nav-card border" rounded="pill" :class="{ 'shift-left': showDynamicButton }">
|
||||
<VCardText class="footer-card-content">
|
||||
<!-- 添加指示器 -->
|
||||
<div ref="indicator" class="nav-indicator"></div>
|
||||
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
|
||||
<!-- 遍历底部菜单项 -->
|
||||
<VBtn
|
||||
v-for="menu in footerMenus"
|
||||
:key="menu.to"
|
||||
:to="menu.to"
|
||||
:variant="currentMenu === menu.to ? 'text' : 'plain'"
|
||||
color="primary"
|
||||
:ripple="false"
|
||||
class="footer-nav-btn"
|
||||
rounded="pill"
|
||||
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
|
||||
:value="menu.to"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<VIcon :icon="menu.icon" size="32"></VIcon>
|
||||
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
|
||||
</div>
|
||||
</VBtn>
|
||||
<TransitionGroup name="footer-nav" tag="div" class="footer-nav-group">
|
||||
<VCard key="main-nav" elevation="3" class="footer-nav-card border" rounded="pill">
|
||||
<VCardText class="footer-card-content">
|
||||
<!-- 添加指示器 -->
|
||||
<div ref="indicator" class="nav-indicator"></div>
|
||||
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
|
||||
<!-- 遍历底部菜单项 -->
|
||||
<VBtn
|
||||
v-for="menu in footerMenus"
|
||||
:key="menu.to"
|
||||
:to="menu.to"
|
||||
:variant="currentMenu === menu.to ? 'text' : 'plain'"
|
||||
color="primary"
|
||||
:ripple="false"
|
||||
class="footer-nav-btn"
|
||||
rounded="pill"
|
||||
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
|
||||
:value="menu.to"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<VIcon :icon="menu.icon" size="32"></VIcon>
|
||||
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
|
||||
</div>
|
||||
</VBtn>
|
||||
|
||||
<!-- 更多按钮 -->
|
||||
<VBtn
|
||||
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
|
||||
color="primary"
|
||||
:ripple="false"
|
||||
to="/apps"
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
|
||||
value="/apps"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
|
||||
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
|
||||
</div>
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<Transition name="fade-slide">
|
||||
<VCard v-if="showDynamicButton" elevation="3" class="footer-nav-card dynamic-btn-card border" rounded="pill">
|
||||
<!-- 更多按钮 -->
|
||||
<VBtn
|
||||
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
|
||||
color="primary"
|
||||
:ripple="false"
|
||||
to="/apps"
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
|
||||
value="/apps"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
|
||||
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
|
||||
</div>
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VCard
|
||||
v-if="showDynamicButton"
|
||||
key="dynamic-btn"
|
||||
elevation="3"
|
||||
class="footer-nav-card dynamic-btn-card border"
|
||||
rounded="pill"
|
||||
>
|
||||
<VCardText class="footer-card-content">
|
||||
<!-- 各页面的动态按钮 -->
|
||||
<VBtn
|
||||
@@ -221,7 +235,7 @@ const showDynamicButton = computed(() => {
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</Transition>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -237,6 +251,12 @@ const showDynamicButton = computed(() => {
|
||||
inset-inline: 0;
|
||||
padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.footer-nav-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// 按钮卡片之间的间距
|
||||
> .v-card + .v-card {
|
||||
@@ -251,6 +271,7 @@ const showDynamicButton = computed(() => {
|
||||
background-color: rgba(var(--v-theme-surface), 0.6);
|
||||
pointer-events: auto;
|
||||
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
will-change: transform, max-width, opacity;
|
||||
|
||||
// 透明主题下的特殊样式
|
||||
.v-theme--transparent & {
|
||||
@@ -258,10 +279,6 @@ const showDynamicButton = computed(() => {
|
||||
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
|
||||
}
|
||||
|
||||
&.shift-left {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.v-btn-toggle {
|
||||
block-size: auto;
|
||||
min-block-size: 56px;
|
||||
@@ -319,6 +336,7 @@ const showDynamicButton = computed(() => {
|
||||
block-size: auto;
|
||||
inline-size: auto;
|
||||
min-block-size: 0;
|
||||
max-width: 60px;
|
||||
|
||||
.footer-card-content {
|
||||
padding: 3px;
|
||||
@@ -340,23 +358,25 @@ const showDynamicButton = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 淡入滑动动画
|
||||
.fade-slide-enter-active {
|
||||
// 底部导航动画
|
||||
.footer-nav-enter-active,
|
||||
.footer-nav-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
.footer-nav-enter-from,
|
||||
.footer-nav-leave-to {
|
||||
opacity: 0;
|
||||
max-width: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
border-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
.footer-nav-move {
|
||||
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
|
||||
@@ -57,159 +57,78 @@ const statusIcon = computed(() => {
|
||||
const colorTheme = computed(() => {
|
||||
return props.type === 'online' ? 'success' : 'error'
|
||||
})
|
||||
|
||||
// 动画时长
|
||||
const ENTER_DURATION = 600
|
||||
const LEAVE_DURATION = 400
|
||||
|
||||
// 进入动画
|
||||
function onEnter(el: HTMLElement, done: () => void) {
|
||||
// 初始状态
|
||||
el.style.opacity = '0'
|
||||
el.style.transform = 'scale(0.9)'
|
||||
el.style.filter = 'blur(10px)'
|
||||
|
||||
// 强制重绘
|
||||
el.offsetHeight
|
||||
|
||||
// 应用过渡
|
||||
el.style.transition = `all ${ENTER_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`
|
||||
|
||||
// 目标状态
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = '1'
|
||||
el.style.transform = 'scale(1)'
|
||||
el.style.filter = 'blur(0)'
|
||||
})
|
||||
|
||||
// 动画完成
|
||||
setTimeout(done, ENTER_DURATION)
|
||||
}
|
||||
|
||||
// 离开动画
|
||||
function onLeave(el: HTMLElement, done: () => void) {
|
||||
// 应用过渡
|
||||
el.style.transition = `all ${LEAVE_DURATION}ms cubic-bezier(0.4, 0, 1, 1)`
|
||||
|
||||
// 目标状态
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = '0'
|
||||
el.style.transform = 'scale(1.1)'
|
||||
el.style.filter = 'blur(20px)'
|
||||
})
|
||||
|
||||
// 动画完成
|
||||
setTimeout(done, LEAVE_DURATION)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
:css="false"
|
||||
@enter="onEnter"
|
||||
@leave="onLeave"
|
||||
>
|
||||
<div v-if="shouldShow" class="offline-page" ref="offlinePage">
|
||||
<div class="offline-container" :class="{ 'container-animate': shouldShow }">
|
||||
<!-- 状态图标 -->
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="64" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要信息 -->
|
||||
<div class="content-section">
|
||||
<h1 class="offline-title">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h1>
|
||||
|
||||
<p class="offline-message">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<!-- 重试按钮 -->
|
||||
<div class="action-section">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="large"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示器 -->
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="footer-section">
|
||||
<p class="app-info">{{ t('app.moviepilot') }}</p>
|
||||
<VDialog :model-value="shouldShow" persistent max-width="420" scrollable>
|
||||
<VCard class="offline-dialog">
|
||||
<!-- 状态图标 -->
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 主要信息 -->
|
||||
<VCardText class="text-center">
|
||||
<h2 class="offline-title mb-4">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h2>
|
||||
|
||||
<p class="offline-message mb-6">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<!-- 重试按钮 -->
|
||||
<div class="action-section mb-6">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="default"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示器 -->
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.offline-page {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
background: linear-gradient(135deg, rgb(var(--v-theme-surface)) 0%, rgb(var(--v-theme-surface-variant)) 100%);
|
||||
inset: 0;
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
.offline-container {
|
||||
padding: 40px;
|
||||
border-radius: 24px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 10%), 0 0 0 1px rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
inline-size: 100%;
|
||||
max-inline-size: 500px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.offline-page .offline-container.container-animate {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition-delay: 0.2s;
|
||||
.offline-dialog {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.status-icon-wrapper {
|
||||
margin-block-end: 32px;
|
||||
padding-block: 24px 0;
|
||||
padding-inline: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
@@ -218,71 +137,61 @@ function onLeave(el: HTMLElement, done: () => void) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
animation: icon-pulse 3s ease-in-out infinite;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
block-size: 120px;
|
||||
inline-size: 120px;
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
animation: iconPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-icon-bg::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: icon-glow 2s ease-in-out infinite alternate;
|
||||
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
|
||||
content: '';
|
||||
inset: -4px;
|
||||
inset: -3px;
|
||||
opacity: 0.1;
|
||||
animation: iconGlow 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes iconPulse {
|
||||
0%, 100% {
|
||||
@keyframes icon-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes iconGlow {
|
||||
@keyframes icon-glow {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-block-end: 32px;
|
||||
}
|
||||
|
||||
.offline-title {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 2rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-block-end: 32px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-block-end: 32px;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -290,41 +199,19 @@ function onLeave(el: HTMLElement, done: () => void) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
margin-block-end: 32px;
|
||||
}
|
||||
|
||||
.help-panels {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (width <= 600px) {
|
||||
.offline-container {
|
||||
padding: 24px;
|
||||
margin: 16px;
|
||||
.status-icon-bg {
|
||||
block-size: 70px;
|
||||
inline-size: 70px;
|
||||
}
|
||||
|
||||
.offline-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
@@ -332,13 +219,4 @@ function onLeave(el: HTMLElement, done: () => void) {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑模式优化 */
|
||||
.v-theme--dark .offline-page {
|
||||
background: linear-gradient(135deg, rgb(var(--v-theme-surface)) 0%, rgba(var(--v-theme-surface-variant), 0.8) 100%);
|
||||
}
|
||||
|
||||
.v-theme--dark .offline-container {
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 30%), 0 0 0 1px rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRecentPlugins } from '@/composables/useRecentPlugins'
|
||||
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
|
||||
import { VCard } from 'vuetify/components'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -136,8 +137,8 @@ const componentOpacity = computed(() => {
|
||||
|
||||
// 计算插件图标路径
|
||||
function getPluginIcon(plugin: Plugin): string {
|
||||
if (!plugin.plugin_icon) return noImage
|
||||
if (pluginIconLoadError.value[plugin.id]) return noImage
|
||||
if (!plugin.plugin_icon) return getLogoUrl('plugin')
|
||||
if (pluginIconLoadError.value[plugin.id]) return getLogoUrl('plugin')
|
||||
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (plugin?.plugin_icon?.startsWith('http'))
|
||||
@@ -205,6 +206,29 @@ function handleClosePluginDataDialog() {
|
||||
currentPlugin.value = null
|
||||
}
|
||||
|
||||
// 管理滚动状态
|
||||
function manageScrollLock() {
|
||||
if (isVisible.value) {
|
||||
// 使用 nextTick 确保 DOM 已经更新
|
||||
nextTick(() => {
|
||||
// 先恢复之前的锁定状态,避免重复锁定
|
||||
const scrollableElement = document.querySelector('.all-plugins-grid')
|
||||
if (scrollableElement) {
|
||||
// 确保元素存在且可见
|
||||
if ((scrollableElement as HTMLElement).offsetHeight > 0) {
|
||||
disableBodyScroll(scrollableElement as HTMLElement)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 恢复背景滚动
|
||||
const scrollableElement = document.querySelector('.all-plugins-grid')
|
||||
if (scrollableElement) {
|
||||
enableBodyScroll(scrollableElement as HTMLElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听可见性变化,加载数据
|
||||
watch(
|
||||
() => isVisible.value,
|
||||
@@ -212,6 +236,9 @@ watch(
|
||||
if (visible) {
|
||||
fetchPluginsWithPage()
|
||||
loadRecentPlugins()
|
||||
manageScrollLock()
|
||||
} else {
|
||||
manageScrollLock()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -221,6 +248,15 @@ onMounted(() => {
|
||||
if (isVisible.value) {
|
||||
fetchPluginsWithPage()
|
||||
loadRecentPlugins()
|
||||
manageScrollLock()
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时确保恢复背景滚动
|
||||
onUnmounted(() => {
|
||||
const scrollableElement = document.querySelector('.all-plugins-grid')
|
||||
if (scrollableElement) {
|
||||
enableBodyScroll(scrollableElement as HTMLElement)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -420,40 +456,41 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
<div class="section-title">{{ t('plugin.allPlugins') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pluginsWithPage.length > 0" class="all-plugins-grid">
|
||||
<div
|
||||
v-for="plugin in pluginsWithPage"
|
||||
:key="plugin.id"
|
||||
class="plugin-item"
|
||||
@click="handlePluginClick(plugin)"
|
||||
>
|
||||
<VBadge
|
||||
dot
|
||||
:color="plugin.state ? 'success' : 'secondary'"
|
||||
location="top end"
|
||||
:offset-x="-1"
|
||||
:offset-y="-1"
|
||||
<div v-if="pluginsWithPage.length > 0" class="all-plugins-container">
|
||||
<div class="all-plugins-grid">
|
||||
<div
|
||||
v-for="plugin in pluginsWithPage"
|
||||
:key="plugin.id"
|
||||
class="plugin-item"
|
||||
@click="handlePluginClick(plugin)"
|
||||
>
|
||||
<div
|
||||
class="plugin-icon"
|
||||
:style="{
|
||||
background: `${getPluginBackgroundColor(plugin)}`,
|
||||
}"
|
||||
<VBadge
|
||||
dot
|
||||
:color="plugin.state ? 'success' : 'secondary'"
|
||||
location="top end"
|
||||
:offset-x="-1"
|
||||
:offset-y="-1"
|
||||
>
|
||||
<VImg
|
||||
:src="getPluginIcon(plugin)"
|
||||
:alt="plugin.plugin_name"
|
||||
cover
|
||||
@load="src => handleIconLoaded(src, plugin)"
|
||||
@error="handleIconError(plugin)"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</VBadge>
|
||||
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
||||
<div
|
||||
class="plugin-icon"
|
||||
:style="{
|
||||
background: `${getPluginBackgroundColor(plugin)}`,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
:src="getPluginIcon(plugin)"
|
||||
:alt="plugin.plugin_name"
|
||||
cover
|
||||
@load="src => handleIconLoaded(src, plugin)"
|
||||
@error="handleIconError(plugin)"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</VBadge>
|
||||
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态(只有在没有插件时显示) -->
|
||||
<div v-else-if="pluginsWithPage.length === 0" class="empty-state">
|
||||
<VIcon icon="mdi-puzzle-outline" size="48" color="grey" />
|
||||
@@ -622,10 +659,34 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.all-plugins-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.all-plugins-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
max-block-size: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: none; // IE/Edge
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 8px;
|
||||
padding-inline: 0;
|
||||
|
||||
// 隐藏滚动条
|
||||
scrollbar-width: none; // Firefox
|
||||
touch-action: pan-y;
|
||||
will-change: scroll-position;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none; // WebKit 浏览器
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-item {
|
||||
@@ -677,6 +738,7 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
line-height: 1.2;
|
||||
max-block-size: 2.4em;
|
||||
text-align: center;
|
||||
|
||||
@@ -5,6 +5,8 @@ import LoggingView from '@/views/system/LoggingView.vue'
|
||||
import RuleTestView from '@/views/system/RuleTestView.vue'
|
||||
import ModuleTestView from '@/views/system/ModuleTestView.vue'
|
||||
import MessageView from '@/views/system/MessageView.vue'
|
||||
import WordsView from '@/views/system/WordsView.vue'
|
||||
import CacheView from '@/views/system/CacheView.vue'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { getQueryValue } from '@/@core/utils'
|
||||
@@ -41,6 +43,12 @@ const systemTestDialog = ref(false)
|
||||
// 消息中心弹窗
|
||||
const messageDialog = ref(false)
|
||||
|
||||
// 词表设置弹窗
|
||||
const wordsDialog = ref(false)
|
||||
|
||||
// 缓存管理弹窗
|
||||
const cacheDialog = ref(false)
|
||||
|
||||
// 输入消息
|
||||
const user_message = ref('')
|
||||
|
||||
@@ -50,6 +58,9 @@ const sendButtonDisabled = ref(false)
|
||||
// 消息对话框引用
|
||||
const messageDialogRef = ref<any>(null)
|
||||
|
||||
// 消息视图引用
|
||||
const messageViewRef = ref<any>(null)
|
||||
|
||||
// 滚动容器引用
|
||||
const messageContentRef = ref<any>()
|
||||
|
||||
@@ -83,6 +94,20 @@ const shortcuts = [
|
||||
dialog: 'netTest',
|
||||
dialogRef: netTestDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.words.title'),
|
||||
subtitle: t('shortcut.words.subtitle'),
|
||||
icon: 'mdi-file-word-box',
|
||||
dialog: 'words',
|
||||
dialogRef: wordsDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.cache.title'),
|
||||
subtitle: t('shortcut.cache.subtitle'),
|
||||
icon: 'mdi-database',
|
||||
dialog: 'cache',
|
||||
dialogRef: cacheDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.system.title'),
|
||||
subtitle: t('shortcut.system.subtitle'),
|
||||
@@ -115,6 +140,12 @@ async function openMessageDialog() {
|
||||
setTimeout(() => {
|
||||
forceScrollToEnd()
|
||||
}, 600)
|
||||
// 等待对话框打开后恢复SSE连接
|
||||
nextTick(() => {
|
||||
if (messageViewRef.value && typeof messageViewRef.value.resumeSSE === 'function') {
|
||||
messageViewRef.value.resumeSSE()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 智能滚动到底部(只有用户在底部附近时才滚动)
|
||||
@@ -184,6 +215,14 @@ defineExpose({
|
||||
openMessageDialog: openMessageDialogFromExternal,
|
||||
})
|
||||
|
||||
// 监听消息对话框状态变化
|
||||
watch(messageDialog, newValue => {
|
||||
if (!newValue && messageViewRef.value && typeof messageViewRef.value.pauseSSE === 'function') {
|
||||
// 对话框关闭时暂停SSE连接
|
||||
messageViewRef.value.pauseSSE()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const shortcut = getQueryValue('shortcut')
|
||||
if (shortcut) {
|
||||
@@ -232,7 +271,15 @@ onMounted(() => {
|
||||
flat
|
||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full"
|
||||
hover
|
||||
@click="item.dialog === 'message' ? openMessageDialog() : openDialog(item.dialogRef)"
|
||||
@click="
|
||||
item.dialog === 'message'
|
||||
? openMessageDialog()
|
||||
: item.dialog === 'words'
|
||||
? openDialog(item.dialogRef)
|
||||
: item.dialog === 'cache'
|
||||
? openDialog(item.dialogRef)
|
||||
: openDialog(item.dialogRef)
|
||||
"
|
||||
>
|
||||
<VAvatar variant="text" size="48" rounded="lg">
|
||||
<VIcon color="primary" :icon="item.icon" size="24" />
|
||||
@@ -248,7 +295,7 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VMenu>
|
||||
<!-- 名称测试弹窗 -->
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="nameTestDialog"
|
||||
v-model="nameTestDialog"
|
||||
max-width="45rem"
|
||||
@@ -268,9 +315,9 @@ onMounted(() => {
|
||||
<NameTestView />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
<!-- 网络测试弹窗 -->
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="netTestDialog"
|
||||
v-model="netTestDialog"
|
||||
max-width="35rem"
|
||||
@@ -290,9 +337,9 @@ onMounted(() => {
|
||||
<NetTestView />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
<!-- 实时日志弹窗 -->
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
@@ -318,9 +365,9 @@ onMounted(() => {
|
||||
<LoggingView logfile="moviepilot.log" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
<!-- 过滤规则弹窗 -->
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="ruleTestDialog"
|
||||
v-model="ruleTestDialog"
|
||||
max-width="35rem"
|
||||
@@ -340,9 +387,41 @@ onMounted(() => {
|
||||
<RuleTestView />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
<!-- 词表设置弹窗 -->
|
||||
<VDialog v-if="wordsDialog" v-model="wordsDialog" max-width="60rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-file-word-box" class="me-2" />
|
||||
{{ t('shortcut.words.subtitle') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="wordsDialog = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<WordsView />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 缓存管理弹窗 -->
|
||||
<VDialog v-if="cacheDialog" v-model="cacheDialog" max-width="90rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-database" class="me-2" />
|
||||
{{ t('shortcut.cache.subtitle') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="cacheDialog = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<CacheView />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 系统健康检查弹窗 -->
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="systemTestDialog"
|
||||
v-model="systemTestDialog"
|
||||
max-width="35rem"
|
||||
@@ -362,9 +441,9 @@ onMounted(() => {
|
||||
<ModuleTestView />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
<!-- 消息中心弹窗 -->
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="messageDialog"
|
||||
v-model="messageDialog"
|
||||
max-width="50rem"
|
||||
@@ -407,5 +486,5 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,8 @@ import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import api from '@/api'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import UserAuthDialog from '@/components/dialog/UserAuthDialog.vue'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import AboutDialog from '@/components/dialog/AboutDialog.vue'
|
||||
import { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
|
||||
@@ -15,15 +16,20 @@ import { saveLocalTheme } from '@/@core/utils/theme'
|
||||
import type { ThemeSwitcherTheme } from '@layouts/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { usePWA, type UIMode } from '@/composables/usePWA'
|
||||
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
// 全局设置 Store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
// 显示器
|
||||
const display = useDisplay()
|
||||
// PWA
|
||||
const { uiMode, setUIMode } = usePWA()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -37,6 +43,9 @@ const siteAuthDialog = ref(false)
|
||||
// 自定义CSS弹窗
|
||||
const cssDialog = ref(false)
|
||||
|
||||
// UI模式菜单是否显示
|
||||
const showUIModeMenu = ref(false)
|
||||
|
||||
// 主题菜单是否显示
|
||||
const showThemeMenu = ref(false)
|
||||
|
||||
@@ -53,6 +62,9 @@ const transparencyLevel = ref(localStorage.getItem('transparency-level') || 'med
|
||||
const isTransparentTheme = computed(() => currentThemeName.value === 'transparent')
|
||||
const showTransparencyDialog = ref(false)
|
||||
|
||||
// 关于对话框
|
||||
const aboutDialog = ref(false)
|
||||
|
||||
// 预设值配置
|
||||
const transparencyPresets = {
|
||||
low: { opacity: 0.1, blur: 5 },
|
||||
@@ -205,6 +217,11 @@ function showSiteAuthDialog() {
|
||||
siteAuthDialog.value = true
|
||||
}
|
||||
|
||||
// 显示关于对话框
|
||||
function showAboutDialog() {
|
||||
aboutDialog.value = true
|
||||
}
|
||||
|
||||
// 用户站点认证成功
|
||||
function siteAuthDone() {
|
||||
siteAuthDialog.value = false
|
||||
@@ -217,9 +234,45 @@ const userName = computed(() => userStore.userName)
|
||||
const avatar = computed(() => userStore.avatar || avatar1)
|
||||
const userLevel = computed(() => userStore.level)
|
||||
|
||||
// 检查是否为高级模式
|
||||
const isAdvancedMode = computed(() => {
|
||||
return globalSettingsStore.get('ADVANCED_MODE') !== false
|
||||
})
|
||||
|
||||
// UI模式相关
|
||||
const uiModes = computed(() => [
|
||||
{
|
||||
name: 'auto',
|
||||
title: t('theme.autoUI'),
|
||||
icon: 'mdi-devices',
|
||||
},
|
||||
{
|
||||
name: 'desktop',
|
||||
title: t('pwa.platforms.desktop'),
|
||||
icon: 'mdi-monitor',
|
||||
},
|
||||
{
|
||||
name: 'app',
|
||||
title: t('pwa.platforms.mobile'),
|
||||
icon: 'mdi-cellphone',
|
||||
},
|
||||
])
|
||||
|
||||
// 切换UI模式
|
||||
function changeUIMode(mode: UIMode) {
|
||||
setUIMode(mode)
|
||||
showUIModeMenu.value = false
|
||||
}
|
||||
|
||||
// 获取当前UI模式图标
|
||||
const getUIModeIcon = computed(() => {
|
||||
const mode = uiModes.value.find(m => m.name === uiMode.value)
|
||||
return mode?.icon || 'mdi-devices'
|
||||
})
|
||||
|
||||
// 主题相关功能
|
||||
const { name: themeName, global: globalTheme } = useTheme()
|
||||
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
|
||||
const savedTheme = ref(localStorage.getItem('theme') ?? 'auto')
|
||||
const currentThemeName = ref(savedTheme.value)
|
||||
|
||||
const themes: ThemeSwitcherTheme[] = [
|
||||
@@ -509,11 +562,17 @@ onUnmounted(() => {
|
||||
<VListItemTitle>{{ t('user.profile') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem v-if="superUser" link @click="router.push('/setting')" class="mb-1 rounded-lg" hover>
|
||||
<VListItem
|
||||
v-if="superUser"
|
||||
link
|
||||
@click="isAdvancedMode ? router.push('/setting') : router.push('/setup-wizard')"
|
||||
class="mb-1 rounded-lg"
|
||||
hover
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog-outline" />
|
||||
<VIcon :icon="isAdvancedMode ? 'mdi-cog-outline' : 'mdi-wizard-hat'" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('user.systemSettings') }}</VListItemTitle>
|
||||
<VListItemTitle>{{ isAdvancedMode ? t('user.systemSettings') : t('user.wizardSettings') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 Site Auth -->
|
||||
@@ -524,6 +583,41 @@ onUnmounted(() => {
|
||||
<VListItemTitle>{{ t('user.siteAuth') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 UI模式设置 - 使用嵌套菜单 -->
|
||||
<VMenu location="end" offset-x min-width="200" v-model="showUIModeMenu" :close-on-content-click="true">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon :icon="getUIModeIcon" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('common.uiMode') }}</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
{{ uiModes.find(m => m.name === uiMode)?.title || t('theme.autoUI') }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon icon="mdi-chevron-right" size="small" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="mode in uiModes"
|
||||
:key="mode.name"
|
||||
@click="changeUIMode(mode.name as UIMode)"
|
||||
:active="uiMode === mode.name"
|
||||
class="mb-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="mode.icon" />
|
||||
</template>
|
||||
<VListItemTitle>{{ mode.title }}</VListItemTitle>
|
||||
<template #append v-if="uiMode === mode.name">
|
||||
<VIcon icon="mdi-check" color="primary" size="small" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
|
||||
<!-- 👉 主题设置 - 使用嵌套菜单 -->
|
||||
<VMenu location="end" offset-x min-width="200" v-model="showThemeMenu" :close-on-content-click="true">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
@@ -531,9 +625,10 @@ onUnmounted(() => {
|
||||
<template #prepend>
|
||||
<VIcon :icon="getThemeIcon" />
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
{{ themes.find(t => t.name === currentThemeName)?.title || t('common.theme') }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle>{{ t('common.theme') }}</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
{{ themes.find(t => t.name === currentThemeName)?.title || t('theme.auto') }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon icon="mdi-chevron-right" size="small" />
|
||||
</template>
|
||||
@@ -620,6 +715,14 @@ onUnmounted(() => {
|
||||
<VListItemTitle>{{ t('user.helpDocs') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 About -->
|
||||
<VListItem @click="showAboutDialog" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information-outline" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('setting.about.title') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- Divider -->
|
||||
<VDivider v-if="superUser" class="my-3" />
|
||||
|
||||
@@ -650,7 +753,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>
|
||||
@@ -671,10 +774,10 @@ onUnmounted(() => {
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
|
||||
<!-- 透明度调整对话框 -->
|
||||
<DialogWrapper v-if="showTransparencyDialog" v-model="showTransparencyDialog" max-width="30rem">
|
||||
<VDialog v-if="showTransparencyDialog" v-model="showTransparencyDialog" max-width="30rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -763,7 +866,10 @@ onUnmounted(() => {
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
|
||||
<!-- 关于对话框 -->
|
||||
<AboutDialog v-if="aboutDialog" v-model="aboutDialog" @close="aboutDialog = false" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
saving: 'Saving',
|
||||
reset: 'Reset',
|
||||
theme: 'Theme',
|
||||
uiMode: 'UI Layout',
|
||||
language: 'Language',
|
||||
pleaseWait: 'Please wait...',
|
||||
viewDetails: 'View Details',
|
||||
@@ -49,6 +50,9 @@ export default {
|
||||
itemsPerPage: 'Items per page',
|
||||
pageText: '{0}-{1} of {2}',
|
||||
noDataText: 'No data',
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
skip: 'Skip',
|
||||
loadingText: 'Loading...',
|
||||
networkRequired: 'This feature requires network connection',
|
||||
networkDisconnected: 'Network connection lost',
|
||||
@@ -63,6 +67,10 @@ export default {
|
||||
serviceUnavailable: 'Service Unavailable',
|
||||
status: 'Status',
|
||||
preset: 'Preset',
|
||||
refresh: 'Refresh',
|
||||
swUpdateReady: 'New version is ready, please refresh the page to get the latest features',
|
||||
versionMismatch: 'Browser cache version does not match server version, please try clearing cache',
|
||||
clearCache: 'Clear Cache',
|
||||
},
|
||||
mediaType: {
|
||||
movie: 'Movie',
|
||||
@@ -126,6 +134,7 @@ export default {
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
auto: 'Follow System',
|
||||
autoUI: 'Auto',
|
||||
transparent: 'Transparent',
|
||||
purple: 'Purple',
|
||||
custom: 'Custom Style',
|
||||
@@ -245,6 +254,22 @@ export default {
|
||||
serverError: 'Login failed, server error!',
|
||||
loginFailed: 'Login Failed',
|
||||
checkCredentials: 'Please check your username, password or two-factor authentication code!',
|
||||
twoFactorAuth: 'Two-Factor Authentication',
|
||||
loginWithPasskey: 'Login with Passkey',
|
||||
loginWithOtp: 'Login with OTP',
|
||||
orUsePasskey: 'Or use Passkey for verification',
|
||||
verifyWithPasskey: 'Verify with Passkey',
|
||||
otpPlaceholder: 'Enter 6-digit code',
|
||||
passkeyLoginStartFailed: 'Failed to start Passkey authentication',
|
||||
passkeyNotSelected: 'No Passkey selected',
|
||||
passkeyLoginFailed: 'Passkey login failed',
|
||||
passkeyAuthCanceled: 'Passkey authentication canceled',
|
||||
passkeyLoginRetry: 'Passkey login failed, please try again',
|
||||
passkeyVerifyFailed: 'Passkey verification failed',
|
||||
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
|
||||
mfa: {
|
||||
selectVerificationMethod: 'Please select a verification method',
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
start: 'Start',
|
||||
@@ -321,11 +346,6 @@ export default {
|
||||
title: 'Notifications',
|
||||
description: 'Notification channels (WeChat, Telegram, Slack, SynologyChat, VoceChat, WebPush), message scope',
|
||||
},
|
||||
words: {
|
||||
title: 'Word Lists',
|
||||
description:
|
||||
'Custom recognition words, custom production/subtitle groups, custom placeholders, file organization block words',
|
||||
},
|
||||
about: {
|
||||
title: 'About',
|
||||
description: 'Software version',
|
||||
@@ -369,8 +389,10 @@ export default {
|
||||
deleteFailed: 'Failed to delete user!',
|
||||
profile: 'Profile',
|
||||
systemSettings: 'System Settings',
|
||||
wizardSettings: 'Setup Wizard',
|
||||
siteAuth: 'User Authentication',
|
||||
helpDocs: 'Help Documents',
|
||||
about: 'About',
|
||||
restart: 'Restart',
|
||||
management: 'User Management',
|
||||
noUsers: 'No Users',
|
||||
@@ -378,8 +400,11 @@ export default {
|
||||
addUser: 'Add User',
|
||||
editUser: 'Edit User',
|
||||
username: 'Username',
|
||||
usernameHint: 'Username for system login',
|
||||
password: 'Password',
|
||||
passwordHint: 'Please enter your login password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
confirmPasswordHint: 'Please enter the password again to confirm',
|
||||
role: 'Role',
|
||||
email: 'Email',
|
||||
enabled: 'Enabled',
|
||||
@@ -408,10 +433,13 @@ export default {
|
||||
name: 'WeChat Work',
|
||||
corpId: 'Corp ID',
|
||||
corpIdHint: 'Corp ID in WeChat Work backend enterprise information',
|
||||
corpIdRequired: 'Corp ID cannot be empty',
|
||||
appId: 'App AgentId',
|
||||
appIdHint: 'AgentId of self-built app in WeChat Work',
|
||||
appIdRequired: 'App AgentId cannot be empty',
|
||||
appSecret: 'App Secret',
|
||||
appSecretHint: 'Secret of self-built app in WeChat Work',
|
||||
appSecretRequired: 'App Secret cannot be empty',
|
||||
proxy: 'Proxy Address',
|
||||
proxyHint:
|
||||
'Proxy address for WeChat message forwarding, required for self-built apps created after June 20, 2022',
|
||||
@@ -427,8 +455,10 @@ export default {
|
||||
name: 'Telegram',
|
||||
token: 'Bot Token',
|
||||
tokenHint: 'Telegram bot token, format: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||
tokenRequired: 'Bot Token cannot be empty',
|
||||
chatId: 'Chat ID',
|
||||
chatIdHint: 'Chat ID of user, group or channel that receives notifications',
|
||||
chatIdRequired: 'Chat ID cannot be empty',
|
||||
users: 'User Whitelist',
|
||||
usersHint: 'User IDs that can use Telegram bot, separated by commas. Leave empty to allow all users',
|
||||
admins: 'Admin Whitelist',
|
||||
@@ -443,15 +473,30 @@ export default {
|
||||
name: 'Slack',
|
||||
oauthToken: 'Slack Bot User OAuth Token',
|
||||
oauthTokenHint: 'Bot User OAuth Token in Slack app OAuth & Permissions page',
|
||||
oauthTokenRequired: 'OAuth Token cannot be empty',
|
||||
appToken: 'Slack App-Level Token',
|
||||
appTokenHint: 'App-Level Token in Slack app OAuth & Permissions page',
|
||||
channel: 'Channel Name',
|
||||
channelHint: 'Channel to send messages, default is "all"',
|
||||
channelRequired: 'Channel Name cannot be empty',
|
||||
},
|
||||
discord: {
|
||||
name: 'Discord',
|
||||
botToken: 'Bot Token',
|
||||
botTokenHint: 'Discord Bot Token (enable Message Content Intent in Dev Portal)',
|
||||
botTokenRequired: 'Bot Token is required',
|
||||
guildId: 'Guild ID',
|
||||
guildIdHint: 'Optional, restrict to a specific guild; leave blank to use any joined guild',
|
||||
guildIdPlaceholder: '123456789012345678',
|
||||
channelId: 'Channel ID',
|
||||
channelIdHint: 'Optional, default broadcast channel; leave blank to auto-pick a writable channel',
|
||||
channelIdPlaceholder: '123456789012345678',
|
||||
},
|
||||
synologychat: {
|
||||
name: 'Synology Chat',
|
||||
webhook: 'Webhook URL',
|
||||
webhookHint: 'Synology Chat bot webhook URL',
|
||||
webhookRequired: 'Webhook URL cannot be empty',
|
||||
token: 'Token',
|
||||
tokenHint: 'Synology Chat bot token',
|
||||
},
|
||||
@@ -459,8 +504,10 @@ export default {
|
||||
name: 'VoceChat',
|
||||
host: 'Address',
|
||||
hostHint: 'VoceChat server address, format: http(s)://ip:port',
|
||||
hostRequired: 'Address cannot be empty',
|
||||
apiKey: 'Bot API Key',
|
||||
apiKeyHint: 'VoceChat bot API key',
|
||||
apiKeyRequired: 'API Key cannot be empty',
|
||||
channelId: 'Channel ID',
|
||||
channelIdHint: 'VoceChat channel ID, without #',
|
||||
},
|
||||
@@ -468,6 +515,7 @@ export default {
|
||||
name: 'WebPush',
|
||||
username: 'Login Username',
|
||||
usernameHint: 'Only push messages to the corresponding logged-in user',
|
||||
usernameRequired: 'Username cannot be empty',
|
||||
},
|
||||
},
|
||||
shortcut: {
|
||||
@@ -496,6 +544,14 @@ export default {
|
||||
title: 'Messages',
|
||||
subtitle: 'Message Center',
|
||||
},
|
||||
words: {
|
||||
title: 'Words',
|
||||
subtitle: 'Word Settings',
|
||||
},
|
||||
cache: {
|
||||
title: 'Cache',
|
||||
subtitle: 'Manage Cache',
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
components: 'Action Components',
|
||||
@@ -766,6 +822,8 @@ export default {
|
||||
originalTitle: 'Original Title',
|
||||
status: 'Status',
|
||||
releaseDate: 'Release Date',
|
||||
digitalRelease: 'Digital Release',
|
||||
physicalRelease: 'Physical Release',
|
||||
originalLanguage: 'Original Language',
|
||||
productionCountries: 'Production Countries',
|
||||
productionCompanies: 'Production Companies',
|
||||
@@ -833,6 +891,24 @@ export default {
|
||||
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',
|
||||
minSubscribers: 'Minimum Subscribers',
|
||||
},
|
||||
recommend: {
|
||||
all: 'All',
|
||||
@@ -1007,6 +1083,7 @@ export default {
|
||||
limitSeconds: 'Access Interval (seconds)',
|
||||
useProxy: 'Use Proxy',
|
||||
browserSimulation: 'Browser Simulation',
|
||||
selectFile: 'Select File',
|
||||
},
|
||||
hints: {
|
||||
url: 'Format: http://www.example.com/',
|
||||
@@ -1024,19 +1101,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 ...',
|
||||
@@ -1068,6 +1174,13 @@ export default {
|
||||
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',
|
||||
@@ -1079,6 +1192,7 @@ export default {
|
||||
program: 'Program',
|
||||
content: 'Content',
|
||||
refreshing: 'Refreshing',
|
||||
initializing: 'Initializing',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: 'Normal',
|
||||
@@ -1117,6 +1231,7 @@ export default {
|
||||
title: 'About MoviePilot',
|
||||
softwareVersion: 'Software Version',
|
||||
frontendVersion: 'Frontend Version',
|
||||
browserVersion: 'Browser Cached Version',
|
||||
authVersion: 'Auth Resource Version',
|
||||
indexerVersion: 'Indexer Resource Version',
|
||||
configDir: 'Config Directory',
|
||||
@@ -1136,6 +1251,7 @@ export default {
|
||||
dataDirectory: '/moviepilot',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
clearCache: 'Clear Cache',
|
||||
},
|
||||
system: {
|
||||
custom: 'Custom',
|
||||
@@ -1160,9 +1276,24 @@ export default {
|
||||
apiTokenLength: 'API Token must be at least 16 characters',
|
||||
githubToken: 'Github Token',
|
||||
githubTokenFormat: 'ghp_**** or github_pat_****',
|
||||
githubTokenHint: 'Used to increase the rate limit threshold when plugins access Github API',
|
||||
githubTokenHint:
|
||||
'Used to increase the rate limit threshold when plugins access Github API,it is recommended to configure, otherwise plugins may not work properly',
|
||||
ocrHost: 'OCR Server',
|
||||
ocrHostHint: 'Used for site check-in, updating site cookies and other captcha recognition',
|
||||
aiAgent: 'Enable AI Assistant',
|
||||
aiAgentEnable: 'Enable AI Assistant',
|
||||
aiAgentEnableHint: 'Enable AI assistant functionality, requires LLM configuration',
|
||||
llmProvider: 'LLM Provider',
|
||||
llmProviderHint: 'Select the LLM service provider to use',
|
||||
llmModel: 'LLM Model Name',
|
||||
llmModelHint: 'Specify the LLM model to use, such as gpt-3.5-turbo, deepseek-chat, etc.',
|
||||
llmApiKey: 'LLM API Key',
|
||||
llmApiKeyHint: 'API key from the LLM service provider for authentication',
|
||||
llmApiKeyPlaceholder: 'Please enter API key',
|
||||
llmBaseUrl: 'LLM Base URL',
|
||||
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
|
||||
aiAgentGlobal: 'Global AI Assistant',
|
||||
aiAgentGlobalHint: 'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
|
||||
advancedSettings: 'Advanced Settings',
|
||||
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
|
||||
downloaders: 'Downloaders',
|
||||
@@ -1207,7 +1338,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',
|
||||
@@ -1368,7 +1499,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',
|
||||
@@ -1609,7 +1745,11 @@ export default {
|
||||
bestVersionRuleGroup: 'Version Upgrade Priority Rule Group',
|
||||
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',
|
||||
timedSearchHint:
|
||||
'Search all sites at specified intervals to supplement resources that may be missed by subscription',
|
||||
searchInterval: 'Subscription Search Interval',
|
||||
searchIntervalHint:
|
||||
'Set the time interval for subscription search, only effective when subscription scheduled search is enabled',
|
||||
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',
|
||||
@@ -1625,6 +1765,8 @@ export default {
|
||||
hour1: '1 hour',
|
||||
hour12: '12 hours',
|
||||
day1: '1 day',
|
||||
day3: '3 days',
|
||||
week1: '1 week',
|
||||
},
|
||||
saveSuccess: 'Subscription sites saved successfully',
|
||||
saveFailed: 'Failed to save subscription sites!',
|
||||
@@ -1634,6 +1776,8 @@ export default {
|
||||
cache: {
|
||||
title: 'Cache Management',
|
||||
subtitle: 'Manage torrent cache data',
|
||||
totalCount: 'Total Count',
|
||||
siteCount: 'Site Count',
|
||||
filterByTitle: 'Filter by Title',
|
||||
filterBySite: 'Filter by Site',
|
||||
selectSite: 'Select Site',
|
||||
@@ -1706,8 +1850,12 @@ export default {
|
||||
add: 'Add User',
|
||||
edit: 'Edit User',
|
||||
username: 'Username',
|
||||
usernameRequired: 'Username cannot be empty',
|
||||
password: 'Password',
|
||||
passwordMinLength: 'Password must be at least 6 characters',
|
||||
confirmPassword: 'Confirm Password',
|
||||
confirmPasswordRequired: 'Please confirm password',
|
||||
passwordMismatch: 'Passwords do not match',
|
||||
email: 'Email',
|
||||
nickname: 'Nickname',
|
||||
status: 'Status',
|
||||
@@ -1728,9 +1876,7 @@ export default {
|
||||
webPush: 'WebPush',
|
||||
creatingUser: 'Creating user [{name}], please wait',
|
||||
updatingUser: 'Updating user [{name}], please wait',
|
||||
usernameRequired: 'Username cannot be empty',
|
||||
usernameExists: 'Username already exists',
|
||||
passwordMismatch: 'The two passwords do not match',
|
||||
userCreated: 'User [{name}] created successfully',
|
||||
userCreateFailed: 'Failed to create user: {message}',
|
||||
userUpdateSuccess: 'User [{name}] updated successfully',
|
||||
@@ -1806,6 +1952,8 @@ export default {
|
||||
startDownload: 'Start Download',
|
||||
downloadSuccess: '{site} {title} downloaded successfully!',
|
||||
downloadFailed: '{site} {title} download failed: {message}!',
|
||||
showAdvancedOptions: 'Show Advanced Options',
|
||||
hideAdvancedOptions: 'Hide Advanced Options',
|
||||
},
|
||||
subscribeShare: {
|
||||
shareSubscription: 'Share Subscription',
|
||||
@@ -2030,6 +2178,10 @@ export default {
|
||||
startAll: 'Start All',
|
||||
refresh: 'Refresh',
|
||||
close: 'Close',
|
||||
processingFile: 'Processing',
|
||||
overallProgress: 'Overall Progress',
|
||||
currentFileProgress: 'Current File Progress',
|
||||
processingStatus: 'Processing',
|
||||
},
|
||||
reorganize: {
|
||||
title: 'Organize',
|
||||
@@ -2432,15 +2584,47 @@ export default {
|
||||
vocechatUser: 'VoceChat User',
|
||||
synologychatUser: 'SynologyChat User',
|
||||
doubanUser: 'Douban User',
|
||||
twoFactorAuthentication: 'Two-Factor Authentication',
|
||||
setupAuthenticator: 'Setup Authenticator',
|
||||
authenticatorManagement: 'Authenticator Management',
|
||||
authenticatorEnabled: 'You have enabled authenticator two-factor authentication',
|
||||
clearAuthenticatorTip: 'To set up a new authenticator, please clear the current configuration first.',
|
||||
clearAuthenticator: 'Clear Authenticator',
|
||||
enableTwoFactor: 'Enable Two-Factor Authentication',
|
||||
disableTwoFactor: 'Disable Two-Factor Authentication',
|
||||
setupMfa: 'Setup Two-Factor Authentication',
|
||||
enableMfa: 'Enable Two-Factor Authentication',
|
||||
useAuthenticator: 'Use Authenticator',
|
||||
usePasskey: 'Use Passkey',
|
||||
enabled: 'Enabled',
|
||||
keysCount: '{count} keys',
|
||||
passkeyManagement: 'Passkey Management',
|
||||
registerNewPasskey: 'Register New Passkey',
|
||||
passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.',
|
||||
passkeyName: 'Passkey Name',
|
||||
passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',
|
||||
registerPasskey: 'Register Passkey',
|
||||
registeredPasskeys: 'Registered Passkeys',
|
||||
createdAt: 'Created At',
|
||||
noPasskeys: 'You have not registered any passkeys yet',
|
||||
passkeyNameRequired: 'Please enter a passkey name',
|
||||
passkeyRegisterSuccess: 'Passkey registered successfully',
|
||||
passkeyRegisterFailed: 'Registration failed',
|
||||
passkeyRegisterCancelled: 'Registration cancelled',
|
||||
passkeyDeleteSuccess: 'Passkey deleted',
|
||||
passkeyDeleteFailed: 'Delete failed',
|
||||
passkeyDomainWarning: 'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
|
||||
otpRequiredForPasskey: 'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
|
||||
accessDomain: 'access domain name',
|
||||
otpAuthenticator: 'OTP Authenticator',
|
||||
otpGenerateFailed: 'Failed to get OTP URI: {message}!',
|
||||
otpDisableSuccess: 'Two-factor authentication disabled successfully!',
|
||||
otpDisableFailed: 'Failed to disable OTP: {message}!',
|
||||
otpCodeRequired: 'Please enter the 6-digit verification code',
|
||||
otpEnableSuccess: 'Two-factor authentication enabled successfully!',
|
||||
otpEnableFailed: 'Failed to enable OTP: {message}!',
|
||||
otpDisableRestrictedByPasskey: 'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
|
||||
confirmToDisableOtp: 'For security reasons, verifying your login password is required to disable two-factor authentication.',
|
||||
confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
authenticatorAppDescription:
|
||||
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code. It will generate a 6-digit code for you to enter below.',
|
||||
@@ -2557,6 +2741,14 @@ export default {
|
||||
nameRequired: 'Name cannot be empty',
|
||||
nameDuplicate: 'Name already exists',
|
||||
defaultChanged: 'Default downloader exists, has been replaced',
|
||||
hostRequired: 'Host cannot be empty',
|
||||
usernameRequired: 'Username cannot be empty',
|
||||
passwordRequired: 'Password cannot be empty',
|
||||
pathMapping: 'Path Mapping',
|
||||
pathMappingRequired: 'Path cannot be empty',
|
||||
pathMappingError: 'Must start with /',
|
||||
storagePath: 'Storage Path',
|
||||
downloadPath: 'Download Path',
|
||||
},
|
||||
filterRule: {
|
||||
title: 'Filter Rule',
|
||||
@@ -2601,9 +2793,15 @@ export default {
|
||||
plexToken: 'X-Plex-Token',
|
||||
plexTokenHint: 'X-Plex-Token obtained from Plex request URL in browser F12 -> Network',
|
||||
username: 'Username',
|
||||
usernameHint: 'Login username',
|
||||
password: 'Password',
|
||||
syncLibraries: 'Sync Libraries',
|
||||
syncLibrariesHint: 'Only selected libraries will be synchronized',
|
||||
hostRequired: 'Host cannot be empty',
|
||||
apiKeyRequired: 'API Key cannot be empty',
|
||||
tokenRequired: 'Token cannot be empty',
|
||||
usernameRequired: 'Username cannot be empty',
|
||||
passwordRequired: 'Password cannot be empty',
|
||||
nameExists: '【{name}】 already exists, please use a different name',
|
||||
},
|
||||
bangumi: {
|
||||
@@ -2637,6 +2835,9 @@ export default {
|
||||
firstAirDateAsc: 'First Air Date Ascending',
|
||||
voteAverageDesc: 'Vote Average Descending',
|
||||
voteAverageAsc: 'Vote Average Ascending',
|
||||
time: 'Latest',
|
||||
count: 'Popular',
|
||||
rating: 'Rating',
|
||||
},
|
||||
genreType: {
|
||||
action: 'Action',
|
||||
@@ -2766,7 +2967,9 @@ export default {
|
||||
libraryStorage: 'Library Storage',
|
||||
libraryDirectory: 'Library Directory',
|
||||
transferType: 'Transfer Type',
|
||||
transferTypeHint: 'File operation organization method, hard link saves space, copy is safer',
|
||||
overwriteMode: 'Overwrite Mode',
|
||||
overwriteModeHint: 'How to handle when target file already exists',
|
||||
smartRename: 'Smart Rename',
|
||||
scrapingMetadata: 'Scrape Metadata',
|
||||
sendNotification: 'Send Notification',
|
||||
@@ -2808,4 +3011,150 @@ export default {
|
||||
customBackgroundImageHint: 'Supports web image URLs, leave blank for gradient background',
|
||||
pluginCount: '{count} Plugins',
|
||||
},
|
||||
setupWizard: {
|
||||
title: 'Welcome to MoviePilot!',
|
||||
subtitle: 'Complete the configuration by the wizard, and start using it immediately.',
|
||||
completed: 'Setup Wizard completed!',
|
||||
failed: 'Setup Wizard failed, please try again',
|
||||
complete: 'Complete Configuration',
|
||||
loading: 'Loading configuration data...',
|
||||
testing: 'Testing',
|
||||
connectivityTestSuccess: 'Connectivity test passed',
|
||||
connectivityTestFailed: 'Connectivity test failed',
|
||||
testingStorage: 'Testing storage',
|
||||
checkingStorage: 'Checking storage connectivity',
|
||||
testingDownloader: 'Testing downloader',
|
||||
checkingDownloader: 'Checking downloader connectivity',
|
||||
testingMediaServer: 'Testing media server',
|
||||
checkingMediaServer: 'Checking media server connectivity',
|
||||
testingNotification: 'Testing notification',
|
||||
checkingNotification: 'Checking notification connectivity',
|
||||
testFailedHint: 'Please check if the configuration is correct, you can retest after modification',
|
||||
unsupportedDownloaderType: 'Unsupported downloader type: {type}',
|
||||
unsupportedMediaServerType: 'Unsupported media server type: {type}',
|
||||
unsupportedNotificationType: 'Unsupported notification type: {type}',
|
||||
passwordUpdateSuccess: 'Password updated successfully',
|
||||
userCreateSuccess: 'User created successfully',
|
||||
passwordUpdateFailed: 'Failed to update password',
|
||||
basic: {
|
||||
title: 'Basic Settings',
|
||||
description: 'Set access domain, username/password and network configuration',
|
||||
appDomain: 'App Domain',
|
||||
appDomainHint: 'Used to add quick jump links when sending notifications',
|
||||
wallpaper: 'Background Wallpaper',
|
||||
wallpaperHint: 'Choose the source of the login page background',
|
||||
recognizeSource: 'Recognize Source',
|
||||
recognizeSourceHint: 'Set the default media info recognition data source',
|
||||
apiToken: 'API Token',
|
||||
apiTokenHint: 'API Token required for accessing MoviePilot API, please record it for subsequent use',
|
||||
currentUserHint: 'Current user, cannot be modified',
|
||||
passwordOptionalHint: 'Leave blank to keep current password',
|
||||
confirmPasswordHint: 'Confirm new password',
|
||||
apiTokenRequired: 'API Token is required',
|
||||
},
|
||||
storage: {
|
||||
title: 'Storage',
|
||||
description: 'Configure download directory and media library directory',
|
||||
info: 'Storage Configuration',
|
||||
infoDesc: 'Configure local storage directories for download and media library management',
|
||||
downloadPath: 'Download Directory',
|
||||
downloadPathHint: 'Set the storage path for downloaded files',
|
||||
libraryPath: 'Media Library Directory',
|
||||
libraryPathHint: 'Set the storage path for media files',
|
||||
downloadPathRequired: 'Download directory is required',
|
||||
libraryPathRequired: 'Media library directory is required',
|
||||
},
|
||||
downloader: {
|
||||
title: 'Downloader',
|
||||
description: 'Configure downloader',
|
||||
info: 'Downloader Configuration',
|
||||
infoDesc: 'Configure downloader for resource download, can choose qBittorrent or Transmission',
|
||||
type: 'Downloader Type',
|
||||
typeHint: 'Select the type of downloader to use',
|
||||
name: 'Downloader Name',
|
||||
nameHint: 'Set a name for the downloader',
|
||||
qbittorrentConfig: 'qBittorrent Configuration',
|
||||
transmissionConfig: 'Transmission Configuration',
|
||||
host: 'Server Address',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
downloadPath: 'Download Path',
|
||||
},
|
||||
mediaServer: {
|
||||
title: 'Media Server',
|
||||
description: 'Configure media server',
|
||||
info: 'Media Server Configuration',
|
||||
infoDesc: 'Configure media server for media library management, can choose Emby, Jellyfin or Plex etc.',
|
||||
type: 'Media Server Type',
|
||||
typeHint: 'Select the type of media server to use',
|
||||
name: 'Server Name',
|
||||
nameHint: 'Set a name for the media server',
|
||||
embyConfig: 'Emby Configuration',
|
||||
jellyfinConfig: 'Jellyfin Configuration',
|
||||
plexConfig: 'Plex Configuration',
|
||||
host: 'Server Address',
|
||||
apiKey: 'API Key',
|
||||
token: 'Access Token',
|
||||
},
|
||||
notification: {
|
||||
title: 'Notification',
|
||||
description: 'Configure notification channels',
|
||||
info: 'Notification Configuration',
|
||||
infoDesc: 'Configure notification channels for receiving system messages (optional)',
|
||||
type: 'Notification Type',
|
||||
typeHint: 'Select the type of notification channel to use',
|
||||
name: 'Notification Name',
|
||||
nameHint: 'Set a name for the notification channel',
|
||||
telegramConfig: 'Telegram Configuration',
|
||||
emailConfig: 'Email Configuration',
|
||||
botToken: 'Bot Token',
|
||||
chatId: 'Chat ID',
|
||||
smtpServer: 'SMTP Server',
|
||||
smtpPort: 'SMTP Port',
|
||||
senderEmail: 'Sender Email',
|
||||
senderPassword: 'Sender Password',
|
||||
receiverEmail: 'Receiver Email',
|
||||
},
|
||||
preferences: {
|
||||
title: 'Resource Preferences',
|
||||
description: 'Set resource download preferences',
|
||||
info: 'Resource Preferences',
|
||||
infoDesc:
|
||||
'Set resource download preferences, the system will automatically select the best resources based on these preferences',
|
||||
quality: 'Quality Preference',
|
||||
qualityHint: 'Select preferred video quality',
|
||||
subtitle: 'Subtitle Preference',
|
||||
subtitleHint: 'Select preferred subtitle type',
|
||||
resolution: 'Resolution Preference',
|
||||
resolutionHint: 'Select preferred video resolution',
|
||||
presetRules: 'Preset Rules',
|
||||
detailedConfig: 'Detailed Configuration',
|
||||
quickPresets: 'Quick Presets',
|
||||
quickPresetsDesc: 'Select preset configuration, system will automatically apply corresponding rules',
|
||||
personalizationOptions: 'Personalization Options',
|
||||
personalizationOptionsDesc: 'Adjust rules according to your needs',
|
||||
excludeDolbyVision: 'Exclude Dolby Vision',
|
||||
excludeDolbyVisionHint: 'Exclude Dolby Vision resources from rules when selected',
|
||||
excludeBluray: 'Exclude Blu-ray',
|
||||
excludeBlurayHint: 'Exclude Blu-ray resources from rules when selected',
|
||||
presets: {
|
||||
'4k-enthusiast': {
|
||||
name: '4K Enthusiast',
|
||||
description: 'Pursue the highest quality, prioritize 4K',
|
||||
},
|
||||
'balanced': {
|
||||
name: 'Balanced Mode',
|
||||
description: 'Balance between quality and storage space',
|
||||
},
|
||||
'space-saver': {
|
||||
name: 'Space Saver',
|
||||
description: 'Prioritize smaller files to save storage space',
|
||||
},
|
||||
'free-priority': {
|
||||
name: 'Free Priority',
|
||||
description: 'Prioritize free resources, no other requirements',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
saving: '保存中',
|
||||
reset: '重置',
|
||||
theme: '主题',
|
||||
uiMode: '界面布局',
|
||||
language: '语言',
|
||||
pleaseWait: '请稍候...',
|
||||
viewDetails: '查看详情',
|
||||
@@ -49,6 +50,9 @@ export default {
|
||||
itemsPerPage: '每页条数',
|
||||
pageText: '{0}-{1} 共 {2} 条',
|
||||
noDataText: '没有数据',
|
||||
next: '下一步',
|
||||
previous: '上一步',
|
||||
skip: '跳过',
|
||||
loadingText: '加载中...',
|
||||
networkRequired: '此功能需要网络连接',
|
||||
networkDisconnected: '网络连接已断开',
|
||||
@@ -63,6 +67,10 @@ export default {
|
||||
serviceUnavailable: '服务不可用',
|
||||
status: '状态',
|
||||
preset: '预设',
|
||||
refresh: '刷新',
|
||||
swUpdateReady: '新版本已就绪,请刷新页面以获取最新功能',
|
||||
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
|
||||
clearCache: '清除缓存',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '电影',
|
||||
@@ -126,6 +134,7 @@ export default {
|
||||
light: '浅色',
|
||||
dark: '深色',
|
||||
auto: '跟随系统',
|
||||
autoUI: '自动',
|
||||
transparent: '透明',
|
||||
purple: '幻紫',
|
||||
custom: '附加样式',
|
||||
@@ -244,6 +253,22 @@ export default {
|
||||
serverError: '登录失败,服务器错误!',
|
||||
loginFailed: '登录失败',
|
||||
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
|
||||
twoFactorAuth: '双重验证',
|
||||
loginWithPasskey: '使用通行密钥登录',
|
||||
loginWithOtp: '使用验证码登录',
|
||||
orUsePasskey: '或使用通行密钥进行验证',
|
||||
verifyWithPasskey: '使用通行密钥验证',
|
||||
otpPlaceholder: '请输入6位验证码',
|
||||
passkeyLoginStartFailed: '启动通行密钥认证失败',
|
||||
passkeyNotSelected: '未选择通行密钥',
|
||||
passkeyLoginFailed: '通行密钥登录失败',
|
||||
passkeyAuthCanceled: '通行密钥认证被取消',
|
||||
passkeyLoginRetry: '通行密钥登录失败,请重试',
|
||||
passkeyVerifyFailed: '通行密钥验证失败',
|
||||
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
|
||||
mfa: {
|
||||
selectVerificationMethod: '请选择验证方式',
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
start: '开始',
|
||||
@@ -320,10 +345,6 @@ export default {
|
||||
title: '通知',
|
||||
description: '通知渠道(微信、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息发送范围',
|
||||
},
|
||||
words: {
|
||||
title: '词表',
|
||||
description: '自定义识别词、自定义制作组/字幕组、自定义占位符、文件整理屏蔽词',
|
||||
},
|
||||
about: {
|
||||
title: '关于',
|
||||
description: '软件版本',
|
||||
@@ -367,8 +388,10 @@ export default {
|
||||
deleteFailed: '用户删除失败!',
|
||||
profile: '个人信息',
|
||||
systemSettings: '系统设定',
|
||||
wizardSettings: '设置向导',
|
||||
siteAuth: '用户认证',
|
||||
helpDocs: '帮助文档',
|
||||
about: '关于',
|
||||
restart: '重启',
|
||||
management: '用户管理',
|
||||
noUsers: '没有用户',
|
||||
@@ -376,8 +399,11 @@ export default {
|
||||
addUser: '添加用户',
|
||||
editUser: '编辑用户',
|
||||
username: '用户名',
|
||||
usernameHint: '用于登录系统的用户名',
|
||||
password: '密码',
|
||||
passwordHint: '请输入登录密码',
|
||||
confirmPassword: '确认密码',
|
||||
confirmPasswordHint: '请再次输入密码以确认',
|
||||
role: '角色',
|
||||
email: '邮箱',
|
||||
enabled: '启用',
|
||||
@@ -406,10 +432,13 @@ export default {
|
||||
name: '企业微信',
|
||||
corpId: '企业ID',
|
||||
corpIdHint: '企业微信后台企业信息中的企业ID',
|
||||
corpIdRequired: '企业ID不能为空',
|
||||
appId: '应用 AgentId',
|
||||
appIdHint: '企业微信自建应用的AgentId',
|
||||
appIdRequired: '应用AgentId不能为空',
|
||||
appSecret: '应用 Secret',
|
||||
appSecretHint: '企业微信自建应用的Secret',
|
||||
appSecretRequired: '应用Secret不能为空',
|
||||
proxy: '代理地址',
|
||||
proxyHint: '微信消息的转发代理地址,2022年6月20日后创建的自建应用才需要,不使用代理时需要保留默认值',
|
||||
token: 'Token',
|
||||
@@ -424,8 +453,10 @@ export default {
|
||||
name: 'Telegram',
|
||||
token: 'Bot Token',
|
||||
tokenHint: 'Telegram机器人token,格式:123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||
tokenRequired: 'Bot Token不能为空',
|
||||
chatId: 'Chat ID',
|
||||
chatIdHint: '接受消息通知的用户、群组或频道Chat ID',
|
||||
chatIdRequired: 'Chat ID不能为空',
|
||||
users: '用户白名单',
|
||||
usersHint: '可使用Telegram机器人的用户ID清单,多个用户用,分隔,不填写则所有用户都能使用',
|
||||
admins: '管理员白名单',
|
||||
@@ -440,15 +471,30 @@ export default {
|
||||
name: 'Slack',
|
||||
oauthToken: 'Slack Bot User OAuth Token',
|
||||
oauthTokenHint: 'Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`',
|
||||
oauthTokenRequired: 'OAuth Token不能为空',
|
||||
appToken: 'Slack App-Level Token',
|
||||
appTokenHint: 'Slack应用`OAuth & Permissions`页面中的`App-Level Token`',
|
||||
channel: '频道名称',
|
||||
channelHint: '消息发送频道,默认`全体`',
|
||||
channelRequired: '频道名称不能为空',
|
||||
},
|
||||
discord: {
|
||||
name: 'Discord',
|
||||
botToken: 'Bot Token',
|
||||
botTokenHint: 'Discord Bot Token(需在开发者后台开启 Message Content Intent)',
|
||||
botTokenRequired: 'Bot Token不能为空',
|
||||
guildId: '服务器 ID',
|
||||
guildIdHint: '可选,限制使用的服务器;为空则使用已加入的任意服务器',
|
||||
guildIdPlaceholder: '123456789012345678',
|
||||
channelId: '频道 ID',
|
||||
channelIdHint: '可选,默认广播频道;为空则自动选择可发送消息的频道',
|
||||
channelIdPlaceholder: '123456789012345678',
|
||||
},
|
||||
synologychat: {
|
||||
name: 'Synology Chat',
|
||||
webhook: '机器人传入URL',
|
||||
webhookHint: 'Synology Chat机器人传入URL',
|
||||
webhookRequired: 'Webhook URL不能为空',
|
||||
token: '令牌',
|
||||
tokenHint: 'Synology Chat机器人令牌',
|
||||
},
|
||||
@@ -456,8 +502,10 @@ export default {
|
||||
name: 'VoceChat',
|
||||
host: '地址',
|
||||
hostHint: 'VoceChat服务端地址,格式:http(s)://ip:port',
|
||||
hostRequired: '地址不能为空',
|
||||
apiKey: '机器人密钥',
|
||||
apiKeyHint: 'VoceChat机器人密钥',
|
||||
apiKeyRequired: 'API密钥不能为空',
|
||||
channelId: '频道ID',
|
||||
channelIdHint: 'VoceChat的频道ID,不包含#号',
|
||||
},
|
||||
@@ -465,6 +513,7 @@ export default {
|
||||
name: 'WebPush',
|
||||
username: '登录用户名',
|
||||
usernameHint: '只有对应的用户登录后才会推送消息',
|
||||
usernameRequired: '用户名不能为空',
|
||||
},
|
||||
},
|
||||
shortcut: {
|
||||
@@ -493,6 +542,14 @@ export default {
|
||||
title: '消息',
|
||||
subtitle: '消息中心',
|
||||
},
|
||||
words: {
|
||||
title: '词表',
|
||||
subtitle: '词表设置',
|
||||
},
|
||||
cache: {
|
||||
title: '缓存',
|
||||
subtitle: '管理缓存',
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
components: '动作组件',
|
||||
@@ -763,6 +820,8 @@ export default {
|
||||
originalTitle: '原始标题',
|
||||
status: '状态',
|
||||
releaseDate: '上映日期',
|
||||
digitalRelease: '数字发行',
|
||||
physicalRelease: '实体发行',
|
||||
originalLanguage: '原始语言',
|
||||
productionCountries: '出品国家',
|
||||
productionCompanies: '制作公司',
|
||||
@@ -829,6 +888,24 @@ export default {
|
||||
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: '批量删除操作失败',
|
||||
minSubscribers: '最小订阅人数',
|
||||
},
|
||||
recommend: {
|
||||
all: '全部',
|
||||
@@ -1003,6 +1080,7 @@ export default {
|
||||
limitSeconds: '访问间隔(秒)',
|
||||
useProxy: '使用代理访问',
|
||||
browserSimulation: '浏览器仿真',
|
||||
selectFile: '选择文件',
|
||||
},
|
||||
hints: {
|
||||
url: '格式:http://www.example.com/',
|
||||
@@ -1020,19 +1098,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: '测试中 ...',
|
||||
@@ -1064,6 +1171,13 @@ export default {
|
||||
accessTime: '访问时间',
|
||||
responseTime: '响应时间',
|
||||
noTimeRecords: '暂无耗时记录',
|
||||
preview: {
|
||||
title: '预览站点',
|
||||
showing: '显示 {count}/{total}',
|
||||
unnamed: '未命名站点',
|
||||
noUrl: '无站点地址',
|
||||
invalid: '数据无效',
|
||||
},
|
||||
},
|
||||
message: {
|
||||
loadMore: '加载更多',
|
||||
@@ -1075,6 +1189,7 @@ export default {
|
||||
program: '程序',
|
||||
content: '内容',
|
||||
refreshing: '正在刷新',
|
||||
initializing: '正在初始化',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: '正常',
|
||||
@@ -1113,6 +1228,7 @@ export default {
|
||||
title: '关于 MoviePilot',
|
||||
softwareVersion: '软件版本',
|
||||
frontendVersion: '前端版本',
|
||||
browserVersion: '浏览器缓存版本',
|
||||
authVersion: '认证资源版本',
|
||||
indexerVersion: '站点资源版本',
|
||||
configDir: '配置目录',
|
||||
@@ -1120,7 +1236,7 @@ export default {
|
||||
timezone: '时区',
|
||||
latest: '最新',
|
||||
supportingSites: '支持站点',
|
||||
support: '支援',
|
||||
support: '支持',
|
||||
documentation: '文档',
|
||||
feedback: '问题反馈',
|
||||
channel: '发布频道',
|
||||
@@ -1132,6 +1248,7 @@ export default {
|
||||
dataDirectory: '/moviepilot',
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
clearCache: '清除缓存',
|
||||
},
|
||||
system: {
|
||||
custom: '自定义',
|
||||
@@ -1156,9 +1273,23 @@ export default {
|
||||
apiTokenLength: 'API Token不得低于16位',
|
||||
githubToken: 'Github Token',
|
||||
githubTokenFormat: 'ghp_**** 或 github_pat_****',
|
||||
githubTokenHint: '用于提高插件等访问Github API时的限流阈值',
|
||||
githubTokenHint: '用于提高插件等访问Github API时的限流阈值,建议配置,否则插件可能无法正常使用',
|
||||
ocrHost: '验证码识别服务器',
|
||||
ocrHostHint: '用于站点签到、更新站点Cookie等识别验证码',
|
||||
aiAgent: '启用智能助手',
|
||||
aiAgentEnable: '启用智能助手',
|
||||
aiAgentEnableHint: '启用后可使用智能助手功能,需要配置LLM相关参数',
|
||||
llmProvider: 'LLM提供商',
|
||||
llmProviderHint: '选择使用的LLM服务提供商',
|
||||
llmModel: 'LLM模型名称',
|
||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||
llmApiKey: 'LLM API密钥',
|
||||
llmApiKeyHint: 'LLM服务提供商的API密钥,用于身份验证',
|
||||
llmApiKeyPlaceholder: '请输入API密钥',
|
||||
llmBaseUrl: 'LLM基础URL',
|
||||
llmBaseUrlHint: 'LLM API的基础URL地址,用于自定义API端点',
|
||||
aiAgentGlobal: '全局智能助手',
|
||||
aiAgentGlobalHint: '启用全局智能助手功能,所有消息对话均使用智能体回答而不用使用/ai命令',
|
||||
advancedSettings: '高级设置',
|
||||
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
|
||||
downloaders: '下载器',
|
||||
@@ -1202,7 +1333,7 @@ export default {
|
||||
workflowStatisticShareHint: '分享工作流统计数据到热门工作流,供其他MPer参考',
|
||||
bigMemoryMode: '大内存模式',
|
||||
bigMemoryModeHint: '使用更大的内存缓存数据,提升系统性能',
|
||||
dbWalEnable: 'WAL模式',
|
||||
dbWalEnable: '数据库WAL模式',
|
||||
dbWalEnableHint: '可提升读写并发性能,但可能在异常情况下增加数据丢失风险,更改后需重启生效',
|
||||
tmdbApiDomain: 'TMDB API服务地址',
|
||||
tmdbApiDomainPlaceholder: 'api.themoviedb.org',
|
||||
@@ -1356,6 +1487,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: '阅读站点消息',
|
||||
@@ -1588,7 +1724,9 @@ export default {
|
||||
bestVersionRuleGroup: '洗版优先级规则组',
|
||||
bestVersionRuleGroupHint: '按选定的过滤规则组对洗版订阅进行过滤',
|
||||
timedSearch: '订阅定时搜索',
|
||||
timedSearchHint: '每隔24小时全站搜索,以补全订阅可能漏掉的资源',
|
||||
timedSearchHint: '每隔指定时间全站搜索,以补全订阅可能漏掉的资源',
|
||||
searchInterval: '订阅搜索时间间隔',
|
||||
searchIntervalHint: '设置订阅搜索的时间间隔,仅在开启订阅定时搜索时生效',
|
||||
checkLocalMedia: '检查文件系统资源',
|
||||
checkLocalMediaHint: '扫描存储目录中是否已存在相应资源文件,以避免重复下载;不管是否开启都会检查媒体服务器',
|
||||
modes: {
|
||||
@@ -1603,6 +1741,8 @@ export default {
|
||||
hour1: '1小时',
|
||||
hour12: '12小时',
|
||||
day1: '1天',
|
||||
day3: '3天',
|
||||
week1: '一周',
|
||||
},
|
||||
saveSuccess: '订阅站点保存成功',
|
||||
saveFailed: '订阅站点保存失败!',
|
||||
@@ -1612,6 +1752,8 @@ export default {
|
||||
cache: {
|
||||
title: '缓存管理',
|
||||
subtitle: '管理缓存的站点资源',
|
||||
totalCount: '总条数',
|
||||
siteCount: '站点数',
|
||||
filterByTitle: '按标题筛选',
|
||||
filterBySite: '按站点筛选',
|
||||
selectSite: '选择站点',
|
||||
@@ -1684,8 +1826,12 @@ export default {
|
||||
add: '添加用户',
|
||||
edit: '编辑用户',
|
||||
username: '用户名',
|
||||
usernameRequired: '用户名不能为空',
|
||||
password: '密码',
|
||||
passwordMinLength: '密码长度不能少于6位',
|
||||
confirmPassword: '确认密码',
|
||||
confirmPasswordRequired: '请确认密码',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
email: '邮箱',
|
||||
nickname: '昵称',
|
||||
status: '状态',
|
||||
@@ -1706,9 +1852,7 @@ export default {
|
||||
webPush: 'WebPush',
|
||||
creatingUser: '正在创建【{name}】用户,请稍后',
|
||||
updatingUser: '正在更新【{name}】用户,请稍后',
|
||||
usernameRequired: '用户名不能为空',
|
||||
usernameExists: '用户名已存在',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
userCreated: '用户【{name}】创建成功',
|
||||
userCreateFailed: '创建用户失败:{message}',
|
||||
userUpdateSuccess: '用户【{name}】更新成功',
|
||||
@@ -1784,6 +1928,8 @@ export default {
|
||||
startDownload: '开始下载',
|
||||
downloadSuccess: '{site} {title} 下载成功!',
|
||||
downloadFailed: '{site} {title} 下载失败:{message}!',
|
||||
showAdvancedOptions: '显示高级选项',
|
||||
hideAdvancedOptions: '隐藏高级选项',
|
||||
},
|
||||
subscribeShare: {
|
||||
shareSubscription: '分享订阅',
|
||||
@@ -2004,6 +2150,10 @@ export default {
|
||||
startAll: '全部开始',
|
||||
refresh: '刷新',
|
||||
close: '关闭',
|
||||
processingFile: '正在整理',
|
||||
overallProgress: '整体进度',
|
||||
currentFileProgress: '当前文件进度',
|
||||
processingStatus: '整理中',
|
||||
},
|
||||
reorganize: {
|
||||
title: '整理',
|
||||
@@ -2403,15 +2553,47 @@ export default {
|
||||
vocechatUser: 'VoceChat用户',
|
||||
synologychatUser: 'SynologyChat用户',
|
||||
doubanUser: '豆瓣用户',
|
||||
twoFactorAuthentication: '登录双重验证',
|
||||
setupAuthenticator: '设置身份验证器',
|
||||
authenticatorManagement: '身份验证器管理',
|
||||
authenticatorEnabled: '您已启用身份验证器双重验证',
|
||||
clearAuthenticatorTip: '如需设置新的身份验证器,请先清除当前配置。',
|
||||
clearAuthenticator: '清除身份验证器',
|
||||
enableTwoFactor: '开启双重验证',
|
||||
disableTwoFactor: '关闭双重验证',
|
||||
setupMfa: '设置双重验证',
|
||||
enableMfa: '开启双重验证',
|
||||
useAuthenticator: '使用身份验证器',
|
||||
usePasskey: '使用通行密钥',
|
||||
enabled: '已启用',
|
||||
keysCount: '{count} 个密钥',
|
||||
passkeyManagement: '通行密钥管理',
|
||||
registerNewPasskey: '注册新通行密钥',
|
||||
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
|
||||
passkeyName: '通行密钥名称',
|
||||
passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
|
||||
registerPasskey: '注册通行密钥',
|
||||
registeredPasskeys: '已注册的通行密钥',
|
||||
createdAt: '创建时间',
|
||||
noPasskeys: '您还没有注册任何通行密钥',
|
||||
passkeyNameRequired: '请输入通行密钥名称',
|
||||
passkeyRegisterSuccess: '通行密钥注册成功',
|
||||
passkeyRegisterFailed: '注册失败',
|
||||
passkeyRegisterCancelled: '注册被取消',
|
||||
passkeyDeleteSuccess: '通行密钥已删除',
|
||||
passkeyDeleteFailed: '删除失败',
|
||||
passkeyDomainWarning: '通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
|
||||
otpRequiredForPasskey: '为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
|
||||
accessDomain: '访问域名',
|
||||
otpAuthenticator: 'OTP 身份验证器',
|
||||
otpGenerateFailed: '获取otp uri失败:{message}!',
|
||||
otpDisableSuccess: '关闭登录双重验证成功!',
|
||||
otpDisableFailed: '关闭otp失败:{message}!',
|
||||
otpCodeRequired: '请填写6位验证码',
|
||||
otpEnableSuccess: '开启登录双重验证成功!',
|
||||
otpEnableFailed: '开启otp失败:{message}!',
|
||||
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。',
|
||||
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
|
||||
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
|
||||
authenticatorApp: '身份验证器',
|
||||
authenticatorAppDescription:
|
||||
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码,供您在下方输入。',
|
||||
@@ -2527,6 +2709,14 @@ export default {
|
||||
nameRequired: '不能为空,且不能重名',
|
||||
nameDuplicate: '名称已存在',
|
||||
defaultChanged: '存在默认下载器,已替换',
|
||||
hostRequired: '地址不能为空',
|
||||
usernameRequired: '用户名不能为空',
|
||||
passwordRequired: '密码不能为空',
|
||||
pathMapping: '路径映射',
|
||||
pathMappingRequired: '路径不能为空',
|
||||
pathMappingError: '必须以 / 开头',
|
||||
storagePath: '存储路径',
|
||||
downloadPath: '下载路径',
|
||||
},
|
||||
filterRule: {
|
||||
title: '过滤规则',
|
||||
@@ -2571,10 +2761,16 @@ export default {
|
||||
plexToken: 'X-Plex-Token',
|
||||
plexTokenHint: '浏览器F12->网络,从Plex请求URL中获取的X-Plex-Token',
|
||||
username: '用户名',
|
||||
usernameHint: '登录用户名',
|
||||
password: '密码',
|
||||
syncLibraries: '同步媒体库',
|
||||
syncLibrariesHint: '只有选中的媒体库才会被同步',
|
||||
nameExists: '【{name}】已存在,请替换为其他名称',
|
||||
hostRequired: '地址不能为空',
|
||||
apiKeyRequired: 'API密钥不能为空',
|
||||
tokenRequired: 'Token不能为空',
|
||||
usernameRequired: '用户名不能为空',
|
||||
passwordRequired: '密码不能为空',
|
||||
},
|
||||
bangumi: {
|
||||
category: '类别',
|
||||
@@ -2607,6 +2803,9 @@ export default {
|
||||
firstAirDateAsc: '首播日期升序',
|
||||
voteAverageDesc: '评分降序',
|
||||
voteAverageAsc: '评分升序',
|
||||
time: '最新',
|
||||
count: '热门',
|
||||
rating: '评分',
|
||||
},
|
||||
genreType: {
|
||||
action: '动作',
|
||||
@@ -2736,7 +2935,9 @@ export default {
|
||||
libraryStorage: '媒体库存储',
|
||||
libraryDirectory: '媒体库目录',
|
||||
transferType: '整理方式',
|
||||
transferTypeHint: '文件操作整理方式,硬链接节省空间,复制更安全',
|
||||
overwriteMode: '覆盖模式',
|
||||
overwriteModeHint: '当目标文件已存在时的处理方式',
|
||||
smartRename: '智能重命名',
|
||||
scrapingMetadata: '刮削元数据',
|
||||
sendNotification: '发送通知',
|
||||
@@ -2777,4 +2978,169 @@ export default {
|
||||
customBackgroundImageHint: '支持网络图片URL,留空则使用渐变背景',
|
||||
pluginCount: '{count} 个插件',
|
||||
},
|
||||
setupWizard: {
|
||||
title: '欢迎使用 MoviePilot !',
|
||||
subtitle: '按向导完成配置,即刻开始使用。',
|
||||
completed: '配置向导完成!',
|
||||
failed: '配置向导失败,请重试',
|
||||
complete: '完成配置',
|
||||
loading: '正在加载配置数据...',
|
||||
testing: '正在测试',
|
||||
connectivityTestSuccess: '连通性测试通过',
|
||||
connectivityTestFailed: '连通性测试失败',
|
||||
testingStorage: '正在测试存储目录',
|
||||
checkingStorage: '检查存储目录连通性',
|
||||
storageTestFailed: '存储目录测试失败',
|
||||
testingDownloader: '正在测试下载器',
|
||||
checkingDownloader: '检查下载器连通性',
|
||||
downloaderTestFailed: '下载器测试失败',
|
||||
downloaderNotSelected: '未选择下载器',
|
||||
unsupportedDownloaderType: '不支持的下载器类型: {type}',
|
||||
testingMediaServer: '正在测试媒体服务器',
|
||||
checkingMediaServer: '检查媒体服务器连通性',
|
||||
mediaServerTestFailed: '媒体服务器测试失败',
|
||||
mediaServerNotSelected: '未选择媒体服务器',
|
||||
unsupportedMediaServerType: '不支持的媒体服务器类型: {type}',
|
||||
testingNotification: '正在测试消息通知',
|
||||
checkingNotification: '检查消息通知连通性',
|
||||
notificationTestFailed: '消息通知测试失败',
|
||||
notificationNotSelected: '未选择通知类型',
|
||||
unsupportedNotificationType: '不支持的通知类型: {type}',
|
||||
testFailedHint: '请检查配置是否正确,修改后可以重新测试',
|
||||
saveStepFailed: '保存步骤设置失败',
|
||||
basicSettingsSaved: '基础设置保存成功',
|
||||
saveBasicSettingsFailed: '保存基础设置失败',
|
||||
storageSettingsSaved: '存储设置保存成功',
|
||||
saveStorageSettingsFailed: '保存存储设置失败',
|
||||
downloaderSettingsSaved: '下载器设置保存成功',
|
||||
saveDownloaderSettingsFailed: '保存下载器设置失败',
|
||||
mediaServerSettingsSaved: '媒体服务器设置保存成功',
|
||||
saveMediaServerSettingsFailed: '保存媒体服务器设置失败',
|
||||
notificationSettingsSaved: '通知设置保存成功',
|
||||
saveNotificationSettingsFailed: '保存通知设置失败',
|
||||
preferenceSettingsSaved: '偏好设置保存成功',
|
||||
savePreferenceSettingsFailed: '保存偏好设置失败',
|
||||
passwordUpdateSuccess: '密码更新成功',
|
||||
passwordUpdateFailed: '密码更新失败',
|
||||
userCreateSuccess: '用户创建成功',
|
||||
basic: {
|
||||
title: '基础设置',
|
||||
description: '设置访问域名、用户名密码和网络配置',
|
||||
appDomain: '访问域名',
|
||||
appDomainHint: '用于发送通知时,添加快捷跳转地址',
|
||||
wallpaper: '背景壁纸',
|
||||
wallpaperHint: '选择登录页面背景来源',
|
||||
recognizeSource: '识别数据源',
|
||||
recognizeSourceHint: '设置默认媒体信息识别数据源',
|
||||
apiToken: 'API 令牌',
|
||||
apiTokenHint: '访问MoviePilot API 需要的访问令牌,请记录下来以便后续使用',
|
||||
currentUserHint: '当前用户,不可修改',
|
||||
passwordOptionalHint: '留空表示不修改密码',
|
||||
confirmPasswordHint: '确认新密码',
|
||||
apiTokenRequired: 'API Token不能为空',
|
||||
},
|
||||
storage: {
|
||||
title: '存储',
|
||||
description: '配置下载目录和媒体库目录',
|
||||
info: '存储配置说明',
|
||||
infoDesc: '配置本地存储目录,用于下载和媒体库管理',
|
||||
downloadPath: '下载目录',
|
||||
downloadPathHint: '设置下载文件的存储路径',
|
||||
libraryPath: '媒体库目录',
|
||||
libraryPathHint: '设置媒体文件的存储路径',
|
||||
downloadPathRequired: '下载目录不能为空',
|
||||
libraryPathRequired: '媒体库目录不能为空',
|
||||
},
|
||||
downloader: {
|
||||
title: '下载器',
|
||||
description: '配置下载器',
|
||||
info: '下载器配置说明',
|
||||
infoDesc: '配置下载器用于下载资源,可选择qBittorrent或Transmission',
|
||||
type: '下载器类型',
|
||||
typeHint: '选择要使用的下载器类型',
|
||||
name: '下载器名称',
|
||||
nameHint: '为下载器设置一个名称',
|
||||
qbittorrentConfig: 'qBittorrent 配置',
|
||||
transmissionConfig: 'Transmission 配置',
|
||||
host: '服务器地址',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
downloadPath: '下载路径',
|
||||
},
|
||||
mediaServer: {
|
||||
title: '媒体服务器',
|
||||
description: '配置媒体服务器',
|
||||
info: '媒体服务器配置说明',
|
||||
infoDesc: '配置媒体服务器用于媒体库管理,可选择Emby、Jellyfin或Plex等',
|
||||
type: '媒体服务器类型',
|
||||
typeHint: '选择要使用的媒体服务器类型',
|
||||
name: '服务器名称',
|
||||
nameHint: '为媒体服务器设置一个名称',
|
||||
embyConfig: 'Emby 配置',
|
||||
jellyfinConfig: 'Jellyfin 配置',
|
||||
plexConfig: 'Plex 配置',
|
||||
host: '服务器地址',
|
||||
apiKey: 'API 密钥',
|
||||
token: '访问令牌',
|
||||
},
|
||||
notification: {
|
||||
title: '通知',
|
||||
description: '配置通知渠道',
|
||||
info: '通知配置说明',
|
||||
infoDesc: '配置通知渠道用于接收系统消息(可选)',
|
||||
type: '通知类型',
|
||||
typeHint: '选择要使用的通知渠道类型',
|
||||
name: '通知名称',
|
||||
nameHint: '为通知渠道设置一个名称',
|
||||
telegramConfig: 'Telegram 配置',
|
||||
emailConfig: '邮件配置',
|
||||
botToken: '机器人令牌',
|
||||
chatId: '聊天ID',
|
||||
smtpServer: 'SMTP 服务器',
|
||||
smtpPort: 'SMTP 端口',
|
||||
senderEmail: '发送邮箱',
|
||||
senderPassword: '发送密码',
|
||||
receiverEmail: '接收邮箱',
|
||||
},
|
||||
preferences: {
|
||||
title: '资源偏好',
|
||||
description: '设置资源下载偏好',
|
||||
info: '资源偏好说明',
|
||||
infoDesc: '设置资源下载的偏好,系统将根据这些偏好自动选择最佳资源',
|
||||
quality: '质量偏好',
|
||||
qualityHint: '选择偏好的视频质量',
|
||||
subtitle: '字幕偏好',
|
||||
subtitleHint: '选择偏好的字幕类型',
|
||||
resolution: '分辨率偏好',
|
||||
resolutionHint: '选择偏好的视频分辨率',
|
||||
presetRules: '预设规则',
|
||||
detailedConfig: '详细配置',
|
||||
quickPresets: '快速预设',
|
||||
quickPresetsDesc: '选择预设配置,系统将自动应用对应的规则',
|
||||
personalizationOptions: '个性化选项',
|
||||
personalizationOptionsDesc: '根据您的需求调整规则',
|
||||
excludeDolbyVision: '排除杜比视界',
|
||||
excludeDolbyVisionHint: '选中后规则中将排除杜比视界资源',
|
||||
excludeBluray: '排除蓝光原盘',
|
||||
excludeBlurayHint: '选中后规则中将排除蓝光原盘资源',
|
||||
presets: {
|
||||
'4k-enthusiast': {
|
||||
name: '4K发烧友',
|
||||
description: '追求最高画质,优先4K',
|
||||
},
|
||||
'balanced': {
|
||||
name: '平衡模式',
|
||||
description: '画质与存储空间的平衡选择',
|
||||
},
|
||||
'space-saver': {
|
||||
name: '节省空间',
|
||||
description: '优先较小文件,节省存储空间',
|
||||
},
|
||||
'free-priority': {
|
||||
name: '免费优先',
|
||||
description: '优先免费资源,其它的没有要求',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
saving: '保存中',
|
||||
reset: '重置',
|
||||
theme: '主題',
|
||||
uiMode: '界面佈局',
|
||||
language: '語言',
|
||||
pleaseWait: '請稍候...',
|
||||
viewDetails: '查看詳情',
|
||||
@@ -49,6 +50,9 @@ export default {
|
||||
itemsPerPage: '每頁條數',
|
||||
pageText: '{0}-{1} 共 {2} 條',
|
||||
noDataText: '沒有數據',
|
||||
next: '下一步',
|
||||
previous: '上一步',
|
||||
skip: '跳過',
|
||||
loadingText: '加載中...',
|
||||
networkRequired: '此功能需要網絡連接',
|
||||
networkDisconnected: '網絡連接已斷開',
|
||||
@@ -63,6 +67,10 @@ export default {
|
||||
serviceUnavailable: '服務不可用',
|
||||
status: '狀態',
|
||||
preset: '預設',
|
||||
refresh: '刷新',
|
||||
swUpdateReady: '新版本已就緒,請刷新頁面以獲取最新功能',
|
||||
versionMismatch: '瀏覽器快取版本與伺服器版本不一致,請嘗試清除快取',
|
||||
clearCache: '清除快取',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '電影',
|
||||
@@ -126,6 +134,7 @@ export default {
|
||||
light: '淺色',
|
||||
dark: '深色',
|
||||
auto: '跟隨系統',
|
||||
autoUI: '自動',
|
||||
transparent: '透明',
|
||||
purple: '幻紫',
|
||||
custom: '附加樣式',
|
||||
@@ -245,6 +254,22 @@ export default {
|
||||
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
|
||||
loginFailed: '登錄失敗',
|
||||
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!',
|
||||
twoFactorAuth: '雙重驗證',
|
||||
loginWithPasskey: '使用通行密鑰登錄',
|
||||
loginWithOtp: '使用驗證碼登錄',
|
||||
orUsePasskey: '或使用通行密鑰進行驗證',
|
||||
verifyWithPasskey: '使用通行密鑰驗證',
|
||||
otpPlaceholder: '請輸入6位驗證碼',
|
||||
passkeyLoginStartFailed: '啟動通行密鑰驗證失敗',
|
||||
passkeyNotSelected: '未選擇通行密鑰',
|
||||
passkeyLoginFailed: '通行密鑰登錄失敗',
|
||||
passkeyAuthCanceled: '通行密鑰驗證被取消',
|
||||
passkeyLoginRetry: '通行密鑰登錄失敗,請重試',
|
||||
passkeyVerifyFailed: '通行密鑰驗证失敗',
|
||||
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
|
||||
mfa: {
|
||||
selectVerificationMethod: '請選擇驗证方式',
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
start: '開始',
|
||||
@@ -321,10 +346,6 @@ export default {
|
||||
title: '通知',
|
||||
description: '通知渠道(微信、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息發送範圍',
|
||||
},
|
||||
words: {
|
||||
title: '詞表',
|
||||
description: '自定義識別詞、自定義製作組/字幕組、自定義占位符、文件整理屏蔽詞',
|
||||
},
|
||||
about: {
|
||||
title: '關於',
|
||||
description: '軟件版本',
|
||||
@@ -368,8 +389,10 @@ export default {
|
||||
deleteFailed: '用戶刪除失敗!',
|
||||
profile: '個人信息',
|
||||
systemSettings: '系統設定',
|
||||
wizardSettings: '設定向導',
|
||||
siteAuth: '用戶認證',
|
||||
helpDocs: '幫助文檔',
|
||||
about: '關於',
|
||||
restart: '重啟',
|
||||
management: '用戶管理',
|
||||
noUsers: '沒有用戶',
|
||||
@@ -377,8 +400,11 @@ export default {
|
||||
addUser: '添加用戶',
|
||||
editUser: '編輯用戶',
|
||||
username: '用戶名',
|
||||
usernameHint: '用於登入系統的用戶名',
|
||||
password: '密碼',
|
||||
passwordHint: '請輸入登入密碼',
|
||||
confirmPassword: '確認密碼',
|
||||
confirmPasswordHint: '請再次輸入密碼以確認',
|
||||
role: '角色',
|
||||
email: '郵箱',
|
||||
enabled: '啟用',
|
||||
@@ -443,6 +469,18 @@ export default {
|
||||
channel: '頻道名稱',
|
||||
channelHint: '消息發送頻道,默認`全體`',
|
||||
},
|
||||
discord: {
|
||||
name: 'Discord',
|
||||
botToken: 'Bot Token',
|
||||
botTokenHint: 'Discord Bot Token(需在開發者後台開啟 Message Content Intent)',
|
||||
botTokenRequired: 'Bot Token不能為空',
|
||||
guildId: '伺服器 ID',
|
||||
guildIdHint: '可選,限制使用的伺服器;空白則使用已加入的任意伺服器',
|
||||
guildIdPlaceholder: '123456789012345678',
|
||||
channelId: '頻道 ID',
|
||||
channelIdHint: '可選,預設廣播頻道;空白則自動選擇可發送消息的頻道',
|
||||
channelIdPlaceholder: '123456789012345678',
|
||||
},
|
||||
synologychat: {
|
||||
name: 'Synology Chat',
|
||||
webhook: '機器人傳入URL',
|
||||
@@ -491,6 +529,14 @@ export default {
|
||||
title: '消息',
|
||||
subtitle: '消息中心',
|
||||
},
|
||||
words: {
|
||||
title: '詞表',
|
||||
subtitle: '詞表設置',
|
||||
},
|
||||
cache: {
|
||||
title: '緩存',
|
||||
subtitle: '管理緩存',
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
components: '動作組件',
|
||||
@@ -761,6 +807,8 @@ export default {
|
||||
originalTitle: '原始標題',
|
||||
status: '狀態',
|
||||
releaseDate: '上映日期',
|
||||
digitalRelease: '數位發行',
|
||||
physicalRelease: '實體發行',
|
||||
originalLanguage: '原始語言',
|
||||
productionCountries: '出品國家',
|
||||
productionCompanies: '製作公司',
|
||||
@@ -827,6 +875,24 @@ export default {
|
||||
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: '批量刪除操作失敗',
|
||||
minSubscribers: '最小訂閱人數',
|
||||
},
|
||||
recommend: {
|
||||
all: '全部',
|
||||
@@ -1002,6 +1068,7 @@ export default {
|
||||
limitSeconds: '訪問間隔(秒)',
|
||||
useProxy: '使用代理訪問',
|
||||
browserSimulation: '瀏覽器仿真',
|
||||
selectFile: '選擇文件',
|
||||
},
|
||||
hints: {
|
||||
url: '格式:http://www.example.com/',
|
||||
@@ -1019,19 +1086,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: '測試中 ...',
|
||||
@@ -1063,6 +1159,13 @@ export default {
|
||||
accessTime: '訪問時間',
|
||||
responseTime: '響應時間',
|
||||
noTimeRecords: '暫無耗時記錄',
|
||||
preview: {
|
||||
title: '預覽站點',
|
||||
showing: '顯示 {count}/{total}',
|
||||
unnamed: '未命名站點',
|
||||
noUrl: '無站點地址',
|
||||
invalid: '數據無效',
|
||||
},
|
||||
},
|
||||
message: {
|
||||
loadMore: '加載更多',
|
||||
@@ -1074,6 +1177,7 @@ export default {
|
||||
program: '程序',
|
||||
content: '內容',
|
||||
refreshing: '正在刷新',
|
||||
initializing: '正在初始化',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: '正常',
|
||||
@@ -1112,6 +1216,7 @@ export default {
|
||||
title: '關於 MoviePilot',
|
||||
softwareVersion: '軟件版本',
|
||||
frontendVersion: '前端版本',
|
||||
browserVersion: '瀏覽器緩存版本',
|
||||
authVersion: '認證資源版本',
|
||||
indexerVersion: '站點資源版本',
|
||||
configDir: '配置目錄',
|
||||
@@ -1131,6 +1236,7 @@ export default {
|
||||
dataDirectory: '/moviepilot',
|
||||
expand: '展開',
|
||||
collapse: '收起',
|
||||
clearCache: '清除快取',
|
||||
},
|
||||
system: {
|
||||
custom: '自定義',
|
||||
@@ -1155,9 +1261,23 @@ export default {
|
||||
apiTokenLength: 'API Token不得低於16位',
|
||||
githubToken: 'Github Token',
|
||||
githubTokenFormat: 'ghp_**** 或 github_pat_****',
|
||||
githubTokenHint: '用於提高插件等訪問Github API時的限流閾值',
|
||||
githubTokenHint: '用於提高插件等訪問Github API時的限流閾值,建議配置,否則插件可能無法正常使用',
|
||||
ocrHost: '驗證碼識別服務器',
|
||||
ocrHostHint: '用於站點簽到、更新站點Cookie等識別驗證碼',
|
||||
aiAgent: '啟用智能助手',
|
||||
aiAgentEnable: '啟用智能助手',
|
||||
aiAgentEnableHint: '啟用後可使用智能助手功能,需要配置LLM相關參數',
|
||||
llmProvider: 'LLM提供商',
|
||||
llmProviderHint: '選擇使用的LLM服務提供商',
|
||||
llmModel: 'LLM模型名稱',
|
||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||
llmApiKey: 'LLM API密鑰',
|
||||
llmApiKeyHint: 'LLM服務提供商的API密鑰,用於身份驗證',
|
||||
llmApiKeyPlaceholder: '請輸入API密鑰',
|
||||
llmBaseUrl: 'LLM基礎URL',
|
||||
llmBaseUrlHint: 'LLM API的基礎URL地址,用於自定義API端點',
|
||||
aiAgentGlobal: '全局智能助手',
|
||||
aiAgentGlobalHint: '啟用全局智能助手功能,所有消息對話均使用智能體回答而不用使用/ai命令',
|
||||
advancedSettings: '高級設置',
|
||||
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
|
||||
downloaders: '下載器',
|
||||
@@ -1201,7 +1321,7 @@ export default {
|
||||
workflowStatisticShareHint: '分享工作流統計數據到熱門工作流,供其他MPer參考',
|
||||
bigMemoryMode: '大內存模式',
|
||||
bigMemoryModeHint: '使用更大的內存緩存數據,提升系統性能',
|
||||
dbWalEnable: 'WAL模式',
|
||||
dbWalEnable: '數據庫WAL模式',
|
||||
dbWalEnableHint: '可提升讀寫併發性能,但可能在異常情況下增加數據丟失風險,更改後需重啟生效',
|
||||
tmdbApiDomain: 'TMDB API服務地址',
|
||||
tmdbApiDomainPlaceholder: 'api.themoviedb.org',
|
||||
@@ -1355,6 +1475,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: '閱讀站點消息',
|
||||
@@ -1586,7 +1711,9 @@ export default {
|
||||
bestVersionRuleGroup: '洗版優先級規則組',
|
||||
bestVersionRuleGroupHint: '按選定的過濾規則組對洗版訂閱進行過濾',
|
||||
timedSearch: '訂閱定時搜索',
|
||||
timedSearchHint: '每隔24小時全站搜索,以補全訂閱可能漏掉的資源',
|
||||
timedSearchHint: '每隔指定時間全站搜索,以補全訂閱可能漏掉的資源',
|
||||
searchInterval: '訂閱搜索時間間隔',
|
||||
searchIntervalHint: '設置訂閱搜索的時間間隔,僅在開啟訂閱定時搜索時生效',
|
||||
checkLocalMedia: '檢查文件系統資源',
|
||||
checkLocalMediaHint: '掃描存儲目錄中是否已存在相應資源文件,以避免重複下載;不管是否開啟都會檢查媒體伺服器',
|
||||
modes: {
|
||||
@@ -1601,6 +1728,8 @@ export default {
|
||||
hour1: '1小時',
|
||||
hour12: '12小時',
|
||||
day1: '1天',
|
||||
day3: '3天',
|
||||
week1: '一週',
|
||||
},
|
||||
saveSuccess: '訂閱站點保存成功',
|
||||
saveFailed: '訂閱站點保存失敗!',
|
||||
@@ -1683,8 +1812,12 @@ export default {
|
||||
add: '添加用戶',
|
||||
edit: '編輯用戶',
|
||||
username: '用戶名',
|
||||
usernameRequired: '用戶名不能為空',
|
||||
password: '密碼',
|
||||
passwordMinLength: '密碼長度不能少於6位',
|
||||
confirmPassword: '確認密碼',
|
||||
confirmPasswordRequired: '請確認密碼',
|
||||
passwordMismatch: '兩次輸入的密碼不一致',
|
||||
email: '郵箱',
|
||||
nickname: '暱稱',
|
||||
status: '狀態',
|
||||
@@ -1705,9 +1838,7 @@ export default {
|
||||
webPush: 'WebPush',
|
||||
creatingUser: '正在創建【{name}】用戶,請稍後',
|
||||
updatingUser: '正在更新【{name}】用戶,請稍後',
|
||||
usernameRequired: '用戶名不能為空',
|
||||
usernameExists: '用戶名已存在',
|
||||
passwordMismatch: '兩次輸入的密碼不一致',
|
||||
userCreated: '用戶【{name}】創建成功',
|
||||
userCreateFailed: '創建用戶失敗:{message}',
|
||||
userUpdateSuccess: '用戶【{name}】更新成功',
|
||||
@@ -1783,6 +1914,8 @@ export default {
|
||||
startDownload: '開始下載',
|
||||
downloadSuccess: '{site} {title} 下載成功!',
|
||||
downloadFailed: '{site} {title} 下載失敗:{message}!',
|
||||
showAdvancedOptions: '顯示高級選項',
|
||||
hideAdvancedOptions: '隱藏高級選項',
|
||||
},
|
||||
subscribeShare: {
|
||||
shareSubscription: '分享訂閱',
|
||||
@@ -2003,6 +2136,10 @@ export default {
|
||||
startAll: '全部開始',
|
||||
refresh: '刷新',
|
||||
close: '關閉',
|
||||
processingFile: '正在整理',
|
||||
overallProgress: '整體進度',
|
||||
currentFileProgress: '當前文件進度',
|
||||
processingStatus: '整理中',
|
||||
},
|
||||
reorganize: {
|
||||
title: '整理',
|
||||
@@ -2402,15 +2539,47 @@ export default {
|
||||
vocechatUser: 'VoceChat用戶',
|
||||
synologychatUser: 'SynologyChat用戶',
|
||||
doubanUser: '豆瓣用戶',
|
||||
twoFactorAuthentication: '登錄雙重驗證',
|
||||
setupAuthenticator: '設置身份驗證器',
|
||||
authenticatorManagement: '身份驗證器管理',
|
||||
authenticatorEnabled: '您已啟用身份驗證器雙重驗證',
|
||||
clearAuthenticatorTip: '如需設置新的身份驗證器,請先清除當前配置。',
|
||||
clearAuthenticator: '清除身份驗證器',
|
||||
enableTwoFactor: '開啟雙重驗證',
|
||||
disableTwoFactor: '關閉雙重驗證',
|
||||
setupMfa: '設置雙重驗證',
|
||||
enableMfa: '開啟雙重驗證',
|
||||
useAuthenticator: '使用身份驗證器',
|
||||
usePasskey: '使用通行密鑰',
|
||||
enabled: '已啟用',
|
||||
keysCount: '{count} 個密鑰',
|
||||
passkeyManagement: '通行密鑰管理',
|
||||
registerNewPasskey: '註冊新通行密鑰',
|
||||
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
|
||||
passkeyName: '通行密鑰名稱',
|
||||
passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
|
||||
registerPasskey: '註冊通行密鑰',
|
||||
registeredPasskeys: '已註冊的通行密鑰',
|
||||
createdAt: '建立時間',
|
||||
noPasskeys: '您還沒有註冊任何通行密鑰',
|
||||
passkeyNameRequired: '請輸入通行密鑰名稱',
|
||||
passkeyRegisterSuccess: '通行密鑰註冊成功',
|
||||
passkeyRegisterFailed: '註冊失敗',
|
||||
passkeyRegisterCancelled: '註冊被取消',
|
||||
passkeyDeleteSuccess: '通行密鑰已刪除',
|
||||
passkeyDeleteFailed: '刪除失敗',
|
||||
passkeyDomainWarning: '通行密鑰(PassKey)的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
|
||||
otpRequiredForPasskey: '為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
|
||||
accessDomain: '訪問域名',
|
||||
otpAuthenticator: 'OTP 身份驗證器',
|
||||
otpGenerateFailed: '獲取otp uri失敗:{message}!',
|
||||
otpDisableSuccess: '關閉登錄雙重驗證成功!',
|
||||
otpDisableFailed: '關閉otp失敗:{message}!',
|
||||
otpCodeRequired: '請填寫6位驗證碼',
|
||||
otpEnableSuccess: '開啟登錄雙重驗證成功!',
|
||||
otpEnableFailed: '開啟otp失敗:{message}!',
|
||||
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。',
|
||||
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
|
||||
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
|
||||
authenticatorApp: '身份驗證器',
|
||||
authenticatorAppDescription:
|
||||
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password這樣的身份驗證器應用程序,掃描二維碼。它將為您生成一個6位數的代碼,供您在下方輸入。',
|
||||
@@ -2526,6 +2695,14 @@ export default {
|
||||
nameRequired: '名稱不能為空',
|
||||
nameDuplicate: '名稱已存在',
|
||||
defaultChanged: '存在預設下載器,已替換',
|
||||
hostRequired: '地址不能為空',
|
||||
usernameRequired: '用戶名不能為空',
|
||||
passwordRequired: '密碼不能為空',
|
||||
pathMapping: '路徑映射',
|
||||
pathMappingRequired: '路徑不能為空',
|
||||
pathMappingError: '必須以 / 開頭',
|
||||
storagePath: '存儲路徑',
|
||||
downloadPath: '下載路徑',
|
||||
},
|
||||
filterRule: {
|
||||
title: '過濾規則',
|
||||
@@ -2561,15 +2738,21 @@ export default {
|
||||
host: '地址',
|
||||
hostPlaceholder: 'http(s)://ip:port',
|
||||
hostHint: '服務端地址,格式:http(s)://ip:port',
|
||||
hostRequired: '地址不能為空',
|
||||
playHost: '外網播放地址',
|
||||
playHostPlaceholder: 'http(s)://domain:port',
|
||||
playHostHint: '跳轉播放頁面使用的地址,格式:http(s)://domain:port',
|
||||
apiKey: 'API密鑰',
|
||||
apiKeyRequired: 'API密鑰不能為空',
|
||||
embyApiKeyHint: 'Emby設置->高級->API密鑰中生成的密鑰',
|
||||
jellyfinApiKeyHint: 'Jellyfin設置->高級->API密鑰中生成的密鑰',
|
||||
plexToken: 'X-Plex-Token',
|
||||
tokenRequired: 'Token不能為空',
|
||||
usernameRequired: '用戶名不能為空',
|
||||
passwordRequired: '密碼不能為空',
|
||||
plexTokenHint: '瀏覽器F12->網絡,從Plex請求URL中獲取的X-Plex-Token',
|
||||
username: '用戶名',
|
||||
usernameHint: '登錄用戶名',
|
||||
password: '密碼',
|
||||
syncLibraries: '同步媒體庫',
|
||||
syncLibrariesHint: '只有選中的媒體庫才會被同步',
|
||||
@@ -2606,6 +2789,9 @@ export default {
|
||||
firstAirDateAsc: '首播日期升序',
|
||||
voteAverageDesc: '評分降序',
|
||||
voteAverageAsc: '評分升序',
|
||||
time: '最新',
|
||||
count: '熱門',
|
||||
rating: '評分',
|
||||
},
|
||||
genreType: {
|
||||
action: '動作',
|
||||
@@ -2735,7 +2921,9 @@ export default {
|
||||
libraryStorage: '媒體庫存儲',
|
||||
libraryDirectory: '媒體庫目錄',
|
||||
transferType: '轉移方式',
|
||||
transferTypeHint: '文件操作整理方式,硬連結節省空間,複製更安全',
|
||||
overwriteMode: '覆蓋模式',
|
||||
overwriteModeHint: '當目標文件已存在時的處理方式',
|
||||
smartRename: '智能重命名',
|
||||
scrapingMetadata: '刮削元數據',
|
||||
sendNotification: '發送通知',
|
||||
@@ -2776,4 +2964,149 @@ export default {
|
||||
customBackgroundImageHint: '支援網路圖片URL,留空則使用漸變背景',
|
||||
pluginCount: '{count} 個插件',
|
||||
},
|
||||
setupWizard: {
|
||||
title: '歡迎使用 MoviePilot !',
|
||||
subtitle: '按向導完成配置,即刻開始使用。',
|
||||
completed: '設定精靈完成!',
|
||||
failed: '設定精靈失敗,請重試',
|
||||
complete: '完成設定',
|
||||
loading: '正在載入配置資料...',
|
||||
testing: '正在測試',
|
||||
connectivityTestSuccess: '連通性測試通過',
|
||||
connectivityTestFailed: '連通性測試失敗',
|
||||
testingStorage: '正在測試存儲目錄',
|
||||
checkingStorage: '檢查存儲目錄連通性',
|
||||
testingDownloader: '正在測試下載器',
|
||||
checkingDownloader: '檢查下載器連通性',
|
||||
testingMediaServer: '正在測試媒體服務器',
|
||||
checkingMediaServer: '檢查媒體服務器連通性',
|
||||
testingNotification: '正在測試消息通知',
|
||||
checkingNotification: '檢查消息通知連通性',
|
||||
testFailedHint: '請檢查配置是否正確,修改後可以重新測試',
|
||||
unsupportedDownloaderType: '不支援的下載器類型: {type}',
|
||||
unsupportedMediaServerType: '不支援的媒體服務器類型: {type}',
|
||||
unsupportedNotificationType: '不支援的通知類型: {type}',
|
||||
passwordUpdateSuccess: '密碼更新成功',
|
||||
userCreateSuccess: '使用者建立成功',
|
||||
passwordUpdateFailed: '密碼更新失敗',
|
||||
basic: {
|
||||
title: '基礎設定',
|
||||
description: '設定存取網域、用戶名密碼和網路配置',
|
||||
appDomain: '存取網域',
|
||||
appDomainHint: '用於發送通知時,新增快速跳轉位址',
|
||||
wallpaper: '背景桌布',
|
||||
wallpaperHint: '選擇登入頁面背景來源',
|
||||
recognizeSource: '識別資料來源',
|
||||
recognizeSourceHint: '設定預設媒體資訊識別資料來源',
|
||||
apiToken: 'API 權杖',
|
||||
apiTokenHint: '訪問MoviePilot API 需要的訪問令牌,請記錄下來以便後續使用',
|
||||
currentUserHint: '目前使用者,不可修改',
|
||||
passwordOptionalHint: '留空表示不修改密碼',
|
||||
confirmPasswordHint: '確認新密碼',
|
||||
apiTokenRequired: 'API Token 不能為空',
|
||||
},
|
||||
storage: {
|
||||
title: '儲存',
|
||||
description: '設定下載目錄和媒體庫目錄',
|
||||
info: '儲存設定說明',
|
||||
infoDesc: '設定本機儲存目錄,用於下載和媒體庫管理',
|
||||
downloadPath: '下載目錄',
|
||||
downloadPathHint: '設定下載檔案的儲存路徑',
|
||||
libraryPath: '媒體庫目錄',
|
||||
libraryPathHint: '設定媒體檔案的儲存路徑',
|
||||
downloadPathRequired: '下載目錄不能為空',
|
||||
libraryPathRequired: '媒體庫目錄不能為空',
|
||||
},
|
||||
downloader: {
|
||||
title: '下載器',
|
||||
description: '設定下載器',
|
||||
info: '下載器設定說明',
|
||||
infoDesc: '設定下載器用於下載資源,可選擇qBittorrent或Transmission',
|
||||
type: '下載器類型',
|
||||
typeHint: '選擇要使用的下載器類型',
|
||||
name: '下載器名稱',
|
||||
nameHint: '為下載器設定一個名稱',
|
||||
qbittorrentConfig: 'qBittorrent 設定',
|
||||
transmissionConfig: 'Transmission 設定',
|
||||
host: '伺服器位址',
|
||||
username: '使用者名稱',
|
||||
password: '密碼',
|
||||
downloadPath: '下載路徑',
|
||||
},
|
||||
mediaServer: {
|
||||
title: '媒體伺服器',
|
||||
description: '設定媒體伺服器',
|
||||
info: '媒體伺服器設定說明',
|
||||
infoDesc: '設定媒體伺服器用於媒體庫管理,可選擇Emby、Jellyfin或Plex等',
|
||||
type: '媒體伺服器類型',
|
||||
typeHint: '選擇要使用的媒體伺服器類型',
|
||||
name: '伺服器名稱',
|
||||
nameHint: '為媒體伺服器設定一個名稱',
|
||||
embyConfig: 'Emby 設定',
|
||||
jellyfinConfig: 'Jellyfin 設定',
|
||||
plexConfig: 'Plex 設定',
|
||||
host: '伺服器位址',
|
||||
apiKey: 'API 金鑰',
|
||||
token: '存取權杖',
|
||||
},
|
||||
notification: {
|
||||
title: '通知',
|
||||
description: '設定通知管道',
|
||||
info: '通知設定說明',
|
||||
infoDesc: '設定通知管道用於接收系統訊息(可選)',
|
||||
type: '通知類型',
|
||||
typeHint: '選擇要使用的通知管道類型',
|
||||
name: '通知名稱',
|
||||
nameHint: '為通知管道設定一個名稱',
|
||||
telegramConfig: 'Telegram 設定',
|
||||
emailConfig: '郵件設定',
|
||||
botToken: '機器人權杖',
|
||||
chatId: '聊天ID',
|
||||
smtpServer: 'SMTP 伺服器',
|
||||
smtpPort: 'SMTP 連接埠',
|
||||
senderEmail: '發送信箱',
|
||||
senderPassword: '發送密碼',
|
||||
receiverEmail: '接收信箱',
|
||||
},
|
||||
preferences: {
|
||||
title: '資源偏好',
|
||||
description: '設定資源下載偏好',
|
||||
info: '資源偏好說明',
|
||||
infoDesc: '設定資源下載的偏好,系統將根據這些偏好自動選擇最佳資源',
|
||||
quality: '品質偏好',
|
||||
qualityHint: '選擇偏好的影片品質',
|
||||
subtitle: '字幕偏好',
|
||||
subtitleHint: '選擇偏好的字幕類型',
|
||||
resolution: '解析度偏好',
|
||||
resolutionHint: '選擇偏好的影片解析度',
|
||||
presetRules: '預設規則',
|
||||
detailedConfig: '詳細設定',
|
||||
quickPresets: '快速預設',
|
||||
quickPresetsDesc: '選擇預設配置,系統將自動應用對應的規則',
|
||||
personalizationOptions: '個性化選項',
|
||||
personalizationOptionsDesc: '根據您的需求調整規則',
|
||||
excludeDolbyVision: '排除杜比視界',
|
||||
excludeDolbyVisionHint: '選中後規則中將排除杜比視界資源',
|
||||
excludeBluray: '排除藍光原盤',
|
||||
excludeBlurayHint: '選中後規則中將排除藍光原盤資源',
|
||||
presets: {
|
||||
'4k-enthusiast': {
|
||||
name: '4K發燒友',
|
||||
description: '追求最高畫質,優先4K',
|
||||
},
|
||||
'balanced': {
|
||||
name: '平衡模式',
|
||||
description: '畫質與儲存空間的平衡選擇',
|
||||
},
|
||||
'space-saver': {
|
||||
name: '節省空間',
|
||||
description: '優先較小檔案,節省儲存空間',
|
||||
},
|
||||
'free-priority': {
|
||||
name: '免費優先',
|
||||
description: '優先免費資源,其它的沒有要求',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||
// 根据header属性对应用进行分类
|
||||
function categorizeApps() {
|
||||
// 获取所有菜单并根据权限过滤
|
||||
const allMenus = getNavMenus()
|
||||
const allMenus = getNavMenus(t)
|
||||
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
|
||||
const menus = filteredMenus.filter((item: NavMenu) => !item.footer)
|
||||
|
||||
|
||||
@@ -389,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>
|
||||
@@ -443,7 +443,7 @@ onDeactivated(() => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.settings-card-header {
|
||||
|
||||
@@ -58,7 +58,7 @@ function initializeColors() {
|
||||
|
||||
// 初始化发现标签
|
||||
function initDiscoverTabs() {
|
||||
const tabs = getDiscoverTabs()
|
||||
const tabs = getDiscoverTabs(t)
|
||||
for (const tab of tabs) {
|
||||
discoverTabs.value.push({
|
||||
name: tab.name,
|
||||
@@ -216,7 +216,7 @@ onActivated(async () => {
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
<!-- 弹窗,根据配置生成选项 -->
|
||||
<DialogWrapper
|
||||
<VDialog
|
||||
v-if="orderConfigDialog"
|
||||
v-model="orderConfigDialog"
|
||||
max-width="35rem"
|
||||
@@ -265,7 +265,7 @@ onActivated(async () => {
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</DialogWrapper>
|
||||
</VDialog>
|
||||
<!-- 快速滚动到顶部按钮 -->
|
||||
<Teleport to="body" v-if="route.path === '/discover'">
|
||||
<VScrollToTopBtn />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user