Compare commits

..

81 Commits

Author SHA1 Message Date
jxxghp
546c82ca40 更新 package.json 2025-11-25 15:54:10 +08:00
jxxghp
f132dc38f4 Merge pull request #404 from wikrin/heartbeat 2025-11-25 15:53:04 +08:00
jxxghp
58c70b8ca6 chore: bump version to 2.8.6 in package.json and add global AI assistant settings in locales and AccountSettingSystem.vue 2025-11-23 13:50:35 +08:00
Attente
147f55eefe feat(App): 添加心跳机制通过后端刷新资源访问令牌 2025-11-23 13:40:34 +08:00
jxxghp
229b7b0c12 chore: bump version to 2.8.5 in package.json 2025-11-20 19:37:58 +08:00
jxxghp
4b7b5ff8a4 fix #397 2025-11-20 19:37:33 +08:00
jxxghp
4906bde746 chore: bump version to 2.8.4 in package.json and refactor AccountSettingSystem.vue to streamline AI agent settings 2025-11-20 19:25:28 +08:00
jxxghp
a87a1a8988 Merge pull request #403 from madrays/v2 2025-11-20 19:11:51 +08:00
madrays
e05f45e681 增加自动拉取可用ai模型的易用性功能 2025-11-20 19:01:25 +08:00
jxxghp
b4acacea81 chore: bump version to 2.8.3 in package.json 2025-11-18 12:49:06 +08:00
jxxghp
fa9645b05b Merge pull request #402 from cddjr/trimemedia 2025-11-17 14:21:39 +08:00
景大侠
1ed4052814 fix #401 2025-11-17 14:08:51 +08:00
jxxghp
7dc814461f 更新 package.json 2025-11-16 06:31:32 +08:00
jxxghp
9154ec0e8c Merge pull request #400 from wikrin/cursor-move 2025-11-16 06:30:57 +08:00
jxxghp
3a2ea60583 Merge pull request #399 from wikrin/release_dates 2025-11-16 06:30:31 +08:00
Attente
b36bff3a1e feat(dashboard): 移除 Vue 渲染模式下的固定拖拽图标
更新`docs/module-federation-guide.md` 文档,使用 `v-hover` 实现仅在鼠标悬停时显示拖拽图标。
2025-11-15 18:03:29 +08:00
Attente
b3d8cbf280 feat: 为媒体信息添加数字/实体发行日期支持 2025-11-13 23:52:54 +08:00
jxxghp
38fb02d112 Merge pull request #398 from cddjr/trimemedia 2025-11-05 23:16:28 +08:00
景大侠
2597f893cd rename 2025-11-05 15:26:34 +08:00
景大侠
ebdd036654 避免飞牛媒体库的图片地址携带敏感数据 2025-11-05 15:15:36 +08:00
景大侠
5032f0e6a9 fix 飞牛影视无法显示图片
图片接口增加Cookies参数
2025-11-04 11:32:41 +08:00
jxxghp
ad963d718d refactor: Remove unused AI agent subheader from account settings 2025-11-01 10:39:25 +08:00
jxxghp
69d314bce3 feat: Add AI agent settings and localization support for LLM configuration 2025-10-31 11:46:45 +08:00
jxxghp
4a7425a947 feat: Add download count formatting function and update card components to use it 2025-10-18 20:13:41 +08:00
jxxghp
c172ac0d5c Merge pull request #395 from jxxghp/cursor/add-default-all-filter-for-subscription-styles-ad8f 2025-09-16 13:38:33 +08:00
Cursor Agent
01a66493a8 feat: Add "All" option to genre filter
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 05:37:47 +00:00
jxxghp
188f8b3faa 更新缓存版本至v13 2025-09-16 13:14:17 +08:00
jxxghp
ebcf5fad71 Merge pull request #394 from jxxghp/cursor/update-subscription-sorting-and-scoring-6aa9 2025-09-16 12:26:44 +08:00
Cursor Agent
d1a656db82 Refactor: Move sort filter to top in subscribe views
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 04:25:38 +00:00
Cursor Agent
4f6a11fd7c Refactor subscribe views to use VChipGroup for sorting
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 04:25:02 +00:00
jxxghp
1d09a946bb Merge pull request #393 from jxxghp/cursor/add-sorting-to-subscription-filters-b700 2025-09-16 12:02:21 +08:00
Cursor Agent
6c4eb7edbd Add sorting options to subscribe views and locales
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 03:46:35 +00:00
jxxghp
4f9f669ac6 Merge pull request #392 from jxxghp/cursor/translate-missing-string-and-adjust-slider-max-value-4d93 2025-09-16 11:12:31 +08:00
Cursor Agent
f9e0e78473 Refactor: Remove rating input, display max rating
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 03:10:14 +00:00
Cursor Agent
b004facfca Refactor: Improve rating filter UI and update locale text
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 02:36:16 +00:00
jxxghp
fb6ee2910f 更新 package.json 2025-09-16 09:00:54 +08:00
jxxghp
3fedc9b730 Merge pull request #391 from jxxghp/cursor/update-popular-subscriptions-api-with-filters-9c20 2025-09-16 08:47:41 +08:00
Cursor Agent
b260427312 feat: Add filtering and genre selection to subscribe share
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 00:46:08 +00:00
Cursor Agent
dd1447e93c feat: Add minSubscribers translation to zh-TW locale
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 00:20:27 +00:00
Cursor Agent
dbcc213562 feat: Add subscribe filtering and localization
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 00:17:40 +00:00
jxxghp
1c019cd5c8 重构离线页面组件 2025-09-13 14:00:03 +08:00
jxxghp
e37bde77a1 fix https://github.com/jxxghp/MoviePilot/issues/4922 2025-09-13 10:18:41 +08:00
jxxghp
57bf0d2021 优化快捷访问组件的滚动管理 2025-09-12 20:57:29 +08:00
jxxghp
88b00f7069 更新viewport设置 2025-09-12 08:25:21 +08:00
jxxghp
7b08cbb2f7 优化进度对话框 2025-09-11 20:33:14 +08:00
jxxghp
97c0ec184d Fix: Center cache statistics on mobile (#389)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-11 18:15:38 +08:00
jxxghp
d18c845088 Refactor cache view for better mobile responsiveness (#388)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-11 18:05:23 +08:00
jxxghp
a64d97774d 优化关于对话框和快捷栏的布局 2025-09-11 17:36:02 +08:00
jxxghp
2ddc51aa4f 调整词表、缓存、关于功能的位置 2025-09-11 15:29:24 +08:00
jxxghp
28afe2a922 统一图标导入方式 2025-09-11 15:03:12 +08:00
jxxghp
c2e97bf191 调整 Vite 配置,增加最大缓存文件大小至 10MB,以支持更大的文件。 2025-09-11 14:40:34 +08:00
jxxghp
c922752a1f Merge pull request #387 from jxxghp/setup-wizard
Setup wizard
2025-09-11 14:32:30 +08:00
jxxghp
08f36a74ca 增强配置向导功能 2025-09-11 14:30:52 +08:00
jxxghp
d7809dd00c 调整配置向导的布局,增加右侧按钮组 2025-09-11 12:36:12 +08:00
jxxghp
27582004da 增强配置向导功能 2025-09-11 08:31:13 +08:00
jxxghp
3d6a176cde 提升配置向导的样式,增加z-index和阴影效果 2025-09-10 17:10:01 +08:00
jxxghp
4a2073a038 优化配置向导 2025-09-10 16:56:06 +08:00
jxxghp
c8a65ecbe4 修复配置向导中的用户信息保存逻辑 2025-09-10 15:23:48 +08:00
jxxghp
3750d5cba0 增强配置向导功能 2025-09-10 14:46:02 +08:00
jxxghp
55b383780e Split setup vue into view components (#386)
* Refactor: Extract setup wizard into composable and components

This commit refactors the setup wizard by extracting its logic into a composable function `useSetupWizard` and breaking down the UI into individual components for better organization and reusability.

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

* Refactor: Move setup wizard components to separate files

This commit refactors the setup wizard by extracting individual steps into their own Vue components. This improves code organization and maintainability.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-10 11:48:09 +08:00
jxxghp
6aec0ddf88 更新配置向导 2025-09-10 08:51:05 +08:00
jxxghp
7c8e94d1df 更新用户配置 2025-09-10 08:30:19 +08:00
jxxghp
5ecbf626c8 更新用户配置向导 2025-09-09 20:31:00 +08:00
jxxghp
584f580e3b 实现配置向导功能 2025-09-09 13:50:37 +08:00
jxxghp
280de47dac 新增配置向导 2025-09-09 12:43:53 +08:00
jxxghp
c7c05f5897 Merge pull request #385 from cddjr/fix_calendar 2025-09-08 21:39:39 +08:00
景大侠
bb86180582 修复日历可能会空白的问题 2025-09-08 21:33:37 +08:00
jxxghp
aff228edd3 更新 Footer 组件以支持动态显示导航 2025-09-08 19:19:31 +08:00
jxxghp
f65ae6d703 更新 DefaultLayout.vue 2025-09-08 18:38:52 +08:00
jxxghp
0fccc06883 修改 ProgressDialog 组件 2025-09-08 17:38:44 +08:00
jxxghp
8652966645 调整 index.html 和默认布局样式,修改溢出属性以改善页面滚动体验 2025-09-08 16:49:58 +08:00
jxxghp
6d84eb9f09 更新 package.json 版本号至 2.8.0 2025-09-08 16:13:10 +08:00
jxxghp
1a3dccac29 更新 index.html 2025-09-08 16:12:28 +08:00
jxxghp
fa8de34fc5 更新页面样式 2025-09-08 08:49:00 +08:00
jxxghp
10cfd6be80 更新 service-worker.ts 2025-09-02 13:47:57 +08:00
jxxghp
a390b36e7c 更新 package.json 版本号至 2.7.9 2025-09-02 12:35:41 +08:00
jxxghp
d6b5994e22 添加搜索时间间隔选项 2025-09-02 11:48:24 +08:00
jxxghp
08611a97e7 Merge pull request #384 from Aqr-K/feat-v2.7.8-filelist-case-insensitive 2025-08-31 07:51:18 +08:00
Aqr-K
35bbb44ce3 更新 FileList.vue 2025-08-30 21:08:17 +08:00
Aqr-K
8ff879661a fix(file): Simplify the selectMode button. 2025-08-30 20:56:58 +08:00
Aqr-K
a8f01f099d feat(file): Add an ignoreCase button. 2025-08-30 20:54:17 +08:00
63 changed files with 5701 additions and 945 deletions

View File

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

View File

@@ -13,7 +13,7 @@
<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" />
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="format-detection" content="telephone=no, date=no, email=no, address=no" />
@@ -95,9 +95,14 @@
<link rel="preload" href="/logo.png" as="image" />
<link rel="modulepreload" href="/src/main.ts" />
<!-- 内联关键CSS -->
<style>
/* 关键路径CSS - 从loader.css内联 */
#app {
block-size: 100%;
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
#loading-bg {
position: fixed;
z-index: 99999;
@@ -115,14 +120,12 @@
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
/* 添加logo完成动画 - 放大虚化效果 */
.loading-complete .loading-logo {
filter: blur(10px);
opacity: 0;
transform: scale(1.5);
}
/* 添加加载背景消失动画 - 放大虚化效果 */
.loading-complete {
filter: blur(15px);
opacity: 0;
@@ -141,7 +144,6 @@
transition: opacity 0.6s ease;
}
/* 完成时隐藏加载动画 */
.loading-complete .loading {
opacity: 0;
}
@@ -197,7 +199,6 @@
}
</style>
<!-- 初始化脚本 -->
<script>
// 检测系统主题是否为深色模式
function checkPrefersColorSchemeIsDark() {
@@ -366,4 +367,4 @@
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.7.8",
"version": "2.8.7",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ html {
background: rgb(var(--v-theme-background));
min-block-size: 100vh;
min-block-size: 100dvh;
overflow-y: hidden;
}
body {

View File

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

View File

@@ -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) {
@@ -234,11 +264,15 @@ onMounted(async () => {
ensureRenderComplete(() => {
nextTick(removeLoadingWithStateCheck)
})
// 启动心跳
startHeartbeat()
})
onUnmounted(() => {
// 清除背景轮换定时器
removeBackgroundTimer('background-rotation')
// 停止心跳
stopHeartbeat()
})
</script>

View File

@@ -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 {
// 上映日期
@@ -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
}
// 消息通知

View File

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

View File

@@ -4,9 +4,7 @@ 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'
@@ -128,11 +126,11 @@ 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')
}
})

View File

@@ -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,31 +52,39 @@ 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 - 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 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')
@@ -107,30 +115,20 @@ async function drawImages(imageList: string[]) {
}
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
const y = 0 // 海报紧贴顶部
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,
0,
POSTER_WIDTH,
REFLECTION_HEIGHT,
)
ctx.drawImage(img, 0, 0, img.width, img.height, x, 0, POSTER_WIDTH, 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.7)')
ctx.globalCompositeOperation = 'destination-out';
ctx.globalCompositeOperation = 'destination-out'
ctx.fillStyle = gradient
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)
@@ -147,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>

View File

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

View File

@@ -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')
}
})
@@ -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"

View File

@@ -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'
@@ -99,19 +93,19 @@ 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 'webpush':
return chrome_image
return getLogoUrl('chrome')
default:
return custom_image
return getLogoUrl('notification')
}
})

View File

@@ -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,7 +104,7 @@ 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(
@@ -244,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">
@@ -327,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>

View File

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

View File

@@ -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
})
// 跳转播放

View File

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

View File

@@ -0,0 +1,382 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 国际化
const { t } = useI18n()
// 定义事件
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)
}
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.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 undefined">
<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 undefined"
><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 undefined">
<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 undefined">
<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 undefined">
<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 undefined">
<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>

View File

