Compare commits

..

70 Commits

Author SHA1 Message Date
jxxghp
9c893abcdf fix: 优化登录页面密码管理器自动填充 2026-03-30 12:56:17 +08:00
jxxghp
ead891ca2f 更新 AccountSettingSystem.vue 2026-03-27 22:22:17 +08:00
jxxghp
8713e3cc86 feat: Add AI agent verbose mode, rename scheduled wake setting to scheduled wake, and update system settings layout. 2026-03-27 20:59:53 +08:00
jxxghp
3cc83d10d3 refactor: Relocate scheduler service settings from the main settings page to a new dedicated system view accessible via the shortcut bar. 2026-03-25 13:37:59 +08:00
jxxghp
192ded374a feat:增加 AI_AGENT_JOB_INTERVAL 设置项 2026-03-25 13:06:44 +08:00
jxxghp
13997c7e74 Merge pull request #457 from wikrin/style/settings-ui 2026-03-20 21:30:54 +08:00
jxxghp
71b0dd4cc2 更新 package.json 2026-03-19 22:25:59 +08:00
Attente
a58a0cdffe refactor(AccountSettingSystem): 重构按钮图标结构样式 2026-03-19 21:47:52 +08:00
jxxghp
6aeb040db4 Merge pull request #456 from wikrin/refactor/scraping-switch-to-policy 2026-03-19 21:35:15 +08:00
Attente
fef20e361e refactor(setting): 更新刮削策略设置界面 2026-03-19 20:13:16 +08:00
jxxghp
a63a07701d 更新 package.json 2026-03-14 18:03:02 +08:00
jxxghp
5dd56f2db3 Merge pull request #455 from EkkoG/wechat_bot 2026-03-14 18:02:18 +08:00
EkkoG
275b095574 feat(wechat): implement AI bot mode configuration and localization updates
- Added functionality to enable AI bot mode for WeChat notifications, including default configuration settings.
- Introduced new input fields for bot-specific settings such as Bot ID, Bot Secret, and WebSocket URL.
- Updated localization files for English, Simplified Chinese, and Traditional Chinese to include new bot-related labels and hints.
2026-03-14 16:18:02 +08:00
jxxghp
05eae71fba Merge pull request #454 from YuF-9468/fix-issue-438-episode-zero 2026-03-13 22:39:25 +08:00
YuF-9468
777b3c9445 refactor(media): remove any cast for episode count fields 2026-03-13 15:31:24 +08:00
YuF-9468
a214168b1e fix(media): avoid false in-library badge for TV seasons with zero episodes 2026-03-13 14:38:58 +08:00
jxxghp
9d55d02557 Merge pull request #453 from DDSRem-Dev/v2 2026-03-11 15:28:30 +08:00
DDSRem
16c084ba80 fix(plugin): build remote entry URL with origin+pathname to fix subpath proxy 404
- Use pathBase (pathname) when building remoteEntry URL so it matches API request base
- Fixes plugin static assets 404 when app is under subpath (e.g. /mp/)