@@ -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'))
@@ -96,6 +118,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)
@@ -202,6 +232,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 +289,15 @@ onMounted(() => {
</VBtn>
</VCardText>
</VCard>
<!-- 媒体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>

View File

@@ -82,6 +82,9 @@ const items = ref<FileItem[]>([])
// 过滤条件
const filter = ref('')
// 是否忽略大小写
const ignoreCase = ref(true)
// 重命名弹窗
const renamePopper = ref(false)
@@ -112,12 +115,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')
@@ -622,9 +639,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>

View File

@@ -10,7 +10,6 @@ const props = defineProps({
root: {
type: String,
default: '/',
required: true,
},
storage: {
type: String,

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -386,7 +386,7 @@ onMounted(() => {
<!-- 👉 Footer -->
<template #footer>
<Footer />
<Footer :show-nav="!showPluginQuickAccess" />
</template>
</VerticalNavLayout>

View File

@@ -7,6 +7,15 @@ 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()
@@ -160,7 +169,7 @@ 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">

View File

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

View File

@@ -1,7 +1,7 @@
<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'
@@ -137,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'))
@@ -206,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,
@@ -213,18 +236,9 @@ watch(
if (visible) {
fetchPluginsWithPage()
loadRecentPlugins()
// 禁用背景滚动,但允许面板内部滚动
// 注意:参数是要允许滚动的目标元素,即面板本身
const panelElement = document.querySelector('.plugin-quick-access')
if (panelElement) {
disableBodyScroll(panelElement as HTMLElement)
}
manageScrollLock()
} else {
// 恢复背景滚动
const panelElement = document.querySelector('.plugin-quick-access')
if (panelElement) {
enableBodyScroll(panelElement as HTMLElement)
}
manageScrollLock()
}
},
{ immediate: true },
@@ -234,14 +248,15 @@ onMounted(() => {
if (isVisible.value) {
fetchPluginsWithPage()
loadRecentPlugins()
manageScrollLock()
}
})
// 组件卸载时确保恢复背景滚动
onUnmounted(() => {
const panelElement = document.querySelector('.plugin-quick-access')
if (panelElement) {
enableBodyScroll(panelElement as HTMLElement)
const scrollableElement = document.querySelector('.all-plugins-grid')
if (scrollableElement) {
enableBodyScroll(scrollableElement as HTMLElement)
}
})
@@ -441,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" />
@@ -643,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 {
@@ -698,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;

View File

@@ -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('')
@@ -86,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'),
@@ -249,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" />
@@ -358,6 +388,38 @@ onMounted(() => {
</VCardText>
</VCard>
</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>
<!-- 系统健康检查弹窗 -->
<VDialog
v-if="systemTestDialog"

View File

@@ -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'
@@ -20,6 +21,8 @@ import { themeManager } from '@/utils/themeManager'
const authStore = useAuthStore()
// 用户 Store
const userStore = useUserStore()
// 全局设置 Store
const globalSettingsStore = useGlobalSettingsStore()
// 国际化
const { t } = useI18n()
// 显示器
@@ -53,6 +56,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 +211,11 @@ function showSiteAuthDialog() {
siteAuthDialog.value = true
}
// 显示关于对话框
function showAboutDialog() {
aboutDialog.value = true
}
// 用户站点认证成功
function siteAuthDone() {
siteAuthDialog.value = false
@@ -217,6 +228,11 @@ 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
})
// 主题相关功能
const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
@@ -509,11 +525,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 -->
@@ -620,6 +642,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" />
@@ -764,6 +794,9 @@ onUnmounted(() => {
</VCardText>
</VCard>
</VDialog>
<!-- 关于对话框 -->
<AboutDialog v-if="aboutDialog" v-model="aboutDialog" @close="aboutDialog = false" />
</template>
<style lang="scss" scoped>

View File

@@ -49,6 +49,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',
@@ -321,11 +324,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 +367,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 +378,11 @@ export default {
addUser: 'Add User',
editUser: 'Edit User',
username: 'Username',
usernameHint: 'Username for system login',
password: 'Password',
passwordHint: 'Password for system login',
confirmPassword: 'Confirm Password',
confirmPasswordHint: 'Please enter the password again to confirm',
role: 'Role',
email: 'Email',
enabled: 'Enabled',
@@ -408,10 +411,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 +433,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 +451,18 @@ 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',
},
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 +470,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 +481,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 +510,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 +788,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',
@@ -850,6 +874,7 @@ export default {
batchEnableError: 'Batch enable operation failed',
batchPauseError: 'Batch pause operation failed',
batchDeleteError: 'Batch delete operation failed',
minSubscribers: 'Minimum Subscribers',
},
recommend: {
all: 'All',
@@ -1215,9 +1240,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 APIit 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',
@@ -1669,7 +1709,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',
@@ -1685,6 +1729,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!',
@@ -1694,6 +1740,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',
@@ -1766,8 +1814,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',
@@ -1788,9 +1840,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',
@@ -1866,6 +1916,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',
@@ -2621,6 +2673,9 @@ 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',
},
filterRule: {
title: 'Filter Rule',
@@ -2665,9 +2720,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: {
@@ -2701,6 +2762,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',
@@ -2830,7 +2894,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',
@@ -2872,4 +2938,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',
},
},
},
},
}

View File

@@ -49,6 +49,9 @@ export default {
itemsPerPage: '每页条数',
pageText: '{0}-{1} 共 {2} 条',
noDataText: '没有数据',
next: '下一步',
previous: '上一步',
skip: '跳过',
loadingText: '加载中...',
networkRequired: '此功能需要网络连接',
networkDisconnected: '网络连接已断开',
@@ -320,10 +323,6 @@ export default {
title: '通知',
description: '通知渠道微信、Telegram、Slack、SynologyChat、VoceChat、WebPush、消息发送范围',
},
words: {
title: '词表',
description: '自定义识别词、自定义制作组/字幕组、自定义占位符、文件整理屏蔽词',
},
about: {
title: '关于',
description: '软件版本',
@@ -367,8 +366,10 @@ export default {
deleteFailed: '用户删除失败!',
profile: '个人信息',
systemSettings: '系统设定',
wizardSettings: '设置向导',
siteAuth: '用户认证',
helpDocs: '帮助文档',
about: '关于',
restart: '重启',
management: '用户管理',
noUsers: '没有用户',
@@ -376,8 +377,11 @@ export default {
addUser: '添加用户',
editUser: '编辑用户',
username: '用户名',
usernameHint: '用于登录系统的用户名',
password: '密码',
passwordHint: '用于登录系统的密码',
confirmPassword: '确认密码',
confirmPasswordHint: '请再次输入密码以确认',
role: '角色',
email: '邮箱',
enabled: '启用',
@@ -406,10 +410,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 +431,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 +449,18 @@ 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: '频道名称不能为空',
},
synologychat: {
name: 'Synology Chat',
webhook: '机器人传入URL',
webhookHint: 'Synology Chat机器人传入URL',
webhookRequired: 'Webhook URL不能为空',
token: '令牌',
tokenHint: 'Synology Chat机器人令牌',
},
@@ -456,8 +468,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 +479,7 @@ export default {
name: 'WebPush',
username: '登录用户名',
usernameHint: '只有对应的用户登录后才会推送消息',
usernameRequired: '用户名不能为空',
},
},
shortcut: {
@@ -493,6 +508,14 @@ export default {
title: '消息',
subtitle: '消息中心',
},
words: {
title: '词表',
subtitle: '词表设置',
},
cache: {
title: '缓存',
subtitle: '管理缓存',
},
},
workflow: {
components: '动作组件',
@@ -763,6 +786,8 @@ export default {
originalTitle: '原始标题',
status: '状态',
releaseDate: '上映日期',
digitalRelease: '数字发行',
physicalRelease: '实体发行',
originalLanguage: '原始语言',
productionCountries: '出品国家',
productionCompanies: '制作公司',
@@ -846,6 +871,7 @@ export default {
batchEnableError: '批量启用操作失败',
batchPauseError: '批量暂停操作失败',
batchDeleteError: '批量删除操作失败',
minSubscribers: '最小订阅人数',
},
recommend: {
all: '全部',
@@ -1211,9 +1237,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: '下载器',
@@ -1648,7 +1688,9 @@ export default {
bestVersionRuleGroup: '洗版优先级规则组',
bestVersionRuleGroupHint: '按选定的过滤规则组对洗版订阅进行过滤',
timedSearch: '订阅定时搜索',
timedSearchHint: '每隔24小时全站搜索,以补全订阅可能漏掉的资源',
timedSearchHint: '每隔指定时间全站搜索,以补全订阅可能漏掉的资源',
searchInterval: '订阅搜索时间间隔',
searchIntervalHint: '设置订阅搜索的时间间隔,仅在开启订阅定时搜索时生效',
checkLocalMedia: '检查文件系统资源',
checkLocalMediaHint: '扫描存储目录中是否已存在相应资源文件,以避免重复下载;不管是否开启都会检查媒体服务器',
modes: {
@@ -1663,6 +1705,8 @@ export default {
hour1: '1小时',
hour12: '12小时',
day1: '1天',
day3: '3天',
week1: '一周',
},
saveSuccess: '订阅站点保存成功',
saveFailed: '订阅站点保存失败!',
@@ -1672,6 +1716,8 @@ export default {
cache: {
title: '缓存管理',
subtitle: '管理缓存的站点资源',
totalCount: '总条数',
siteCount: '站点数',
filterByTitle: '按标题筛选',
filterBySite: '按站点筛选',
selectSite: '选择站点',
@@ -1744,8 +1790,12 @@ export default {
add: '添加用户',
edit: '编辑用户',
username: '用户名',
usernameRequired: '用户名不能为空',
password: '密码',
passwordMinLength: '密码长度不能少于6位',
confirmPassword: '确认密码',
confirmPasswordRequired: '请确认密码',
passwordMismatch: '两次输入的密码不一致',
email: '邮箱',
nickname: '昵称',
status: '状态',
@@ -1766,9 +1816,7 @@ export default {
webPush: 'WebPush',
creatingUser: '正在创建【{name}】用户,请稍后',
updatingUser: '正在更新【{name}】用户,请稍后',
usernameRequired: '用户名不能为空',
usernameExists: '用户名已存在',
passwordMismatch: '两次输入的密码不一致',
userCreated: '用户【{name}】创建成功',
userCreateFailed: '创建用户失败:{message}',
userUpdateSuccess: '用户【{name}】更新成功',
@@ -1844,6 +1892,8 @@ export default {
startDownload: '开始下载',
downloadSuccess: '{site} {title} 下载成功!',
downloadFailed: '{site} {title} 下载失败:{message}',
showAdvancedOptions: '显示高级选项',
hideAdvancedOptions: '隐藏高级选项',
},
subscribeShare: {
shareSubscription: '分享订阅',
@@ -2591,6 +2641,9 @@ export default {
nameRequired: '不能为空,且不能重名',
nameDuplicate: '名称已存在',
defaultChanged: '存在默认下载器,已替换',
hostRequired: '地址不能为空',
usernameRequired: '用户名不能为空',
passwordRequired: '密码不能为空',
},
filterRule: {
title: '过滤规则',
@@ -2635,10 +2688,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: '类别',
@@ -2671,6 +2730,9 @@ export default {
firstAirDateAsc: '首播日期升序',
voteAverageDesc: '评分降序',
voteAverageAsc: '评分升序',
time: '最新',
count: '热门',
rating: '评分',
},
genreType: {
action: '动作',
@@ -2800,7 +2862,9 @@ export default {
libraryStorage: '媒体库存储',
libraryDirectory: '媒体库目录',
transferType: '整理方式',
transferTypeHint: '文件操作整理方式,硬链接节省空间,复制更安全',
overwriteMode: '覆盖模式',
overwriteModeHint: '当目标文件已存在时的处理方式',
smartRename: '智能重命名',
scrapingMetadata: '刮削元数据',
sendNotification: '发送通知',
@@ -2841,4 +2905,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: '优先免费资源,其它的没有要求',
},
},
},
},
}

View File

@@ -49,6 +49,9 @@ export default {
itemsPerPage: '每頁條數',
pageText: '{0}-{1} 共 {2} 條',
noDataText: '沒有數據',
next: '下一步',
previous: '上一步',
skip: '跳過',
loadingText: '加載中...',
networkRequired: '此功能需要網絡連接',
networkDisconnected: '網絡連接已斷開',
@@ -321,10 +324,6 @@ export default {
title: '通知',
description: '通知渠道微信、Telegram、Slack、SynologyChat、VoceChat、WebPush、消息發送範圍',
},
words: {
title: '詞表',
description: '自定義識別詞、自定義製作組/字幕組、自定義占位符、文件整理屏蔽詞',
},
about: {
title: '關於',
description: '軟件版本',
@@ -368,8 +367,10 @@ export default {
deleteFailed: '用戶刪除失敗!',
profile: '個人信息',
systemSettings: '系統設定',
wizardSettings: '設定向導',
siteAuth: '用戶認證',
helpDocs: '幫助文檔',
about: '關於',
restart: '重啟',
management: '用戶管理',
noUsers: '沒有用戶',
@@ -377,8 +378,11 @@ export default {
addUser: '添加用戶',
editUser: '編輯用戶',
username: '用戶名',
usernameHint: '用於登入系統的用戶名',
password: '密碼',
passwordHint: '用於登入系統的密碼',
confirmPassword: '確認密碼',
confirmPasswordHint: '請再次輸入密碼以確認',
role: '角色',
email: '郵箱',
enabled: '啟用',
@@ -491,6 +495,14 @@ export default {
title: '消息',
subtitle: '消息中心',
},
words: {
title: '詞表',
subtitle: '詞表設置',
},
cache: {
title: '緩存',
subtitle: '管理緩存',
},
},
workflow: {
components: '動作組件',
@@ -761,6 +773,8 @@ export default {
originalTitle: '原始標題',
status: '狀態',
releaseDate: '上映日期',
digitalRelease: '數位發行',
physicalRelease: '實體發行',
originalLanguage: '原始語言',
productionCountries: '出品國家',
productionCompanies: '製作公司',
@@ -844,6 +858,7 @@ export default {
batchEnableError: '批量啟用操作失敗',
batchPauseError: '批量暫停操作失敗',
batchDeleteError: '批量刪除操作失敗',
minSubscribers: '最小訂閱人數',
},
recommend: {
all: '全部',
@@ -1210,9 +1225,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: '下載器',
@@ -1646,7 +1675,9 @@ export default {
bestVersionRuleGroup: '洗版優先級規則組',
bestVersionRuleGroupHint: '按選定的過濾規則組對洗版訂閱進行過濾',
timedSearch: '訂閱定時搜索',
timedSearchHint: '每隔24小時全站搜索,以補全訂閱可能漏掉的資源',
timedSearchHint: '每隔指定時間全站搜索,以補全訂閱可能漏掉的資源',
searchInterval: '訂閱搜索時間間隔',
searchIntervalHint: '設置訂閱搜索的時間間隔,僅在開啟訂閱定時搜索時生效',
checkLocalMedia: '檢查文件系統資源',
checkLocalMediaHint: '掃描存儲目錄中是否已存在相應資源文件,以避免重複下載;不管是否開啟都會檢查媒體伺服器',
modes: {
@@ -1661,6 +1692,8 @@ export default {
hour1: '1小時',
hour12: '12小時',
day1: '1天',
day3: '3天',
week1: '一週',
},
saveSuccess: '訂閱站點保存成功',
saveFailed: '訂閱站點保存失敗!',
@@ -1743,8 +1776,12 @@ export default {
add: '添加用戶',
edit: '編輯用戶',
username: '用戶名',
usernameRequired: '用戶名不能為空',
password: '密碼',
passwordMinLength: '密碼長度不能少於6位',
confirmPassword: '確認密碼',
confirmPasswordRequired: '請確認密碼',
passwordMismatch: '兩次輸入的密碼不一致',
email: '郵箱',
nickname: '暱稱',
status: '狀態',
@@ -1765,9 +1802,7 @@ export default {
webPush: 'WebPush',
creatingUser: '正在創建【{name}】用戶,請稍後',
updatingUser: '正在更新【{name}】用戶,請稍後',
usernameRequired: '用戶名不能為空',
usernameExists: '用戶名已存在',
passwordMismatch: '兩次輸入的密碼不一致',
userCreated: '用戶【{name}】創建成功',
userCreateFailed: '創建用戶失敗:{message}',
userUpdateSuccess: '用戶【{name}】更新成功',
@@ -1843,6 +1878,8 @@ export default {
startDownload: '開始下載',
downloadSuccess: '{site} {title} 下載成功!',
downloadFailed: '{site} {title} 下載失敗:{message}',
showAdvancedOptions: '顯示高級選項',
hideAdvancedOptions: '隱藏高級選項',
},
subscribeShare: {
shareSubscription: '分享訂閱',
@@ -2590,6 +2627,9 @@ export default {
nameRequired: '名稱不能為空',
nameDuplicate: '名稱已存在',
defaultChanged: '存在預設下載器,已替換',
hostRequired: '地址不能為空',
usernameRequired: '用戶名不能為空',
passwordRequired: '密碼不能為空',
},
filterRule: {
title: '過濾規則',
@@ -2625,15 +2665,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: '只有選中的媒體庫才會被同步',
@@ -2670,6 +2716,9 @@ export default {
firstAirDateAsc: '首播日期升序',
voteAverageDesc: '評分降序',
voteAverageAsc: '評分升序',
time: '最新',
count: '熱門',
rating: '評分',
},
genreType: {
action: '動作',
@@ -2799,7 +2848,9 @@ export default {
libraryStorage: '媒體庫存儲',
libraryDirectory: '媒體庫目錄',
transferType: '轉移方式',
transferTypeHint: '文件操作整理方式,硬連結節省空間,複製更安全',
overwriteMode: '覆蓋模式',
overwriteModeHint: '當目標文件已存在時的處理方式',
smartRename: '智能重命名',
scrapingMetadata: '刮削元數據',
sendNotification: '發送通知',
@@ -2840,4 +2891,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: '優先免費資源,其它的沒有要求',
},
},
},
},
}

View File

@@ -117,12 +117,17 @@ async function subscribeForPushNotifications() {
// 登录后处理
async function afterLogin(superuser: boolean, userPayload: userState, filteredMenus: any[]) {
// 如果有原始路径,优先跳转到原始路径
if (authStore.originalPath && authStore.originalPath !== '/') {
router.push(authStore.originalPath)
// 如果需要显示设置向导,跳转到设置向导页面
if (userPayload.wizard) {
router.push('/setup-wizard')
} else {
// 跳转到第一个有权限的菜单
router.push(filteredMenus[0].to)
// 如果有原始路径,优先跳转到原始路径
if (authStore.originalPath && authStore.originalPath !== '/') {
router.push(authStore.originalPath)
} else {
// 跳转到第一个有权限的菜单
router.push(filteredMenus[0].to)
}
}
// 订阅推送通知
@@ -165,6 +170,7 @@ function login() {
avatar: response.avatar,
level: response.level,
permissions: response.permissions,
wizard: response.widzard,
}
// 在保存用户信息之前检查权限

View File

@@ -49,7 +49,7 @@ const dataList = ref<Array<Context>>([])
const isRefreshed = ref(false)
// 加载进度文本
const progressText = ref('')
const progressText = ref(t('common.pleaseWait'))
// 加载进度
const progressValue = ref(0)

View File

@@ -3,15 +3,12 @@ import { useRoute } from 'vue-router'
import router from '@/router'
import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'
import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
import AccountSettingWords from '@/views/setting/AccountSettingWords.vue'
import AccountSettingAbout from '@/views/setting/AccountSettingAbout.vue'
import AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue'
import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
import AccountSettingService from '@/views/setting/AccountSettingService.vue'
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
import AccountSettingCache from '@/views/setting/AccountSettingCache.vue'
import { getSettingTabs } from '@/router/i18n-menu'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
@@ -104,15 +101,6 @@ onMounted(() => {
</transition>
</VWindowItem>
<!-- 缓存 -->
<VWindowItem value="cache">
<transition name="fade-slide" appear>
<div>
<AccountSettingCache />
</div>
</transition>
</VWindowItem>
<!-- 通知 -->
<VWindowItem value="notification">
<transition name="fade-slide" appear>
@@ -121,24 +109,6 @@ onMounted(() => {
</div>
</transition>
</VWindowItem>
<!-- 词表 -->
<VWindowItem value="words">
<transition name="fade-slide" appear>
<div>
<AccountSettingWords />
</div>
</transition>
</VWindowItem>
<!-- 关于 -->
<VWindowItem value="about">
<transition name="fade-slide" appear>
<div>
<AccountSettingAbout />
</div>
</transition>
</VWindowItem>
</VWindow>
</div>
</template>

190
src/pages/setup.vue Normal file
View File

@@ -0,0 +1,190 @@
<script lang="ts" setup>
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSetupWizard } from '@/composables/useSetupWizard'
import BasicSettingsStep from '@/views/setup/BasicSettingsStep.vue'
import StorageSettingsStep from '@/views/setup/StorageSettingsStep.vue'
import DownloaderSettingsStep from '@/views/setup/DownloaderSettingsStep.vue'
import MediaServerSettingsStep from '@/views/setup/MediaServerSettingsStep.vue'
import NotificationSettingsStep from '@/views/setup/NotificationSettingsStep.vue'
import PreferencesSettingsStep from '@/views/setup/PreferencesSettingsStep.vue'
import ConnectivityTest from '@/views/setup/ConnectivityTest.vue'
import { useDisplay } from 'vuetify'
const { t } = useI18n()
const router = useRouter()
// 显示器宽度
const display = useDisplay()
const {
currentStep,
totalSteps,
stepTitles,
connectivityTest,
nextStep,
prevStep,
completeWizard,
initialize,
isLoading,
} = useSetupWizard()
// 初始化
onMounted(async () => {
await initialize()
})
</script>
<template>
<div class="setup-wizard-fullscreen">
<!-- 全屏头部 -->
<div class="setup-wizard-header">
<div class="d-flex align-center justify-space-between">
<!-- 左侧占位 -->
<div v-if="display.mdAndUp.value" style="inline-size: 96px"></div>
<!-- 中间标题 -->
<div class="d-flex align-center text-center">
<div>
<h1 class="text-h3 font-weight-bold text-moviepilot mb-3">{{ t('setupWizard.title') }}</h1>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.subtitle') }}</p>
</div>
</div>
<!-- 右侧按钮组 -->
<div v-if="display.mdAndUp.value" class="d-flex gap-2 px-3">
<VBtn
variant="text"
icon="mdi-cog"
@click="router.push('/setting')"
size="small"
class="text-medium-emphasis"
/>
<VBtn variant="text" icon="mdi-close" @click="router.push('/')" size="small" />
</div>
</div>
</div>
<!-- 向导内容 -->
<VCard max-width="800px" class="mx-auto my-5">
<VCardText class="px-1">
<!-- 加载状态 -->
<div v-if="isLoading" class="d-flex flex-column align-center justify-center py-16">
<VProgressCircular indeterminate color="primary" size="64" class="mb-4" />
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.loading') }}</p>
</div>
<!-- 使用 VStepper 组件 -->
<VStepper v-else v-model="currentStep" class="elevation-0" flat alt-labels :mobile="display.smAndDown.value">
<!-- 步骤标题 -->
<VStepperHeader class="elevation-0">
<template v-for="(step, index) in stepTitles" :key="index">
<VStepperItem
:value="index + 1"
:complete="currentStep > index + 1"
:color="currentStep >= index + 1 ? 'primary' : 'default'"
complete-icon="mdi-check-circle"
>
<template #title>
<span class="text-caption">{{ step }}</span>
</template>
</VStepperItem>
<VDivider v-if="index < stepTitles.length - 1" />
</template>
</VStepperHeader>
<!-- 步骤内容 -->
<VStepperWindow>
<!-- 步骤1基础参数 -->
<VStepperWindowItem :value="1">
<BasicSettingsStep />
</VStepperWindowItem>
<!-- 步骤2存储目录 -->
<VStepperWindowItem :value="2">
<StorageSettingsStep />
</VStepperWindowItem>
<!-- 步骤3下载器 -->
<VStepperWindowItem :value="3">
<DownloaderSettingsStep />
</VStepperWindowItem>
<!-- 步骤4媒体服务器 -->
<VStepperWindowItem :value="4">
<MediaServerSettingsStep />
</VStepperWindowItem>
<!-- 步骤5通知 -->
<VStepperWindowItem :value="5">
<NotificationSettingsStep />
</VStepperWindowItem>
<!-- 步骤6资源偏好 -->
<VStepperWindowItem :value="6">
<PreferencesSettingsStep />
</VStepperWindowItem>
</VStepperWindow>
<!-- 连通性测试进度条 -->
<ConnectivityTest />
<!-- 操作按钮 -->
<VCardActions class="justify-space-between">
<div class="d-flex gap-2">
<VBtn
v-if="currentStep !== 1"
prepend-icon="mdi-chevron-left"
@click="prevStep"
:disabled="connectivityTest.isTesting"
>
{{ t('common.previous') }}
</VBtn>
</div>
<div class="d-flex gap-2">
<VBtn
v-if="currentStep < totalSteps"
color="primary"
append-icon="mdi-chevron-right"
@click="nextStep"
:disabled="connectivityTest.isTesting"
>
{{ connectivityTest.isTesting ? t('setupWizard.testing') : t('common.next') }}
</VBtn>
<VBtn
v-else
color="success"
prepend-icon="mdi-check"
@click="completeWizard"
:disabled="connectivityTest.isTesting"
>
{{ t('setupWizard.complete') }}
</VBtn>
</div>
</VCardActions>
</VStepper>
</VCardText>
</VCard>
</div>
</template>
<style scoped>
.setup-wizard-fullscreen {
position: fixed;
background-color: rgb(var(--v-theme-background));
inset: 0;
overflow-y: auto;
}
.setup-wizard-header {
position: sticky;
z-index: 2000;
background-color: rgb(var(--v-theme-surface));
border-block-end: 1px solid rgb(var(--v-theme-outline-variant));
box-shadow: 0 0 5px rgba(0, 0, 0, 4%);
inset-block-start: 0;
padding-block: calc(16px + env(safe-area-inset-top)) 16px;
}
</style>

View File

@@ -1,8 +1,13 @@
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
// 构建路由菜单,每次调用时使用当前的语言环境
export function getNavMenus() {
const { t } = useI18n()
const globalSettingsStore = useGlobalSettingsStore()
// 检查是否为高级模式
const isAdvancedMode = globalSettingsStore.get('ADVANCED_MODE') !== false
return [
{
@@ -127,14 +132,18 @@ export function getNavMenus() {
admin: true,
permission: 'admin',
},
{
title: t('navItems.settings'),
icon: 'mdi-cog-outline',
to: '/setting',
header: t('menu.system'),
admin: true,
permission: 'admin',
},
...(isAdvancedMode
? [
{
title: t('navItems.settings'),
icon: 'mdi-cog-outline',
to: '/setting',
header: t('menu.system'),
admin: true,
permission: 'admin',
},
]
: []),
]
}
@@ -185,30 +194,12 @@ export function getSettingTabs() {
tab: 'scheduler',
description: t('settingTabs.scheduler.description'),
},
{
title: t('settingTabs.cache.title'),
icon: 'mdi-database',
tab: 'cache',
description: t('settingTabs.cache.description'),
},
{
title: t('settingTabs.notification.title'),
icon: 'mdi-bell',
tab: 'notification',
description: t('settingTabs.notification.description'),
},
{
title: t('settingTabs.words.title'),
icon: 'mdi-file-word-box',
tab: 'words',
description: t('settingTabs.words.description'),
},
{
title: t('settingTabs.about.title'),
icon: 'mdi-information',
tab: 'about',
description: t('settingTabs.about.description'),
},
]
}

View File

@@ -208,6 +208,13 @@ const router = createRouter({
path: 'login',
component: () => import('../pages/login.vue'),
},
{
path: 'setup-wizard',
component: () => import('../pages/setup.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/:pathMatch(.*)*',
component: () => import('../pages/[...all].vue'),

View File

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

View File

@@ -20,6 +20,8 @@ export interface userState {
level: number
// 权限
permissions: { [key: string]: any }
// 是否需要显示设置向导
wizard: boolean
}
export interface globalSettingsState {

View File

@@ -10,6 +10,7 @@ export const useUserStore = defineStore('user', {
avatar: '',
level: 1,
permissions: DEFAULT_PERMISSIONS,
wizard: false,
}),
// 全局持久化
@@ -34,6 +35,9 @@ export const useUserStore = defineStore('user', {
setPermissions(permissions: object) {
this.permissions = { ...DEFAULT_PERMISSIONS, ...permissions }
},
setWizard(wizard: boolean) {
this.wizard = wizard
},
loginUser(payload: userState) {
this.setSuperUser(payload.superUser)
this.setUserID(payload.userID)
@@ -41,6 +45,7 @@ export const useUserStore = defineStore('user', {
this.setAvatar(payload.avatar)
this.setLevel(payload.level)
this.setPermissions(payload.permissions)
this.setWizard(payload.wizard)
},
reset() {
this.setSuperUser(false)
@@ -49,6 +54,7 @@ export const useUserStore = defineStore('user', {
this.setAvatar('')
this.setLevel(1)
this.setPermissions(DEFAULT_PERMISSIONS)
this.setWizard(false)
},
},
@@ -59,5 +65,6 @@ export const useUserStore = defineStore('user', {
getAvatar: state => state.avatar,
getLevel: state => state.level,
getPermissions: state => state.permissions,
getWizard: state => state.wizard,
},
})

84
src/utils/imageUtils.ts Normal file
View File

@@ -0,0 +1,84 @@
/**
* 静态资源导入工具函数
* 用于在生产环境中正确引用静态资源
*/
// 导入所有 logo 图标
import qbittorrentLogo from '@/assets/images/logos/qbittorrent.png'
import transmissionLogo from '@/assets/images/logos/transmission.png'
import embyLogo from '@/assets/images/logos/emby.png'
import jellyfinLogo from '@/assets/images/logos/jellyfin.png'
import plexLogo from '@/assets/images/logos/plex.png'
import trimemediaLogo from '@/assets/images/logos/trimemedia.png'
import wechatLogo from '@/assets/images/logos/wechat.png'
import telegramLogo from '@/assets/images/logos/telegram.webp'
import slackLogo from '@/assets/images/logos/slack.webp'
import synologychatLogo from '@/assets/images/logos/synologychat.png'
import vocechatLogo from '@/assets/images/logos/vocechat.png'
import downloaderLogo from '@/assets/images/logos/downloader.png'
import mediaserverLogo from '@/assets/images/logos/mediaserver.png'
import notificationLogo from '@/assets/images/logos/notification.png'
import chromeLogo from '@/assets/images/logos/chrome.png'
import doubanLogo from '@/assets/images/logos/douban.png'
import githubLogo from '@/assets/images/logos/github.png'
import tmdbLogo from '@/assets/images/logos/tmdb.png'
import fanartLogo from '@/assets/images/logos/fanart.webp'
import pythonLogo from '@/assets/images/logos/python.png'
import pluginLogo from '@/assets/images/logos/plugin.png'
import siteLogo from '@/assets/images/logos/site.webp'
import bangumiLogo from '@/assets/images/logos/bangumi.png'
import doubanBlackLogo from '@/assets/images/logos/douban-black.png'
// 图标映射表
const logoMap: Record<string, string> = {
qbittorrent: qbittorrentLogo,
transmission: transmissionLogo,
emby: embyLogo,
jellyfin: jellyfinLogo,
plex: plexLogo,
trimemedia: trimemediaLogo,
wechat: wechatLogo,
telegram: telegramLogo,
slack: slackLogo,
synologychat: synologychatLogo,
vocechat: vocechatLogo,
downloader: downloaderLogo,
mediaserver: mediaserverLogo,
notification: notificationLogo,
chrome: chromeLogo,
douban: doubanLogo,
github: githubLogo,
tmdb: tmdbLogo,
fanart: fanartLogo,
python: pythonLogo,
plugin: pluginLogo,
site: siteLogo,
bangumi: bangumiLogo,
'douban-black': doubanBlackLogo,
}
/**
* 获取图标 URL
* @param logoName 图标名称
* @returns 图标的 URL
*/
export function getLogoUrl(logoName: string): string {
return logoMap[logoName] || ''
}
/**
* 获取所有可用的图标名称
* @returns 图标名称数组
*/
export function getAvailableLogos(): string[] {
return Object.keys(logoMap)
}
/**
* 检查图标是否存在
* @param logoName 图标名称
* @returns 是否存在
*/
export function hasLogo(logoName: string): boolean {
return logoName in logoMap
}

View File

@@ -428,6 +428,17 @@ const getProductionCompanies = computed(() => {
return mediaDetail.value.production_companies?.map(company => company.name)
})
// 获取最早实体/数字发行日期
const getEarliestReleaseDate = computed(() => {
const filteredDates = mediaDetail.value.release_dates?.filter(date => [4, 5].includes(date.type))
if (!filteredDates || filteredDates.length === 0)
return null
return filteredDates.reduce((earliest, current) =>
new Date(current.date) < new Date(earliest.date) ? current : earliest,
)
})
// 计算存在状态的颜色
function getExistColor(season: number) {
const state = seasonsNotExisted.value[season]
@@ -840,6 +851,17 @@ onBeforeMount(() => {
</span>
</span>
</div>
<div v-if="mediaDetail.type === '电影' && getEarliestReleaseDate" class="media-fact">
<span>{{ t(getEarliestReleaseDate.type === 4 ? 'media.info.digitalRelease' : 'media.info.physicalRelease') }}</span>
<span class="media-fact-value">
<span class="flex items-center justify-end">
<span class="inline-flex items-center justify-center h-4 w-4 text-[0.6rem] font-bold text-current border border-current leading-none">
{{ getEarliestReleaseDate.iso_code }}
</span>
<span class="ml-1.5">{{ getEarliestReleaseDate.date.slice(0, 10) }}</span>
</span>
</span>
</div>
<div v-if="mediaDetail.original_language" class="media-fact">
<span>{{ t('media.info.originalLanguage') }}</span>
<span class="media-fact-value">{{ mediaDetail.original_language }}</span>

View File

@@ -5,7 +5,7 @@ import api from '@/api'
import type { Plugin } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
import noImage from '@images/logos/plugin.png'
import { getLogoUrl } from '@/utils/imageUtils'
import { useDisplay } from 'vuetify'
import { isNullOrEmptyObject } from '@/@core/utils'
import { getPluginTabs } from '@/router/i18n-menu'
@@ -675,7 +675,7 @@ function pluginIconError(item: Plugin) {
// 插件图标地址
function pluginIcon(item: Plugin) {
// 如果图片加载错误
if (pluginIconLoaded.value[item.id || '0'] === false) return noImage
if (pluginIconLoaded.value[item.id || '0'] === false) return getLogoUrl('plugin')
// 如果是网络图片则使用代理后返回
if (item?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}&cache=true`

View File

@@ -1,364 +0,0 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 系统环境变量
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)
}
onMounted(() => {
querySystemEnv()
queryAllRelease()
querySupportingSites()
})
</script>
<template>
<div class="px-3">
<div class="section">
<div>
<h3 class="heading">{{ t('setting.about.title') }}</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.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.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 undefined">
<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 undefined"
><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 undefined">
<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 undefined">
<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 undefined">
<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 undefined">
<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>
<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>
</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>

View File

@@ -54,12 +54,20 @@ const rssIntervalItems = [
{ title: t('setting.subscribe.intervals.day1'), value: 1440 },
]
// 订阅搜索时间间隔选择项(小时)
const subscribeSearchIntervalItems = [
{ title: t('setting.subscribe.intervals.day1'), value: 24 },
{ title: t('setting.subscribe.intervals.day3'), value: 72 },
{ title: t('setting.subscribe.intervals.week1'), value: 168 },
]
// 系统设置项
const SystemSettings = ref<any>({
// 基础设置
Basic: {
SUBSCRIBE_MODE: 'auto',
SUBSCRIBE_SEARCH: false,
SUBSCRIBE_SEARCH_INTERVAL: 24,
SUBSCRIBE_RSS_INTERVAL: 30,
LOCAL_EXISTS_SEARCH: false,
},
@@ -252,6 +260,16 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.SUBSCRIBE_SEARCH" cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.SUBSCRIBE_SEARCH_INTERVAL"
:items="subscribeSearchIntervalItems"
:label="t('setting.subscribe.searchInterval')"
:hint="t('setting.subscribe.searchIntervalHint')"
persistent-hint
prepend-inner-icon="mdi-timer"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.LOCAL_EXISTS_SEARCH"

View File

@@ -31,6 +31,12 @@ const SystemSettings = ref<any>({
GITHUB_TOKEN: null,
OCR_HOST: null,
CUSTOMIZE_WALLPAPER_API_URL: null,
AI_AGENT_ENABLE: false,
AI_AGENT_GLOBAL: false,
LLM_PROVIDER: 'deepseek',
LLM_MODEL: 'deepseek-chat',
LLM_API_KEY: null,
LLM_BASE_URL: 'https://api.deepseek.com',
},
// 高级系统设置
Advanced: {
@@ -114,6 +120,10 @@ const progressDialog = ref(false)
// 高级设置对话框
const advancedDialog = ref(false)
// LLM 模型列表
const llmModels = ref<string[]>([])
const loadingModels = ref(false)
const activeTab = ref('system')
// 元数据语言
@@ -149,6 +159,30 @@ const logLevelItems = [
// 安全域名添加变量
const newSecurityDomain = ref('')
// 加载LLM模型列表
async function loadLlmModels() {
loadingModels.value = true
try {
const result: { [key: string]: any } = await api.get('system/llm-models', {
params: {
provider: SystemSettings.value.Basic.LLM_PROVIDER,
api_key: SystemSettings.value.Basic.LLM_API_KEY,
base_url: SystemSettings.value.Basic.LLM_BASE_URL,
},
})
if (result.success) {
llmModels.value = result.data
if (llmModels.value.length > 0) SystemSettings.value.Basic.LLM_MODEL = llmModels.value[0]
} else {
$toast.error(result.message)
}
} catch (error) {
console.log(error)
}
loadingModels.value = false
}
// 添加安全域名
function addSecurityDomain() {
if (
@@ -607,6 +641,82 @@ onDeactivated(() => {
/>
</VCol>
</VRow>
<VDivider class="my-4" />
<VRow>
<VCol cols="12">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
:label="t('setting.system.aiAgentEnable')"
:hint="t('setting.system.aiAgentEnableHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.LLM_PROVIDER"
:label="t('setting.system.llmProvider')"
:hint="t('setting.system.llmProviderHint')"
persistent-hint
:items="[
{ title: 'OpenAI', value: 'openai' },
{ title: 'Google', value: 'google' },
{ title: 'DeepSeek', value: 'deepseek' },
]"
prepend-inner-icon="mdi-robot"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VTextField
v-model="SystemSettings.Basic.LLM_BASE_URL"
:label="t('setting.system.llmBaseUrl')"
:hint="t('setting.system.llmBaseUrlHint')"
placeholder="https://api.deepseek.com"
persistent-hint
prepend-inner-icon="mdi-link"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VTextField
v-model="SystemSettings.Basic.LLM_API_KEY"
:label="t('setting.system.llmApiKey')"
:hint="t('setting.system.llmApiKeyHint')"
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
persistent-hint
type="password"
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VCombobox
v-model="SystemSettings.Basic.LLM_MODEL"
:label="t('setting.system.llmModel')"
:hint="t('setting.system.llmModelHint')"
:placeholder="t('setting.system.llmModelHint')"
persistent-hint
:items="llmModels"
:loading="loadingModels"
prepend-inner-icon="mdi-brain"
>
<template #append-inner>
<VBtn
variant="text"
icon="mdi-refresh"
size="small"
@click="loadLlmModels"
:disabled="!SystemSettings.Basic.LLM_API_KEY"
/>
</template>
</VCombobox>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
:label="t('setting.system.aiAgentGlobal')"
:hint="t('setting.system.aiAgentGlobalHint')"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>

View File

@@ -0,0 +1,160 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useSetupWizard } from '@/composables/useSetupWizard'
const { t } = useI18n()
const { wizardData, createRandomString, copyValue, validateCurrentStep } = useSetupWizard()
// 密码可见性控制
const isPasswordVisible = ref(false)
const isConfirmPasswordVisible = ref(false)
// 验证状态
const validation = computed(() => validateCurrentStep())
const hasErrors = computed(() => !validation.value.isValid)
// 密码相关验证
const passwordError = computed(() => {
if (!wizardData.value.basic.password) return false
return wizardData.value.basic.password.length < 6
})
const confirmPasswordError = computed(() => {
if (!wizardData.value.basic.password) return false
if (!wizardData.value.basic.confirmPassword) return true
return wizardData.value.basic.password !== wizardData.value.basic.confirmPassword
})
const passwordErrorMessage = computed(() => {
if (passwordError.value) return t('dialog.userAddEdit.passwordMinLength')
return ''
})
const confirmPasswordErrorMessage = computed(() => {
if (!wizardData.value.basic.password) return ''
if (!wizardData.value.basic.confirmPassword) return t('dialog.userAddEdit.confirmPasswordRequired')
if (confirmPasswordError.value) return t('dialog.userAddEdit.passwordMismatch')
return ''
})
// API Token验证
const apiTokenError = computed(() => {
return !wizardData.value.basic.apiToken && hasErrors.value
})
const apiTokenErrorMessage = computed(() => {
if (apiTokenError.value) return t('setupWizard.basic.apiTokenRequired')
return ''
})
// 用户名验证(虽然是只读的,但为了完整性)
const usernameError = computed(() => {
return !wizardData.value.basic.username && hasErrors.value
})
const usernameErrorMessage = computed(() => {
if (usernameError.value) return t('dialog.userAddEdit.usernameRequired')
return ''
})
</script>
<template>
<VCard variant="outlined">
<VCardText>
<div class="text-center mb-6">
<h3 class="text-h4 mb-2">{{ t('setupWizard.basic.title') }}</h3>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.basic.description') }}</p>
</div>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.appDomain"
:label="t('setupWizard.basic.appDomain')"
:hint="t('setupWizard.basic.appDomainHint')"
placeholder="http://localhost:3000"
persistent-hint
prepend-inner-icon="mdi-web"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.username"
:label="t('user.username')"
:hint="t('setupWizard.basic.currentUserHint')"
persistent-hint
prepend-inner-icon="mdi-account"
readonly
:error="usernameError"
:error-messages="usernameError ? [usernameErrorMessage] : []"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.password"
:type="isPasswordVisible ? 'text' : 'password'"
:label="t('user.password')"
:hint="t('setupWizard.basic.passwordOptionalHint')"
persistent-hint
prepend-inner-icon="mdi-lock"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
:error="passwordError"
:error-messages="passwordError ? [passwordErrorMessage] : []"
clearable
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.confirmPassword"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:label="t('user.confirmPassword')"
:hint="t('setupWizard.basic.confirmPasswordHint')"
persistent-hint
prepend-inner-icon="mdi-lock-check"
:append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
:disabled="!wizardData.basic.password"
:error="confirmPasswordError"
:error-messages="confirmPasswordError ? [confirmPasswordErrorMessage] : []"
clearable
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.proxyHost"
:label="t('setting.system.proxyHost')"
:hint="t('setting.system.proxyHostHint')"
placeholder="http://127.0.0.1:7890"
persistent-hint
prepend-inner-icon="mdi-server-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.githubToken"
:label="t('setting.system.githubToken')"
:placeholder="t('setting.system.githubTokenFormat')"
:hint="t('setting.system.githubTokenHint')"
persistent-hint
prepend-inner-icon="mdi-github"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.apiToken"
:label="t('setupWizard.basic.apiToken')"
:hint="t('setupWizard.basic.apiTokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
:append-inner-icon="wizardData.basic.apiToken ? 'mdi-content-copy' : 'mdi-reload'"
@click:append-inner="
wizardData.basic.apiToken ? copyValue(wizardData.basic.apiToken) : createRandomString()
"
:error="apiTokenError"
:error-messages="apiTokenError ? [apiTokenErrorMessage] : []"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,64 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useSetupWizard } from '@/composables/useSetupWizard'
const { t } = useI18n()
const { connectivityTest } = useSetupWizard()
</script>
<template>
<!-- 连通性测试进度条 -->
<VCard v-if="connectivityTest.isTesting || connectivityTest.showResult" variant="outlined" class="mx-4 mb-4">
<VCardText class="text-center py-4">
<!-- 测试中 -->
<div v-if="connectivityTest.isTesting">
<VIcon icon="mdi-cog-sync" class="rotating mb-2" color="primary" size="24" />
<div class="text-body-2 mb-2">{{ connectivityTest.testMessage }}</div>
<VProgressLinear
v-model="connectivityTest.testProgress"
color="primary"
height="6"
rounded
class="mb-2"
/>
<div class="text-caption text-medium-emphasis">{{ Math.round(connectivityTest.testProgress) }}%</div>
</div>
<!-- 测试结果 -->
<div v-else-if="connectivityTest.showResult">
<VIcon
:icon="connectivityTest.testResult === 'success' ? 'mdi-check-circle' : 'mdi-alert-circle'"
:color="connectivityTest.testResult === 'success' ? 'success' : 'error'"
size="24"
class="mb-2"
/>
<div
:class="connectivityTest.testResult === 'success' ? 'text-success' : 'text-error'"
class="text-body-2 mb-2 font-weight-medium"
>
{{ connectivityTest.testMessage }}
</div>
<div v-if="connectivityTest.testResult === 'error'" class="text-caption text-medium-emphasis">
{{ t('setupWizard.testFailedHint') }}
</div>
</div>
</VCardText>
</VCard>
</template>
<style scoped>
/* 旋转动画 */
.rotating {
animation: rotate 2s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,262 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useSetupWizard } from '@/composables/useSetupWizard'
import { getLogoUrl } from '@/utils/imageUtils'
const { t } = useI18n()
const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
</script>
<template>
<VCard variant="outlined">
<VCardText>
<div class="text-center mb-6">
<h3 class="text-h4 mb-2">{{ t('setupWizard.downloader.title') }}</h3>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.downloader.description') }}</p>
</div>
<VRow>
<VCol cols="12">
<VAlert type="info" variant="tonal" class="mb-4">
<VAlertTitle>{{ t('setupWizard.downloader.info') }}</VAlertTitle>
{{ t('setupWizard.downloader.infoDesc') }}
</VAlert>
</VCol>
<!-- 下载器选择 -->
<VCol cols="12">
<div class="mb-4">
<h4 class="text-h6 mb-4">{{ t('setupWizard.downloader.type') }}</h4>
<VRow>
<VCol cols="12" md="6">
<VCard
:color="wizardData.downloader.type === 'qbittorrent' ? 'primary' : 'default'"
:variant="wizardData.downloader.type === 'qbittorrent' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectDownloader('qbittorrent')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('qbittorrent')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">qBittorrent</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="6">
<VCard
:color="wizardData.downloader.type === 'transmission' ? 'primary' : 'default'"
:variant="wizardData.downloader.type === 'transmission' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectDownloader('transmission')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('transmission')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">Transmission</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</VCol>
<!-- 下载器配置 -->
<VCol v-if="wizardData.downloader.type" cols="12">
<VCard>
<VCardText>
<VForm>
<VRow v-if="wizardData.downloader.type === 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
:error="validationErrors.downloader.name"
:error-messages="validationErrors.downloader.name ? [t('downloader.nameRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
:error="validationErrors.downloader.host"
:error-messages="validationErrors.downloader.host ? [t('downloader.hostRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-server"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:error="validationErrors.downloader.username"
:error-messages="validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-account"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:error="validationErrors.downloader.password"
:error-messages="validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-lock"
required
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="wizardData.downloader.config.sequentail"
:label="t('downloader.sequentail')"
:hint="t('downloader.sequentail')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="wizardData.downloader.config.force_resume"
:label="t('downloader.force_resume')"
:hint="t('downloader.force_resume')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="wizardData.downloader.config.first_last_piece"
:label="t('downloader.first_last_piece')"
:hint="t('downloader.first_last_piece')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.downloader.type === 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
:error="validationErrors.downloader.name"
:error-messages="validationErrors.downloader.name ? [t('downloader.nameRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
:error="validationErrors.downloader.host"
:error-messages="validationErrors.downloader.host ? [t('downloader.hostRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-server"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:error="validationErrors.downloader.username"
:error-messages="validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-account"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:error="validationErrors.downloader.password"
:error-messages="validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-lock"
required
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.type"
:label="t('downloader.type')"
:hint="t('downloader.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.name"
:label="t('downloader.name')"
:hint="t('downloader.nameRequired')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>
<style scoped>
.cursor-pointer {
cursor: pointer;
transition: all 0.2s ease;
}
.cursor-pointer:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);
transform: translateY(-2px);
}
.cursor-pointer:active {
transform: translateY(0);
}
/* 选中状态的样式 */
.v-card--variant-tonal.v-theme--light {
border: 2px solid rgb(var(--v-theme-primary));
background-color: rgb(var(--v-theme-primary), 0.12);
}
.v-card--variant-tonal.v-theme--dark {
border: 2px solid rgb(var(--v-theme-primary));
background-color: rgb(var(--v-theme-primary), 0.2);
}
</style>

View File

@@ -0,0 +1,507 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useSetupWizard } from '@/composables/useSetupWizard'
import api from '@/api'
import { getLogoUrl } from '@/utils/imageUtils'
const { t } = useI18n()
const { wizardData, selectMediaServer, validationErrors } = useSetupWizard()
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: t('common.all'),
value: 'all',
},
])
// 调用API查询媒体库
async function loadLibrary(server: string) {
try {
console.log('Loading library for server:', server)
const result: any[] = await api.get('mediaserver/library', { params: { server } })
if (result && result.length > 0) {
librariesOptions.value = result.map(item => ({
title: item.name,
value: item.id?.toString(),
}))
console.log('Loaded libraries:', librariesOptions.value)
} else {
librariesOptions.value = []
console.log('No libraries found')
}
librariesOptions.value.unshift({
title: t('common.all'),
value: 'all',
})
} catch (e) {
console.log('Error loading library:', e)
}
}
// 选择媒体服务器并自动加载媒体库
async function selectMediaServerWithLibrary(type: string) {
selectMediaServer(type)
// 如果选择了媒体服务器类型,自动加载媒体库
if (type && wizardData.value.mediaServer.name) {
await loadLibrary(wizardData.value.mediaServer.name)
}
}
// 组件挂载时检查是否需要加载媒体库
onMounted(async () => {
// 如果已经有媒体服务器配置,自动加载媒体库
if (wizardData.value.mediaServer.type && wizardData.value.mediaServer.name) {
await loadLibrary(wizardData.value.mediaServer.name)
}
})
// 监听媒体服务器配置变化,自动加载媒体库
watch(
() => [wizardData.value.mediaServer.type, wizardData.value.mediaServer.name],
async ([type, name]) => {
console.log('Media server changed:', { type, name })
if (type && name) {
await loadLibrary(name)
}
},
{ immediate: true },
)
</script>
<template>
<VCard variant="outlined">
<VCardText>
<div class="text-center mb-6">
<h3 class="text-h4 mb-2">{{ t('setupWizard.mediaServer.title') }}</h3>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.mediaServer.description') }}</p>
</div>
<VRow>
<VCol cols="12">
<VAlert type="info" variant="tonal" class="mb-4">
<VAlertTitle>{{ t('setupWizard.mediaServer.info') }}</VAlertTitle>
{{ t('setupWizard.mediaServer.infoDesc') }}
</VAlert>
</VCol>
<!-- 媒体服务器选择 -->
<VCol cols="12">
<div class="mb-4">
<h4 class="text-h6 mb-4">{{ t('setupWizard.mediaServer.type') }}</h4>
<VRow>
<VCol cols="12" md="3">
<VCard
:color="wizardData.mediaServer.type === 'emby' ? 'primary' : 'default'"
:variant="wizardData.mediaServer.type === 'emby' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectMediaServerWithLibrary('emby')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('emby')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">Emby</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.mediaServer.type === 'jellyfin' ? 'primary' : 'default'"
:variant="wizardData.mediaServer.type === 'jellyfin' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectMediaServerWithLibrary('jellyfin')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('jellyfin')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">Jellyfin</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.mediaServer.type === 'plex' ? 'primary' : 'default'"
:variant="wizardData.mediaServer.type === 'plex' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectMediaServerWithLibrary('plex')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('plex')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">Plex</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.mediaServer.type === 'trimemedia' ? 'primary' : 'default'"
:variant="wizardData.mediaServer.type === 'trimemedia' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectMediaServerWithLibrary('trimemedia')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('trimemedia')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">飞牛影视</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</VCol>
<!-- 媒体服务器配置 -->
<VCol v-if="wizardData.mediaServer.type" cols="12">
<VCard>
<VCardText>
<VForm>
<VRow v-if="wizardData.mediaServer.type === 'emby'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
:error="validationErrors.mediaServer.name"
:error-messages="validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
:error="validationErrors.mediaServer.host"
:error-messages="validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-server"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.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="wizardData.mediaServer.config.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.embyApiKeyHint')"
:error="validationErrors.mediaServer.apikey"
:error-messages="validationErrors.mediaServer.apikey ? [t('mediaserver.apiKeyRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-key"
required
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="wizardData.mediaServer.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(wizardData.mediaServer.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.mediaServer.type === 'jellyfin'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
:error="validationErrors.mediaServer.name"
:error-messages="validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
:error="validationErrors.mediaServer.host"
:error-messages="validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-server"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.jellyfinApiKeyHint')"
:error="validationErrors.mediaServer.apikey"
:error-messages="validationErrors.mediaServer.apikey ? [t('mediaserver.apiKeyRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-key"
required
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="wizardData.mediaServer.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(wizardData.mediaServer.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.mediaServer.type === 'trimemedia'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
:error="validationErrors.mediaServer.name"
:error-messages="validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
:error="validationErrors.mediaServer.host"
:error-messages="validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-server"
required
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="wizardData.mediaServer.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.username"
:label="t('mediaserver.username')"
:error="validationErrors.mediaServer.username"
:error-messages="validationErrors.mediaServer.username ? [t('mediaserver.usernameRequired')] : []"
active
prepend-inner-icon="mdi-account"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="wizardData.mediaServer.config.password"
:label="t('mediaserver.password')"
:error="validationErrors.mediaServer.password"
:error-messages="validationErrors.mediaServer.password ? [t('mediaserver.passwordRequired')] : []"
active
prepend-inner-icon="mdi-lock"
required
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="wizardData.mediaServer.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(wizardData.mediaServer.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.mediaServer.type === 'plex'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
:error="validationErrors.mediaServer.name"
:error-messages="validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
:error="validationErrors.mediaServer.host"
:error-messages="validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-server"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.token"
:label="t('mediaserver.plexToken')"
:hint="t('mediaserver.plexTokenHint')"
:error="validationErrors.mediaServer.token"
:error-messages="validationErrors.mediaServer.token ? [t('mediaserver.tokenRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-key"
required
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="wizardData.mediaServer.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(wizardData.mediaServer.name)"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.type"
:label="t('mediaserver.type')"
:hint="t('mediaserver.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.name"
:label="t('common.name')"
:hint="t('mediaserver.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>
<style scoped>
.cursor-pointer {
cursor: pointer;
transition: all 0.2s ease;
}
.cursor-pointer:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);
transform: translateY(-2px);
}
.cursor-pointer:active {
transform: translateY(0);
}
/* 选中状态的样式 */
.v-card--variant-tonal.v-theme--light {
border: 2px solid rgb(var(--v-theme-primary));
background-color: rgb(var(--v-theme-primary), 0.12);
}
.v-card--variant-tonal.v-theme--dark {
border: 2px solid rgb(var(--v-theme-primary));
background-color: rgb(var(--v-theme-primary), 0.2);
}
</style>

View File

@@ -0,0 +1,553 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useSetupWizard } from '@/composables/useSetupWizard'
import { getLogoUrl } from '@/utils/imageUtils'
const { t } = useI18n()
const { wizardData, selectNotification, validationErrors } = useSetupWizard()
// 消息类型下拉字典
const notificationTypes = [
{ value: '资源下载', title: t('notificationSwitch.resourceDownload') },
{ value: '整理入库', title: t('notificationSwitch.organize') },
{ value: '订阅', title: t('notificationSwitch.subscribe') },
{ value: '站点', title: t('notificationSwitch.site') },
{ value: '媒体服务器', title: t('notificationSwitch.mediaServer') },
{ value: '手动处理', title: t('notificationSwitch.manual') },
{ value: '插件', title: t('notificationSwitch.plugin') },
{ value: '其它', title: t('notificationSwitch.other') },
]
</script>
<template>
<VCard variant="outlined">
<VCardText>
<div class="text-center mb-6">
<h3 class="text-h4 mb-2">{{ t('setupWizard.notification.title') }}</h3>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.notification.description') }}</p>
</div>
<VRow>
<VCol cols="12">
<VAlert type="info" variant="tonal" class="mb-4">
<VAlertTitle>{{ t('setupWizard.notification.info') }}</VAlertTitle>
{{ t('setupWizard.notification.infoDesc') }}
</VAlert>
</VCol>
<!-- 通知选择 -->
<VCol cols="12">
<div class="mb-4">
<h4 class="text-h6 mb-4">{{ t('setupWizard.notification.type') }}</h4>
<VRow>
<VCol cols="12" md="3">
<VCard
:color="wizardData.notification.type === 'wechat' ? 'primary' : 'default'"
:variant="wizardData.notification.type === 'wechat' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectNotification('wechat')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('wechat')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">微信</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.notification.type === 'telegram' ? 'primary' : 'default'"
:variant="wizardData.notification.type === 'telegram' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectNotification('telegram')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('telegram')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">Telegram</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.notification.type === 'slack' ? 'primary' : 'default'"
:variant="wizardData.notification.type === 'slack' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectNotification('slack')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('slack')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">Slack</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.notification.type === 'synologychat' ? 'primary' : 'default'"
:variant="wizardData.notification.type === 'synologychat' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectNotification('synologychat')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('synologychat')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">Synology Chat</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.notification.type === 'vocechat' ? 'primary' : 'default'"
:variant="wizardData.notification.type === 'vocechat' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectNotification('vocechat')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('vocechat')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">VoceChat</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.notification.type === 'webpush' ? 'primary' : 'default'"
:variant="wizardData.notification.type === 'webpush' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectNotification('webpush')"
>
<VCardText class="text-center">
<VIcon icon="mdi-apple-safari" size="48" class="mb-2" />
<div class="text-h6">WebPush</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</VCol>
<!-- 通知配置 -->
<VCol v-if="wizardData.notification.type" cols="12">
<VCard>
<VCardText>
<VForm>
<VRow>
<VCol cols="12">
<VAutocomplete
v-model="wizardData.notification.switchs"
:items="notificationTypes"
:label="t('notification.type')"
:hint="t('notification.typeHint')"
multiple
clearable
chips
persistent-hint
prepend-inner-icon="mdi-bell-outline"
/>
</VCol>
</VRow>
<VRow v-if="wizardData.notification.type === 'wechat'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
:error="validationErrors.notification.name"
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.WECHAT_CORPID"
:label="t('notification.wechat.corpId')"
:hint="t('notification.wechat.corpIdHint')"
:error="validationErrors.notification.WECHAT_CORPID"
:error-messages="
validationErrors.notification.WECHAT_CORPID ? [t('notification.wechat.corpIdRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-domain"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.WECHAT_APP_ID"
:label="t('notification.wechat.appId')"
:hint="t('notification.wechat.appIdHint')"
:error="validationErrors.notification.WECHAT_APP_ID"
:error-messages="
validationErrors.notification.WECHAT_APP_ID ? [t('notification.wechat.appIdRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-application"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.WECHAT_APP_SECRET"
:label="t('notification.wechat.appSecret')"
:hint="t('notification.wechat.appSecretHint')"
:error="validationErrors.notification.WECHAT_APP_SECRET"
:error-messages="
validationErrors.notification.WECHAT_APP_SECRET
? [t('notification.wechat.appSecretRequired')]
: []
"
persistent-hint
prepend-inner-icon="mdi-key"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.WECHAT_PROXY"
:label="t('notification.wechat.proxy')"
:hint="t('notification.wechat.proxyHint')"
persistent-hint
prepend-inner-icon="mdi-server-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.WECHAT_TOKEN"
:label="t('notification.wechat.token')"
:hint="t('notification.wechat.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.WECHAT_ENCODING_AESKEY"
:label="t('notification.wechat.encodingAesKey')"
:hint="t('notification.wechat.encodingAesKeyHint')"
persistent-hint
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.WECHAT_ADMINS"
:label="t('notification.wechat.admins')"
:placeholder="t('notification.wechat.adminsPlaceholder')"
:hint="t('notification.wechat.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.notification.type === 'telegram'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
:error="validationErrors.notification.name"
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.TELEGRAM_TOKEN"
:label="t('notification.telegram.token')"
:hint="t('notification.telegram.tokenHint')"
:error="validationErrors.notification.TELEGRAM_TOKEN"
:error-messages="
validationErrors.notification.TELEGRAM_TOKEN ? [t('notification.telegram.tokenRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-key"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.TELEGRAM_CHAT_ID"
:label="t('notification.telegram.chatId')"
:hint="t('notification.telegram.chatIdHint')"
:error="validationErrors.notification.TELEGRAM_CHAT_ID"
:error-messages="
validationErrors.notification.TELEGRAM_CHAT_ID
? [t('notification.telegram.chatIdRequired')]
: []
"
persistent-hint
prepend-inner-icon="mdi-chat"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.TELEGRAM_USERS"
:label="t('notification.telegram.users')"
:placeholder="t('notification.telegram.usersPlaceholder')"
:hint="t('notification.telegram.usersHint')"
persistent-hint
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.TELEGRAM_ADMINS"
:label="t('notification.telegram.admins')"
:placeholder="t('notification.telegram.adminsPlaceholder')"
:hint="t('notification.telegram.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.API_URL"
:label="t('notification.telegram.apiUrl')"
:placeholder="t('notification.telegram.apiUrlPlaceholder')"
:hint="t('notification.telegram.apiUrlHint')"
persistent-hint
prepend-inner-icon="mdi-web"
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.notification.type === 'slack'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
:error="validationErrors.notification.name"
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.SLACK_OAUTH_TOKEN"
:label="t('notification.slack.oauthToken')"
:placeholder="t('notification.slack.oauthTokenPlaceholder')"
:hint="t('notification.slack.oauthTokenHint')"
:error="validationErrors.notification.SLACK_OAUTH_TOKEN"
:error-messages="
validationErrors.notification.SLACK_OAUTH_TOKEN
? [t('notification.slack.oauthTokenRequired')]
: []
"
persistent-hint
prepend-inner-icon="mdi-key"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.SLACK_APP_TOKEN"
:label="t('notification.slack.appToken')"
:placeholder="t('notification.slack.appTokenPlaceholder')"
:hint="t('notification.slack.appTokenHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.SLACK_CHANNEL"
:label="t('notification.slack.channel')"
:placeholder="t('notification.slack.channelPlaceholder')"
:hint="t('notification.slack.channelHint')"
:error="validationErrors.notification.SLACK_CHANNEL"
:error-messages="
validationErrors.notification.SLACK_CHANNEL ? [t('notification.slack.channelRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-pound"
required
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.notification.type === 'synologychat'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
:error="validationErrors.notification.name"
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.SYNOLOGYCHAT_WEBHOOK"
:label="t('notification.synologychat.webhook')"
:hint="t('notification.synologychat.webhookHint')"
:error="validationErrors.notification.SYNOLOGYCHAT_WEBHOOK"
:error-messages="
validationErrors.notification.SYNOLOGYCHAT_WEBHOOK
? [t('notification.synologychat.webhookRequired')]
: []
"
persistent-hint
prepend-inner-icon="mdi-webhook"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.SYNOLOGYCHAT_TOKEN"
:label="t('notification.synologychat.token')"
:hint="t('notification.synologychat.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.notification.type === 'vocechat'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
:error="validationErrors.notification.name"
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.VOCECHAT_HOST"
:label="t('notification.vocechat.host')"
:hint="t('notification.vocechat.hostHint')"
:error="validationErrors.notification.VOCECHAT_HOST"
:error-messages="
validationErrors.notification.VOCECHAT_HOST ? [t('notification.vocechat.hostRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-server"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.VOCECHAT_API_KEY"
:label="t('notification.vocechat.apiKey')"
:hint="t('notification.vocechat.apiKeyHint')"
:error="validationErrors.notification.VOCECHAT_API_KEY"
:error-messages="
validationErrors.notification.VOCECHAT_API_KEY
? [t('notification.vocechat.apiKeyRequired')]
: []
"
persistent-hint
prepend-inner-icon="mdi-key"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.VOCECHAT_CHANNEL_ID"
:label="t('notification.vocechat.channelId')"
:placeholder="t('notification.vocechat.channelIdPlaceholder')"
:hint="t('notification.vocechat.channelIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.notification.type === 'webpush'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
:error="validationErrors.notification.name"
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.WEBPUSH_USERNAME"
:label="t('notification.webpush.username')"
:hint="t('notification.webpush.usernameHint')"
:error="validationErrors.notification.WEBPUSH_USERNAME"
:error-messages="
validationErrors.notification.WEBPUSH_USERNAME
? [t('notification.webpush.usernameRequired')]
: []
"
persistent-hint
prepend-inner-icon="mdi-account"
required
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.type"
:label="t('notification.type')"
:hint="t('notification.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.name"
:label="t('notification.name')"
:hint="t('notification.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>
<style scoped>
.cursor-pointer {
cursor: pointer;
transition: all 0.2s ease;
}
.cursor-pointer:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);
transform: translateY(-2px);
}
.cursor-pointer:active {
transform: translateY(0);
}
/* 选中状态的样式 */
.v-card--variant-tonal.v-theme--light {
border: 2px solid rgb(var(--v-theme-primary));
background-color: rgb(var(--v-theme-primary), 0.12);
}
.v-card--variant-tonal.v-theme--dark {
border: 2px solid rgb(var(--v-theme-primary));
background-color: rgb(var(--v-theme-primary), 0.2);
}
</style>

View File

@@ -0,0 +1,279 @@
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSetupWizard } from '@/composables/useSetupWizard'
import api from '@/api'
const { t } = useI18n()
const { updatePreferences } = useSetupWizard()
// 个性化选项
const personalizationOptions = ref({
excludeDolbyVision: true, // 排除杜比视界
excludeBluray: true, // 排除蓝光原盘
})
// 预设配置 - 使用多语言
const presetConfigs = computed(() => ({
'4k-enthusiast': {
name: t('setupWizard.preferences.presets.4k-enthusiast.name'),
description: t('setupWizard.preferences.presets.4k-enthusiast.description'),
icon: 'mdi-4k',
color: 'primary',
ruleString:
' SPECSUB & 4K & 60FPS & UHD & !BLU & !DOLBY > CNSUB & 4K & 60FPS & UHD & !BLU & !DOLBY > 4K & 60FPS & UHD & !BLU & !DOLBY > SPECSUB & 4K & UHD & !BLU & !DOLBY > CNSUB & 4K & UHD & !BLU & !DOLBY > 4K & UHD & !BLU & !DOLBY > SPECSUB & 4K & !BLU & !DOLBY > CNSUB & 4K & !BLU & !DOLBY > 4K & !BLU & !DOLBY ',
},
'balanced': {
name: t('setupWizard.preferences.presets.balanced.name'),
description: t('setupWizard.preferences.presets.balanced.description'),
icon: 'mdi-scale-unbalanced',
color: 'success',
ruleString:
' SPECSUB & 4K & !BLU & !DOLBY & !UHD & !60FPS > CNSUB & 4K & !BLU & !DOLBY & !REMUX & !60FPS > SPECSUB & 1080P & !BLU & !DOLBY & !60FPS & !UHD > CNSUB & 1080P & !BLU & !DOLBY & !UHD & !60FPS > 4K & BLU & !DOLBY & !UHD & !60FPS > 1080P & !BLU & !DOLBY & !UHD & !60FPS ',
},
'space-saver': {
name: t('setupWizard.preferences.presets.space-saver.name'),
description: t('setupWizard.preferences.presets.space-saver.description'),
icon: 'mdi-harddisk',
color: 'warning',
ruleString:
' SPECSUB & 1080P & !BLU & !UHD & !60FPS & !DOLBY > CNSUB & 1080P & !BLU & !UHD & !60FPS & !DOLBY > 1080P & !BLU & !UHD & !60FPS & !DOLBY > !BLU & !UHD & !60FPS & !DOLBY ',
},
'free-priority': {
name: t('setupWizard.preferences.presets.free-priority.name'),
description: t('setupWizard.preferences.presets.free-priority.description'),
icon: 'mdi-gift',
color: 'info',
ruleString:
' SPECSUB & FREE & !BLU & !DOLBY > CNSUB & FREE & !BLU & !DOLBY > FREE & !BLU & !DOLBY > !BLU & !DOLBY ',
},
}))
// 当前选中的预设
const selectedPreset = ref('')
// 加载用户当前的规则组设置
async function loadUserFilterRuleGroups() {
try {
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
if (result.success && result.data?.value && result.data.value.length > 0) {
const userRuleGroups = result.data.value
// 查找匹配的预设
for (const [presetKey, preset] of Object.entries(presetConfigs.value)) {
const matchingRule = userRuleGroups.find((rule: any) => rule.name === preset.name)
if (matchingRule) {
selectedPreset.value = presetKey
// 分析规则字符串,判断个性化选项
const ruleString = matchingRule.rule_string || ''
personalizationOptions.value.excludeDolbyVision = ruleString.includes('!DOLBY')
personalizationOptions.value.excludeBluray = ruleString.includes('!BLU')
// 更新向导数据
updateWizardData()
break
}
}
}
} catch (error) {
console.log('Load user filter rule groups failed:', error)
}
}
// 选择预设
function selectPreset(presetKey: string) {
if (selectedPreset.value === presetKey) {
// 如果再次点击同一个预设,则取消选择
selectedPreset.value = ''
return
}
selectedPreset.value = presetKey
updateWizardData()
}
// 生成规则序列的逻辑
const generateRuleSequences = computed(() => {
if (!selectedPreset.value) {
return []
}
const preset = presetConfigs.value[selectedPreset.value as keyof typeof presetConfigs.value]
if (!preset) {
return []
}
let ruleString = preset.ruleString
// 根据个性化选项调整规则
if (!personalizationOptions.value.excludeDolbyVision) {
// 移除所有 !DOLBY 条件
ruleString = ruleString.replace(/ & !DOLBY/g, '').replace(/!DOLBY & /g, '')
}
if (!personalizationOptions.value.excludeBluray) {
// 移除所有 !BLU 条件
ruleString = ruleString.replace(/ & !BLU/g, '').replace(/!BLU & /g, '')
}
return [
{
name: preset.name,
rule_string: ruleString,
media_type: '',
category: '',
},
]
})
// 监听偏好变化更新到wizardData
function updateWizardData() {
if (updatePreferences) {
updatePreferences(personalizationOptions.value, generateRuleSequences.value)
}
}
// 组件挂载时加载用户设置
onMounted(() => {
loadUserFilterRuleGroups()
})
</script>
<template>
<VCard variant="outlined">
<VCardText>
<div class="text-center mb-6">
<h3 class="text-h4 mb-2">{{ t('setupWizard.preferences.title') }}</h3>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.preferences.description') }}</p>
</div>
<!-- 快速预设 -->
<VCard class="mb-6">
<VCardTitle class="text-h6 d-flex align-center">
<VIcon icon="mdi-flash" class="me-2" />
{{ t('setupWizard.preferences.quickPresets') }}
</VCardTitle>
<VCardText>
<p class="text-body-2 text-medium-emphasis mb-4">{{ t('setupWizard.preferences.quickPresetsDesc') }}</p>
<VRow>
<VCol v-for="(preset, key) in presetConfigs" :key="key" cols="12" sm="6" md="3">
<VCard
:color="selectedPreset === key ? preset.color : 'default'"
:variant="selectedPreset === key ? 'tonal' : 'outlined'"
class="cursor-pointer preset-card"
@click="selectPreset(key)"
>
<VCardText class="text-center pa-4">
<VIcon :icon="preset.icon" size="40" class="mb-3" />
<div class="text-h6 mb-2">{{ preset.name }}</div>
<div class="text-body-2 text-medium-emphasis">{{ preset.description }}</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- 个性化选项 -->
<VCard class="mb-6">
<VCardTitle class="text-h6 d-flex align-center">
<VIcon icon="mdi-cog" class="me-2" />
{{ t('setupWizard.preferences.personalizationOptions') }}
</VCardTitle>
<VCardText>
<p class="text-body-2 text-medium-emphasis mb-4">
{{ t('setupWizard.preferences.personalizationOptionsDesc') }}
</p>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="personalizationOptions.excludeDolbyVision"
:label="t('setupWizard.preferences.excludeDolbyVision')"
color="primary"
hide-details
@change="updateWizardData"
/>
<p class="text-caption text-medium-emphasis mt-1">
{{ t('setupWizard.preferences.excludeDolbyVisionHint') }}
</p>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="personalizationOptions.excludeBluray"
:label="t('setupWizard.preferences.excludeBluray')"
color="primary"
hide-details
@change="updateWizardData"
/>
<p class="text-caption text-medium-emphasis mt-1">{{ t('setupWizard.preferences.excludeBlurayHint') }}</p>
</VCol>
</VRow>
</VCardText>
</VCard>
</VCardText>
</VCard>
</template>
<style scoped>
.cursor-pointer {
cursor: pointer;
transition: all 0.3s ease;
}
.preset-card:hover {
box-shadow: 0 8px 25px rgba(0, 0, 0, 15%);
transform: translateY(-4px);
}
.preset-card:active {
transform: translateY(-2px);
}
/* 预设卡片选中状态的样式 */
.v-card--variant-tonal.v-theme--light {
border: 2px solid rgb(var(--v-theme-primary));
background-color: rgb(var(--v-theme-primary), 0.12);
}
.v-card--variant-tonal.v-theme--dark {
border: 2px solid rgb(var(--v-theme-primary));
background-color: rgb(var(--v-theme-primary), 0.2);
}
/* 规则代码样式 */
.v-code {
padding: 12px;
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.3);
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
/* 展开面板样式 */
.v-expansion-panel-title {
font-weight: 500;
}
.v-expansion-panel-text {
padding-block-start: 16px;
}
/* 开关组件样式优化 */
.v-switch {
margin-block-end: 8px;
}
/* 芯片组样式 */
.v-chip-group {
gap: 8px;
}
.v-chip {
margin-block: 4px;
margin-inline: 0;
}
</style>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useSetupWizard } from '@/composables/useSetupWizard'
const { t } = useI18n()
const { wizardData, validateCurrentStep } = useSetupWizard()
// 验证状态
const validation = computed(() => validateCurrentStep())
const hasErrors = computed(() => !validation.value.isValid)
// 整理方式选项
const transferTypeItems = [
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
{ title: '复制', value: 'copy' },
{ title: '移动', value: 'move' },
]
// 覆盖模式选项
const overwriteModeItems = [
{ title: '从不覆盖', value: 'never' },
{ title: '总是覆盖', value: 'always' },
{ title: '按文件大小', value: 'size' },
{ title: '仅保留最新', value: 'latest' },
]
</script>
<template>
<VCard variant="outlined">
<VCardText>
<div class="text-center mb-6">
<h3 class="text-h4 mb-2">{{ t('setupWizard.storage.title') }}</h3>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.storage.description') }}</p>
</div>
<VRow>
<VCol cols="12">
<VAlert type="info" variant="tonal" class="mb-4">
<VAlertTitle>{{ t('setupWizard.storage.info') }}</VAlertTitle>
{{ t('setupWizard.storage.infoDesc') }}
</VAlert>
</VCol>
<VCol cols="12" md="6">
<VPathField
v-model="wizardData.storage.downloadPath"
:label="t('setupWizard.storage.downloadPath')"
:hint="t('setupWizard.storage.downloadPathHint')"
persistent-hint
prepend-inner-icon="mdi-download"
placeholder="/downloads"
:error="!wizardData.storage.downloadPath && hasErrors"
:error-messages="
!wizardData.storage.downloadPath && hasErrors ? [t('setupWizard.storage.downloadPathRequired')] : []
"
/>
</VCol>
<VCol cols="12" md="6">
<VPathField
v-model="wizardData.storage.libraryPath"
:label="t('setupWizard.storage.libraryPath')"
:hint="t('setupWizard.storage.libraryPathHint')"
persistent-hint
prepend-inner-icon="mdi-folder-multiple"
placeholder="/media"
:error="!wizardData.storage.libraryPath && hasErrors"
:error-messages="
!wizardData.storage.libraryPath && hasErrors ? [t('setupWizard.storage.libraryPathRequired')] : []
"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.storage.transferType"
:label="t('directory.transferType')"
:hint="t('directory.transferTypeHint')"
persistent-hint
:items="transferTypeItems"
prepend-inner-icon="mdi-swap-horizontal"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.storage.overwriteMode"
:label="t('directory.overwriteMode')"
:hint="t('directory.overwriteModeHint')"
persistent-hint
:items="overwriteModeItems"
prepend-inner-icon="mdi-file-replace"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>

View File

@@ -120,8 +120,9 @@ async function getSubscribes() {
loading.value = true
const subscribes: Subscribe[] = await api.get('subscribe/')
loading.value = false
const subEvents = await Promise.all(subscribes.map(async sub => eventsHander(sub)))
calendarOptions.value.events = subEvents.flat().filter(event => event.start) as EventSourceInput
const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub)))
const succEvents = subEvents.filter(result => result.status === 'fulfilled').map(result => result.value)
calendarOptions.value.events = succEvents.flat().filter(event => event.start) as EventSourceInput
isLoaded.value = true
} catch (error) {
console.error(error)

View File

@@ -34,13 +34,104 @@ const isRefreshed = ref(false)
const dataList = ref<MediaInfo[]>([])
const currData = ref<MediaInfo[]>([])
// 筛选参数
const filterParams = reactive({
genre_id: '', // 空字符串表示选中"全部"
min_rating: 0,
max_rating: 10,
min_sub: 1,
sort_type: 'count', // 默认按热度排序
})
// 当前Key用于重新加载数据
const currentKey = ref(0)
// TMDB电影风格字典
const tmdbMovieGenreDict: Record<string, string> = {
'28': t('tmdb.genreType.action'),
'12': t('tmdb.genreType.adventure'),
'16': t('tmdb.genreType.animation'),
'35': t('tmdb.genreType.comedy'),
'80': t('tmdb.genreType.crime'),
'99': t('tmdb.genreType.documentary'),
'18': t('tmdb.genreType.drama'),
'10751': t('tmdb.genreType.family'),
'14': t('tmdb.genreType.fantasy'),
'36': t('tmdb.genreType.history'),
'27': t('tmdb.genreType.horror'),
'10402': t('tmdb.genreType.music'),
'9648': t('tmdb.genreType.mystery'),
'10749': t('tmdb.genreType.romance'),
'878': t('tmdb.genreType.scienceFiction'),
'10770': t('tmdb.genreType.tvMovie'),
'53': t('tmdb.genreType.thriller'),
'10752': t('tmdb.genreType.war'),
'37': t('tmdb.genreType.western'),
}
// TMDB电视剧风格字典
const tmdbTvGenreDict: Record<string, string> = {
'10759': t('tmdb.genreType.actionAdventure'),
'16': t('tmdb.genreType.animation'),
'35': t('tmdb.genreType.comedy'),
'80': t('tmdb.genreType.crime'),
'99': t('tmdb.genreType.documentary'),
'18': t('tmdb.genreType.drama'),
'10751': t('tmdb.genreType.family'),
'10762': t('tmdb.genreType.kids'),
'9648': t('tmdb.genreType.mystery'),
'10763': t('tmdb.genreType.news'),
'10764': t('tmdb.genreType.reality'),
'10765': t('tmdb.genreType.sciFiFantasy'),
'10766': t('tmdb.genreType.soap'),
'10767': t('tmdb.genreType.talk'),
'10768': t('tmdb.genreType.warPolitics'),
'37': t('tmdb.genreType.western'),
}
// 获取当前类型对应的风格字典
const currentGenreDict = computed(() => {
return props.type === '电影' ? tmdbMovieGenreDict : tmdbTvGenreDict
})
// 监听筛选参数变化
watch(
filterParams,
() => {
// 重置数据
dataList.value = []
page.value = 1
isRefreshed.value = false
currentKey.value++
},
{ deep: true },
)
// 拼装参数
function getParams() {
let params = {
let params: { [key: string]: any } = {
stype: props.type,
page: page.value,
count: 30,
}
// 添加筛选参数
if (filterParams.genre_id) {
params.genre_id = parseInt(filterParams.genre_id)
}
if (filterParams.min_rating > 0) {
params.min_rating = filterParams.min_rating
}
if (filterParams.max_rating < 10) {
params.max_rating = filterParams.max_rating
}
if (filterParams.min_sub > 1) {
params.min_sub = filterParams.min_sub
}
if (filterParams.sort_type) {
params.sort_type = filterParams.sort_type
}
return params
}
@@ -110,8 +201,77 @@ async function fetchData({ done }: { done: any }) {
</script>
<template>
<!-- 筛选器 -->
<div class="px-3 mb-4">
<div class="flex justify-start align-center mb-3">
<div class="mr-5">
<VLabel>{{ t('tmdb.sort') }}</VLabel>
</div>
<VChipGroup v-model="filterParams.sort_type">
<VChip :color="filterParams.sort_type == 'time' ? 'primary' : ''" filter tile value="time">
{{ t('tmdb.sortType.time') }}
</VChip>
<VChip :color="filterParams.sort_type == 'count' ? 'primary' : ''" filter tile value="count">
{{ t('tmdb.sortType.count') }}
</VChip>
<VChip :color="filterParams.sort_type == 'rating' ? 'primary' : ''" filter tile value="rating">
{{ t('tmdb.sortType.rating') }}
</VChip>
</VChipGroup>
</div>
<div class="flex justify-start align-center mb-3">
<div class="mr-5">
<VLabel>{{ t('tmdb.genre') }}</VLabel>
</div>
<VChipGroup v-model="filterParams.genre_id">
<VChip
:color="filterParams.genre_id == '' ? 'primary' : ''"
filter
tile
value=""
>
{{ t('common.all') }}
</VChip>
<VChip
:color="filterParams.genre_id == key ? 'primary' : ''"
filter
tile
:value="key"
v-for="(value, key) in currentGenreDict"
:key="key"
>
{{ value }}
</VChip>
</VChipGroup>
</div>
<div class="flex justify-start align-center mb-3">
<div class="mr-5">
<VLabel>{{ t('tmdb.rating') }}</VLabel>
</div>
<VSlider
v-model="filterParams.min_rating"
thumb-label
max="10"
min="0"
:step="1"
class="align-center"
hide-details
>
</VSlider>
</div>
</div>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData">
<VInfiniteScroll
mode="intersect"
side="end"
:items="dataList"
class="overflow-visible px-2"
@load="fetchData"
:key="currentKey"
>
<template #loading />
<template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">

View File

@@ -28,6 +28,66 @@ const page = ref(1)
// 搜索关键字
const keyword = ref(props.keyword)
// 筛选参数
const filterParams = reactive({
genre_id: '', // 空字符串表示选中"全部"
min_rating: 0,
max_rating: 10,
sort_type: 'time', // 默认按时间排序
})
// 当前Key用于重新加载数据
const currentKey = ref(0)
// TMDB电影风格字典
const tmdbMovieGenreDict: Record<string, string> = {
'28': t('tmdb.genreType.action'),
'12': t('tmdb.genreType.adventure'),
'16': t('tmdb.genreType.animation'),
'35': t('tmdb.genreType.comedy'),
'80': t('tmdb.genreType.crime'),
'99': t('tmdb.genreType.documentary'),
'18': t('tmdb.genreType.drama'),
'10751': t('tmdb.genreType.family'),
'14': t('tmdb.genreType.fantasy'),
'36': t('tmdb.genreType.history'),
'27': t('tmdb.genreType.horror'),
'10402': t('tmdb.genreType.music'),
'9648': t('tmdb.genreType.mystery'),
'10749': t('tmdb.genreType.romance'),
'878': t('tmdb.genreType.scienceFiction'),
'10770': t('tmdb.genreType.tvMovie'),
'53': t('tmdb.genreType.thriller'),
'10752': t('tmdb.genreType.war'),
'37': t('tmdb.genreType.western'),
}
// TMDB电视剧风格字典
const tmdbTvGenreDict: Record<string, string> = {
'10759': t('tmdb.genreType.actionAdventure'),
'16': t('tmdb.genreType.animation'),
'35': t('tmdb.genreType.comedy'),
'80': t('tmdb.genreType.crime'),
'99': t('tmdb.genreType.documentary'),
'18': t('tmdb.genreType.drama'),
'10751': t('tmdb.genreType.family'),
'10762': t('tmdb.genreType.kids'),
'9648': t('tmdb.genreType.mystery'),
'10763': t('tmdb.genreType.news'),
'10764': t('tmdb.genreType.reality'),
'10765': t('tmdb.genreType.sciFiFantasy'),
'10766': t('tmdb.genreType.soap'),
'10767': t('tmdb.genreType.talk'),
'10768': t('tmdb.genreType.warPolitics'),
'37': t('tmdb.genreType.western'),
}
// 获取当前类型对应的风格字典(订阅分享包含电影和电视剧,所以显示所有风格)
const currentGenreDict = computed(() => {
// 合并电影和电视剧风格字典
return { ...tmdbMovieGenreDict, ...tmdbTvGenreDict }
})
// 监听 props.keyword 变化
watch(
() => props.keyword,
@@ -37,9 +97,23 @@ watch(
page.value = 1
dataList.value = []
isRefreshed.value = false
currentKey.value++
},
)
// 监听筛选参数变化
watch(
filterParams,
() => {
// 重置数据
dataList.value = []
page.value = 1
isRefreshed.value = false
currentKey.value++
},
{ deep: true },
)
// 是否加载中
const loading = ref(false)
@@ -52,11 +126,26 @@ const currData = ref<SubscribeShare[]>([])
// 拼装参数
function getParams() {
let params = {
let params: { [key: string]: any } = {
page: page.value,
count: 30,
name: keyword.value,
}
// 添加筛选参数
if (filterParams.genre_id) {
params.genre_id = parseInt(filterParams.genre_id)
}
if (filterParams.min_rating > 0) {
params.min_rating = filterParams.min_rating
}
if (filterParams.max_rating < 10) {
params.max_rating = filterParams.max_rating
}
if (filterParams.sort_type) {
params.sort_type = filterParams.sort_type
}
return params
}
@@ -131,9 +220,78 @@ function removeData(id: number) {
</script>
<template>
<!-- 筛选器 -->
<div class="px-3 mb-4">
<div class="flex justify-start align-center mb-3">
<div class="mr-5">
<VLabel>{{ t('tmdb.sort') }}</VLabel>
</div>
<VChipGroup v-model="filterParams.sort_type">
<VChip :color="filterParams.sort_type == 'time' ? 'primary' : ''" filter tile value="time">
{{ t('tmdb.sortType.time') }}
</VChip>
<VChip :color="filterParams.sort_type == 'count' ? 'primary' : ''" filter tile value="count">
{{ t('tmdb.sortType.count') }}
</VChip>
<VChip :color="filterParams.sort_type == 'rating' ? 'primary' : ''" filter tile value="rating">
{{ t('tmdb.sortType.rating') }}
</VChip>
</VChipGroup>
</div>
<div class="flex justify-start align-center mb-3">
<div class="mr-5">
<VLabel>{{ t('tmdb.genre') }}</VLabel>
</div>
<VChipGroup v-model="filterParams.genre_id">
<VChip
:color="filterParams.genre_id == '' ? 'primary' : ''"
filter
tile
value=""
>
{{ t('common.all') }}
</VChip>
<VChip
:color="filterParams.genre_id == key ? 'primary' : ''"
filter
tile
:value="key"
v-for="(value, key) in currentGenreDict"
:key="key"
>
{{ value }}
</VChip>
</VChipGroup>
</div>
<div class="flex justify-start align-center mb-3">
<div class="mr-5">
<VLabel>{{ t('tmdb.rating') }}</VLabel>
</div>
<VSlider
v-model="filterParams.min_rating"
thumb-label
max="10"
min="0"
:step="1"
class="align-center"
hide-details
>
</VSlider>
</div>
</div>
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}${keyword}`" />
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData">
<VInfiniteScroll
mode="intersect"
side="end"
:items="dataList"
class="overflow-visible px-2"
@load="fetchData"
:key="currentKey"
>
<template #loading />
<template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-subscribe-card" tabindex="0">

View File

@@ -232,64 +232,91 @@ onMounted(() => {
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>{{ t('setting.cache.title') }}</VCardTitle>
<VCardSubtitle>{{ t('setting.cache.subtitle') }}</VCardSubtitle>
<div>
<!-- 工具栏统计信息和操作按钮 -->
<VCard class="mb-4">
<VCardItem>
<!-- 移动端垂直布局桌面端水平布局 -->
<div class="d-flex flex-column flex-md-row align-center justify-space-between w-100 gap-4">
<!-- 左侧统计信息 -->
<div class="d-flex align-center justify-center justify-md-start gap-2 gap-md-6 w-100 w-md-auto">
<!-- 统计信息卡片 -->
<div class="d-flex gap-2 gap-md-4 flex-wrap justify-center justify-md-start">
<VCard variant="tonal" color="primary" class="pa-2 pa-md-3 flex-grow-1 flex-md-grow-0" style="min-width: 120px;">
<div class="d-flex align-center gap-2">
<VIcon color="primary" size="small">mdi-database</VIcon>
<div>
<div class="text-h6 text-md-h6 font-weight-bold">{{ cacheData.count }}</div>
<div class="text-caption text-medium-emphasis">{{ t('setting.cache.totalCount') }}</div>
</div>
</div>
</VCard>
<template #append>
<div class="d-flex gap-2">
<VBtn icon color="primary" :loading="loading" @click="refreshCache">
<VIcon>mdi-refresh</VIcon>
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refresh') }}</VTooltip>
</VBtn>
<VCard variant="tonal" color="success" class="pa-2 pa-md-3 flex-grow-1 flex-md-grow-0" style="min-width: 120px;">
<div class="d-flex align-center gap-2">
<VIcon color="success" size="small">mdi-web</VIcon>
<div>
<div class="text-h6 text-md-h6 font-weight-bold">{{ cacheData.sites }}</div>
<div class="text-caption text-medium-emphasis">{{ t('setting.cache.siteCount') }}</div>
</div>
</div>
</VCard>
</div>
</div>
<VBtn
icon
color="warning"
:loading="loading"
:disabled="selectedItems.length === 0"
@click="deleteSelectedItems"
>
<VIcon>mdi-delete-sweep</VIcon>
<VTooltip activator="parent" location="bottom"
>{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})</VTooltip
<!-- 右侧操作按钮 -->
<div class="d-flex gap-1 gap-md-2 flex-wrap justify-center justify-md-end">
<VBtn icon color="primary" :loading="loading" @click="refreshCache" size="small">
<VIcon size="small">mdi-refresh</VIcon>
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refresh') }}</VTooltip>
</VBtn>
<VBtn
icon
color="warning"
:loading="loading"
:disabled="selectedItems.length === 0"
@click="deleteSelectedItems"
size="small"
>
</VBtn>
<VIcon size="small">mdi-delete-sweep</VIcon>
<VTooltip activator="parent" location="bottom"
>{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})</VTooltip
>
</VBtn>
<VBtn icon color="error" :loading="loading" @click="clearAllCache">
<VIcon>mdi-delete-variant</VIcon>
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
</VBtn>
<VBtn icon color="error" :loading="loading" @click="clearAllCache" size="small">
<VIcon size="small">mdi-delete-variant</VIcon>
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
</VBtn>
</div>
</div>
</template>
</VCardItem>
</VCardItem>
</VCard>
<!-- 筛选框 -->
<VCardText>
<VRow>
<VCol cols="6">
<VTextField
v-model="titleFilter"
:label="t('setting.cache.filterByTitle')"
prepend-inner-icon="mdi-magnify"
clearable
density="compact"
/>
</VCol>
<VCol cols="6">
<VAutocomplete
v-model="siteFilter"
:label="t('setting.cache.filterBySite')"
:items="siteOptions"
prepend-inner-icon="mdi-web"
clearable
density="compact"
:placeholder="t('setting.cache.selectSite')"
/>
</VCol>
</VRow>
</VCardText>
<VRow class="mb-4">
<VCol cols="6">
<VTextField
v-model="titleFilter"
:label="t('setting.cache.filterByTitle')"
prepend-inner-icon="mdi-magnify"
clearable
density="compact"
/>
</VCol>
<VCol cols="6">
<VAutocomplete
v-model="siteFilter"
:label="t('setting.cache.filterBySite')"
:items="siteOptions"
prepend-inner-icon="mdi-web"
clearable
density="compact"
:placeholder="t('setting.cache.selectSite')"
/>
</VCol>
</VRow>
<!-- 缓存列表 -->
<VDataTable
@@ -419,54 +446,54 @@ onMounted(() => {
</div>
</template>
</VDataTable>
</VCard>
<!-- 重新识别对话框 -->
<VDialog v-model="reidentifyDialog" scrollable max-width="35rem">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon>mdi-text-recognition</VIcon>
</template>
<VCardTitle>{{ t('setting.cache.reidentifyDialog.title') }}</VCardTitle>
<VCardSubtitle>{{ currentReidentifyItem?.title }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="reidentifyDialog = false" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-if="globalSettings.RECOGNIZE_SOURCE === 'themoviedb'"
v-model="tmdbId"
:label="t('setting.cache.reidentifyDialog.tmdbId')"
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
clearable
prepend-inner-icon="mdi-id-card"
persistent-hint
/>
<VTextField
v-else
v-model="doubanId"
:label="t('setting.cache.reidentifyDialog.doubanId')"
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
clearable
prepend-inner-icon="mdi-id-card"
persistent-hint
/>
</VCol>
</VRow>
<VAlert type="info" variant="tonal" class="mt-4">
{{ t('setting.cache.reidentifyDialog.autoHint') }}
</VAlert>
</VCardText>
<!-- 重新识别对话框 -->
<VDialog v-model="reidentifyDialog" scrollable max-width="35rem">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon>mdi-text-recognition</VIcon>
</template>
<VCardTitle>{{ t('setting.cache.reidentifyDialog.title') }}</VCardTitle>
<VCardSubtitle>{{ currentReidentifyItem?.title }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="reidentifyDialog = false" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-if="globalSettings.RECOGNIZE_SOURCE === 'themoviedb'"
v-model="tmdbId"
:label="t('setting.cache.reidentifyDialog.tmdbId')"
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
clearable
prepend-inner-icon="mdi-id-card"
persistent-hint
/>
<VTextField
v-else
v-model="doubanId"
:label="t('setting.cache.reidentifyDialog.doubanId')"
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
clearable
prepend-inner-icon="mdi-id-card"
persistent-hint
/>
</VCol>
</VRow>
<VAlert type="info" variant="tonal" class="mt-4">
{{ t('setting.cache.reidentifyDialog.autoHint') }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" :loading="loading" prepend-icon="mdi-check" @click="performReidentify">
{{ t('setting.cache.reidentifyDialog.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VCardActions>
<VSpacer />
<VBtn color="primary" :loading="loading" prepend-icon="mdi-check" @click="performReidentify">
{{ t('setting.cache.reidentifyDialog.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,14 +1,7 @@
<script setup lang="ts">
import api from '@/api'
import douban from '@images/logos/douban.png'
import github from '@images/logos/github.png'
import slack from '@images/logos/slack.webp'
import telegram from '@images/logos/telegram.webp'
import tmdb from '@images/logos/tmdb.png'
import wechat from '@images/logos/wechat.png'
import fanart from '@images/logos/fanart.webp'
import { getLogoUrl } from '@/utils/imageUtils'
import tvdb from '@images/logos/thetvdb.jpeg'
import python from '@images/logos/python.png'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -36,7 +29,7 @@ interface Address {
// 测试集
const targets = ref<Address[]>([
{
image: tmdb,
image: getLogoUrl('tmdb'),
name: 'api.themoviedb.org',
url: 'https://api.themoviedb.org/3/movie/550?api_key={TMDBAPIKEY}',
proxy: true,
@@ -46,7 +39,7 @@ const targets = ref<Address[]>([
btndisable: false,
},
{
image: tmdb,
image: getLogoUrl('tmdb'),
name: 'api.tmdb.org',
url: 'https://api.tmdb.org/3/movie/550?api_key={TMDBAPIKEY}',
proxy: true,
@@ -56,7 +49,7 @@ const targets = ref<Address[]>([
btndisable: false,
},
{
image: tmdb,
image: getLogoUrl('tmdb'),
name: 'www.themoviedb.org',
url: 'https://www.themoviedb.org',
proxy: true,
@@ -76,7 +69,7 @@ const targets = ref<Address[]>([
btndisable: false,
},
{
image: fanart,
image: getLogoUrl('fanart'),
name: 'webservice.fanart.tv',
url: 'https://webservice.fanart.tv',
proxy: true,
@@ -86,7 +79,7 @@ const targets = ref<Address[]>([
btndisable: false,
},
{
image: telegram,
image: getLogoUrl('telegram'),
name: 'api.telegram.org',
url: 'https://api.telegram.org',
proxy: true,
@@ -96,7 +89,7 @@ const targets = ref<Address[]>([
btndisable: false,
},
{
image: wechat,
image: getLogoUrl('wechat'),
name: 'qyapi.weixin.qq.com',
url: 'https://qyapi.weixin.qq.com/cgi-bin/gettoken',
proxy: false,
@@ -106,7 +99,7 @@ const targets = ref<Address[]>([
btndisable: false,
},
{
image: douban,
image: getLogoUrl('douban'),
name: 'frodo.douban.com',
url: 'https://frodo.douban.com',
proxy: false,
@@ -116,7 +109,7 @@ const targets = ref<Address[]>([
btndisable: false,
},
{
image: slack,
image: getLogoUrl('slack'),
name: 'slack.com',
url: 'https://slack.com',
proxy: false,
@@ -126,7 +119,7 @@ const targets = ref<Address[]>([
btndisable: false,
},
{
image: python,
image: getLogoUrl('python'),
name: 'pypi.org',
url: '{PIP_PROXY}rsa/',
proxy: true,
@@ -137,7 +130,7 @@ const targets = ref<Address[]>([
include: 'pypi:repository-version',
},
{
image: github,
image: getLogoUrl('github'),
name: 'github.com',
url: '{GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/blob/v2/README.md',
proxy: true,
@@ -148,7 +141,7 @@ const targets = ref<Address[]>([
include: 'MoviePilot',
},
{
image: github,
image: getLogoUrl('github'),
name: 'codeload.github.com',
url: 'https://codeload.github.com',
proxy: true,
@@ -158,7 +151,7 @@ const targets = ref<Address[]>([
btndisable: false,
},
{
image: github,
image: getLogoUrl('github'),
name: 'api.github.com',
url: 'https://api.github.com',
proxy: true,
@@ -168,7 +161,7 @@ const targets = ref<Address[]>([
btndisable: false,
},
{
image: github,
image: getLogoUrl('github'),
name: 'raw.githubusercontent.com',
url: '{GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/README.md',
proxy: true,
@@ -188,7 +181,7 @@ const resolveStatusColor: Status = {
}
const abortControllers = new Set<AbortController>()
const isUnmounting = ref(false);
const isUnmounting = ref(false)
// 调用API测试网络连接
async function netTest(index: number) {
@@ -229,17 +222,16 @@ async function netTest(index: number) {
// 加载时测试所有连接
onMounted(async () => {
isUnmounting.value = false;
for (let i = 0; !isUnmounting.value && i < targets.value.length; i++)
await netTest(i)
isUnmounting.value = false
for (let i = 0; !isUnmounting.value && i < targets.value.length; i++) await netTest(i)
})
onBeforeUnmount(() => {
isUnmounting.value = true;
isUnmounting.value = true
for (const controller of abortControllers) {
controller.abort()
}
abortControllers.clear()
});
})
</script>
<template>

View File

@@ -149,7 +149,7 @@ export default defineConfig({
},
injectManifest: {
rollupFormat: 'iife',
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
},
devOptions: {
enabled: true,