Made-with: Cursor
2026-03-11 15:12:42 +08:00
jxxghp
b0f4ccc186 Merge pull request #451 from WongWang/feat-plugin-priority 2026-03-10 12:55:00 +08:00
jxxghp
96d0606b4d chore: bump version to 2.9.14 2026-03-08 08:52:53 +08:00
jxxghp
450b9ec28a feat: Add QQ logo for qqbot notifications, update Ugreen logo to PNG, and adjust VImg styling in media server card. 2026-03-08 08:52:29 +08:00
jxxghp
2ccf03fc1b Merge pull request #452 from EkkoG/qqbot 2026-03-08 07:51:02 +08:00
EkkoG
38dfb3af07 feat: add QQ notification channel support with validation and localization 2026-03-07 23:21:09 +08:00
Castell
ae4c59bfdb feat: 新增优先使用插件识别的功能 2026-03-02 21:04:43 +08:00
jxxghp
c9f4fdbee8 Merge pull request #450 from baozaodetudou/v2 2026-03-01 08:08:35 +08:00
doumao
d21f461dda Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2026-02-28 22:58:29 +08:00
doumao
28a5a83315 feat: 前端支持绿联SSL证书校验开关配置 2026-02-28 22:54:27 +08:00
jxxghp
11d11b88bf Merge pull request #449 from baozaodetudou/v2 2026-02-28 22:45:12 +08:00
doumao
ff7658b5ba feat: 完成绿联媒体服务前端接入与展示优化 2026-02-28 22:09:09 +08:00
jxxghp
351faf2891 更新 package.json 2026-02-28 12:52:34 +08:00
jxxghp
7d66229bad Merge pull request #448 from wumode/fix-progress-displaying 2026-02-28 12:34:17 +08:00
wumode
2b08be1e7d fix(reorganize): add progress tracking for log transfer 2026-02-28 01:17:18 +08:00
wumode
8255cfd479 fix(reorganize): dynamically update progress SSE connection based on item path 2026-02-27 16:20:04 +08:00
jxxghp
f356bb4407 更新 package.json 2026-02-22 16:11:33 +08:00
jxxghp
07e60291a2 Merge pull request #446 from DDSRem-Dev/rtorrent 2026-02-22 16:11:12 +08:00
DDSRem
2dbe8e6685 feat(downloader): add rTorrent UI support
Add rTorrent as a downloader option in settings, setup wizard, and
downloader card with config form (host, username, password) and
ruTorrent logo. Include i18n translations for zh-CN, zh-TW, and en-US.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:12:56 +08:00
jxxghp
40f36b2afd Merge pull request #445 from cddjr/feat/media_card_list_deduplication 2026-02-17 07:15:17 +08:00
景大侠
d4260d5103 fix(types): 移除由提交 1dab013 引入的 episode_group 重复定义 2026-02-13 22:10:28 +08:00
景大侠
45f68bc936 feat(MediaCardListView): 添加去重逻辑以防止重复项 2026-02-13 19:08:20 +08:00
jxxghp
9469074837 v2.9.11 2026-02-12 07:01:38 +08:00
jxxghp
193807bb6f Merge pull request #444 from cddjr/fix_s0 2026-02-06 12:48:57 +08:00
景大侠
d4548db5b9 fix: 完善几处S00订阅相关的问题 2026-02-06 12:48:32 +08:00
jxxghp
29aaea6fe6 更新 package.json 2026-02-06 12:22:59 +08:00
jxxghp
369cc6438f Merge pull request #443 from jxxghp/copilot/fix-season-null-value 2026-02-06 12:22:30 +08:00
copilot-swe-agent[bot]
d80b39c77b Add package-lock.json to .gitignore and remove it from repo
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-02-06 04:07:50 +00:00
copilot-swe-agent[bot]
626725a8ca Fix: pass null instead of 0 for season parameter when subscribing to movies
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-02-06 04:07:13 +00:00
copilot-swe-agent[bot]
8be96358ae Initial plan 2026-02-06 04:01:58 +00:00
jxxghp
f2bfbfa3c5 更新 package.json 2026-02-02 12:36:27 +08:00
jxxghp
7c9ffd6abc Merge pull request #442 from CHANTXU64/v2 2026-02-02 12:36:11 +08:00
CHANTXU64
b370354287 fix: TMDB 剧集详情页支持显示第 0 季(特别篇)并将其排序到末尾 (jxxghp/MoviePilot#5444) 2026-02-02 10:20:40 +08:00
jxxghp
145d71e283 Merge pull request #441 from cddjr/feat_group_select 2026-01-31 13:28:40 +08:00
景大侠
eeea82d815 feat(TransferHistory): 增加分组选择功能 2026-01-31 11:29:03 +08:00
jxxghp
babd267bc4 更新 package.json 2026-01-29 22:22:42 +08:00
jxxghp
e136c931ac Merge pull request #440 from DDSRem-Dev/v2 2026-01-29 22:22:03 +08:00
DDSRem
ae00602345 feat: u115 support oauth 2026-01-29 21:49:17 +08:00
jxxghp
5382108ee7 Merge pull request #439 from DemoJameson/fix-nickname-2 2026-01-29 11:58:39 +08:00
DemoJameson
514063d3fb fix: 删除昵称后保存无效 2026-01-29 11:31:51 +08:00
jxxghp
b08f396fec Merge pull request #437 from DemoJameson/fix-nickname 2026-01-26 18:42:10 +08:00
DemoJameson
d37a7f06f1 fix: 个人信息页面不显示昵称 2026-01-26 13:34:22 +08:00
jxxghp
ad7bca3aae feat: 更新了分类配置的API端点,并为对话框添加了小屏幕全屏显示功能。 2026-01-26 12:52:46 +08:00
jxxghp
4fb70ba80e 更新 package.json 2026-01-25 15:57:11 +08:00
jxxghp
1225b2eb9e Merge pull request #435 from z-henry/v2
修复:按照指定剧集组订阅,日历读取不到对应的日期
2026-01-25 15:50:01 +08:00
jxxghp
24b2f103b9 Merge branch 'v2' into v2 2026-01-25 15:49:15 +08:00
jxxghp
0d304b58ca feat:二级分类设置界面 2026-01-25 09:40:50 +08:00
HenryZZZZZ
f419dbd794 Merge branch 'jxxghp:v2' into v2 2026-01-24 16:04:45 +08:00
jxxghp
7854cc81a8 fix message ui 2026-01-24 11:52:49 +08:00
jxxghp
9ad1bd29bd fix markdown ui 2026-01-23 22:46:25 +08:00
jxxghp
b88d4f0ecb feat:LLM上下文窗口设置 2026-01-23 22:35:03 +08:00
HenryZZZZZ
44168b62d2 Merge pull request #1 from z-henry/codex/update-api-call-for-episode-group
Add episode_group query param for TMDB season episode requests
2026-01-23 11:09:50 +08:00
HenryZZZZZ
1dab013436 Add episode_group param to TMDB episode requests 2026-01-23 11:08:55 +08:00
42 changed files with 2322 additions and 527 deletions

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ dist
dist-ssr
dev-dist
*.local
package-lock.json
/cypress/videos/
/cypress/screenshots/

View File

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

View File

@@ -52,6 +52,10 @@ export const downloaderOptions = [
value: 'transmission',
title: i18n.global.t('setting.system.transmission'),
},
{
value: 'rtorrent',
title: i18n.global.t('setting.system.rtorrent'),
},
]
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
@@ -76,6 +80,10 @@ export const mediaServerOptions = [
value: 'trimemedia',
title: i18n.global.t('setting.system.trimeMedia'),
},
{
value: 'ugreen',
title: i18n.global.t('setting.system.ugreen'),
},
]
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {

View File

@@ -885,8 +885,8 @@ export interface MediaStatistic {
movie_count: number
// 电视剧总数
tv_count: number
// 电视剧总集数
episode_count: number
// 电视剧总集数,未获取时为 null
episode_count: number | null
// 用户数量
user_count: number
}
@@ -1134,7 +1134,7 @@ export interface StorageConf {
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/jellyfin/plex
// 类型 emby/jellyfin/plex/trimemedia/ugreen
type: string
// 配置
config: { [key: string]: any }
@@ -1445,4 +1445,19 @@ export interface ApiResponse<T = any> {
success: boolean
message?: string
data: T
}
}
// 分类规则
export interface CategoryRule {
genre_ids?: string
original_language?: string
production_countries?: string
origin_country?: string
release_year?: string
}
// 分类配置
export interface CategoryConfig {
movie?: { [key: string]: CategoryRule }
tv?: { [key: string]: CategoryRule }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
@@ -10,12 +11,18 @@ const props = defineProps({
// 图片是否加载完成
const imageLoaded = ref(false)
const imageLoadError = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 图片加载失败响应
function imageErrorHandler() {
imageLoadError.value = true
}
// 跳转播放
async function goPlay() {
if (props.media?.link) {
@@ -26,6 +33,7 @@ async function goPlay() {
// 计算图片地址
const getImgUrl = computed(() => {
const image = props.media?.image || ''
if (!image || imageLoadError.value) return noImage
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) {
@@ -50,7 +58,7 @@ const getImgUrl = computed(() => {
@click="goPlay"
>
<template #image>
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler">
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />

View File

@@ -206,6 +206,8 @@ const getIcon = computed(() => {
return getLogoUrl('qbittorrent')
case 'transmission':
return getLogoUrl('transmission')
case 'rtorrent':
return getLogoUrl('rtorrent')
default:
return getLogoUrl('downloader')
}
@@ -443,6 +445,51 @@ onUnmounted(() => {
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port/RPC2"
:hint="t('downloader.rtorrentHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField

View File

@@ -33,6 +33,7 @@ function imageLoadHandler() {
// 图片加载错误
function imageErrorHandler() {
imageError.value = true
imgUrl.value = getDefaultImage()
}
// 默认图片
@@ -41,6 +42,7 @@ function getDefaultImage() {
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 getLogoUrl('trimemedia')
else if (props.media?.server_type === 'ugreen') return getLogoUrl('ugreen')
else return plex
}
@@ -53,7 +55,7 @@ async function goPlay() {
// 生成图片代理路径
function getImgUrl(url: string, use_cookies?: boolean) {
if (!url) return getDefaultImage()
if (!url || imageError.value) return getDefaultImage()
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
if (use_cookies) {
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
@@ -64,7 +66,7 @@ function getImgUrl(url: string, use_cookies?: boolean) {
// 根据多张图片生成媒体库封面
async function drawImages(imageList: string[], use_cookies?: boolean) {
// 图片
const IMAGES = imageList
const IMAGES = [...imageList]
if (IMAGES.length === 0) return getDefaultImage()
// 为所有图片添加system/img前缀

View File

@@ -18,9 +18,14 @@ import { hasPermission } from '@/utils/permission'
// 国际化
const { t } = useI18n()
interface MediaCardMedia extends MediaInfo {
total_episode?: number
episode_count?: number
}
// 输入参数
const props = defineProps({
media: Object as PropType<MediaInfo>,
media: Object as PropType<MediaCardMedia>,
width: String,
height: String,
})
@@ -138,7 +143,7 @@ async function handleAddSubscribe() {
}
// 调用API添加订阅电视剧的话需要指定季
async function addSubscribe(season: number = 0, best_version: number = 0) {
async function addSubscribe(season: number | null = null, best_version: number = 0) {
// 开始处理
startNProgress()
try {
@@ -153,7 +158,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
doubanid: props.media?.douban_id,
bangumiid: props.media?.bangumi_id,
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
season,
season: props.media?.type === '电影' ? null : season,
best_version,
episode_group: episodeGroup.value,
})
@@ -183,8 +188,8 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
}
// 弹出添加订阅提示
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
if (season) title = `${title} ${formatSeason(season.toString())}`
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
let subname = t('subscribe.normalSub')
if (best_version > 0) subname = t('subscribe.versionSub')
@@ -222,7 +227,7 @@ async function removeSubscribe() {
// 查询当前媒体是否已订阅
async function handleCheckSubscribe() {
try {
const result = await checkSubscribe(props.media?.season)
const result = await checkSubscribe(props.media?.season ?? null)
if (result) isSubscribed.value = true
} catch (error) {
console.error(error)
@@ -232,6 +237,14 @@ async function handleCheckSubscribe() {
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
// 对于总集数为 0 的电视剧季TMDB 未返回有效集数),不展示“已入库”角标,避免误判
const totalEpisode = props.media?.total_episode ?? props.media?.episode_count ?? props.media?.number_of_episodes ?? 0
if (props.media?.type === '电视剧' && totalEpisode === 0) {
isExists.value = false
return
}
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
@@ -249,7 +262,7 @@ async function handleCheckExists() {
}
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) {
async function checkSubscribe(season: number | null) {
try {
// AbortController 现在由全局请求优化器自动管理
const mediaid = getMediaId()
@@ -300,7 +313,7 @@ function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number
if (season && props.media?.tmdb_id)
// 全部存在时洗版
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
addSubscribe(season.season_number, best_version)
addSubscribe(season.season_number ?? null, best_version)
})
}

View File

@@ -61,6 +61,12 @@ const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
},
])
const ugreenScanModeOptions = computed(() => [
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
])
// 媒体服务器详情弹窗
const mediaServerInfoDialog = ref(false)
@@ -77,6 +83,15 @@ function openMediaServerInfoDialog() {
loadLibrary(props.mediaserver.name)
// 深复制
mediaServerInfo.value = cloneDeep(props.mediaserver)
if (mediaServerInfo.value.type === 'ugreen') {
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
if (!mediaServerInfo.value.config.scan_mode) {
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
}
if (mediaServerInfo.value.config.verify_ssl === undefined) {
mediaServerInfo.value.config.verify_ssl = true
}
}
mediaServerInfoDialog.value = true
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
@@ -110,6 +125,8 @@ const getIcon = computed(() => {
return getLogoUrl('jellyfin')
case 'trimemedia':
return getLogoUrl('trimemedia')
case 'ugreen':
return getLogoUrl('ugreen')
case 'plex':
return getLogoUrl('plex')
default:
@@ -196,7 +213,7 @@ onMounted(() => {
<span class="me-2 mb-1">自定义媒体服务器</span>
</div>
</div>
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" class="mt-8 me-3 max-h-12" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>
@@ -424,6 +441,95 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.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="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.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(mediaServerInfo.name)"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="mediaServerInfo.config.scan_mode"
:label="t('mediaserver.scanMode')"
:items="ugreenScanModeOptions"
:hint="t('mediaserver.scanModeHint')"
persistent-hint
active
prepend-inner-icon="mdi-radar"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaServerInfo.config.verify_ssl"
:label="t('mediaserver.verifySsl')"
:hint="t('mediaserver.verifySslHint')"
persistent-hint
color="primary"
inset
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -102,7 +102,7 @@ function renderMarkdown(value: string) {
</VCardTitle>
<div
v-if="props.message?.text && props.message?.action === 0"
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right mb-1"
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
>
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
</div>
@@ -155,12 +155,23 @@ function renderMarkdown(value: string) {
text-decoration: underline;
}
ul,
ol {
ul {
list-style-type: disc;
margin-block-end: 0.5rem;
padding-inline-start: 1.5rem;
}
ol {
list-style-type: decimal;
margin-block-end: 0.5rem;
padding-inline-start: 1.5rem;
}
li {
display: list-item;
margin-block-end: 0.25rem;
}
code {
border-radius: 4px;
background-color: rgba(var(--v-border-color), 0.1);

View File

@@ -46,6 +46,7 @@ const notificationInfo = ref<NotificationConf>({
const notificationTypeNames: { [key: string]: string } = {
wechat: t('notification.wechat.name'),
telegram: t('notification.telegram.name'),
qqbot: t('notification.qqbot.name'),
vocechat: t('notification.vocechat.name'),
synologychat: t('notification.synologychat.name'),
slack: t('notification.slack.name'),
@@ -66,10 +67,39 @@ const notificationTypes = [
{ value: '其它', title: t('notificationSwitch.other') },
]
function ensureWechatConfigDefaults(notification: NotificationConf) {
if (notification.type !== 'wechat') {
return
}
if (!notification.config) {
notification.config = {}
}
if (!notification.config.WECHAT_MODE) {
notification.config.WECHAT_MODE = 'app'
}
if (!notification.config.WECHAT_BOT_WS_URL) {
notification.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
}
}
const isWechatBotMode = computed({
get: () => notificationInfo.value.config?.WECHAT_MODE === 'bot',
set: value => {
if (!notificationInfo.value.config) {
notificationInfo.value.config = {}
}
notificationInfo.value.config.WECHAT_MODE = value ? 'bot' : 'app'
if (value && !notificationInfo.value.config.WECHAT_BOT_WS_URL) {
notificationInfo.value.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
}
},
})
// 打开详情弹窗
function openNotificationInfoDialog() {
// 替换成深复制,避免修改时影响原数据
notificationInfo.value = cloneDeep(props.notification)
ensureWechatConfigDefaults(notificationInfo.value)
notificationInfoDialog.value = true
}
@@ -85,6 +115,7 @@ function saveNotificationInfo() {
$toast.error(t('notification.channel') + `${notificationInfo.value.name}` + t('common.exists'))
return
}
ensureWechatConfigDefaults(notificationInfo.value)
notificationInfoDialog.value = false
emit('change', notificationInfo.value, props.notification.name)
emit('done')
@@ -97,6 +128,8 @@ const getIcon = computed(() => {
return getLogoUrl('wechat')
case 'telegram':
return getLogoUrl('telegram')
case 'qqbot':
return getLogoUrl('qq')
case 'vocechat':
return getLogoUrl('vocechat')
case 'synologychat':
@@ -187,69 +220,129 @@ function onClose() {
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_CORPID"
:label="t('notification.wechat.corpId')"
:hint="t('notification.wechat.corpIdHint')"
<VSwitch
v-model="isWechatBotMode"
:label="t('notification.wechat.useBotMode')"
:hint="t('notification.wechat.useBotModeHint')"
persistent-hint
prepend-inner-icon="mdi-domain"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_ID"
:label="t('notification.wechat.appId')"
:hint="t('notification.wechat.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_SECRET"
:label="t('notification.wechat.appSecret')"
:hint="t('notification.wechat.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.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="notificationInfo.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="notificationInfo.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="notificationInfo.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"
color="primary"
/>
</VCol>
<template v-if="isWechatBotMode">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_ID"
:label="t('notification.wechat.botId')"
:hint="t('notification.wechat.botIdHint')"
persistent-hint
prepend-inner-icon="mdi-robot"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_SECRET"
:label="t('notification.wechat.botSecret')"
:hint="t('notification.wechat.botSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_CHAT_ID"
:label="t('notification.wechat.botChatId')"
:placeholder="t('notification.wechat.botChatIdPlaceholder')"
:hint="t('notification.wechat.botChatIdHint')"
persistent-hint
prepend-inner-icon="mdi-chat-processing"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_WS_URL"
:label="t('notification.wechat.botWsUrl')"
:hint="t('notification.wechat.botWsUrlHint')"
persistent-hint
prepend-inner-icon="mdi-lan-connect"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.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>
</template>
<template v-else>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_CORPID"
:label="t('notification.wechat.corpId')"
:hint="t('notification.wechat.corpIdHint')"
persistent-hint
prepend-inner-icon="mdi-domain"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_ID"
:label="t('notification.wechat.appId')"
:hint="t('notification.wechat.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_SECRET"
:label="t('notification.wechat.appSecret')"
:hint="t('notification.wechat.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.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="notificationInfo.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="notificationInfo.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="notificationInfo.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>
</template>
</VRow>
<VRow v-else-if="notificationInfo.type == 'telegram'">
<VCol cols="12" md="6">
@@ -464,6 +557,56 @@ function onClose() {
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'qqbot'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_APP_ID"
:label="t('notification.qqbot.appId')"
:hint="t('notification.qqbot.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_APP_SECRET"
:label="t('notification.qqbot.appSecret')"
:hint="t('notification.qqbot.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_OPENID"
:label="t('notification.qqbot.openId')"
:placeholder="t('notification.qqbot.openIdPlaceholder')"
:hint="t('notification.qqbot.openIdHint')"
persistent-hint
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_GROUP_OPENID"
:label="t('notification.qqbot.groupOpenId')"
:placeholder="t('notification.qqbot.groupOpenIdPlaceholder')"
:hint="t('notification.qqbot.groupOpenIdHint')"
persistent-hint
prepend-inner-icon="mdi-account-group"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'webpush'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -0,0 +1,663 @@
<script setup lang="ts">
import draggable from 'vuedraggable'
import api from '@/api'
import type { CategoryConfig } from '@/api/types'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 定义输入参数
defineProps<{
modelValue?: boolean
}>()
// 定义事件
const emit = defineEmits(['close', 'save'])
const activeTab = ref('movie')
const loading = ref(false)
const saving = ref(false)
const toast = useToast()
const { t } = useI18n()
const generateId = () => {
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now()
}
interface CategoryItem {
id: string
name: string
rule: any
}
const movieList = ref<CategoryItem[]>([])
const tvList = ref<CategoryItem[]>([])
// TMDB 类型映射
const genreOptions = [
{ title: '动作 (Action)', value: '28' },
{ title: '冒险 (Adventure)', value: '12' },
{ title: '动画 (Animation)', value: '16' },
{ title: '喜剧 (Comedy)', value: '35' },
{ title: '犯罪 (Crime)', value: '80' },
{ title: '纪录 (Documentary)', value: '99' },
{ title: '剧情 (Drama)', value: '18' },
{ title: '家庭 (Family)', value: '10751' },
{ title: '奇幻 (Fantasy)', value: '14' },
{ title: '历史 (History)', value: '36' },
{ title: '恐怖 (Horror)', value: '27' },
{ title: '音乐 (Music)', value: '10402' },
{ title: '悬疑 (Mystery)', value: '9648' },
{ title: '爱情 (Romance)', value: '10749' },
{ title: '科幻 (SF)', value: '878' },
{ title: '电视电影', value: '10770' },
{ title: '惊悚 (Thriller)', value: '53' },
{ title: '战争 (War)', value: '10752' },
{ title: '西部 (Western)', value: '37' },
{ title: '儿童 (Kids)', value: '10762' },
{ title: '新闻 (News)', value: '10763' },
{ title: '真人秀 (Reality)', value: '10764' },
{ title: '科幻/奇幻 (Sci-Fi)', value: '10765' },
{ title: '肥皂剧 (Soap)', value: '10766' },
{ title: '访谈 (Talk)', value: '10767' },
{ title: '战争/政治', value: '10768' },
]
// 语种选项 (original_language)
const languageOptions = [
{ title: '中文', value: 'zh' },
{ title: '中文', value: 'cn' },
{ title: '英语 (English)', value: 'en' },
{ title: '日语 (Japanese)', value: 'ja' },
{ title: '韩语 (Korean)', value: 'ko' },
{ title: '法语 (French)', value: 'fr' },
{ title: '德语 (German)', value: 'de' },
{ title: '西班牙语 (Spanish)', value: 'es' },
{ title: '意大利语 (Italian)', value: 'it' },
{ title: '葡萄牙语 (Portuguese)', value: 'pt' },
{ title: '俄语 (Russian)', value: 'ru' },
{ title: '阿拉伯语', value: 'ar' },
{ title: '泰语 (Thai)', value: 'th' },
{ title: '越南语 (Vietnamese)', value: 'vi' },
{ title: '印地语 (Hindi)', value: 'hi' },
{ title: '土耳其语 (Turkish)', value: 'tr' },
{ title: '荷兰语 (Dutch)', value: 'nl' },
{ title: '波兰语 (Polish)', value: 'pl' },
{ title: '瑞典语 (Swedish)', value: 'sv' },
{ title: '丹麦语 (Danish)', value: 'da' },
{ title: '挪威语 (Norwegian)', value: 'nb' },
{ title: '芬兰语 (Finnish)', value: 'fi' },
{ title: '希腊语 (Greek)', value: 'el' },
{ title: '捷克语 (Czech)', value: 'cs' },
{ title: '匈牙利语 (Hungarian)', value: 'hu' },
{ title: '罗马尼亚语 (Romanian)', value: 'ro' },
{ title: '乌克兰语 (Ukrainian)', value: 'uk' },
{ title: '印度尼西亚语 (Indonesian)', value: 'id' },
{ title: '马来语 (Malay)', value: 'ms' },
{ title: '希伯来语 (Hebrew)', value: 'he' },
]
// 国家/地区选项 (origin_country/production_countries)
const countryOptions = [
{ title: '中国大陆 (CN)', value: 'CN' },
{ title: '中国香港 (HK)', value: 'HK' },
{ title: '中国台湾 (TW)', value: 'TW' },
{ title: '美国 (US)', value: 'US' },
{ title: '英国 (GB)', value: 'GB' },
{ title: '日本 (JP)', value: 'JP' },
{ title: '韩国 (KR)', value: 'KR' },
{ title: '法国 (FR)', value: 'FR' },
{ title: '德国 (DE)', value: 'DE' },
{ title: '意大利 (IT)', value: 'IT' },
{ title: '西班牙 (ES)', value: 'ES' },
{ title: '加拿大 (CA)', value: 'CA' },
{ title: '澳大利亚 (AU)', value: 'AU' },
{ title: '俄罗斯 (RU)', value: 'RU' },
{ title: '印度 (IN)', value: 'IN' },
{ title: '泰国 (TH)', value: 'TH' },
{ title: '新加坡 (SG)', value: 'SG' },
{ title: '马来西亚 (MY)', value: 'MY' },
{ title: '越南 (VN)', value: 'VN' },
{ title: '菲律宾 (PH)', value: 'PH' },
{ title: '巴西 (BR)', value: 'BR' },
{ title: '墨西哥 (MX)', value: 'MX' },
{ title: '阿根廷 (AR)', value: 'AR' },
{ title: '荷兰 (NL)', value: 'NL' },
{ title: '比利时 (BE)', value: 'BE' },
{ title: '瑞士 (CH)', value: 'CH' },
{ title: '瑞典 (SE)', value: 'SE' },
{ title: '挪威 (NO)', value: 'NO' },
{ title: '丹麦 (DK)', value: 'DK' },
{ title: '波兰 (PL)', value: 'PL' },
{ title: '捷克 (CZ)', value: 'CZ' },
{ title: '土耳其 (TR)', value: 'TR' },
{ title: '以色列 (IL)', value: 'IL' },
{ title: '埃及 (EG)', value: 'EG' },
{ title: '南非 (ZA)', value: 'ZA' },
{ title: '新西兰 (NZ)', value: 'NZ' },
]
const fetchConfig = async () => {
loading.value = true
try {
const res: any = await api.get('media/category/config')
if (res && res.data) {
parseConfig(res.data)
}
} catch (e) {
console.error(e)
toast.error(t('setting.category.loadFailed'))
} finally {
loading.value = false
}
}
const parseConfig = (data: CategoryConfig) => {
// 将对象 { "Name": { ... } } 转换为数组 [ { id: uuid, name: "Name", rule: { ... } } ]
movieList.value = []
if (data.movie) {
for (const [key, value] of Object.entries(data.movie)) {
// 为了UI一致性处理 genre_ids 为数组或字符串,但 API 发送的是字符串
const rule = { ...value }
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
// UI 多选预期为数组,检查输入。实际上 VAutocomplete 多选预期数组。我们需要将字符串分割为数组。
// @ts-ignore
rule.genre_ids = rule.genre_ids.split(',')
} else {
// @ts-ignore
rule.genre_ids = []
}
// 处理语种
if (rule.original_language && typeof rule.original_language === 'string') {
// @ts-ignore
rule.original_language = rule.original_language.split(',')
} else {
// @ts-ignore
rule.original_language = []
}
// 处理制片国家/地区
if (rule.production_countries && typeof rule.production_countries === 'string') {
// @ts-ignore
rule.production_countries = rule.production_countries.split(',')
} else {
// @ts-ignore
rule.production_countries = []
}
movieList.value.push({
id: generateId(),
name: key,
rule: rule as any,
})
}
}
tvList.value = []
if (data.tv) {
for (const [key, value] of Object.entries(data.tv)) {
const rule = { ...value }
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
// @ts-ignore
rule.genre_ids = rule.genre_ids.split(',')
} else {
// @ts-ignore
rule.genre_ids = []
}
// 处理语种
if (rule.original_language && typeof rule.original_language === 'string') {
// @ts-ignore
rule.original_language = rule.original_language.split(',')
} else {
// @ts-ignore
rule.original_language = []
}
// 处理发行国家/地区
if (rule.origin_country && typeof rule.origin_country === 'string') {
// @ts-ignore
rule.origin_country = rule.origin_country.split(',')
} else {
// @ts-ignore
rule.origin_country = []
}
tvList.value.push({
id: generateId(),
name: key,
rule: rule as any,
})
}
}
}
const addMovieItem = () => {
movieList.value.push({
id: generateId(),
name: '新分类',
rule: { genre_ids: [] as any },
})
}
const removeMovieItem = (index: number) => {
movieList.value.splice(index, 1)
}
const addTvItem = () => {
tvList.value.push({
id: generateId(),
name: '新分类',
rule: { genre_ids: [] as any },
})
}
const removeTvItem = (index: number) => {
tvList.value.splice(index, 1)
}
const saveConfig = async () => {
saving.value = true
try {
// 将数组转换回对象
const payload: CategoryConfig = {
movie: {},
tv: {},
}
movieList.value.forEach(item => {
if (item.name) {
const rule = { ...item.rule }
// 将 genre_ids 数组转换回字符串
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
rule.genre_ids = rule.genre_ids.join(',')
} else {
// @ts-ignore
rule.genre_ids = null
}
// 将 original_language 数组转换回字符串
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
rule.original_language = rule.original_language.join(',')
} else {
rule.original_language = undefined
}
// 将 production_countries 数组转换回字符串
if (Array.isArray(rule.production_countries) && rule.production_countries.length > 0) {
rule.production_countries = rule.production_countries.join(',')
} else {
rule.production_countries = undefined
}
// 清理空字符串
if (!rule.release_year) rule.release_year = undefined
// @ts-ignore
payload.movie[item.name] = rule
}
})
tvList.value.forEach(item => {
if (item.name) {
const rule = { ...item.rule }
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
rule.genre_ids = rule.genre_ids.join(',')
} else {
// @ts-ignore
rule.genre_ids = null
}
// 将 original_language 数组转换回字符串
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
rule.original_language = rule.original_language.join(',')
} else {
rule.original_language = undefined
}
// 将 origin_country 数组转换回字符串
if (Array.isArray(rule.origin_country) && rule.origin_country.length > 0) {
rule.origin_country = rule.origin_country.join(',')
} else {
rule.origin_country = undefined
}
// 清理空字符串
if (!rule.release_year) rule.release_year = undefined
// @ts-ignore
payload.tv[item.name] = rule
}
})
const res: any = await api.post('media/category/config', payload)
if (res && res.success) {
toast.success(t('setting.category.saveSuccess'))
emit('save')
emit('close')
} else {
toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))
}
} catch (e) {
console.error(e)
toast.error(t('setting.category.saveFailed', { message: 'Network or Config Error' }))
} finally {
saving.value = false
}
}
onMounted(() => {
fetchConfig()
})
</script>
<template>
<VDialog :model-value="modelValue" max-width="1000" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem class="py-3">
<template #prepend>
<VIcon icon="mdi-shape-outline" class="me-2" />
</template>
<VCardTitle>
{{ t('setting.category.title') }}
</VCardTitle>
<VCardSubtitle>
{{ t('setting.category.subtitle') }}
</VCardSubtitle>
</VCardItem>
<VCardText>
<VTabs v-model="activeTab" show-arrows class="mb-4">
<VTab value="movie">
<VIcon icon="mdi-movie-outline" class="me-2" />
{{ t('setting.category.movie') }}
</VTab>
<VTab value="tv">
<VIcon icon="mdi-television" class="me-2" />
{{ t('setting.category.tv') }}
</VTab>
</VTabs>
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
<VProgressCircular indeterminate color="primary" size="64" />
</div>
<VWindow v-else v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindowItem value="movie">
<draggable v-model="movieList" handle=".drag-handle" item-key="id" animation="200">
<template #item="{ element, index }">
<VCard variant="tonal" class="mb-4 category-item">
<VCardText class="pa-4">
<div class="d-flex align-center mb-5">
<VTextField
v-model="element.name"
:label="t('setting.category.name')"
density="comfortable"
hide-details
variant="plain"
class="font-bold"
prepend-inner-icon="mdi-tag-outline"
/>
<VSpacer />
<VBtn
icon="mdi-drag-vertical"
variant="text"
size="small"
class="drag-handle me-2"
color="primary"
/>
<VBtn
icon="mdi-delete-outline"
color="error"
variant="text"
size="small"
@click="removeMovieItem(index)"
/>
</div>
<VRow>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.genre_ids"
:items="genreOptions"
:label="t('setting.category.genre')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-movie-filter-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.production_countries"
:items="countryOptions"
:label="t('setting.category.country')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-earth"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.original_language"
:items="languageOptions"
:label="t('setting.category.language')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-translate"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="element.rule.release_year"
:label="t('setting.category.year')"
:placeholder="t('setting.category.yearPlaceholder')"
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-calendar-range"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>
</draggable>
<VBtn
block
variant="outlined"
size="large"
prepend-icon="mdi-plus-circle-outline"
class="mt-2 add-category-btn"
@click="addMovieItem"
>
{{ t('setting.category.addMovie') }}
</VBtn>
</VWindowItem>
<VWindowItem value="tv">
<draggable v-model="tvList" handle=".drag-handle" item-key="id" animation="200">
<template #item="{ element, index }">
<VCard variant="tonal" class="mb-4 category-item">
<VCardText class="pa-4">
<div class="d-flex align-center mb-5">
<VTextField
v-model="element.name"
:label="t('setting.category.name')"
density="comfortable"
hide-details
variant="plain"
class="font-bold"
prepend-inner-icon="mdi-tag-outline"
/>
<VSpacer />
<VBtn
icon="mdi-drag-vertical"
variant="text"
size="small"
class="drag-handle me-2"
color="primary"
/>
<VBtn
icon="mdi-delete-outline"
color="error"
variant="text"
size="small"
@click="removeTvItem(index)"
/>
</div>
<VRow>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.genre_ids"
:items="genreOptions"
:label="t('setting.category.genre')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-movie-filter-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.origin_country"
:items="countryOptions"
:label="t('setting.category.country')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-earth"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.original_language"
:items="languageOptions"
:label="t('setting.category.language')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-translate"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="element.rule.release_year"
:label="t('setting.category.year')"
:placeholder="t('setting.category.yearPlaceholder')"
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-calendar-range"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>
</draggable>
<VBtn
block
variant="outlined"
size="large"
prepend-icon="mdi-plus-circle-outline"
class="mt-2 add-category-btn"
@click="addTvItem"
>
{{ t('setting.category.addTv') }}
</VBtn>
</VWindowItem>
</VWindow>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn variant="text" @click="emit('close')">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.drag-handle {
cursor: grab;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.drag-handle:hover {
opacity: 1;
}
.drag-handle:active {
cursor: grabbing;
}
.category-item {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
}
.category-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.add-category-btn {
border-style: dashed !important;
transition: all 0.2s ease;
}
.add-category-btn:hover {
border-style: solid !important;
transform: translateY(-1px);
}
.disable-tab-transition > * {
transition: none !important;
}
</style>

View File

@@ -10,6 +10,7 @@ import { FileItem, StorageConf, TransferDirectoryConf, TransferForm } from '@/ap
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import CryptoJS from 'crypto-js'
// 国际化
const { t } = useI18n()
@@ -63,6 +64,9 @@ const progressText = ref(t('dialog.reorganize.processing'))
// 整理进度
const progressValue = ref(0)
// 进度SSE连接
const progressSSE = ref<any>(null)
// 所有存储
const storages = ref<StorageConf[]>([])
@@ -200,25 +204,31 @@ function handleProgressMessage(event: MessageEvent) {
}
}
// 使用优化的进度SSE连接
const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`,
handleProgressMessage,
'reorganize-progress',
progressActive,
)
// 使用SSE监听加载进度
function startLoadingProgress() {
function startLoadingProgress(key: string) {
progressText.value = t('dialog.reorganize.processing')
progressActive.value = true
progressSSE.start()
// 如果已经有连接,先停止
if (progressSSE.value) {
progressSSE.value.stop()
}
const url = `${import.meta.env.VITE_API_BASE_URL}system/progress/${key}`
// 创建新的SSE连接
progressSSE.value = useProgressSSE(url, handleProgressMessage, `reorganize-progress-${key}`, progressActive)
progressSSE.value.start()
}
// 停止监听加载进度
function stopLoadingProgress() {
progressActive.value = false
progressSSE.stop()
if (progressSSE.value) {
progressSSE.value.stop()
progressSSE.value = null
}
}
// 整理文件
@@ -228,25 +238,30 @@ async function transfer(background: boolean = false) {
// 显示进度条
progressDialog.value = true
if (!background) {
// 开始监听进度
startLoadingProgress()
}
// 文件整理
if (props.items) {
for (const item of props.items) {
if (!background) {
// 如果是文件计算MD5
const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString()
// 开始监听进度
startLoadingProgress(key)
}
await handleTransfer(item, background)
}
}
// 日志整理
if (props.logids) {
if (!background) {
// 为日志整理任务开启进度监听
startLoadingProgress('filetransfer')
}
for (const logid of props.logids) {
await handleTransferLog(logid, background)
}
}
if (!background) {
// 停止监听进度
stopLoadingProgress()

View File

@@ -1,16 +1,22 @@
<script lang="ts" setup>
import api from '@/api'
import QRCode from 'qrcode'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 常量定义
const AUTH_WINDOW_WIDTH = 600
const AUTH_WINDOW_HEIGHT = 700
const POLL_INTERVAL = 2000
const AUTH_STATUS_SUCCESS = 2
const AUTH_STATUS_FAILED = -1
// 显示器宽度
const display = useDisplay()
// 多语言支持
const { t } = useI18n()
// 定义输入
// Props 定义
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
@@ -18,27 +24,40 @@ const props = defineProps({
},
})
// 定义事件
// Events 定义
const emit = defineEmits(['done', 'close'])
// 二维码内容
const qrCodeContent = ref('')
// 响应式状态
const authUrl = ref('')
const authState = ref('')
const text = ref('')
const alertType = ref<'success' | 'info' | 'error' | 'warning'>('info')
// 二维码图片 base64
const qrCodeImage = ref('')
// 授权窗口引用
let authWindow: Window | null = null
let pollTimer: NodeJS.Timeout | undefined
// 下方的提示信息
const text = ref(t('dialog.u115Auth.scanQrCode'))
// 清理资源
function cleanup() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = undefined
}
if (authWindow && !authWindow.closed) {
authWindow.close()
authWindow = null
}
}
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
// 设置提示消息
function setMessage(type: typeof alertType.value, message: string) {
alertType.value = type
text.value = message
}
// timeout定时器
let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
// 完成授权
function handleDone() {
cleanup()
emit('done')
}
@@ -47,78 +66,118 @@ async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/u115')
if (result.success) {
// 重置成功
alertType.value = 'success'
setMessage('success', t('dialog.u115Auth.authSuccess'))
handleDone()
} else {
alertType.value = 'error'
text.value = result.message
}
} catch (e) {
console.error(e)
else {
setMessage('error', result.message || t('dialog.u115Auth.authFailed'))
}
}
}
// 调用/u115/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
// 生成二维码图片
qrCodeImage.value = await QRCode.toDataURL(result.data.codeContent, {
width: 200,
margin: 1,
})
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
text.value = result.message
}
} catch (e) {
console.error(e)
catch (error) {
console.error('Reset failed:', error)
setMessage('error', t('dialog.u115Auth.authFailed'))
}
}
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
// 获取授权URL
async function fetchAuthUrl() {
try {
const result: { [key: string]: any } = await api.get('/storage/auth_url/u115')
if (result.success && result.data) {
authUrl.value = result.data.authUrl
authState.value = result.data.state
}
else {
setMessage('error', result.message || t('dialog.u115Auth.urlFetchFailed'))
}
}
catch (error) {
console.error('Fetch auth URL failed:', error)
setMessage('error', t('dialog.u115Auth.urlFetchFailed'))
}
}
// 打开授权窗口
function openAuthWindow() {
if (!authUrl.value) {
setMessage('error', t('dialog.u115Auth.urlEmpty'))
return
}
const left = (window.screen.width - AUTH_WINDOW_WIDTH) / 2
const top = (window.screen.height - AUTH_WINDOW_HEIGHT) / 2
const features = [
`width=${AUTH_WINDOW_WIDTH}`,
`height=${AUTH_WINDOW_HEIGHT}`,
`left=${left}`,
`top=${top}`,
'toolbar=no',
'location=no',
'status=no',
'menubar=no',
'scrollbars=yes',
'resizable=yes',
].join(',')
authWindow = window.open(authUrl.value, '115授权', features)
if (authWindow) {
setMessage('info', t('dialog.u115Auth.authorizing'))
pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)
}
else {
setMessage('error', t('dialog.u115Auth.popupBlocked'))
}
}
// 检查授权状态
async function checkAuthStatus() {
try {
const result: { [key: string]: any } = await api.get('/storage/check/u115')
if (result.success && result.data) {
const status = result.data.status
text.value = result.data.tip
if (status == 0) {
alertType.value = 'info'
// 新建、待扫码
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else if (status == 1) {
// 已扫码
alertType.value = 'info'
text.value = t('dialog.u115Auth.scanned')
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else if (status == 2) {
// 已确认完成
alertType.value = 'success'
const { status, tip } = result.data
if (status === AUTH_STATUS_SUCCESS) {
// 授权成功
setMessage('success', t('dialog.u115Auth.authSuccess'))
handleDone()
} else {
// 过期或者已取消
alertType.value = 'error'
return
}
} else {
alertType.value = 'error'
text.value = result.message
if (status === AUTH_STATUS_FAILED) {
// 授权失败或过期
setMessage('error', tip || t('dialog.u115Auth.authFailed'))
cleanup()
return
}
// status === 0 或 1继续等待
}
} catch (e) {
console.error(e)
}
catch (error) {
console.error('Check auth status failed:', error)
}
// 检查窗口是否被用户关闭
if (authWindow?.closed) {
setMessage('warning', t('dialog.u115Auth.authCanceled'))
cleanup()
return
}
// 继续轮询
pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)
}
onMounted(async () => {
await getQrcode()
// 生命周期钩子
onMounted(() => {
fetchAuthUrl()
})
onUnmounted(() => {
if (timeoutTimer) clearTimeout(timeoutTimer)
cleanup()
})
</script>
@@ -126,37 +185,63 @@ onUnmounted(() => {
<VDialog width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<template #prepend>
<VIcon icon="mdi-qrcode" class="me-2" />
<VIcon icon="mdi-shield-key" class="me-2" />
</template>
<VCardTitle>
{{ t('dialog.u115Auth.loginTitle') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pt-2 flex flex-col items-center justify-center">
<div class="mt-6 rounded text-center p-3 border">
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
<!-- 授权按钮 -->
<div class="mt-6 mb-4 text-center">
<VBtn
size="x-large"
color="primary"
prepend-icon="mdi-login"
:disabled="!authUrl"
class="px-8"
@click="openAuthWindow"
>
{{ t('dialog.u115Auth.openAuthWindow') }}
</VBtn>
</div>
<div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<!-- 状态提示 -->
<div v-if="text" class="w-full">
<VAlert
variant="tonal"
:type="alertType"
:text="text"
class="my-4 text-center"
>
<template #prepend />
</VAlert>
</div>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
<VBtn
color="error"
prepend-icon="mdi-restore"
class="px-5 me-3"
@click="handleReset"
>
{{ t('dialog.u115Auth.reset') }}
</VBtn>
<VSpacer />
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
<VBtn
prepend-icon="mdi-check"
class="px-5 me-3"
@click="handleDone"
>
{{ t('dialog.u115Auth.complete') }}
</VBtn>
</VCardActions>

View File

@@ -199,6 +199,7 @@ async function fetchUserInfo() {
userForm.value = await api.get(`user/${props.username}`)
if (userForm.value) {
userForm.value.avatar = userForm.value.avatar || avatar1
userForm.value.nickname = userForm.value.settings?.nickname ?? ''
currentAvatar.value = userForm.value.avatar
currentUserName.value = userForm.value.name
userName.value = userForm.value.name
@@ -273,12 +274,10 @@ async function updateUser() {
}
// 将nickname保存到settings中后端可以直接处理JSON对象
if (userForm.value.nickname) {
if (!userForm.value.settings) {
userForm.value.settings = {}
}
userForm.value.settings.nickname = userForm.value.nickname
if (!userForm.value.settings) {
userForm.value.settings = {}
}
userForm.value.settings.nickname = userForm.value.nickname ?? ''
const oldUserName = userForm.value.name
userForm.value.name = currentUserName.value

View File

@@ -188,6 +188,7 @@ export function useSetupWizard() {
'jellyfin': 'JellyfinModule',
'plex': 'PlexModule',
'trimemedia': 'TrimeMediaModule',
'ugreen': 'UgreenModule',
},
// 通知映射
notification: {
@@ -405,7 +406,7 @@ export function useSetupWizard() {
errors.push(t('mediaserver.tokenRequired'))
validationErrors.value.mediaServer.token = true
}
} else if (wizardData.value.mediaServer.type === 'trimemedia') {
} else if (wizardData.value.mediaServer.type === 'trimemedia' || wizardData.value.mediaServer.type === 'ugreen') {
if (!wizardData.value.mediaServer.config?.username?.trim()) {
errors.push(t('mediaserver.usernameRequired'))
validationErrors.value.mediaServer.username = true
@@ -486,6 +487,16 @@ export function useSetupWizard() {
validationErrors.value.notification.VOCECHAT_API_KEY = true
}
break
case 'qqbot':
if (!config.QQ_APP_ID?.trim()) {
errors.push(t('notification.qqbot.appIdRequired'))
validationErrors.value.notification.QQ_APP_ID = true
}
if (!config.QQ_APP_SECRET?.trim()) {
errors.push(t('notification.qqbot.appSecretRequired'))
validationErrors.value.notification.QQ_APP_SECRET = true
}
break
}
return {

View File

@@ -7,6 +7,7 @@ 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 AccountSettingService from '@/views/system/ServiceView.vue'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { getQueryValue } from '@/@core/utils'
@@ -49,6 +50,9 @@ const wordsDialog = ref(false)
// 缓存管理弹窗
const cacheDialog = ref(false)
// 定时服务弹窗
const schedulerDialog = ref(false)
// 输入消息
const user_message = ref('')
@@ -108,6 +112,13 @@ const shortcuts = [
dialog: 'cache',
dialogRef: cacheDialog,
},
{
title: t('shortcut.scheduler.title'),
subtitle: t('shortcut.scheduler.subtitle'),
icon: 'mdi-list-box',
dialog: 'scheduler',
dialogRef: schedulerDialog,
},
{
title: t('shortcut.system.title'),
subtitle: t('shortcut.system.subtitle'),
@@ -275,10 +286,10 @@ onMounted(() => {
item.dialog === 'message'
? openMessageDialog()
: item.dialog === 'words'
? openDialog(item.dialogRef)
: item.dialog === 'cache'
? openDialog(item.dialogRef)
: openDialog(item.dialogRef)
? openDialog(item.dialogRef)
: item.dialog === 'cache'
? openDialog(item.dialogRef)
: openDialog(item.dialogRef)
"
>
<VAvatar variant="text" size="48" rounded="lg">
@@ -420,6 +431,29 @@ onMounted(() => {
</VCardText>
</VCard>
</VDialog>
<!-- 定时服务弹窗 -->
<VDialog
v-if="schedulerDialog"
v-model="schedulerDialog"
max-width="60rem"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<VCardTitle>
<VIcon icon="mdi-list-box" class="me-2" />
{{ t('shortcut.scheduler.subtitle') }}
</VCardTitle>
<VCardSubtitle>{{ t('setting.scheduler.subtitle') }}</VCardSubtitle>
<VDialogCloseBtn @click="schedulerDialog = false" />
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<AccountSettingService />
</VCardText>
</VCard>
</VDialog>
<!-- 系统健康检查弹窗 -->
<VDialog
v-if="systemTestDialog"

View File

@@ -46,6 +46,7 @@ export default {
unsubscribe: 'Unsubscribe',
media: 'Media',
unknown: 'Unknown',
notFetched: 'Not Fetched',
notice: 'Notice',
itemsPerPage: 'Items per page',
pageText: '{0}-{1} of {2}',
@@ -314,7 +315,8 @@ export default {
settingTabs: {
system: {
title: 'System',
description: 'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex)',
description:
'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex, TrimeMedia, Ugreen)',
},
directory: {
title: 'Storage & Directories',
@@ -433,6 +435,8 @@ export default {
config: 'Configuration',
wechat: {
name: 'WeChat Work',
useBotMode: 'Use AI Bot',
useBotModeHint: 'Enable WebSocket bot mode with fixed dmPolicy=open and groupPolicy=disabled',
corpId: 'Corp ID',
corpIdHint: 'Corp ID in WeChat Work backend enterprise information',
corpIdRequired: 'Corp ID cannot be empty',
@@ -449,6 +453,15 @@ export default {
tokenHint: 'Token in WeChat Work self-built app -> API message receiving configuration',
encodingAesKey: 'EncodingAESKey',
encodingAesKeyHint: 'EncodingAESKey in WeChat Work self-built app -> API message receiving configuration',
botId: 'Bot ID',
botIdHint: 'Bot ID of the WeChat Work AI bot',
botSecret: 'Bot Secret',
botSecretHint: 'WebSocket secret of the WeChat Work AI bot',
botChatId: 'Default Target',
botChatIdHint: 'Use user userid; for proactive group messages use group:chatid. Leave empty to notify known interacted users',
botChatIdPlaceholder: 'userid or group:chatid',
botWsUrl: 'WebSocket URL',
botWsUrlHint: 'WebSocket endpoint for the WeChat Work AI bot, usually the default value',
admins: 'Admin Whitelist',
adminsHint: 'User IDs that can use admin menu and commands, separated by commas',
adminsPlaceholder: 'User IDs list, separated by commas',
@@ -519,6 +532,21 @@ export default {
usernameHint: 'Only push messages to the corresponding logged-in user',
usernameRequired: 'Username cannot be empty',
},
qqbot: {
name: 'QQ',
appId: 'App ID',
appIdHint: 'QQ Open Platform bot App ID',
appIdRequired: 'App ID cannot be empty',
appSecret: 'App Secret',
appSecretHint: 'QQ Open Platform bot App Secret',
appSecretRequired: 'App Secret cannot be empty',
openId: 'User OpenID',
openIdHint: 'Default recipient openid (C2C), user must have interacted with bot before',
openIdPlaceholder: '32-char hex',
groupOpenId: 'Group OpenID',
groupOpenIdHint: 'Default group openid (group chat), use either this or User OpenID',
groupOpenIdPlaceholder: 'Group openid',
},
},
shortcut: {
title: 'Shortcuts',
@@ -554,6 +582,10 @@ export default {
title: 'Cache',
subtitle: 'Manage Cache',
},
scheduler: {
title: 'Services',
subtitle: 'Scheduled Services',
},
},
workflow: {
components: 'Action Components',
@@ -1292,23 +1324,42 @@ export default {
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.',
llmMaxContextTokens: 'LLM Max Context Tokens (K)',
llmMaxContextTokensHint:
'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',
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',
aiAgentGlobalHint:
'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
aiAgentJobInterval: 'Scheduled Wake',
aiAgentJobIntervalHint:
'Set the check interval for scheduled wake. Select "Disabled" to disable scheduled tasks.',
aiAgentVerbose: 'Verbose Mode',
aiAgentVerboseHint: 'When enabled, tool call process will be displayed in AI agent responses',
aiAgentJobIntervalDisabled: 'Disabled',
aiAgentJobInterval1h: '1 Hour',
aiAgentJobInterval3h: '3 Hours',
aiAgentJobInterval6h: '6 Hours',
aiAgentJobInterval12h: '12 Hours',
aiAgentJobInterval24h: '24 Hours',
aiAgentJobInterval1w: '1 Week',
aiAgentJobInterval1M: '1 Month',
advancedSettings: 'Advanced Settings',
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
downloaders: 'Downloaders',
downloadersDesc: 'Only the default downloader will be used by default.',
aiRecommendEnabled: 'AI Search Recommendation',
aiRecommendEnabledHint: 'Enable AI search recommendation. When enabled, an AI recommendation button will be displayed on the search result page, recommending resources based on user preferences.',
aiRecommendEnabledHint:
'Enable AI search recommendation. When enabled, an AI recommendation button will be displayed on the search result page, recommending resources based on user preferences.',
aiRecommendUserPreference: 'User Preference',
aiRecommendUserPreferenceHint: 'Set user preferences for AI recommendation, e.g., 4K WEB-DL Dolby Vision',
aiRecommendMaxItems: 'AI Recommendation Analysis Limit',
aiRecommendMaxItemsHint: 'Limit the number of search results sent to the AI assistant for analysis. More items mean slower analysis and more token consumption. It is recommended to manually filter to a general range before using AI recommendation.',
aiRecommendMaxItemsHint:
'Limit the number of search results sent to the AI assistant for analysis. More items mean slower analysis and more token consumption. It is recommended to manually filter to a general range before using AI recommendation.',
mediaServers: 'Media Servers',
mediaServersDesc: 'All enabled media servers will be used.',
trimeMedia: 'TrimeMedia',
@@ -1331,9 +1382,11 @@ export default {
reloading: 'Applying configuration...',
qbittorrent: 'Qbittorrent',
transmission: 'Transmission',
rtorrent: 'rTorrent',
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: 'Ugreen',
reloadSuccess: 'System configuration has taken effect',
reloadFailed: 'Failed to reload system!',
auxAuthEnable: 'User Auxiliary Authentication',
@@ -1376,6 +1429,8 @@ export default {
fanartEnableHint: 'Use image data from fanart.tv',
fanartLang: 'Fanart Language',
fanartLangHint: 'Set language preference for Fanart images, ordered by priority when multiple selected',
recognizePluginFirst: "Prioritize Plugin Recognition",
recognizePluginFirstHint: "Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped",
githubProxy: 'Github Acceleration Proxy',
githubProxyPlaceholder: 'Leave empty for no proxy',
githubProxyHint: 'Use proxy to accelerate Github access speed',
@@ -1490,6 +1545,11 @@ export default {
episodeThumb: 'Thumb',
scrapingSwitchSaveFailed: 'Scraping switch settings save failed: {message}',
scrapingSwitchSaveError: 'Scraping switch settings save failed',
policy: {
skipDesc: 'Skip scraping, this file will not be generated',
missingOnlyDesc: 'Scrape only if missing, existing file remains unchanged',
overwriteDesc: 'Always scrape, existing file will be overwritten',
}
},
site: {
siteSync: 'Site Synchronization',
@@ -1587,6 +1647,7 @@ export default {
synologyChat: 'SynologyChat',
voceChat: 'VoceChat',
webPush: 'WebPush',
qq: 'QQ',
custom: 'Custom Notification',
},
words: {
@@ -1694,6 +1755,25 @@ export default {
storageSaveSuccess: 'Storage settings saved successfully',
storageSaveFailed: 'Failed to save storage settings!',
},
category: {
title: 'Category Policy',
subtitle: 'Configure media auto-categorization rules by type, language, region, etc.',
movie: 'Movies',
tv: 'TV Shows',
name: 'Category Name (Directory)',
genre: 'Genre',
language: 'Language',
languagePlaceholder: 'e.g., en,fr,zh (comma separated)',
country: 'Country/Region',
countryPlaceholder: 'e.g., US,CN,JP',
year: 'Year',
yearPlaceholder: 'e.g., 2023, 2020-2024',
addMovie: 'Add Movie Category',
addTv: 'Add TV Category',
saveSuccess: 'Category policy saved successfully',
loadFailed: 'Failed to load category configuration',
saveFailed: 'Save failed: {message}',
},
rule: {
customRules: 'Custom Rules',
customRulesDesc: 'Custom priority rule items',
@@ -1999,9 +2079,15 @@ export default {
'Before sharing, please ensure the workflow does not contain sensitive information such as PassKey in RSS links to avoid information leakage.',
},
u115Auth: {
loginTitle: '115 Cloud Login',
scanQrCode: 'Please scan with WeChat or 115 client',
scanned: 'Scanned, please confirm login',
loginTitle: '115 Cloud Authorization',
openAuthWindow: 'Open Authorization Window',
authorizing: 'Please complete authorization in the new window...',
authSuccess: 'Authorization successful!',
authFailed: 'Authorization failed or expired',
authCanceled: 'Authorization canceled, please try again',
urlEmpty: 'Authorization URL is empty',
urlFetchFailed: 'Failed to fetch authorization URL',
popupBlocked: 'Unable to open authorization window, please check browser popup settings',
complete: 'Complete',
reset: 'Reset',
},
@@ -2617,7 +2703,8 @@ export default {
passkeyManagement: 'Passkey Management',
registerNewPasskey: 'Register New Passkey',
passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.',
passkeyAppDescription: 'Passkeys are a simpler, more secure way to sign in, serving as an alternative to passwords. You can authenticate using passkey-supported apps like iCloud Keychain, Bitwarden, or hardware keys.',
passkeyAppDescription:
'Passkeys are a simpler, more secure way to sign in, serving as an alternative to passwords. You can authenticate using passkey-supported apps like iCloud Keychain, Bitwarden, or hardware keys.',
passkeyName: 'Passkey Name',
passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',
registerPasskey: 'Register Passkey',
@@ -2631,8 +2718,10 @@ export default {
passkeyDeleteSuccess: 'Passkey deleted',
passkeyDeleteFailed: 'Delete failed',
deletePasskey: 'Delete Passkey',
passkeyDomainWarning: 'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
otpRequiredForPasskey: 'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
passkeyDomainWarning:
'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
otpRequiredForPasskey:
'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
accessDomain: 'access domain name',
otpAuthenticator: 'OTP Authenticator',
otpGenerateFailed: 'Failed to get OTP URI: {message}!',
@@ -2641,8 +2730,10 @@ export default {
otpCodeRequired: 'Please enter the 6-digit verification code',
otpEnableSuccess: 'Two-factor authentication enabled successfully!',
otpEnableFailed: 'Failed to enable OTP: {message}!',
otpDisableRestrictedByPasskey: 'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
confirmToDisableOtp: 'For security reasons, verifying your login password is required to disable two-factor authentication.',
otpDisableRestrictedByPasskey:
'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
confirmToDisableOtp:
'For security reasons, verifying your login password is required to disable two-factor authentication.',
confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
authenticatorAppDescription:
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code and generate a 6-digit code.',
@@ -2746,6 +2837,7 @@ export default {
type: 'Type',
enabled: 'Enabled',
customTypeHint: 'Custom downloader type, for plugin scenarios',
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 or SCGI: scgi://ip:port',
default: 'Default',
host: 'Host',
username: 'Username',
@@ -2815,6 +2907,15 @@ export default {
password: 'Password',
syncLibraries: 'Sync Libraries',
syncLibrariesHint: 'Only selected libraries will be synchronized',
scanMode: 'Scan Mode',
scanModeHint: 'Applies to full-library and targeted refresh: New & Modified / Supplement Missing / Full Override',
verifySsl: 'Verify SSL Certificate',
verifySslHint: 'When enabled, HTTPS certificates are verified; disable for self-signed certificates',
scanModeOptions: {
newAndModified: 'New & Modified',
supplementMissing: 'Supplement Missing',
fullOverride: 'Full Override',
},
hostRequired: 'Host cannot be empty',
apiKeyRequired: 'API Key cannot be empty',
tokenRequired: 'Token cannot be empty',
@@ -3122,7 +3223,8 @@ export default {
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.',
infoDesc:
'Configure media server for media library management, can choose Emby, Jellyfin, Plex, TrimeMedia or Ugreen.',
type: 'Media Server Type',
typeHint: 'Select the type of media server to use',
name: 'Server Name',

View File

@@ -46,6 +46,7 @@ export default {
unsubscribe: '取消订阅',
media: '媒体',
unknown: '未知',
notFetched: '未获取',
notice: '注意',
itemsPerPage: '每页条数',
pageText: '{0}-{1} 共 {2} 条',
@@ -313,7 +314,7 @@ export default {
settingTabs: {
system: {
title: '系统',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex、飞牛影视、绿联影视',
},
directory: {
title: '存储 & 目录',
@@ -432,6 +433,8 @@ export default {
config: '配置',
wechat: {
name: '企业微信',
useBotMode: '使用智能机器人',
useBotModeHint: '开启后使用智能机器人长连接,固定 dmPolicy=open、groupPolicy=disabled',
corpId: '企业ID',
corpIdHint: '企业微信后台企业信息中的企业ID',
corpIdRequired: '企业ID不能为空',
@@ -447,6 +450,15 @@ export default {
tokenHint: '微信企业自建应用->API接收消息配置中的Token',
encodingAesKey: 'EncodingAESKey',
encodingAesKeyHint: '微信企业自建应用->API接收消息配置中的EncodingAESKey',
botId: '机器人 BotID',
botIdHint: '企业微信智能机器人的 BotID',
botSecret: '机器人 Secret',
botSecretHint: '企业微信智能机器人长连接专用 Secret',
botChatId: '默认通知目标',
botChatIdHint: '可填写用户 userid如需主动发群消息可填写 group:群聊chatid不填则默认发给已互动用户',
botChatIdPlaceholder: 'userid 或 group:chatid',
botWsUrl: '长连接地址',
botWsUrlHint: '企业微信智能机器人 WebSocket 地址,通常使用默认值',
admins: '管理员白名单',
adminsHint: '可使用管理菜单及命令的用户ID列表多个ID使用,分隔',
adminsPlaceholder: '用户ID列表多个ID使用,分隔',
@@ -517,6 +529,21 @@ export default {
usernameHint: '只有对应的用户登录后才会推送消息',
usernameRequired: '用户名不能为空',
},
qqbot: {
name: 'QQ',
appId: 'AppID',
appIdHint: 'QQ 开放平台机器人 AppID',
appIdRequired: 'AppID 不能为空',
appSecret: 'AppSecret',
appSecretHint: 'QQ 开放平台机器人 AppSecret',
appSecretRequired: 'AppSecret 不能为空',
openId: '用户 OpenID',
openIdHint: '默认接收者 openid单聊用户需曾与机器人交互过',
openIdPlaceholder: '32位十六进制',
groupOpenId: '群组 OpenID',
groupOpenIdHint: '默认群组 openid群聊与用户 OpenID 二选一',
groupOpenIdPlaceholder: '群组 openid',
},
},
shortcut: {
title: '捷径',
@@ -552,6 +579,10 @@ export default {
title: '缓存',
subtitle: '管理缓存',
},
scheduler: {
title: '服务',
subtitle: '定时服务',
},
},
workflow: {
components: '动作组件',
@@ -1288,6 +1319,9 @@ export default {
llmProviderHint: '选择使用的LLM服务提供商',
llmModel: 'LLM模型名称',
llmModelHint: '指定使用的LLM模型如gpt-3.5-turbo、deepseek-chat等',
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
llmMaxContextTokensHint:
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
llmApiKey: 'LLM API密钥',
llmApiKeyHint: 'LLM服务提供商的API密钥用于身份验证',
llmApiKeyPlaceholder: '请输入API密钥',
@@ -1295,16 +1329,30 @@ export default {
llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点',
aiAgentGlobal: '全局智能助手',
aiAgentGlobalHint: '启用全局智能助手功能,所有消息对话均使用智能体回答而不用使用/ai命令',
aiAgentJobInterval: '定时唤醒',
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
aiAgentVerbose: '啰嗦模式',
aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',
aiAgentJobIntervalDisabled: '不启用',
aiAgentJobInterval1h: '1小时',
aiAgentJobInterval3h: '3小时',
aiAgentJobInterval6h: '6小时',
aiAgentJobInterval12h: '12小时',
aiAgentJobInterval24h: '24小时',
aiAgentJobInterval1w: '1周',
aiAgentJobInterval1M: '1个月',
advancedSettings: '高级设置',
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
downloaders: '下载器',
downloadersDesc: '只有默认下载器才会被默认使用。',
aiRecommendEnabled: '搜索结果智能推荐',
aiRecommendEnabledHint: '启用搜索结果智能推荐功能,开启后将在搜索结果页面显示智能推荐按钮,可根据用户偏好智能推荐资源',
aiRecommendEnabledHint:
'启用搜索结果智能推荐功能,开启后将在搜索结果页面显示智能推荐按钮,可根据用户偏好智能推荐资源',
aiRecommendUserPreference: '用户偏好',
aiRecommendUserPreferenceHint: '设置智能推荐时的用户偏好例如4K WEB-DL Dolby Vision',
aiRecommendMaxItems: '智能推荐分析条目上限',
aiRecommendMaxItemsHint: '限制发送给智能助手进行分析的搜索结果数量,数量越多分析越慢且消耗 Token 越多,建议先手动筛选,筛选出大致范围后再进行智能推荐',
aiRecommendMaxItemsHint:
'限制发送给智能助手进行分析的搜索结果数量,数量越多分析越慢且消耗 Token 越多,建议先手动筛选,筛选出大致范围后再进行智能推荐',
mediaServers: '媒体服务器',
mediaServersDesc: '所有启用的媒体服务器都会被使用。',
trimeMedia: '飞牛影视',
@@ -1327,9 +1375,11 @@ export default {
reloading: '正在应用配置...',
qbittorrent: 'Qbittorrent',
transmission: 'Transmission',
rtorrent: 'rTorrent',
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '绿联影视',
reloadSuccess: '系统配置已生效',
reloadFailed: '重载系统失败!',
auxAuthEnable: '用户辅助认证',
@@ -1369,6 +1419,8 @@ export default {
fanartEnableHint: '使用 fanart.tv 的图片数据',
fanartLang: 'Fanart语言',
fanartLangHint: '设置Fanart图片的语言偏好多选时按优先级顺序排列',
recognizePluginFirst: "优先使用插件识别",
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别",
githubProxy: 'Github加速代理',
githubProxyPlaceholder: '留空表示不使用代理',
githubProxyHint: '使用代理加速Github访问速度',
@@ -1479,6 +1531,11 @@ export default {
episodeThumb: '缩略图',
scrapingSwitchSaveFailed: '刮削开关设置保存失败:{message}',
scrapingSwitchSaveError: '刮削开关设置保存失败',
policy: {
skipDesc: '跳过刮削,不生成该文件',
missingOnlyDesc: '仅在缺失时刮削,已存在则保持不变',
overwriteDesc: '始终刮削,已存在则覆盖',
}
},
site: {
siteSync: '站点同步',
@@ -1573,6 +1630,7 @@ export default {
synologyChat: 'SynologyChat',
voceChat: 'VoceChat',
webPush: 'WebPush',
qq: 'QQ',
custom: '自定义通知',
},
words: {
@@ -1672,6 +1730,25 @@ export default {
storageSaveSuccess: '存储设置保存成功',
storageSaveFailed: '存储设置保存失败!',
},
category: {
title: '分类策略',
subtitle: '配置媒体自动分类规则,按类型、语言、地区等条件自动归类',
movie: '电影 (Movie)',
tv: '电视剧 (TV)',
name: '分类名称 (目录名)',
genre: '内容类型 (Genre)',
language: '语种 (Language)',
languagePlaceholder: '如: zh,cn,en (使用逗号分隔)',
country: '国家/地区 (Country)',
countryPlaceholder: '如: US,CN,JP',
year: '年份 (Year)',
yearPlaceholder: '如: 2023, 2020-2024',
addMovie: '添加电影分类',
addTv: '添加电视剧分类',
saveSuccess: '分类策略保存成功',
loadFailed: '加载分类配置失败',
saveFailed: '保存失败: {message}',
},
rule: {
customRules: '自定义规则',
customRulesDesc: '自定义优先级规则项',
@@ -1971,9 +2048,15 @@ export default {
securityWarningMessage: '分享前请确保工作流没有敏感信息比如RSS链接中的PassKey等避免产生信息泄露。',
},
u115Auth: {
loginTitle: '115网盘登录',
scanQrCode: '请使用微信或115客户端扫码',
scanned: '已扫码,请确认登录',
loginTitle: '115网盘授权',
openAuthWindow: '打开授权窗口',
authorizing: '请在新窗口中完成授权...',
authSuccess: '授权成功!',
authFailed: '授权失败或已过期',
authCanceled: '授权已取消,请重试',
urlEmpty: '授权URL为空',
urlFetchFailed: '获取授权URL失败',
popupBlocked: '无法打开授权窗口,请检查浏览器弹窗设置',
complete: '完成',
reset: '重置',
},
@@ -2585,7 +2668,8 @@ export default {
passkeyManagement: '通行密钥管理',
registerNewPasskey: '注册新通行密钥',
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
passkeyAppDescription: '通行密钥是一种更简单、更安全的登录方式,可以替代密码进行登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',
passkeyAppDescription:
'通行密钥是一种更简单、更安全的登录方式,可以替代密码进行登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',
passkeyName: '通行密钥名称',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '注册通行密钥',
@@ -2599,8 +2683,10 @@ export default {
passkeyDeleteSuccess: '通行密钥已删除',
passkeyDeleteFailed: '删除失败',
deletePasskey: '删除通行密钥',
passkeyDomainWarning: '通行密钥PassKey的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey: '为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
passkeyDomainWarning:
'通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey:
'为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
accessDomain: '访问域名',
otpAuthenticator: 'OTP 身份验证器',
otpGenerateFailed: '获取otp uri失败{message}',
@@ -2713,6 +2799,7 @@ export default {
type: '类型',
enabled: '启用',
customTypeHint: '自定义下载器类型,用于插件等场景',
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
default: '默认',
host: '地址',
username: '用户名',
@@ -2782,6 +2869,15 @@ export default {
password: '密码',
syncLibraries: '同步媒体库',
syncLibrariesHint: '只有选中的媒体库才会被同步',
scanMode: '扫描模式',
scanModeHint: '用于全库刷新和按库刷新:新添加和修改 / 补充缺失 / 覆盖扫描',
verifySsl: '校验 SSL 证书',
verifySslHint: '开启后会校验 HTTPS 证书;如使用自签名证书可关闭',
scanModeOptions: {
newAndModified: '新添加和修改',
supplementMissing: '补充缺失',
fullOverride: '覆盖扫描',
},
nameExists: '【{name}】已存在,请替换为其他名称',
hostRequired: '地址不能为空',
apiKeyRequired: 'API密钥不能为空',
@@ -3088,7 +3184,7 @@ export default {
title: '媒体服务器',
description: '配置媒体服务器',
info: '媒体服务器配置说明',
infoDesc: '配置媒体服务器用于媒体库管理可选择Emby、JellyfinPlex',
infoDesc: '配置媒体服务器用于媒体库管理可选择Emby、JellyfinPlex、飞牛影视或绿联影视',
type: '媒体服务器类型',
typeHint: '选择要使用的媒体服务器类型',
name: '服务器名称',
@@ -3161,3 +3257,7 @@ export default {
},
},
}
// Apply patch to add category strings
// This is a temporary placeholder command to show intent.
// I will use replace_file_content to actually edit the file safely.

View File

@@ -46,6 +46,7 @@ export default {
unsubscribe: '取消訂閱',
media: '媒體',
unknown: '未知',
notFetched: '未獲取',
notice: '注意',
itemsPerPage: '每頁條數',
pageText: '{0}-{1} 共 {2} 條',
@@ -313,7 +314,7 @@ export default {
settingTabs: {
system: {
title: '系統',
description: '基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex',
description: '基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex、飛牛影視、綠聯影視',
},
directory: {
title: '存儲 & 目錄',
@@ -432,6 +433,8 @@ export default {
config: '配置',
wechat: {
name: '企業微信',
useBotMode: '使用智能機器人',
useBotModeHint: '開啟後使用智能機器人長連線,固定 dmPolicy=open、groupPolicy=disabled',
corpId: '企業ID',
corpIdHint: '企業微信後台企業信息中的企業ID',
corpIdRequired: '企業ID不能為空',
@@ -447,6 +450,15 @@ export default {
tokenHint: '微信企業自建應用->API接收消息配置中的Token',
encodingAesKey: 'EncodingAESKey',
encodingAesKeyHint: '微信企業自建應用->API接收消息配置中的EncodingAESKey',
botId: '機器人 BotID',
botIdHint: '企業微信智能機器人的 BotID',
botSecret: '機器人 Secret',
botSecretHint: '企業微信智能機器人長連線專用 Secret',
botChatId: '預設通知目標',
botChatIdHint: '可填寫使用者 userid如需主動發群消息可填寫 group:群聊chatid不填則預設發給已互動使用者',
botChatIdPlaceholder: 'userid 或 group:chatid',
botWsUrl: '長連線地址',
botWsUrlHint: '企業微信智能機器人 WebSocket 位址,通常使用預設值',
admins: '管理員白名單',
adminsHint: '可使用管理菜單及命令的用戶ID列表多個ID使用,分隔',
adminsPlaceholder: '用戶ID列表多個ID使用,分隔',
@@ -517,6 +529,21 @@ export default {
usernameHint: '只有對應的用戶登錄後才會推送消息',
usernameRequired: '用戶名不能為空',
},
qqbot: {
name: 'QQ',
appId: 'AppID',
appIdHint: 'QQ 開放平台機器人 AppID',
appIdRequired: 'AppID 不能為空',
appSecret: 'AppSecret',
appSecretHint: 'QQ 開放平台機器人 AppSecret',
appSecretRequired: 'AppSecret 不能為空',
openId: '用戶 OpenID',
openIdHint: '默認接收者 openid單聊用戶需曾與機器人交互過',
openIdPlaceholder: '32位十六進制',
groupOpenId: '群組 OpenID',
groupOpenIdHint: '默認群組 openid群聊與用戶 OpenID 二選一',
groupOpenIdPlaceholder: '群組 openid',
},
},
shortcut: {
title: '捷徑',
@@ -552,6 +579,10 @@ export default {
title: '緩存',
subtitle: '管理緩存',
},
scheduler: {
title: '服務',
subtitle: '定時服務',
},
},
workflow: {
components: '動作組件',
@@ -1289,6 +1320,9 @@ export default {
llmProviderHint: '選擇使用的LLM服務提供商',
llmModel: 'LLM模型名稱',
llmModelHint: '指定使用的LLM模型如gpt-3.5-turbo、deepseek-chat等',
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
llmMaxContextTokensHint:
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
llmApiKey: 'LLM API密鑰',
llmApiKeyHint: 'LLM服務提供商的API密鑰用於身份驗證',
llmApiKeyPlaceholder: '請輸入API密鑰',
@@ -1296,16 +1330,30 @@ export default {
llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點',
aiAgentGlobal: '全局智能助手',
aiAgentGlobalHint: '啟用全局智能助手功能,所有消息對話均使用智能體回答而不用使用/ai命令',
aiAgentJobInterval: '定時喚醒',
aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔,選擇「不啟用」則不執行定時任務',
aiAgentVerbose: '囉嗦模式',
aiAgentVerboseHint: '開啟後會在智能體回覆時顯示工具調用過程',
aiAgentJobIntervalDisabled: '不啟用',
aiAgentJobInterval1h: '1小時',
aiAgentJobInterval3h: '3小時',
aiAgentJobInterval6h: '6小時',
aiAgentJobInterval12h: '12小時',
aiAgentJobInterval24h: '24小時',
aiAgentJobInterval1w: '1週',
aiAgentJobInterval1M: '1個月',
advancedSettings: '高級設置',
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
downloaders: '下載器',
downloadersDesc: '只有默認下載器才會被默認使用。',
aiRecommendEnabled: '搜索結果智能推薦',
aiRecommendEnabledHint: '啟用搜索結果智能推薦功能,開啟後將在搜索結果頁面顯示智能推薦按鈕,可根據用戶偏好智能推薦資源',
aiRecommendEnabledHint:
'啟用搜索結果智能推薦功能,開啟後將在搜索結果頁面顯示智能推薦按鈕,可根據用戶偏好智能推薦資源',
aiRecommendUserPreference: '用戶偏好',
aiRecommendUserPreferenceHint: '設置智能推薦時的用戶偏好例如4K WEB-DL Dolby Vision',
aiRecommendMaxItems: '智能推薦分析條目上限',
aiRecommendMaxItemsHint: '限制發送給智能助手進行分析的搜索結果數量,數量越多分析越慢且消耗 Token 越多,建議先手動篩選,篩選出大致範圍後再進行智能推薦',
aiRecommendMaxItemsHint:
'限制發送給智能助手進行分析的搜索結果數量,數量越多分析越慢且消耗 Token 越多,建議先手動篩選,篩選出大致範圍後再進行智能推薦',
mediaServers: '媒體服務器',
mediaServersDesc: '所有啟用的媒體服務器都會被使用。',
trimeMedia: '飛牛影視',
@@ -1328,9 +1376,11 @@ export default {
reloading: '正在應用配置...',
qbittorrent: 'Qbittorrent',
transmission: 'Transmission',
rtorrent: 'rTorrent',
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '綠聯影視',
reloadSuccess: '系統配置已生效',
reloadFailed: '重載系統失敗!',
auxAuthEnable: '用戶輔助認證',
@@ -1370,6 +1420,8 @@ export default {
fanartEnableHint: '使用 fanart.tv 的圖片數據',
fanartLang: 'Fanart語言',
fanartLangHint: '設定Fanart圖片的語言偏好多選時按優先級順序排列',
recognizePluginFirst: "優先使用插件識別",
recognizePluginFirstHint: "優先調用插件識別媒體信息,若插件命中則不再調用原生識別",
githubProxy: 'Github加速代理',
githubProxyPlaceholder: '留空表示不使用代理',
githubProxyHint: '使用代理加速Github訪問速度',
@@ -1480,6 +1532,11 @@ export default {
episodeThumb: '縮略圖',
scrapingSwitchSaveFailed: '刮削開關設定保存失敗:{message}',
scrapingSwitchSaveError: '刮削開關設定保存失敗',
policy: {
skipDesc: '跳過刮削,不生成該文件',
missingOnlyDesc: '僅在缺失時刮削,已存在則保持不變',
overwriteDesc: '始終刮削,已存在則覆蓋',
}
},
site: {
siteSync: '站點同步',
@@ -1574,6 +1631,7 @@ export default {
synologyChat: 'SynologyChat',
voceChat: 'VoceChat',
webPush: 'WebPush',
qq: 'QQ',
custom: '自定義通知',
},
words: {
@@ -1673,6 +1731,25 @@ export default {
storageSaveSuccess: '存儲設置保存成功',
storageSaveFailed: '存儲設置保存失敗!',
},
category: {
title: '分類策略',
subtitle: '配置媒體自動分類規則,按類型、語言、地區等條件自動歸類',
movie: '電影 (Movie)',
tv: '電視劇 (TV)',
name: '分類名稱 (目錄名)',
genre: '內容類型 (Genre)',
language: '語種 (Language)',
languagePlaceholder: '如: zh,cn,en (使用逗號分隔)',
country: '國家/地區 (Country)',
countryPlaceholder: '如: US,CN,JP',
year: '年份 (Year)',
yearPlaceholder: '如: 2023, 2020-2024',
addMovie: '添加電影分類',
addTv: '添加電視劇分類',
saveSuccess: '分類策略保存成功',
loadFailed: '加載分類配置失敗',
saveFailed: '保存失敗: {message}',
},
rule: {
customRules: '自定義規則',
customRulesDesc: '自定義優先級規則項',
@@ -1972,9 +2049,15 @@ export default {
securityWarningMessage: '分享前請確保工作流沒有敏感資訊比如RSS連結中的PassKey等避免產生資訊洩露。',
},
u115Auth: {
loginTitle: '115網盤登錄',
scanQrCode: '請使用微信或115客戶端掃碼',
scanned: '已掃碼,請確認登錄',
loginTitle: '115網盤授權',
openAuthWindow: '打開授權窗口',
authorizing: '請在新窗口中完成授權...',
authSuccess: '授權成功!',
authFailed: '授權失敗或已過期',
authCanceled: '授權已取消,請重試',
urlEmpty: '授權URL為空',
urlFetchFailed: '獲取授權URL失敗',
popupBlocked: '無法打開授權窗口,請檢查瀏覽器彈窗設置',
complete: '完成',
reset: '重置',
},
@@ -2586,7 +2669,8 @@ export default {
passkeyManagement: '通行密鑰管理',
registerNewPasskey: '註冊新通行密鑰',
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
passkeyAppDescription: '通行密鑰是一種更簡單、更安全的登入方式,可以替代密碼進行登入。您可以使用 iCloud 鑰匙圈、Bitwarden 等支援通行密鑰的應用程式或硬體金鑰完成驗證。',
passkeyAppDescription:
'通行密鑰是一種更簡單、更安全的登入方式,可以替代密碼進行登入。您可以使用 iCloud 鑰匙圈、Bitwarden 等支援通行密鑰的應用程式或硬體金鑰完成驗證。',
passkeyName: '通行密鑰名稱',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '註冊通行密鑰',
@@ -2715,6 +2799,7 @@ export default {
name: '名稱',
type: '類型',
customTypeHint: '自定義下載器類型,用於插件等場景',
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
enabled: '啟用',
default: '預設',
host: '地址',
@@ -2790,6 +2875,15 @@ export default {
password: '密碼',
syncLibraries: '同步媒體庫',
syncLibrariesHint: '只有選中的媒體庫才會被同步',
scanMode: '掃描模式',
scanModeHint: '用於全庫刷新和按庫刷新:新添加和修改 / 補充缺失 / 覆蓋掃描',
verifySsl: '校驗 SSL 憑證',
verifySslHint: '開啟後會校驗 HTTPS 憑證;如使用自簽憑證可關閉',
scanModeOptions: {
newAndModified: '新添加和修改',
supplementMissing: '補充缺失',
fullOverride: '覆蓋掃描',
},
nameExists: '【{name}】已存在,請替換為其他名稱',
},
bangumi: {
@@ -3091,7 +3185,7 @@ export default {
title: '媒體伺服器',
description: '設定媒體伺服器',
info: '媒體伺服器設定說明',
infoDesc: '設定媒體伺服器用於媒體庫管理可選擇Emby、JellyfinPlex',
infoDesc: '設定媒體伺服器用於媒體庫管理可選擇Emby、JellyfinPlex、飛牛影視或綠聯影視',
type: '媒體伺服器類型',
typeHint: '選擇要使用的媒體伺服器類型',
name: '伺服器名稱',

View File

@@ -582,7 +582,7 @@ onUnmounted(() => {
type="text"
name="username"
id="username"
autocomplete="username webauthn"
autocomplete="username"
:rules="[requiredValidator]"
hide-details
/>

View File

@@ -6,7 +6,6 @@ import AccountSettingSite from '@/views/setting/AccountSettingSite.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 { getSettingTabs } from '@/router/i18n-menu'
@@ -93,15 +92,6 @@ onMounted(() => {
</transition>
</VWindowItem>
<!-- 服务 -->
<VWindowItem value="scheduler">
<transition name="fade-slide" appear>
<div>
<AccountSettingService />
</div>
</transition>
</VWindowItem>
<!-- 通知 -->
<VWindowItem value="notification">
<transition name="fade-slide" appear>

View File

@@ -185,12 +185,6 @@ export function getSettingTabs(t: Composer['t']) {
tab: 'subscribe',
description: t('settingTabs.subscribe.description'),
},
{
title: t('settingTabs.scheduler.title'),
icon: 'mdi-list-box',
tab: 'scheduler',
description: t('settingTabs.scheduler.description'),
},
{
title: t('settingTabs.notification.title'),
icon: 'mdi-bell',

View File

@@ -80,9 +80,9 @@ async function fetchRemoteModules(): Promise<RemoteModule[]> {
* @param modules 远程模块列表
*/
function injectRemoteModule(module: RemoteModule): void {
// 从浏览器地址栏获取当前地址前缀
// 与 API 请求一致:使用 origin + pathname 作为前缀,子路径代理时 pathname 含 /mp 等
const baseUrl = new URL(window.location.href)
// 环境变量
const pathBase = baseUrl.pathname.replace(/\/$/, '') || ''
let apiBase = import.meta.env.VITE_API_BASE_URL
if (apiBase.startsWith('/')) {
apiBase = apiBase.slice(1)
@@ -90,8 +90,10 @@ function injectRemoteModule(module: RemoteModule): void {
if (apiBase.endsWith('/')) {
apiBase = apiBase.slice(0, -1)
}
const pathWithoutLeadingSlash = module.url.startsWith('/') ? module.url.slice(1) : module.url
const remoteEntryUrl = `${baseUrl.origin}${pathBase}/${apiBase}/${pathWithoutLeadingSlash}`
__federation_method_setRemote(module.id, {
url: () => Promise.resolve(`${baseUrl.origin}/${apiBase}${module.url}`),
url: () => Promise.resolve(remoteEntryUrl),
format: 'esm',
from: 'vite',
})

View File

@@ -6,10 +6,12 @@
// 导入所有 logo 图标
import qbittorrentLogo from '@/assets/images/logos/qbittorrent.png'
import transmissionLogo from '@/assets/images/logos/transmission.png'
import rtorrentLogo from '@/assets/images/logos/rtorrent.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 ugreenLogo from '@/assets/images/logos/ugreen.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'
@@ -29,15 +31,18 @@ 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'
import qqLogo from '@/assets/images/logos/qq.png'
// 图标映射表
const logoMap: Record<string, string> = {
qbittorrent: qbittorrentLogo,
transmission: transmissionLogo,
rtorrent: rtorrentLogo,
emby: embyLogo,
jellyfin: jellyfinLogo,
plex: plexLogo,
trimemedia: trimemediaLogo,
ugreen: ugreenLogo,
wechat: wechatLogo,
telegram: telegramLogo,
slack: slackLogo,
@@ -57,6 +62,7 @@ const logoMap: Record<string, string> = {
site: siteLogo,
bangumi: bangumiLogo,
'douban-black': doubanBlackLogo,
qq: qqLogo,
}
/**

View File

@@ -28,7 +28,7 @@ async function loadMediaStatistic() {
},
{
title: t('dashboard.episodes'),
stats: res.episode_count.toLocaleString(),
stats: res.episode_count == null ? t('common.notFetched') : res.episode_count.toLocaleString(),
icon: 'mdi-television-classic',
color: 'warning',
},

View File

@@ -31,6 +31,9 @@ const isRefreshed = ref(false)
const dataList = ref<MediaInfo[]>([])
const currData = ref<MediaInfo[]>([])
// 用于保存已处理过的 key
const seenKeys = ref<Set<string>>(new Set<string>())
// 拼装参数
function getParams() {
let params = {
@@ -41,6 +44,31 @@ function getParams() {
return params
}
// MediaInfo 去重的字段
const dedupFields = [
"source",
"type",
"season",
"tmdb_id",
"imdb_id",
"tvdb_id",
"douban_id",
"bangumi_id",
"mediaid_prefix",
"media_id",
] as const;
function deduplicate(items: MediaInfo[]): MediaInfo[] {
return items.filter(item => {
const key = dedupFields.map(field => String(item[field])).join('~');
if (seenKeys.value.has(key)) {
return false;
}
seenKeys.value.add(key);
return true;
});
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
try {
@@ -71,8 +99,10 @@ async function fetchData({ done }: { done: any }) {
done('empty')
return
}
// 去重
currData.value = deduplicate(currData.value)
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
dataList.value.push(...currData.value)
// 页码+1
page.value++
// 返回加载成功
@@ -92,8 +122,10 @@ async function fetchData({ done }: { done: any }) {
// 如果没有数据,跳出
done('empty')
} else {
// 去重
currData.value = deduplicate(currData.value)
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
dataList.value.push(...currData.value)
// 页码+1
page.value++
// 返回加载成功

View File

@@ -150,7 +150,8 @@ async function loadSeasonEpisodes(season: number) {
// 加载季集信息
if (seasonEpisodesInfo.value[season]) return
try {
const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`)
const params = mediaDetail.value.episode_group ? { episode_group: mediaDetail.value.episode_group } : undefined
const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`, params ? { params } : undefined)
seasonEpisodesInfo.value[season] = result || []
} catch (error) {
console.error(error)
@@ -189,7 +190,7 @@ async function checkExists() {
}
// 查询当前媒体是否已订阅
async function checkSubscribe(season = 0) {
async function checkSubscribe(season: number | null = null) {
try {
const mediaid = getMediaId()
@@ -233,9 +234,14 @@ async function checkMovieSubscribed() {
isSubscribed.value = await checkSubscribe()
}
// 过滤掉第0
// 季列表第0季排在最后
const getMediaSeasons = computed(() => {
return mediaDetail.value?.season_info?.filter(season => season.season_number !== 0)
if (!mediaDetail.value?.season_info) return []
return [...mediaDetail.value.season_info].sort((a, b) => {
if (a.season_number === 0) return 1
if (b.season_number === 0) return -1
return (a.season_number || 0) - (b.season_number || 0)
})
})
// 检查所有季的订阅状态
@@ -243,7 +249,7 @@ async function checkSeasonsSubscribed() {
if (mediaDetail.value.type !== '电视剧') return
try {
mediaDetail.value?.season_info?.forEach(async item => {
seasonsSubscribed.value[item.season_number ?? 0] = await checkSubscribe(item.season_number)
seasonsSubscribed.value[item.season_number ?? 0] = await checkSubscribe(item.season_number ?? null)
})
} catch (error) {
console.error(error)
@@ -251,13 +257,13 @@ async function checkSeasonsSubscribed() {
}
// 调用API添加订阅电视剧的话需要指定季
async function addSubscribe(season = 0) {
async function addSubscribe(season: number | null) {
// 开始处理
startNProgress()
try {
// 是否洗版
let best_version = existsItemId.value ? 1 : 0
if (season)
if (season !== null)
// 全部存在时洗版
best_version = !seasonsNotExisted.value[season] ? 1 : 0
// 请求API
@@ -268,7 +274,7 @@ async function addSubscribe(season = 0) {
tmdbid: mediaDetail.value?.tmdb_id,
doubanid: mediaDetail.value?.douban_id,
bangumiid: mediaDetail.value?.bangumi_id,
season,
season: mediaDetail.value?.type === '电影' ? null : season,
best_version,
})
@@ -276,7 +282,7 @@ async function addSubscribe(season = 0) {
if (result.success) {
// 订阅成功
isSubscribed.value = true
if (season) seasonsSubscribed.value[season] = true
if (season !== null) seasonsSubscribed.value[season] = true
}
// 提示
@@ -297,8 +303,8 @@ async function addSubscribe(season = 0) {
}
// 弹出添加订阅提示
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
if (season) title = `${title} ${formatSeason(season.toString())}`
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
let subname = t('media.subscribe.normal')
if (best_version > 0) subname = t('media.subscribe.bestVersion')
@@ -307,7 +313,7 @@ function showSubscribeAddToast(result: boolean, title: string, season: number, m
}
// 调用API取消订阅
async function removeSubscribe(season: number) {
async function removeSubscribe(season: number | null) {
// 开始处理
startNProgress()
try {
@@ -321,7 +327,7 @@ async function removeSubscribe(season: number) {
if (result.success) {
isSubscribed.value = false
if (season) seasonsSubscribed.value[season] = false
if (season !== null) seasonsSubscribed.value[season] = false
$toast.success(`${mediaDetail.value?.title} ${t('media.subscribe.canceled')}`)
} else {
$toast.error(`${mediaDetail.value?.title} ${t('media.subscribe.cancelFailed', { reason: result.message })}`)
@@ -333,7 +339,7 @@ async function removeSubscribe(season: number) {
}
// 订阅按钮响应
function handleSubscribe(season = 0) {
function handleSubscribe(season: number | null = null) {
if (isSubscribed.value) removeSubscribe(season)
else addSubscribe(season)
}
@@ -641,7 +647,7 @@ onBeforeMount(() => {
class="ms-2 mb-2"
:color="getSubscribeColor"
variant="tonal"
@click="handleSubscribe(0)"
@click="handleSubscribe()"
>
<template #prepend>
<VIcon :icon="getSubscribeIcon" />
@@ -741,8 +747,9 @@ onBeforeMount(() => {
<template #default>
<div class="flex flex-row items-center justify-between">
<span class="font-weight-bold">{{
t('media.seasonNumber', { number: season.season_number })
}}</span>
season.season_number === 0 && season.name ?
season.name : t('media.seasonNumber', { number: season.season_number })
}}</span>
<VChip size="small" class="ms-1">
{{ t('media.episodeCount', { count: season.episode_count }) }}
</VChip>
@@ -754,7 +761,7 @@ onBeforeMount(() => {
class="ms-1"
:color="seasonsSubscribed[season.season_number || 0] ? 'error' : 'warning'"
variant="text"
@click.stop="handleSubscribe(season.season_number)"
@click.stop="handleSubscribe(season.season_number ?? null)"
>
<VIcon
:icon="seasonsSubscribed[season.season_number || 0] ? 'mdi-heart' : 'mdi-heart-outline'"

View File

@@ -177,7 +177,7 @@ const loading = ref(false)
const totalItems = ref(0)
// 是否要分组
const group = ref(false)
const group = ref<boolean>(route.query.grouped === 'true')
// 分组条件
const groupBy = ref<any>([
@@ -487,6 +487,9 @@ function reloadPage(resetPage = false) {
if (currentPage.value) {
url = addUrlQuery(url, 'currentPage', resetPage ? 1 : currentPage.value)
}
if (group.value) {
url = addUrlQuery(url, 'grouped', 'true')
}
router.push(url)
}
@@ -500,6 +503,26 @@ function ensureNumber(value: any, defaultValue: number = 0) {
return value
}
// 按标题分组后的选中数量统计,键为标题,值为对应分组的选中数
const selectedCountsGroupedByTitle = computed(() => {
return selected.value.reduce((acc, item) => {
const title = item.title || '';
acc[title] = (acc[title] || 0) + 1;
return acc;
}, {} as Record<string, number>);
});
// 控制分组内所有子项的选中状态
const toggleGroupSelection = (checked: boolean | null, items: readonly any[]) => {
const values = items.map(item => item.value);
if (checked) {
selected.value = [...new Set([...selected.value, ...values])];
} else {
const itemsSet = new Set(values);
selected.value = selected.value.filter(item => !itemsSet.has(item));
}
};
// 初始加载数据
onMounted(() => {
loadStorages()
@@ -563,13 +586,20 @@ onMounted(() => {
<template v-slot:group-header="{ item, columns, toggleGroup, isGroupOpen }">
<tr>
<td :colspan="columns.length">
<VBtn
:icon="isGroupOpen(item) ? '$expand' : '$next'"
size="small"
variant="text"
@click="toggleGroup(item)"
/>
{{ item.value }}
<div class="d-flex align-center gap-2">
<VBtn
:icon="isGroupOpen(item) ? '$expand' : '$next'"
size="small"
variant="text"
@click="toggleGroup(item)"
/>
<VCheckbox
:model-value="selectedCountsGroupedByTitle[item.value] == item.items.length"
:indeterminate="selectedCountsGroupedByTitle[item.value] < item.items.length"
@update:modelValue="(checked) => toggleGroupSelection(checked, item.items)"
/>
{{ item.value }}
</div>
</td>
</tr>
</template>

View File

@@ -8,6 +8,7 @@ import { TransferDirectoryConf, StorageConf } from '@/api/types'
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
import StorageCard from '@/components/cards/StorageCard.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue'
import { useI18n } from 'vue-i18n'
import { storageAttributes } from '@/api/constants'
@@ -28,6 +29,9 @@ const $toast = useToast()
// 进度框
const progressDialog = ref(false)
// 分类编辑对话框
const categoryDialog = ref(false)
// 数据源
const sourceItems = [
{ 'title': 'TheMovieDb', 'value': 'themoviedb' },
@@ -292,7 +296,12 @@ onMounted(() => {
:directory="element"
:categories="mediaCategories"
:storages="storages"
@update:modelValue="(value: any) => {element.download_path = value?.download; element.library_path = value?.library}"
@update:modelValue="
(value: any) => {
element.download_path = value?.download
element.library_path = value?.library
}
"
@close="removeDirectory(element)"
/>
</template>
@@ -304,9 +313,13 @@ onMounted(() => {
<VBtn type="submit" @click="saveDirectories" prepend-icon="mdi-content-save">
{{ t('common.save') }}
</VBtn>
<VBtn color="success" variant="tonal" @click="addDirectory">
<VBtn color="success" variant="tonal" @click="addDirectory" class="me-2">
<VIcon icon="mdi-plus" />
</VBtn>
<VSpacer />
<VBtn color="info" variant="tonal" prepend-icon="mdi-shape-plus" @click="categoryDialog = true">
{{ t('setting.category.title') }}
</VBtn>
</div>
</VForm>
</VCardText>
@@ -370,4 +383,12 @@ onMounted(() => {
</VRow>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="t('setting.system.reloading')" />
<!-- 分类对话框 -->
<CategoryEditDialog
v-if="categoryDialog"
v-model="categoryDialog"
:categories="mediaCategories"
@close="categoryDialog = false"
@done="loadMediaCategories"
/>
</template>

View File

@@ -300,6 +300,9 @@ onMounted(() => {
<VListItem @click="addNotification('synologychat')">
<VListItemTitle>{{ t('setting.notification.synologyChat') }}</VListItemTitle>
</VListItem>
<VListItem @click="addNotification('qqbot')">
<VListItemTitle>{{ t('setting.notification.qq') }}</VListItemTitle>
</VListItem>
<VListItem @click="addNotification('vocechat')">
<VListItemTitle>{{ t('setting.notification.voceChat') }}</VListItemTitle>
</VListItem>

View File

@@ -33,6 +33,8 @@ const SystemSettings = ref<any>({
CUSTOMIZE_WALLPAPER_API_URL: null,
AI_AGENT_ENABLE: false,
AI_AGENT_GLOBAL: false,
AI_AGENT_VERBOSE: false,
AI_AGENT_JOB_INTERVAL: 24,
LLM_PROVIDER: 'deepseek',
LLM_MODEL: 'deepseek-chat',
LLM_API_KEY: null,
@@ -40,6 +42,7 @@ const SystemSettings = ref<any>({
AI_RECOMMEND_ENABLED: false,
AI_RECOMMEND_USER_PREFERENCE: null,
AI_RECOMMEND_MAX_ITEMS: 50,
LLM_MAX_CONTEXT_TOKENS: 64,
},
// 高级系统设置
Advanced: {
@@ -54,6 +57,7 @@ const SystemSettings = ref<any>({
AUTO_UPDATE_RESOURCE: true,
MOVIEPILOT_AUTO_UPDATE: false,
// 媒体
RECOGNIZE_PLUGIN_FIRST: false,
TMDB_API_DOMAIN: null,
TMDB_IMAGE_DOMAIN: null,
TMDB_LOCALE: null,
@@ -83,28 +87,53 @@ const SystemSettings = ref<any>({
},
})
// 刮削开关设
const ScrapingSwitchs = ref<any>({
movie_nfo: true, // 电影NFO
movie_poster: true, // 电影海报
movie_backdrop: true, // 电影背景图
movie_logo: true, // 电影Logo
movie_disc: true, // 电影光盘图
movie_banner: true, // 电影横幅图
movie_thumb: true, // 电影缩略图
tv_nfo: true, // 电视剧NFO
tv_poster: true, // 电视剧海报
tv_backdrop: true, // 电视剧背景图
tv_banner: true, // 电视剧横幅图
tv_logo: true, // 电视剧Logo
tv_thumb: true, // 电视剧缩略图
season_nfo: true, // 季NFO
season_poster: true, // 季海报
season_banner: true, // 季横幅图
season_thumb: true, // 季缩略图
episode_nfo: true, // 集NFO
episode_thumb: true, // 集缩略图
})
// 刮削
const scrapingConfig = [
{
section: 'movie',
items: [
{ key: 'movie_nfo', label: 'setting.system.movieNfo' },
{ key: 'movie_poster', label: 'setting.system.moviePoster' },
{ key: 'movie_backdrop', label: 'setting.system.movieBackdrop' },
{ key: 'movie_logo', label: 'setting.system.movieLogo' },
{ key: 'movie_disc', label: 'setting.system.movieDisc' },
{ key: 'movie_banner', label: 'setting.system.movieBanner' },
{ key: 'movie_thumb', label: 'setting.system.movieThumb' },
],
},
{
section: 'tv',
items: [
{ key: 'tv_nfo', label: 'setting.system.tvNfo' },
{ key: 'tv_poster', label: 'setting.system.tvPoster' },
{ key: 'tv_backdrop', label: 'setting.system.tvBackdrop' },
{ key: 'tv_banner', label: 'setting.system.tvBanner' },
{ key: 'tv_logo', label: 'setting.system.tvLogo' },
{ key: 'tv_thumb', label: 'setting.system.tvThumb' },
],
},
{
section: 'season',
items: [
{ key: 'season_nfo', label: 'setting.system.seasonNfo' },
{ key: 'season_poster', label: 'setting.system.seasonPoster' },
{ key: 'season_banner', label: 'setting.system.seasonBanner' },
{ key: 'season_thumb', label: 'setting.system.seasonThumb' },
],
},
{
section: 'episode',
items: [
{ key: 'episode_nfo', label: 'setting.system.episodeNfo' },
{ key: 'episode_thumb', label: 'setting.system.episodeThumb' },
],
},
]
// 刮削策略设置
const ScrapingPolicies = ref<Record<string, 'skip' | 'missingOnly' | 'overwrite'>>(
Object.fromEntries(scrapingConfig.flatMap(section => section.items.map(item => [item.key, 'missingOnly']))),
)
// 是否发送请求的总开关
const isRequest = ref(true)
@@ -479,7 +508,14 @@ async function loadScrapingSwitchs() {
try {
const result: { [key: string]: any } = await api.get('system/setting/ScrapingSwitchs')
if (result.success && result.data?.value) {
ScrapingSwitchs.value = { ...ScrapingSwitchs.value, ...result.data.value }
const loadedSwitches = result.data.value
for (const key in loadedSwitches) {
if (typeof loadedSwitches[key] === 'boolean') {
// 兼容旧数据
loadedSwitches[key] = loadedSwitches[key] ? 'missingOnly' : 'skip'
}
}
ScrapingPolicies.value = { ...ScrapingPolicies.value, ...loadedSwitches }
}
} catch (error) {
console.log(error)
@@ -489,7 +525,7 @@ async function loadScrapingSwitchs() {
// 保存刮削开关设置
async function saveScrapingSwitchs() {
try {
const result: { [key: string]: any } = await api.post('system/setting/ScrapingSwitchs', ScrapingSwitchs.value)
const result: { [key: string]: any } = await api.post('system/setting/ScrapingSwitchs', ScrapingPolicies.value)
if (result.success) {
return true
} else {
@@ -647,7 +683,7 @@ onDeactivated(() => {
</VRow>
<VDivider class="my-4" />
<VRow>
<VCol cols="12">
<VCol cols="12" md="4">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
:label="t('setting.system.aiAgentEnable')"
@@ -655,6 +691,22 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
:label="t('setting.system.aiAgentGlobal')"
:hint="t('setting.system.aiAgentGlobalHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_VERBOSE"
:label="t('setting.system.aiAgentVerbose')"
:hint="t('setting.system.aiAgentVerboseHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.LLM_PROVIDER"
@@ -713,14 +765,35 @@ onDeactivated(() => {
</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')"
<VTextField
v-model.number="SystemSettings.Basic.LLM_MAX_CONTEXT_TOKENS"
:label="t('setting.system.llmMaxContextTokens')"
:hint="t('setting.system.llmMaxContextTokensHint')"
persistent-hint
type="number"
prepend-inner-icon="mdi-counter"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.AI_AGENT_JOB_INTERVAL"
:label="t('setting.system.aiAgentJobInterval')"
:hint="t('setting.system.aiAgentJobIntervalHint')"
persistent-hint
:items="[
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
{ title: t('setting.system.aiAgentJobInterval1h'), value: 1 },
{ title: t('setting.system.aiAgentJobInterval3h'), value: 3 },
{ title: t('setting.system.aiAgentJobInterval6h'), value: 6 },
{ title: t('setting.system.aiAgentJobInterval12h'), value: 12 },
{ title: t('setting.system.aiAgentJobInterval24h'), value: 24 },
{ title: t('setting.system.aiAgentJobInterval1w'), value: 168 },
{ title: t('setting.system.aiAgentJobInterval1M'), value: 720 },
]"
prepend-inner-icon="mdi-timer-outline"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
<VSwitch
v-model="SystemSettings.Basic.AI_RECOMMEND_ENABLED"
:label="t('setting.system.aiRecommendEnabled')"
@@ -728,7 +801,11 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED" cols="12">
<VCol
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED"
cols="12"
md="6"
>
<VTextarea
v-model="SystemSettings.Basic.AI_RECOMMEND_USER_PREFERENCE"
:label="t('setting.system.aiRecommendUserPreference')"
@@ -1087,6 +1164,16 @@ onDeactivated(() => {
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.RECOGNIZE_PLUGIN_FIRST"
:label="t('setting.system.recognizePluginFirst')"
:hint="t('setting.system.recognizePluginFirstHint')"
persistent-hint
/>
</VCol>
</VRow>
<!-- 刮削开关设置 -->
<VRow class="mt-4">
@@ -1094,173 +1181,67 @@ onDeactivated(() => {
<VExpansionPanels>
<VExpansionPanel>
<VExpansionPanelTitle class="text-lg">
<template #default>
<VIcon icon="mdi-checkbox-multiple-outline" class="me-2" />
{{ t('setting.system.scrapingSwitchSettings') }}
</template>
<VIcon icon="mdi-checkbox-multiple-outline" class="me-2" />
{{ t('setting.system.scrapingSwitchSettings') }}
<!-- 帮助图标 -->
<VTooltip location="bottom" open-delay="200">
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon="mdi-help-circle"
size="small"
variant="text"
color="medium-emphasis"
class="ml-2"
@click.stop
/>
</template>
<div class="d-flex flex-column gap-2 py-2">
<div class="d-flex align-center">
<VIcon icon="mdi-file-remove" color="error" class="mr-2" />
<span>{{ t('setting.system.policy.skipDesc') }}</span>
</div>
<div class="d-flex align-center">
<VIcon icon="mdi-file-plus" color="success" class="mr-2" />
<span>{{ t('setting.system.policy.missingOnlyDesc') }}</span>
</div>
<div class="d-flex align-center">
<VIcon icon="mdi-file-replace" color="primary" class="mr-2" />
<span>{{ t('setting.system.policy.overwriteDesc') }}</span>
</div>
</div>
</VTooltip>
</VExpansionPanelTitle>
<VExpansionPanelText>
<VRow>
<VRow v-for="section in scrapingConfig" :key="section.section">
<VCol cols="12" class="pb-2">
<VListSubheader class="text-lg">{{ t('setting.system.movie') }}</VListSubheader>
<VListSubheader class="text-lg">
{{ t(`setting.system.${section.section}`) }}
</VListSubheader>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_nfo"
:label="t('setting.system.movieNfo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_poster"
:label="t('setting.system.moviePoster')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_backdrop"
:label="t('setting.system.movieBackdrop')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_logo"
:label="t('setting.system.movieLogo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_disc"
:label="t('setting.system.movieDisc')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_banner"
:label="t('setting.system.movieBanner')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_thumb"
:label="t('setting.system.movieThumb')"
density="compact"
/>
</VCol>
</VRow>
<VDivider class="my-4" />
<VRow>
<VCol cols="12" class="pb-2">
<VListSubheader class="text-lg">{{ t('setting.system.tv') }}</VListSubheader>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_nfo"
:label="t('setting.system.tvNfo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_poster"
:label="t('setting.system.tvPoster')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_backdrop"
:label="t('setting.system.tvBackdrop')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_banner"
:label="t('setting.system.tvBanner')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_logo"
:label="t('setting.system.tvLogo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_thumb"
:label="t('setting.system.tvThumb')"
density="compact"
/>
</VCol>
</VRow>
<VDivider class="my-4" />
<VRow>
<VCol cols="12" class="pb-2">
<VListSubheader class="text-lg">{{ t('setting.system.season') }}</VListSubheader>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.season_nfo"
:label="t('setting.system.seasonNfo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.season_poster"
:label="t('setting.system.seasonPoster')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.season_banner"
:label="t('setting.system.seasonBanner')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.season_thumb"
:label="t('setting.system.seasonThumb')"
density="compact"
/>
</VCol>
</VRow>
<VDivider class="my-4" />
<VRow>
<VCol cols="12" class="pb-2">
<VListSubheader class="text-lg">{{ t('setting.system.episode') }}</VListSubheader>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.episode_nfo"
:label="t('setting.system.episodeNfo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.episode_thumb"
:label="t('setting.system.episodeThumb')"
density="compact"
/>
<VCol v-for="item in section.items" :key="item.key" cols="12" md="4">
<div class="d-flex align-center">
<VBtnToggle
:model-value="ScrapingPolicies[item.key]"
@update:model-value="ScrapingPolicies[item.key] = $event"
color="primary"
variant="tonal"
rounded="lg"
>
<VBtn value="skip" color="error">
<VIcon icon="mdi-file-remove" />
</VBtn>
<VBtn value="missingOnly" color="success">
<VIcon icon="mdi-file-plus" />
</VBtn>
<VBtn value="overwrite" color="primary">
<VIcon icon="mdi-file-replace" />
</VBtn>
</VBtnToggle>
<span class="ml-2">{{ t(item.label) }}</span>
</div>
</VCol>
<VDivider v-if="section.section !== 'episode'" class="my-4" />
</VRow>
</VExpansionPanelText>
</VExpansionPanel>
@@ -1414,7 +1395,10 @@ onDeactivated(() => {
min="1"
type="number"
:suffix="t('setting.system.mb')"
:rules="[(v: any) => v === 0 || !!v || t('setting.system.logMaxFileSizeRequired'), (v: any) => v >= 1 || t('setting.system.logMaxFileSizeMin')]"
:rules="[
(v: any) => v === 0 || !!v || t('setting.system.logMaxFileSizeRequired'),
(v: any) => v >= 1 || t('setting.system.logMaxFileSizeMin'),
]"
prepend-inner-icon="mdi-file-document"
/>
</VCol>
@@ -1426,7 +1410,10 @@ onDeactivated(() => {
persistent-hint
min="1"
type="number"
:rules="[(v: any) => v === 0 || !!v || t('setting.system.logBackupCountRequired'), (v: any) => v >= 1 || t('setting.system.logBackupCountMin')]"
:rules="[
(v: any) => v === 0 || !!v || t('setting.system.logBackupCountRequired'),
(v: any) => v >= 1 || t('setting.system.logBackupCountMin'),
]"
prepend-inner-icon="mdi-backup-restore"
/>
</VCol>

View File

@@ -27,7 +27,7 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
<div class="mb-4">
<h4 class="text-h6 mb-4">{{ t('setupWizard.downloader.type') }}</h4>
<VRow>
<VCol cols="12" md="6">
<VCol cols="12" md="4">
<VCard
:color="wizardData.downloader.type === 'qbittorrent' ? 'primary' : 'default'"
:variant="wizardData.downloader.type === 'qbittorrent' ? 'tonal' : 'outlined'"
@@ -40,7 +40,7 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="6">
<VCol cols="12" md="4">
<VCard
:color="wizardData.downloader.type === 'transmission' ? 'primary' : 'default'"
:variant="wizardData.downloader.type === 'transmission' ? 'tonal' : 'outlined'"
@@ -53,6 +53,19 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="4">
<VCard
:color="wizardData.downloader.type === 'rtorrent' ? 'primary' : 'default'"
:variant="wizardData.downloader.type === 'rtorrent' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectDownloader('rtorrent')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('rtorrent')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">rTorrent</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</VCol>
@@ -203,6 +216,63 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.downloader.type === 'rtorrent'">
<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/RPC2"
:hint="t('downloader.rtorrentHostHint')"
: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

View File

@@ -15,6 +15,23 @@ const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
},
])
const ugreenScanModeOptions = computed(() => [
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
])
function ensureUgreenConfig() {
if (wizardData.value.mediaServer.type !== 'ugreen') return
wizardData.value.mediaServer.config = wizardData.value.mediaServer.config || {}
if (!wizardData.value.mediaServer.config.scan_mode) {
wizardData.value.mediaServer.config.scan_mode = 'supplement_missing'
}
if (wizardData.value.mediaServer.config.verify_ssl === undefined) {
wizardData.value.mediaServer.config.verify_ssl = true
}
}
// 调用API查询媒体库
async function loadLibrary(server: string) {
try {
@@ -42,6 +59,7 @@ async function loadLibrary(server: string) {
// 选择媒体服务器并自动加载媒体库
async function selectMediaServerWithLibrary(type: string) {
selectMediaServer(type)
ensureUgreenConfig()
// 如果选择了媒体服务器类型,自动加载媒体库
if (type && wizardData.value.mediaServer.name) {
await loadLibrary(wizardData.value.mediaServer.name)
@@ -50,6 +68,7 @@ async function selectMediaServerWithLibrary(type: string) {
// 组件挂载时检查是否需要加载媒体库
onMounted(async () => {
ensureUgreenConfig()
// 如果已经有媒体服务器配置,自动加载媒体库
if (wizardData.value.mediaServer.type && wizardData.value.mediaServer.name) {
await loadLibrary(wizardData.value.mediaServer.name)
@@ -60,6 +79,7 @@ onMounted(async () => {
watch(
() => [wizardData.value.mediaServer.type, wizardData.value.mediaServer.name],
async ([type, name]) => {
ensureUgreenConfig()
console.log('Media server changed:', { type, name })
if (type && name) {
await loadLibrary(name)
@@ -141,6 +161,19 @@ watch(
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.mediaServer.type === 'ugreen' ? 'primary' : 'default'"
:variant="wizardData.mediaServer.type === 'ugreen' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectMediaServerWithLibrary('ugreen')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('ugreen')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">绿联影视</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</VCol>
@@ -380,6 +413,107 @@ watch(
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.mediaServer.type === 'ugreen'">
<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>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.mediaServer.config.scan_mode"
:label="t('mediaserver.scanMode')"
:items="ugreenScanModeOptions"
:hint="t('mediaserver.scanModeHint')"
persistent-hint
active
prepend-inner-icon="mdi-radar"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="wizardData.mediaServer.config.verify_ssl"
:label="t('mediaserver.verifySsl')"
:hint="t('mediaserver.verifySslHint')"
persistent-hint
color="primary"
inset
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.mediaServer.type === 'plex'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -91,6 +91,19 @@ const notificationTypes = [
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.notification.type === 'qqbot' ? 'primary' : 'default'"
:variant="wizardData.notification.type === 'qqbot' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectNotification('qqbot')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('notification')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">QQ</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.notification.type === 'vocechat' ? 'primary' : 'default'"
@@ -312,6 +325,59 @@ const notificationTypes = [
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.notification.type === 'qqbot'">
<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.QQ_APP_ID"
:label="t('notification.qqbot.appId')"
:hint="t('notification.qqbot.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.QQ_APP_SECRET"
:label="t('notification.qqbot.appSecret')"
:hint="t('notification.qqbot.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.QQ_OPENID"
:label="t('notification.qqbot.openId')"
:placeholder="t('notification.qqbot.openIdPlaceholder')"
:hint="t('notification.qqbot.openIdHint')"
persistent-hint
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.QQ_GROUP_OPENID"
:label="t('notification.qqbot.groupOpenId')"
:placeholder="t('notification.qqbot.groupOpenIdPlaceholder')"
:hint="t('notification.qqbot.groupOpenIdHint')"
persistent-hint
prepend-inner-icon="mdi-account-group"
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.notification.type === 'slack'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -71,7 +71,8 @@ async function eventsHander(subscribe: Subscribe) {
}
} else {
// 调用API查询集信息
const episodes: TmdbEpisode[] = await api.get(`tmdb/${subscribe.tmdbid}/${subscribe.season}`)
const params = subscribe.episode_group ? { episode_group: subscribe.episode_group } : undefined
const episodes: TmdbEpisode[] = await api.get(`tmdb/${subscribe.tmdbid}/${subscribe.season}`, params ? { params } : undefined)
interface EpisodeInfo {
title: string

View File

@@ -70,10 +70,6 @@ useDataRefresh(
<template>
<VCard>
<VCardItem>
<VCardTitle>{{ t('setting.scheduler.title') }}</VCardTitle>
<VCardSubtitle>{{ t('setting.scheduler.subtitle') }}</VCardSubtitle>
</VCardItem>
<VTable class="text-no-wrap">
<thead>
<tr>

View File

@@ -132,6 +132,7 @@ async function fetchUserInfo() {
if (result) {
accountInfo.value = result
accountInfo.value.avatar = accountInfo.value.avatar ? accountInfo.value.avatar : avatar1
accountInfo.value.nickname = accountInfo.value.settings?.nickname ?? ''
currentUserName.value = accountInfo.value.name
currentAvatar.value = accountInfo.value.avatar
// 同时加载PassKey列表
@@ -161,12 +162,10 @@ async function saveAccountInfo() {
}
// 将nickname保存到settings中后端可以直接处理JSON对象
if (accountInfo.value.nickname) {
if (!accountInfo.value.settings) {
accountInfo.value.settings = {}
}
accountInfo.value.settings.nickname = accountInfo.value.nickname
if (!accountInfo.value.settings) {
accountInfo.value.settings = {}
}
accountInfo.value.settings.nickname = accountInfo.value.nickname ?? ''
const oldUserName = accountInfo.value.name
const oldAvatar = accountInfo.value.avatar

View File

@@ -861,24 +861,24 @@
integrity sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==
"@emnapi/core@^1.4.3":
version "1.4.5"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.5.tgz#bfbb0cbbbb9f96ec4e2c4fd917b7bbe5495ceccb"
integrity sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==
version "1.8.1"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349"
integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==
dependencies:
"@emnapi/wasi-threads" "1.0.4"
"@emnapi/wasi-threads" "1.1.0"
tslib "^2.4.0"
"@emnapi/runtime@^1.2.0", "@emnapi/runtime@^1.4.3":
version "1.4.5"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.4.5.tgz#c67710d0661070f38418b6474584f159de38aba9"
integrity sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==
version "1.8.1"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5"
integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==
dependencies:
tslib "^2.4.0"
"@emnapi/wasi-threads@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz#703fc094d969e273b1b71c292523b2f792862bf4"
integrity sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==
"@emnapi/wasi-threads@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf"
integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
dependencies:
tslib "^2.4.0"
@@ -1147,21 +1147,21 @@
"@iconify-json/line-md@^1.2.13":
version "1.2.13"
resolved "https://registry.yarnpkg.com/@iconify-json/line-md/-/line-md-1.2.13.tgz#19714b8471ebac5871e20036512eaffa869a04b7"
resolved "https://registry.npmjs.org/@iconify-json/line-md/-/line-md-1.2.13.tgz"
integrity sha512-XFXThXsEQ2Wzzn+ze2T1d+JHkkFvI1AxiVKnOox4qFbdR9EVikckZlUK+/DUsV4zSy6pMQAgXpIk+1xG8qFYPQ==
dependencies:
"@iconify/types" "*"
"@iconify-json/lucide@^1.2.85":
version "1.2.85"
resolved "https://registry.yarnpkg.com/@iconify-json/lucide/-/lucide-1.2.85.tgz#0074b64f50798da4b89f9f74e4db5a4e56c640b1"
resolved "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.85.tgz"
integrity sha512-VXUWT6KRDiVK4Ty/7Ypu+U0KnSbHzDAOOiSgLLPhU8u3ES5IusP1X7ahZb1iwiVKGWRG6gkKywaRUIZLgYWXyA==
dependencies:
"@iconify/types" "*"
"@iconify-json/material-symbols@^1.2.51":
version "1.2.51"
resolved "https://registry.yarnpkg.com/@iconify-json/material-symbols/-/material-symbols-1.2.51.tgz#270862a21bb65a8632de4943146096b5a58863ae"
resolved "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.51.tgz"
integrity sha512-GkxlK8ocHi3NVVozaW62jm3qR9fNY3xX2penFtIRvoe1OtNhJ2KD4KRzv8x34pugMOAZYK8sALMcU30gDgCi1A==
dependencies:
"@iconify/types" "*"
@@ -1942,20 +1942,20 @@
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
"@tybys/wasm-util@^0.10.0":
version "0.10.0"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.0.tgz#2fd3cd754b94b378734ce17058d0507c45c88369"
integrity sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==
version "0.10.1"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==
dependencies:
tslib "^2.4.0"
"@types/body-scroll-lock@^3.1.2":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@types/body-scroll-lock/-/body-scroll-lock-3.1.2.tgz#1ae7857d98180dbe6c3b05abbe7ec1fa67b614e3"
resolved "https://registry.npmjs.org/@types/body-scroll-lock/-/body-scroll-lock-3.1.2.tgz"
integrity sha512-ELhtuphE/YbhEcpBf/rIV9Tl3/O0A0gpCVD+oYFSS8bWstHFJUgA4nNw1ZakVlRC38XaQEIsBogUZKWIPBvpfQ==
"@types/crypto-js@^4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.2.2.tgz#771c4a768d94eb5922cc202a3009558204df0cea"
resolved "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz"
integrity sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==
"@types/debug@^4.1.12":
@@ -1999,7 +1999,7 @@
"@types/linkify-it@^5":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
resolved "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz"
integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
"@types/lodash-es@^4.17.12":
@@ -2016,14 +2016,14 @@
"@types/markdown-it-link-attributes@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/markdown-it-link-attributes/-/markdown-it-link-attributes-3.0.5.tgz#521179990cd2ced55761d9b8c93e502b679df329"
resolved "https://registry.npmjs.org/@types/markdown-it-link-attributes/-/markdown-it-link-attributes-3.0.5.tgz"
integrity sha512-VZ2BGN3ywUg7mBD8W6PwR8ChpOxaQSBDbLqPgvNI+uIra3zY2af1eG/3XzWTKjEraTWskMKnZqZd6m1fDF67Bg==
dependencies:
"@types/markdown-it" "*"
"@types/markdown-it@*", "@types/markdown-it@^14.1.2":
version "14.1.2"
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
resolved "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz"
integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==
dependencies:
"@types/linkify-it" "^5"
@@ -2031,7 +2031,7 @@
"@types/mdurl@^2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
resolved "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz"
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
"@types/mousetrap@^1.6.15":
@@ -2068,7 +2068,7 @@
"@types/qrcode@^1.5.6":
version "1.5.6"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.6.tgz#07c33cb9ec0ad88be4636e636e28e54d99b65f42"
resolved "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz"
integrity sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==
dependencies:
"@types/node" "*"
@@ -2939,7 +2939,7 @@ body-parser@1.20.3:
body-scroll-lock@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/body-scroll-lock/-/body-scroll-lock-3.1.5.tgz#c1392d9217ed2c3e237fee1e910f6cdd80b7aaec"
resolved "https://registry.npmjs.org/body-scroll-lock/-/body-scroll-lock-3.1.5.tgz"
integrity sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg==
boolbase@^1.0.0:
@@ -3071,7 +3071,7 @@ camelcase-css@^2.0.1:
camelcase@^5.0.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702:
@@ -3164,7 +3164,7 @@ clean-regexp@^1.0.0:
cliui@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
resolved "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz"
integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
dependencies:
string-width "^4.2.0"
@@ -3330,7 +3330,7 @@ cross-spawn@^7.0.6:
crypto-js@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
resolved "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz"
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
crypto-random-string@^2.0.0:
@@ -3529,7 +3529,7 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3
decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
deep-is@^0.1.3:
@@ -3617,7 +3617,7 @@ didyoumean@^1.2.2:
dijkstrajs@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
resolved "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz"
integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==
dir-glob@^3.0.1:
@@ -4531,7 +4531,7 @@ gensync@^1.0.0-beta.2:
get-caller-file@^2.0.1:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0:
@@ -5397,7 +5397,7 @@ lines-and-columns@^1.1.6:
linkify-it@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
resolved "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz"
integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
dependencies:
uc.micro "^2.0.0"
@@ -5505,12 +5505,12 @@ magic-string@^0.30.11, magic-string@^0.30.17:
markdown-it-link-attributes@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/markdown-it-link-attributes/-/markdown-it-link-attributes-4.0.1.tgz#25751f2cf74fd91f0a35ba7b3247fa45f2056d88"
resolved "https://registry.npmjs.org/markdown-it-link-attributes/-/markdown-it-link-attributes-4.0.1.tgz"
integrity sha512-pg5OK0jPLg62H4k7M9mRJLT61gUp9nvG0XveKYHMOOluASo9OEF13WlXrpAp2aj35LbedAy3QOCgQCw0tkLKAQ==
markdown-it@^14.1.0:
version "14.1.0"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz"
integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
dependencies:
argparse "^2.0.1"
@@ -5552,7 +5552,7 @@ mdn-data@^2.15.0:
mdurl@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
resolved "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz"
integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
media-typer@0.3.0:
@@ -6158,7 +6158,7 @@ pluralize@^8.0.0:
pngjs@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
possible-typed-array-names@^1.0.0:
@@ -6315,7 +6315,7 @@ pump@^3.0.0:
punycode.js@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
resolved "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz"
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
punycode@^2.1.0:
@@ -6325,7 +6325,7 @@ punycode@^2.1.0:
qrcode@^1.5.4:
version "1.5.4"
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88"
resolved "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz"
integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==
dependencies:
dijkstrajs "^1.0.1"
@@ -6538,7 +6538,7 @@ regjsparser@^0.12.0:
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz"
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
require-from-string@^2.0.2:
@@ -6548,7 +6548,7 @@ require-from-string@^2.0.2:
require-main-filename@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
requires-port@^1.0.0:
@@ -6754,7 +6754,7 @@ serve-static@1.16.2:
set-blocking@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz"
integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
set-function-length@^1.2.2:
@@ -7548,7 +7548,7 @@ typescript@^5, typescript@^5.0.4:
uc.micro@^2.0.0, uc.micro@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
resolved "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz"
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
ufo@^1.5.4, ufo@^1.6.1:
@@ -8062,7 +8062,7 @@ which-collection@^1.0.2:
which-module@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409"
resolved "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz"
integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==
which-typed-array@^1.1.16, which-typed-array@^1.1.18:
@@ -8266,7 +8266,7 @@ workbox-window@7.3.0, workbox-window@^7.3.0:
wrap-ansi@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz"
integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
dependencies:
ansi-styles "^4.0.0"
@@ -8302,7 +8302,7 @@ xml-name-validator@^4.0.0:
y18n@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz"
integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
yallist@^3.0.2:
@@ -8330,7 +8330,7 @@ yaml@^2.0.0, yaml@^2.3.4, yaml@^2.7.0:
yargs-parser@^18.1.2:
version "18.1.3"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz"
integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
dependencies:
camelcase "^5.0.0"
@@ -8338,7 +8338,7 @@ yargs-parser@^18.1.2:
yargs@^15.3.1:
version "15.4.1"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz"
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
dependencies:
cliui "^6.0.0"