mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-28 11:01:41 +08:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
258e64bca7 | ||
|
|
e905df014e | ||
|
|
b93f8f2bff | ||
|
|
9aa0a5e1b7 | ||
|
|
ee9f41d015 | ||
|
|
ad6a664cbe | ||
|
|
3387067636 | ||
|
|
07dc3c3e9a | ||
|
|
262b4bebd4 | ||
|
|
6e50cf31de | ||
|
|
14aa75dfae | ||
|
|
348aa4757b | ||
|
|
6e6819acc1 | ||
|
|
51a58aaae0 | ||
|
|
fbde99389e | ||
|
|
5a4e345529 | ||
|
|
b446afb6d8 | ||
|
|
8580af36d1 | ||
|
|
95ca092117 | ||
|
|
ba200cae5c | ||
|
|
87c73e0253 | ||
|
|
d4d7f635f5 | ||
|
|
729db1510e | ||
|
|
8a12ecf918 | ||
|
|
cacc2602df | ||
|
|
8c6cfa7fc5 | ||
|
|
0113f28d8c | ||
|
|
d870b788bc |
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
<!-- iOS Safari PWA 优化 -->
|
<!-- iOS Safari PWA 优化 -->
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
|
||||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
|
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
|
||||||
|
|
||||||
<!-- iOS Safari 全屏模式 -->
|
<!-- iOS Safari 全屏模式 -->
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "2.13.0",
|
"version": "2.13.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
|
|||||||
BIN
public/apple-touch-icon-precomposed.png
Normal file
BIN
public/apple-touch-icon-precomposed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -1025,6 +1025,10 @@ export interface FileItem {
|
|||||||
export interface MediaServerPlayItem {
|
export interface MediaServerPlayItem {
|
||||||
// ID
|
// ID
|
||||||
id?: string | number
|
id?: string | number
|
||||||
|
// 媒体服务器项目ID
|
||||||
|
item_id?: string | number
|
||||||
|
// 媒体服务器ID
|
||||||
|
server_id?: string
|
||||||
// 标题
|
// 标题
|
||||||
title: string
|
title: string
|
||||||
// 副标题
|
// 副标题
|
||||||
@@ -1049,6 +1053,10 @@ export interface MediaServerLibrary {
|
|||||||
server: string
|
server: string
|
||||||
// ID
|
// ID
|
||||||
id?: string | number
|
id?: string | number
|
||||||
|
// 媒体服务器项目ID
|
||||||
|
item_id?: string | number
|
||||||
|
// 媒体服务器ID
|
||||||
|
server_id?: string
|
||||||
// 名称
|
// 名称
|
||||||
name: string
|
name: string
|
||||||
// 路径
|
// 路径
|
||||||
@@ -1292,7 +1300,7 @@ export interface TransferForm {
|
|||||||
// 目标存储
|
// 目标存储
|
||||||
target_storage: string
|
target_storage: string
|
||||||
// 目标路径
|
// 目标路径
|
||||||
target_path: string
|
target_path: string | null
|
||||||
// TMDB ID
|
// TMDB ID
|
||||||
tmdbid?: number
|
tmdbid?: number
|
||||||
// 豆瓣 ID
|
// 豆瓣 ID
|
||||||
@@ -1335,6 +1343,22 @@ export interface ManualTransferPayload extends Omit<TransferForm, 'fileitem'> {
|
|||||||
fileitems?: FileItem[]
|
fileitems?: FileItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手动整理目的路径匹配结果
|
||||||
|
export interface ManualTransferTargetPathData {
|
||||||
|
// 目标存储
|
||||||
|
target_storage?: string | null
|
||||||
|
// 目标路径
|
||||||
|
target_path?: string | null
|
||||||
|
// 整理方式
|
||||||
|
transfer_type?: string | null
|
||||||
|
// 刮削
|
||||||
|
scrape?: boolean
|
||||||
|
// 媒体库类型子目录
|
||||||
|
library_type_folder?: boolean
|
||||||
|
// 媒体库类别子目录
|
||||||
|
library_category_folder?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// 手动整理预览统计
|
// 手动整理预览统计
|
||||||
export interface ManualTransferPreviewSummary {
|
export interface ManualTransferPreviewSummary {
|
||||||
// 总数
|
// 总数
|
||||||
@@ -1369,6 +1393,14 @@ export interface ManualTransferPreviewItem {
|
|||||||
episode_end?: number | string
|
episode_end?: number | string
|
||||||
// Part
|
// Part
|
||||||
part?: string
|
part?: string
|
||||||
|
// 原始识别字符串
|
||||||
|
org_string?: string
|
||||||
|
// 应用的自定义识别词
|
||||||
|
apply_words?: string[]
|
||||||
|
// 制作组/字幕组
|
||||||
|
resource_team?: string
|
||||||
|
// 自定义占位符
|
||||||
|
customization?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 手动整理预览数据
|
// 手动整理预览数据
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { MediaServerPlayItem } from '@/api/types'
|
import type { MediaServerPlayItem } from '@/api/types'
|
||||||
import noImage from '@images/no-image.jpeg'
|
import noImage from '@images/no-image.jpeg'
|
||||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
media: Object as PropType<MediaServerPlayItem>,
|
media: Object as PropType<MediaServerPlayItem>,
|
||||||
@@ -25,8 +25,8 @@ function imageErrorHandler() {
|
|||||||
|
|
||||||
// 跳转播放
|
// 跳转播放
|
||||||
async function goPlay() {
|
async function goPlay() {
|
||||||
if (props.media?.link) {
|
if (props.media) {
|
||||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
await openMediaServerItem(props.media)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import plex from '@images/misc/plex.png'
|
|||||||
import emby from '@images/misc/emby.png'
|
import emby from '@images/misc/emby.png'
|
||||||
import jellyfin from '@images/misc/jellyfin.png'
|
import jellyfin from '@images/misc/jellyfin.png'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -49,8 +49,8 @@ function getDefaultImage() {
|
|||||||
|
|
||||||
// 跳转播放
|
// 跳转播放
|
||||||
async function goPlay() {
|
async function goPlay() {
|
||||||
if (props.media?.link) {
|
if (props.media) {
|
||||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
await openMediaServerItem(props.media)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import noImage from '@images/no-image.jpeg'
|
import noImage from '@images/no-image.jpeg'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||||
@@ -464,13 +464,7 @@ function setupIntersectionObserver() {
|
|||||||
const getImgUrl: Ref<string> = computed(() => {
|
const getImgUrl: Ref<string> = computed(() => {
|
||||||
if (imageLoadError.value) return noImage
|
if (imageLoadError.value) return noImage
|
||||||
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
||||||
// 使用图片缓存
|
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE)
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
// 如果地址中包含douban则使用中转代理
|
|
||||||
if (url.includes('doubanio.com'))
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 移除订阅
|
// 移除订阅
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import personIcon from '@images/misc/person-icon.png'
|
|||||||
import type { Person } from '@/api/types'
|
import type { Person } from '@/api/types'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
const personProps = defineProps({
|
const personProps = defineProps({
|
||||||
person: Object as PropType<Person>,
|
person: Object as PropType<Person>,
|
||||||
@@ -40,9 +41,7 @@ function getPersonImage() {
|
|||||||
} else {
|
} else {
|
||||||
return personIcon
|
return personIcon
|
||||||
}
|
}
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 人物姓名
|
// 人物姓名
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import type { MediaServerPlayItem } from '@/api/types'
|
import type { MediaServerPlayItem } from '@/api/types'
|
||||||
import noImage from '@images/no-image.jpeg'
|
import noImage from '@images/no-image.jpeg'
|
||||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -38,8 +38,8 @@ const getImgUrl = computed(() => {
|
|||||||
|
|
||||||
// 跳转播放
|
// 跳转播放
|
||||||
async function goPlay(isHovering: boolean | null = false) {
|
async function goPlay(isHovering: boolean | null = false) {
|
||||||
if (props.media?.link && isHovering) {
|
if (props.media && isHovering) {
|
||||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
await openMediaServerItem(props.media)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||||
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
|
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
|
||||||
@@ -363,19 +364,13 @@ watch(
|
|||||||
// 计算backdrop图片地址
|
// 计算backdrop图片地址
|
||||||
const backdropUrl = computed(() => {
|
const backdropUrl = computed(() => {
|
||||||
const url = props.media?.backdrop || props.media?.poster
|
const url = props.media?.backdrop || props.media?.poster
|
||||||
// 使用图片缓存
|
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算海报图片地址
|
// 计算海报图片地址
|
||||||
const posterUrl = computed(() => {
|
const posterUrl = computed(() => {
|
||||||
const url = props.media?.poster
|
const url = props.media?.poster
|
||||||
// 使用图片缓存
|
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 订阅编辑保存
|
// 订阅编辑保存
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { SubscribeShare } from '@/api/types'
|
|||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue'))
|
const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue'))
|
||||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||||
@@ -35,19 +36,13 @@ const dateText = ref(props.media && props.media?.date ? formatDateDifference(pro
|
|||||||
// 计算backdrop图片地址
|
// 计算backdrop图片地址
|
||||||
const backdropUrl = computed(() => {
|
const backdropUrl = computed(() => {
|
||||||
const url = props.media?.backdrop || props.media?.poster
|
const url = props.media?.backdrop || props.media?.poster
|
||||||
// 使用图片缓存
|
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算海报图片地址
|
// 计算海报图片地址
|
||||||
const posterUrl = computed(() => {
|
const posterUrl = computed(() => {
|
||||||
const url = props.media?.poster
|
const url = props.media?.poster
|
||||||
// 使用图片缓存
|
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获得mediaid
|
// 获得mediaid
|
||||||
|
|||||||
@@ -84,6 +84,33 @@ const releaseDialogTitle = ref('')
|
|||||||
// 变更日志对话框内容
|
// 变更日志对话框内容
|
||||||
const releaseDialogBody = ref('')
|
const releaseDialogBody = ref('')
|
||||||
|
|
||||||
|
// 版本统计对话框
|
||||||
|
const versionStatisticDialog = ref(false)
|
||||||
|
|
||||||
|
// 版本统计加载状态
|
||||||
|
const versionStatisticLoading = ref(false)
|
||||||
|
|
||||||
|
// 版本统计数据
|
||||||
|
const versionStatistic = ref<any>({})
|
||||||
|
|
||||||
|
// 后端版本统计
|
||||||
|
const backendVersionStatistics = computed(() => versionStatistic.value?.backend_versions ?? [])
|
||||||
|
|
||||||
|
// 前端版本统计
|
||||||
|
const frontendVersionStatistics = computed(() => versionStatistic.value?.frontend_versions ?? [])
|
||||||
|
|
||||||
|
// 活跃用户统计
|
||||||
|
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
|
||||||
|
|
||||||
|
/** 格式化版本安装统计数字为千分位展示。 */
|
||||||
|
function formatVersionStatisticNumber(value: unknown) {
|
||||||
|
const numberValue = Number(value ?? 0)
|
||||||
|
|
||||||
|
if (!Number.isFinite(numberValue)) return '0'
|
||||||
|
|
||||||
|
return numberValue.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
// 打开日志对话框
|
// 打开日志对话框
|
||||||
function showReleaseDialog(title: string, body: string) {
|
function showReleaseDialog(title: string, body: string) {
|
||||||
releaseDialogTitle.value = title
|
releaseDialogTitle.value = title
|
||||||
@@ -91,6 +118,28 @@ function showReleaseDialog(title: string, body: string) {
|
|||||||
releaseDialog.value = true
|
releaseDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询版本统计
|
||||||
|
async function queryVersionStatistic() {
|
||||||
|
if (!systemEnv.value.USAGE_STATISTIC_SHARE) return
|
||||||
|
versionStatisticLoading.value = true
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('system/usage/statistic')
|
||||||
|
|
||||||
|
versionStatistic.value = result.data ?? {}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
versionStatistic.value = {}
|
||||||
|
} finally {
|
||||||
|
versionStatisticLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开版本统计对话框
|
||||||
|
async function showVersionStatisticDialog() {
|
||||||
|
versionStatisticDialog.value = true
|
||||||
|
await queryVersionStatistic()
|
||||||
|
}
|
||||||
|
|
||||||
// 查询系统环境变量
|
// 查询系统环境变量
|
||||||
async function querySystemEnv() {
|
async function querySystemEnv() {
|
||||||
try {
|
try {
|
||||||
@@ -182,6 +231,18 @@ onMounted(() => {
|
|||||||
{{ t('setting.about.latest') }}
|
{{ t('setting.about.latest') }}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
<VTooltip v-if="systemEnv.USAGE_STATISTIC_SHARE" :text="t('setting.about.versionStatistic')">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<VBtn
|
||||||
|
v-bind="props"
|
||||||
|
icon="mdi-chart-bar"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
class="ms-2 flex-shrink-0"
|
||||||
|
@click="showVersionStatisticDialog"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VTooltip>
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,6 +467,86 @@ onMounted(() => {
|
|||||||
<VCardText class="markdown-body" v-html="releaseDialogBody" />
|
<VCardText class="markdown-body" v-html="releaseDialogBody" />
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
<VDialog v-if="versionStatisticDialog" v-model="versionStatisticDialog" width="680" scrollable max-height="85vh">
|
||||||
|
<VCard>
|
||||||
|
<VCardItem>
|
||||||
|
<VDialogCloseBtn @click="versionStatisticDialog = false" />
|
||||||
|
<VCardTitle>
|
||||||
|
<VIcon icon="mdi-chart-bar" class="me-2" />
|
||||||
|
{{ t('setting.about.versionStatisticTitle') }}
|
||||||
|
</VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VDivider />
|
||||||
|
<VProgressLinear v-if="versionStatisticLoading" indeterminate color="primary" />
|
||||||
|
<VCardText>
|
||||||
|
<div class="version-stat-summary">
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</div>
|
||||||
|
<div class="version-stat-number">{{ formatVersionStatisticNumber(versionStatistic.total_users) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-medium-emphasis">{{ t('setting.about.activeToday') }}</div>
|
||||||
|
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.today) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</div>
|
||||||
|
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_7_days) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active30Days') }}</div>
|
||||||
|
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_30_days) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="text-subtitle-2 mb-2">{{ t('setting.about.backendVersionStatistic') }}</div>
|
||||||
|
<VTable density="compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('setting.about.version') }}</th>
|
||||||
|
<th class="text-end">{{ t('setting.about.users') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in backendVersionStatistics" :key="`backend-${item.version}`">
|
||||||
|
<td>
|
||||||
|
<code>{{ item.version }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!backendVersionStatistics.length">
|
||||||
|
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="text-subtitle-2 mb-2">{{ t('setting.about.frontendVersionStatistic') }}</div>
|
||||||
|
<VTable density="compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('setting.about.version') }}</th>
|
||||||
|
<th class="text-end">{{ t('setting.about.users') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in frontendVersionStatistics" :key="`frontend-${item.version}`">
|
||||||
|
<td>
|
||||||
|
<code>{{ item.version }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!frontendVersionStatistics.length">
|
||||||
|
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</div>
|
||||||
|
<div v-if="versionStatistic.updated_at" class="mt-4 text-caption text-medium-emphasis">
|
||||||
|
{{ t('setting.about.lastUpdated') }}: {{ versionStatistic.updated_at }}
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -422,6 +563,18 @@ onMounted(() => {
|
|||||||
margin-block: 0.5rem 2.5rem;
|
margin-block: 0.5rem 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-stat-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-stat-number {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-body :deep(h1),
|
.markdown-body :deep(h1),
|
||||||
.markdown-body :deep(h2),
|
.markdown-body :deep(h2),
|
||||||
.markdown-body :deep(h3) {
|
.markdown-body :deep(h3) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useToast } from 'vue-toastification'
|
|||||||
import { VBtn } from 'vuetify/lib/components/index.mjs'
|
import { VBtn } from 'vuetify/lib/components/index.mjs'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -88,10 +89,7 @@ async function unfollowUser() {
|
|||||||
// 计算海报图片地址
|
// 计算海报图片地址
|
||||||
const posterUrl = computed(() => {
|
const posterUrl = computed(() => {
|
||||||
const url = props.media?.poster
|
const url = props.media?.poster
|
||||||
// 使用图片缓存
|
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获得mediaid
|
// 获得mediaid
|
||||||
|
|||||||
@@ -41,42 +41,69 @@ const otpPassword = ref('')
|
|||||||
|
|
||||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||||
|
|
||||||
|
// OTP 初始化加载状态
|
||||||
|
const otpLoading = ref(false)
|
||||||
|
|
||||||
|
// OTP 初始化失败信息
|
||||||
|
const otpGenerateError = ref('')
|
||||||
|
|
||||||
// 二维码图片 base64
|
// 二维码图片 base64
|
||||||
const qrCodeImage = ref('')
|
const qrCodeImage = ref('')
|
||||||
|
|
||||||
// 二维码信息
|
// 二维码信息
|
||||||
const qrCode = ref('')
|
const qrCode = ref('')
|
||||||
|
|
||||||
// 为当前用户获取Otp Uri
|
// 清空当前 OTP 设置流程的临时数据。
|
||||||
|
function resetOtpSetupState() {
|
||||||
|
qrCodeImage.value = ''
|
||||||
|
qrCode.value = ''
|
||||||
|
otpUri.value = ''
|
||||||
|
secret.value = ''
|
||||||
|
otpGenerateError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记 OTP 初始化失败,并向用户显示明确错误。
|
||||||
|
function setOtpGenerateError(message?: string) {
|
||||||
|
const errorMessage = message || t('common.error')
|
||||||
|
otpGenerateError.value = t('profile.otpGenerateFailed', { message: errorMessage })
|
||||||
|
$toast.error(otpGenerateError.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为当前用户获取 OTP URI 并生成二维码图片。
|
||||||
async function getOtpUri() {
|
async function getOtpUri() {
|
||||||
|
resetOtpSetupState()
|
||||||
// 如果已经启用OTP,只打开对话框,不生成新的二维码
|
// 如果已经启用OTP,只打开对话框,不生成新的二维码
|
||||||
if (props.isOtp) {
|
if (props.isOtp) {
|
||||||
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
|
|
||||||
qrCodeImage.value = ''
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未启用OTP,生成新的二维码
|
// 未启用OTP,生成新的二维码
|
||||||
|
otpLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
|
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
|
||||||
uri: string
|
uri: string
|
||||||
secret: string
|
secret: string
|
||||||
}>
|
}>
|
||||||
if (result.success) {
|
const uri = result.data?.uri?.trim()
|
||||||
otpUri.value = result.data.uri
|
const otpSecret = result.data?.secret?.trim()
|
||||||
secret.value = result.data.secret
|
|
||||||
qrCode.value = result.data.uri
|
if (result.success && uri) {
|
||||||
|
otpUri.value = uri
|
||||||
|
secret.value = otpSecret || ''
|
||||||
|
qrCode.value = uri
|
||||||
// 生成二维码图片
|
// 生成二维码图片
|
||||||
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
|
qrCodeImage.value = await QRCode.toDataURL(uri, {
|
||||||
width: 200,
|
width: 200,
|
||||||
margin: 1,
|
margin: 1,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
|
setOtpGenerateError(result.message || 'empty otp uri')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
|
setOtpGenerateError(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
otpLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,13 +172,12 @@ watch(
|
|||||||
otpPassword.value = ''
|
otpPassword.value = ''
|
||||||
} else {
|
} else {
|
||||||
// 弹窗关闭时,清空数据
|
// 弹窗关闭时,清空数据
|
||||||
qrCodeImage.value = ''
|
resetOtpSetupState()
|
||||||
qrCode.value = ''
|
otpLoading.value = false
|
||||||
otpUri.value = ''
|
|
||||||
secret.value = ''
|
|
||||||
otpPassword.value = ''
|
otpPassword.value = ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -193,16 +219,29 @@ watch(
|
|||||||
|
|
||||||
<!-- 设置新的OTP -->
|
<!-- 设置新的OTP -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
|
<div
|
||||||
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
|
class="my-6 rounded text-center p-3 border d-flex align-center justify-center"
|
||||||
<template #placeholder>
|
style="width: 226px; height: 226px; margin: 0 auto"
|
||||||
<div class="w-full h-full">
|
>
|
||||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
<img
|
||||||
</div>
|
v-if="qrCodeImage"
|
||||||
</template>
|
class="mx-auto d-block otp-qrcode-image"
|
||||||
</VImg>
|
:src="qrCodeImage"
|
||||||
|
:alt="t('profile.setupAuthenticator')"
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
|
/>
|
||||||
|
<VProgressCircular v-else-if="otpLoading" indeterminate color="primary" />
|
||||||
|
<div v-else class="w-100">
|
||||||
|
<VAlert type="error" variant="tonal" density="compact" class="mb-3">
|
||||||
|
{{ otpGenerateError || t('profile.otpGenerateFailed', { message: t('common.error') }) }}
|
||||||
|
</VAlert>
|
||||||
|
<VBtn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="getOtpUri">
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
<VAlert v-if="secret" :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
||||||
<template #prepend />
|
<template #prepend />
|
||||||
</VAlert>
|
</VAlert>
|
||||||
<VForm @submit.prevent="judgeOtpPassword">
|
<VForm @submit.prevent="judgeOtpPassword">
|
||||||
@@ -220,7 +259,7 @@ watch(
|
|||||||
<VBtn variant="outlined" color="secondary" @click="show = false">
|
<VBtn variant="outlined" color="secondary" @click="show = false">
|
||||||
{{ t('common.cancel') }}
|
{{ t('common.cancel') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn type="submit">
|
<VBtn type="submit" :disabled="!otpUri || otpLoading">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-check" />
|
<VIcon icon="mdi-check" />
|
||||||
</template>
|
</template>
|
||||||
@@ -233,3 +272,10 @@ watch(
|
|||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.otp-qrcode-image {
|
||||||
|
inline-size: 200px;
|
||||||
|
block-size: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,27 +10,121 @@ const display = useDisplay()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
|
type EditorMode = 'list' | 'text'
|
||||||
|
|
||||||
|
interface RepoParseResult {
|
||||||
|
repos: string[]
|
||||||
|
invalidRepos: string[]
|
||||||
|
duplicateRepos: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorMode = ref<EditorMode>('list')
|
||||||
const repoList = ref<string[]>([])
|
const repoList = ref<string[]>([])
|
||||||
|
const repoText = ref('')
|
||||||
const newRepoUrl = ref('')
|
const newRepoUrl = ref('')
|
||||||
const editingIndex = ref<number | null>(null)
|
const editingIndex = ref<number | null>(null)
|
||||||
const editingUrl = ref('')
|
const editingUrl = ref('')
|
||||||
|
|
||||||
const emit = defineEmits(['save', 'close'])
|
const emit = defineEmits(['save', 'close'])
|
||||||
|
|
||||||
|
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
|
||||||
|
const activeRepoCount = computed(() => (editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length))
|
||||||
|
const saveDisabled = computed(
|
||||||
|
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 判断仓库地址是否为可保存的 HTTP URL。 */
|
||||||
|
function isValidRepoUrl(url: string) {
|
||||||
|
return /^https?:\/\//i.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将粘贴的仓库地址文本解析为有效、无效和重复地址列表。 */
|
||||||
|
function parseRepoInput(value: string): RepoParseResult {
|
||||||
|
const repos: string[] = []
|
||||||
|
const invalidRepos: string[] = []
|
||||||
|
const duplicateRepos: string[] = []
|
||||||
|
const seenRepos = new Set<string>()
|
||||||
|
|
||||||
|
value
|
||||||
|
.split(/[\n,,]+/)
|
||||||
|
.map(repo => repo.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach(repo => {
|
||||||
|
if (!isValidRepoUrl(repo)) {
|
||||||
|
invalidRepos.push(repo)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenRepos.has(repo)) {
|
||||||
|
duplicateRepos.push(repo)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seenRepos.add(repo)
|
||||||
|
repos.push(repo)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
repos,
|
||||||
|
invalidRepos,
|
||||||
|
duplicateRepos: [...new Set(duplicateRepos)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将列表模式中的仓库地址同步到文本模式。 */
|
||||||
|
function syncTextFromList() {
|
||||||
|
repoText.value = repoList.value.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将文本模式中的仓库地址同步到列表模式,并忽略无法加入列表的无效地址。 */
|
||||||
|
function syncListFromText() {
|
||||||
|
const result = parseRepoInput(repoText.value)
|
||||||
|
|
||||||
|
repoList.value = result.repos
|
||||||
|
syncTextFromList()
|
||||||
|
|
||||||
|
if (result.invalidRepos.length > 0) {
|
||||||
|
$toast.warning(t('dialog.pluginMarketSetting.invalidTextIgnored', { count: result.invalidRepos.length }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换仓库维护模式,并在切换时同步当前模式的编辑内容。 */
|
||||||
|
function switchEditorMode(mode: EditorMode | undefined) {
|
||||||
|
if (!mode || mode === editorMode.value) return
|
||||||
|
|
||||||
|
if (editorMode.value === 'text') {
|
||||||
|
syncListFromText()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'text') {
|
||||||
|
syncTextFromList()
|
||||||
|
}
|
||||||
|
|
||||||
|
editorMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载插件市场仓库配置。 */
|
||||||
async function queryMarketRepoSetting() {
|
async function queryMarketRepoSetting() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
|
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
|
||||||
if (result && result.data && result.data.value) {
|
if (result && result.data && result.data.value) {
|
||||||
repoList.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
|
repoList.value = parseRepoInput(result.data.value).repos
|
||||||
|
syncTextFromList()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存插件市场仓库配置。 */
|
||||||
async function saveHandle() {
|
async function saveHandle() {
|
||||||
try {
|
try {
|
||||||
const repoStringToSave = repoList.value.join(',')
|
const reposToSave = normalizeCurrentRepos()
|
||||||
|
if (!reposToSave) return
|
||||||
|
|
||||||
|
const repoStringToSave = reposToSave.join(',')
|
||||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
|
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -42,54 +136,88 @@ async function saveHandle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取当前维护模式下可保存的仓库地址。 */
|
||||||
|
function normalizeCurrentRepos() {
|
||||||
|
if (editorMode.value === 'text') {
|
||||||
|
const result = parseRepoInput(repoText.value)
|
||||||
|
|
||||||
|
if (result.invalidRepos.length > 0) {
|
||||||
|
$toast.error(t('dialog.pluginMarketSetting.invalidText', { count: result.invalidRepos.length }))
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
repoList.value = result.repos
|
||||||
|
syncTextFromList()
|
||||||
|
|
||||||
|
return result.repos
|
||||||
|
}
|
||||||
|
|
||||||
|
return repoList.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验单个仓库地址是否可以加入或更新到列表。 */
|
||||||
|
function validateRepoUrl(url: string, editingRepoIndex: number | null = null) {
|
||||||
|
if (!url) return false
|
||||||
|
|
||||||
|
if (!isValidRepoUrl(url)) {
|
||||||
|
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicated = repoList.value.some((repo, index) => repo === url && index !== editingRepoIndex)
|
||||||
|
if (duplicated) {
|
||||||
|
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加一个仓库地址到列表。 */
|
||||||
function addRepo() {
|
function addRepo() {
|
||||||
const url = newRepoUrl.value.trim()
|
const url = newRepoUrl.value.trim()
|
||||||
if (!url) return
|
if (!validateRepoUrl(url)) return
|
||||||
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
||||||
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repoList.value.includes(url)) {
|
|
||||||
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
repoList.value.push(url)
|
repoList.value.push(url)
|
||||||
newRepoUrl.value = ''
|
newRepoUrl.value = ''
|
||||||
|
syncTextFromList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从列表中删除一个仓库地址。 */
|
||||||
function removeRepo(index: number) {
|
function removeRepo(index: number) {
|
||||||
repoList.value.splice(index, 1)
|
repoList.value.splice(index, 1)
|
||||||
|
syncTextFromList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 进入指定仓库地址的行内编辑状态。 */
|
||||||
function startEdit(index: number) {
|
function startEdit(index: number) {
|
||||||
editingIndex.value = index
|
editingIndex.value = index
|
||||||
editingUrl.value = repoList.value[index]
|
editingUrl.value = repoList.value[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveEdit() {
|
/** 保存当前行内编辑的仓库地址。 */
|
||||||
if (editingIndex.value === null) return
|
function saveEdit(index = editingIndex.value) {
|
||||||
|
if (index === null) return
|
||||||
|
|
||||||
const url = editingUrl.value.trim()
|
const url = editingUrl.value.trim()
|
||||||
if (!url) return
|
if (!validateRepoUrl(url, index)) return
|
||||||
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
repoList.value[index] = url
|
||||||
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
|
syncTextFromList()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
repoList.value[editingIndex.value] = url
|
|
||||||
editingIndex.value = null
|
editingIndex.value = null
|
||||||
editingUrl.value = ''
|
editingUrl.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 取消当前行内编辑状态。 */
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
editingIndex.value = null
|
editingIndex.value = null
|
||||||
editingUrl.value = ''
|
editingUrl.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将仓库地址格式化为更易扫描的显示名称。 */
|
||||||
function formatRepoDisplay(url: string) {
|
function formatRepoDisplay(url: string) {
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(url)
|
const parsedUrl = new URL(url)
|
||||||
@@ -108,6 +236,7 @@ function formatRepoDisplay(url: string) {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回拖拽列表项的稳定键。 */
|
||||||
function repoItemKey(repo: string) {
|
function repoItemKey(repo: string) {
|
||||||
return repo
|
return repo
|
||||||
}
|
}
|
||||||
@@ -118,108 +247,192 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
<VDialog width="56rem" :fullscreen="!display.mdAndUp.value">
|
||||||
<VCard class="plugin-market-dialog-card">
|
<VCard class="plugin-market-dialog-card">
|
||||||
<VCardItem>
|
<VCardItem class="plugin-market-card-item">
|
||||||
<VCardTitle>
|
<div class="plugin-market-header">
|
||||||
<VIcon icon="mdi-store-cog" class="me-2" />
|
<VCardTitle class="plugin-market-title d-flex align-center pa-0">
|
||||||
{{ t('dialog.pluginMarketSetting.title') }}
|
<VIcon icon="mdi-store-cog" class="me-2" />
|
||||||
</VCardTitle>
|
{{ t('dialog.pluginMarketSetting.title') }}
|
||||||
|
</VCardTitle>
|
||||||
|
</div>
|
||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
|
||||||
<VCardText class="plugin-market-dialog-body pt-4">
|
<VCardText class="plugin-market-dialog-body pt-4">
|
||||||
<div class="plugin-market-input mb-4">
|
<div class="plugin-market-toolbar">
|
||||||
<VTextField
|
<VBtnToggle
|
||||||
v-model="newRepoUrl"
|
:model-value="editorMode"
|
||||||
density="compact"
|
mandatory
|
||||||
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
|
color="primary"
|
||||||
prepend-inner-icon="mdi-link-plus"
|
density="comfortable"
|
||||||
clearable
|
variant="tonal"
|
||||||
@keyup.enter="addRepo"
|
class="plugin-market-mode-toggle"
|
||||||
|
@update:model-value="switchEditorMode"
|
||||||
>
|
>
|
||||||
<template #append>
|
<VBtn value="list" prepend-icon="mdi-format-list-bulleted">
|
||||||
<VBtn icon="mdi-plus" variant="text" color="primary" @click="addRepo" />
|
{{ t('dialog.pluginMarketSetting.listMode') }}
|
||||||
</template>
|
</VBtn>
|
||||||
</VTextField>
|
<VBtn value="text" prepend-icon="mdi-text-box-edit-outline">
|
||||||
|
{{ t('dialog.pluginMarketSetting.textMode') }}
|
||||||
|
</VBtn>
|
||||||
|
</VBtnToggle>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plugin-market-list-wrap">
|
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
|
||||||
<VList v-if="repoList.length > 0" class="px-0">
|
<div class="plugin-market-input">
|
||||||
<draggable
|
<VTextField
|
||||||
v-model="repoList"
|
v-model="newRepoUrl"
|
||||||
:item-key="repoItemKey"
|
density="compact"
|
||||||
handle=".drag-handle"
|
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
|
||||||
animation="200"
|
prepend-inner-icon="mdi-link-plus"
|
||||||
:disabled="editingIndex !== null"
|
clearable
|
||||||
|
hide-details
|
||||||
|
@keyup.enter="addRepo"
|
||||||
>
|
>
|
||||||
<template #item="{ element: repo, index }">
|
<template #append>
|
||||||
<div>
|
<VBtn
|
||||||
<VListItem class="py-2">
|
icon="mdi-plus"
|
||||||
<template #prepend>
|
variant="tonal"
|
||||||
<VBtn
|
color="primary"
|
||||||
icon="mdi-drag-vertical"
|
:aria-label="t('dialog.pluginMarketSetting.addRepo')"
|
||||||
size="small"
|
@click="addRepo"
|
||||||
variant="text"
|
/>
|
||||||
color="primary"
|
</template>
|
||||||
class="drag-handle me-2"
|
</VTextField>
|
||||||
:disabled="editingIndex !== null"
|
</div>
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VListItemTitle v-if="editingIndex !== index">
|
<div class="plugin-market-list-wrap">
|
||||||
<span class="text-truncate" :title="repo">{{ formatRepoDisplay(repo) }}</span>
|
<VList v-if="repoList.length > 0" class="plugin-market-repo-list px-0">
|
||||||
</VListItemTitle>
|
<draggable
|
||||||
|
v-model="repoList"
|
||||||
<VTextField
|
:item-key="repoItemKey"
|
||||||
v-else
|
handle=".drag-handle"
|
||||||
v-model="editingUrl"
|
animation="200"
|
||||||
density="compact"
|
:disabled="editingIndex !== null"
|
||||||
variant="outlined"
|
@end="syncTextFromList"
|
||||||
hide-details
|
>
|
||||||
@keyup.enter="saveEdit"
|
<template #item="{ element: repo, index }">
|
||||||
@keyup.escape="cancelEdit"
|
<div>
|
||||||
/>
|
<VListItem class="plugin-market-repo-item py-3">
|
||||||
|
<template #prepend>
|
||||||
<template #append v-if="editingIndex !== index">
|
<VBtn
|
||||||
<div class="d-flex align-center">
|
icon="mdi-drag-vertical"
|
||||||
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
|
|
||||||
<IconBtn
|
|
||||||
icon="mdi-delete"
|
|
||||||
size="small"
|
size="small"
|
||||||
variant="text"
|
variant="text"
|
||||||
color="error"
|
color="primary"
|
||||||
@click="removeRepo(index)"
|
class="drag-handle me-2"
|
||||||
|
:disabled="editingIndex !== null"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #append v-else>
|
<template v-if="editingIndex !== index">
|
||||||
<div class="d-flex align-center">
|
<VListItemTitle>
|
||||||
<IconBtn icon="mdi-check" size="small" variant="text" color="success" @click="saveEdit" />
|
<div class="plugin-market-repo-title">
|
||||||
<IconBtn icon="mdi-close" size="small" variant="text" @click="cancelEdit" />
|
<span class="plugin-market-repo-index">{{ index + 1 }}</span>
|
||||||
</div>
|
<span class="plugin-market-repo-name" :title="repo">{{ formatRepoDisplay(repo) }}</span>
|
||||||
</template>
|
</div>
|
||||||
</VListItem>
|
</VListItemTitle>
|
||||||
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
|
<VListItemSubtitle class="plugin-market-repo-url mt-1" :title="repo">
|
||||||
</div>
|
{{ repo }}
|
||||||
</template>
|
</VListItemSubtitle>
|
||||||
</draggable>
|
</template>
|
||||||
</VList>
|
|
||||||
|
|
||||||
<div v-else class="text-center text-medium-emphasis py-8">
|
<VTextField
|
||||||
<VIcon icon="mdi-folder-open-outline" size="48" class="mb-2" />
|
v-else
|
||||||
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
|
v-model="editingUrl"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
autofocus
|
||||||
|
@keyup.enter="saveEdit(index)"
|
||||||
|
@keyup.escape="cancelEdit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template #append v-if="editingIndex !== index">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
|
||||||
|
<IconBtn
|
||||||
|
icon="mdi-delete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
@click="removeRepo(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #append v-else>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<VBtn
|
||||||
|
icon="mdi-check"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="success"
|
||||||
|
@click.stop="saveEdit(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</VList>
|
||||||
|
|
||||||
|
<div v-else class="plugin-market-empty text-center text-medium-emphasis">
|
||||||
|
<VIcon icon="mdi-source-repository-multiple" size="48" class="mb-2" />
|
||||||
|
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="plugin-market-text-panel">
|
||||||
|
<div class="plugin-market-textarea-field">
|
||||||
|
<VIcon icon="mdi-text-box-edit-outline" class="plugin-market-textarea-icon" />
|
||||||
|
<textarea
|
||||||
|
v-model="repoText"
|
||||||
|
class="plugin-market-textarea"
|
||||||
|
:placeholder="t('dialog.pluginMarketSetting.textPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="plugin-market-text-hint">
|
||||||
|
{{ t('dialog.pluginMarketSetting.textHint') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VAlert
|
||||||
|
v-if="parsedTextRepos.invalidRepos.length > 0"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
class="plugin-market-invalid-alert"
|
||||||
|
>
|
||||||
|
<div>{{ t('dialog.pluginMarketSetting.invalidText', { count: parsedTextRepos.invalidRepos.length }) }}</div>
|
||||||
|
<div class="text-truncate">
|
||||||
|
{{ parsedTextRepos.invalidRepos.slice(0, 3).join(', ') }}
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VAlert
|
||||||
|
v-else-if="parsedTextRepos.duplicateRepos.length > 0"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
{{ t('dialog.pluginMarketSetting.duplicateTextIgnored') }}
|
||||||
|
</VAlert>
|
||||||
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
|
||||||
|
<VCardActions class="plugin-market-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
@click="saveHandle"
|
@click="saveHandle"
|
||||||
prepend-icon="mdi-content-save-check"
|
prepend-icon="mdi-content-save-check"
|
||||||
class="px-5 me-3"
|
class="px-5"
|
||||||
:disabled="repoList.length === 0"
|
:disabled="saveDisabled"
|
||||||
>
|
>
|
||||||
{{ t('dialog.pluginMarketSetting.save') }}
|
{{ t('dialog.pluginMarketSetting.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
@@ -232,6 +445,24 @@ onMounted(() => {
|
|||||||
.plugin-market-dialog-card {
|
.plugin-market-dialog-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
block-size: min(82vh, 50rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-card-item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-block: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-inline-end: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-title {
|
||||||
|
min-inline-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-dialog-body {
|
.plugin-market-dialog-body {
|
||||||
@@ -239,6 +470,31 @@ onMounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
min-block-size: 0;
|
||||||
|
padding-block: 0.875rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-mode-toggle {
|
||||||
|
inline-size: 100%;
|
||||||
|
|
||||||
|
:deep(.v-btn) {
|
||||||
|
flex: 1;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-list-panel,
|
||||||
|
.plugin-market-text-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +504,173 @@ onMounted(() => {
|
|||||||
|
|
||||||
.plugin-market-list-wrap {
|
.plugin-market-list-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(var(--v-theme-surface), 0.72);
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plugin-market-repo-list {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-repo-item {
|
||||||
|
min-block-size: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-repo-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-repo-name,
|
||||||
|
.plugin-market-repo-url {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-break: anywhere;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-repo-url {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-repo-index {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.48);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
inline-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
min-block-size: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-textarea-field {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(var(--v-theme-surface), 0.72);
|
||||||
|
min-block-size: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: rgb(var(--v-theme-primary));
|
||||||
|
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-textarea-icon {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||||
|
inset-block-start: 1.25rem;
|
||||||
|
inset-inline-start: 1rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-textarea {
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
block-size: 100%;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-block-size: 0;
|
||||||
|
outline: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1rem 1rem 3.25rem;
|
||||||
|
resize: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-text-hint {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-invalid-alert {
|
||||||
|
:deep(.v-alert__content) {
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.plugin-market-dialog-card {
|
||||||
|
block-size: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-card-item {
|
||||||
|
padding: 0.75rem 1rem 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-header {
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-inline-end: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-header :deep(.v-card-title) {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-dialog-body {
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.75rem 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-mode-toggle {
|
||||||
|
inline-size: 100%;
|
||||||
|
|
||||||
|
:deep(.v-btn) {
|
||||||
|
flex: 1;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-list-panel,
|
||||||
|
.plugin-market-text-panel {
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-list-wrap {
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-empty {
|
||||||
|
min-block-size: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-actions {
|
||||||
|
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ManualTransferPayload,
|
ManualTransferPayload,
|
||||||
ManualTransferPreviewData,
|
ManualTransferPreviewData,
|
||||||
ManualTransferPreviewItem,
|
ManualTransferPreviewItem,
|
||||||
|
ManualTransferTargetPathData,
|
||||||
StorageConf,
|
StorageConf,
|
||||||
TransferDirectoryConf,
|
TransferDirectoryConf,
|
||||||
TransferForm,
|
TransferForm,
|
||||||
@@ -113,6 +114,14 @@ const episodeFormatRecommendState = reactive<{
|
|||||||
|
|
||||||
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
interface ManualTransferTargetPathRequest {
|
||||||
|
fileitem?: FileItem
|
||||||
|
fileitems?: FileItem[]
|
||||||
|
logid?: number
|
||||||
|
logids?: number[]
|
||||||
|
target_storage?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// 生成文件项稳定键,用于去重和状态同步。
|
// 生成文件项稳定键,用于去重和状态同步。
|
||||||
function getFileItemKey(item?: FileItem) {
|
function getFileItemKey(item?: FileItem) {
|
||||||
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||||
@@ -265,7 +274,7 @@ const transferForm = reactive<TransferForm>({
|
|||||||
fileitem: {} as FileItem,
|
fileitem: {} as FileItem,
|
||||||
logid: 0,
|
logid: 0,
|
||||||
target_storage: props.target_storage ?? 'local',
|
target_storage: props.target_storage ?? 'local',
|
||||||
target_path: props.target_path ?? '',
|
target_path: normalizeTargetPath(props.target_path),
|
||||||
transfer_type: '',
|
transfer_type: '',
|
||||||
min_filesize: 0,
|
min_filesize: 0,
|
||||||
scrape: false,
|
scrape: false,
|
||||||
@@ -292,6 +301,79 @@ const targetDirectories = computed(() => {
|
|||||||
return [...new Set(libraryDirectories)]
|
return [...new Set(libraryDirectories)]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 构造目的路径自动匹配请求,只传用户真实上下文,避免用默认存储误导后端匹配。
|
||||||
|
function createTargetPathMatchRequest(): ManualTransferTargetPathRequest | undefined {
|
||||||
|
const payload: ManualTransferTargetPathRequest = {}
|
||||||
|
|
||||||
|
if (props.target_storage) {
|
||||||
|
payload.target_storage = props.target_storage
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedItems.value.length === 1) {
|
||||||
|
payload.fileitem = normalizedItems.value[0]
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedItems.value.length > 1) {
|
||||||
|
payload.fileitems = normalizedItems.value
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.logids?.length) {
|
||||||
|
if (props.logids.length > 1) {
|
||||||
|
payload.logids = props.logids
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.logid = props.logids[0]
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用后端匹配到的目的路径配置,未匹配时保持 null 等待用户手工选择。
|
||||||
|
function applyMatchedTargetPath(data?: ManualTransferTargetPathData) {
|
||||||
|
const matchedTargetPath = normalizeTargetPath(data?.target_path)
|
||||||
|
if (!matchedTargetPath) {
|
||||||
|
transferForm.target_path = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transferForm.target_storage = data?.target_storage || transferForm.target_storage || 'local'
|
||||||
|
transferForm.transfer_type = data?.transfer_type || transferForm.transfer_type
|
||||||
|
transferForm.scrape = data?.scrape ?? false
|
||||||
|
transferForm.library_type_folder = data?.library_type_folder ?? false
|
||||||
|
transferForm.library_category_folder = data?.library_category_folder ?? false
|
||||||
|
transferForm.target_path = matchedTargetPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求后端按源目录匹配最合适的手动整理目的路径。
|
||||||
|
async function autoSelectTargetPath() {
|
||||||
|
if (normalizeTargetPath(props.target_path) || transferForm.target_path) return
|
||||||
|
|
||||||
|
const payload = createTargetPathMatchRequest()
|
||||||
|
if (!payload) {
|
||||||
|
transferForm.target_path = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.post<ApiResponse<ManualTransferTargetPathData>, ApiResponse<ManualTransferTargetPathData>>(
|
||||||
|
'transfer/manual/target-path',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
transferForm.target_path = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMatchedTargetPath(result.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
transferForm.target_path = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 监听目的路径变化,配置默认值
|
// 监听目的路径变化,配置默认值
|
||||||
watch(
|
watch(
|
||||||
() => transferForm.target_path,
|
() => transferForm.target_path,
|
||||||
@@ -344,6 +426,16 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => transferForm.episode_group,
|
||||||
|
episodeGroup => {
|
||||||
|
const normalizedEpisodeGroup = normalizeEpisodeGroup(episodeGroup)
|
||||||
|
if (episodeGroup !== normalizedEpisodeGroup) {
|
||||||
|
transferForm.episode_group = normalizedEpisodeGroup
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 过滤后的预览数据
|
// 过滤后的预览数据
|
||||||
const filteredPreviewItems = computed(() => {
|
const filteredPreviewItems = computed(() => {
|
||||||
return previewData.value?.items ?? []
|
return previewData.value?.items ?? []
|
||||||
@@ -397,6 +489,28 @@ function getUniqueValues(values: (string | undefined)[]) {
|
|||||||
return [...new Set(values.map(item => item?.trim()).filter(Boolean) as string[])]
|
return [...new Set(values.map(item => item?.trim()).filter(Boolean) as string[])]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 归一化可选目的路径,保证未指定时向接口传递 null 而不是空字符串。
|
||||||
|
function normalizeTargetPath(path?: string | null) {
|
||||||
|
const normalizedPath = path?.trim()
|
||||||
|
return normalizedPath || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归一化剧集组值,兼容历史对象态值。
|
||||||
|
function normalizeEpisodeGroup(
|
||||||
|
episodeGroup?: string | { value?: string | null } | null,
|
||||||
|
) {
|
||||||
|
if (!episodeGroup) return null
|
||||||
|
if (typeof episodeGroup === 'string') {
|
||||||
|
const normalizedEpisodeGroup = episodeGroup.trim()
|
||||||
|
return normalizedEpisodeGroup || null
|
||||||
|
}
|
||||||
|
if (typeof episodeGroup === 'object' && typeof episodeGroup.value === 'string') {
|
||||||
|
const normalizedEpisodeGroup = episodeGroup.value.trim()
|
||||||
|
return normalizedEpisodeGroup || null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// 统一解析接口返回的数字字段,兼容 string/number
|
// 统一解析接口返回的数字字段,兼容 string/number
|
||||||
function toPreviewNumber(value: unknown) {
|
function toPreviewNumber(value: unknown) {
|
||||||
if (value === undefined || value === null || value === '') return undefined
|
if (value === undefined || value === null || value === '') return undefined
|
||||||
@@ -511,6 +625,22 @@ const previewFileRows = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 标准化预览项中的识别词命中详情
|
||||||
|
function getPreviewApplyWords(item: ManualTransferPreviewItem) {
|
||||||
|
return (item.apply_words ?? []).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动整理识别词应用详情
|
||||||
|
const previewCustomWordDetails = computed(() => {
|
||||||
|
return filteredPreviewItems.value
|
||||||
|
.map(item => ({
|
||||||
|
sourceName: getFileName(item.source),
|
||||||
|
orgString: item.org_string,
|
||||||
|
applyWords: getPreviewApplyWords(item),
|
||||||
|
}))
|
||||||
|
.filter(item => item.applyWords.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
// 是否需要拓宽窗口
|
// 是否需要拓宽窗口
|
||||||
const previewNeedsWideLayout = computed(() => {
|
const previewNeedsWideLayout = computed(() => {
|
||||||
const candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)]
|
const candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)]
|
||||||
@@ -620,7 +750,8 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
|
|||||||
...transferForm,
|
...transferForm,
|
||||||
fileitem: sourceItem,
|
fileitem: sourceItem,
|
||||||
logid: options.logid ?? 0,
|
logid: options.logid ?? 0,
|
||||||
episode_group: transferForm.episode_group?.trim() || null,
|
target_path: normalizeTargetPath(transferForm.target_path),
|
||||||
|
episode_group: normalizeEpisodeGroup(transferForm.episode_group),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.items?.length) {
|
if (options.items?.length) {
|
||||||
@@ -1099,8 +1230,9 @@ async function transfer(background: boolean = false) {
|
|||||||
emit('done')
|
emit('done')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
loadDirectories()
|
await loadDirectories()
|
||||||
|
await autoSelectTargetPath()
|
||||||
loadStorages()
|
loadStorages()
|
||||||
loadEpisodeFormatRuleConfiguration()
|
loadEpisodeFormatRuleConfiguration()
|
||||||
})
|
})
|
||||||
@@ -1218,9 +1350,11 @@ onUnmounted(() => {
|
|||||||
</VRow>
|
</VRow>
|
||||||
<VRow v-show="transferForm.type_name === '电视剧'">
|
<VRow v-show="transferForm.type_name === '电视剧'">
|
||||||
<VCol v-if="mediaSource === 'themoviedb'" cols="12" md="6">
|
<VCol v-if="mediaSource === 'themoviedb'" cols="12" md="6">
|
||||||
<VCombobox
|
<VSelect
|
||||||
v-model="transferForm.episode_group"
|
v-model="transferForm.episode_group"
|
||||||
:items="episodeGroupOptions"
|
:items="episodeGroupOptions"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
:item-props="episodeGroupItemProps"
|
:item-props="episodeGroupItemProps"
|
||||||
:loading="episodeGroupLoading"
|
:loading="episodeGroupLoading"
|
||||||
:disabled="!transferForm.tmdbid"
|
:disabled="!transferForm.tmdbid"
|
||||||
@@ -1439,6 +1573,36 @@ onUnmounted(() => {
|
|||||||
<span class="preview-overview-card__value">{{ previewEpisodeCountText }}</span>
|
<span class="preview-overview-card__value">{{ previewEpisodeCountText }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="previewCustomWordDetails.length" class="preview-custom-words">
|
||||||
|
<div class="preview-custom-words__title">
|
||||||
|
<VIcon icon="mdi-tag-text-outline" size="16" />
|
||||||
|
<span>{{ t('dialog.reorganize.customWordsApplied') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-custom-words__items">
|
||||||
|
<div
|
||||||
|
v-for="(detail, index) in previewCustomWordDetails"
|
||||||
|
:key="`${detail.sourceName}-${index}`"
|
||||||
|
class="preview-custom-words__item"
|
||||||
|
>
|
||||||
|
<div class="preview-custom-words__source">{{ detail.sourceName }}</div>
|
||||||
|
<div v-if="detail.orgString" class="preview-custom-words__original">
|
||||||
|
{{ detail.orgString }}
|
||||||
|
</div>
|
||||||
|
<div class="preview-custom-words__chips">
|
||||||
|
<VChip
|
||||||
|
v-for="(word, wordIndex) in detail.applyWords"
|
||||||
|
:key="`${word}-${wordIndex}`"
|
||||||
|
variant="outlined"
|
||||||
|
color="info"
|
||||||
|
size="small"
|
||||||
|
class="preview-custom-words__chip"
|
||||||
|
>
|
||||||
|
{{ word }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="reorganize-preview-list">
|
<div class="reorganize-preview-list">
|
||||||
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
|
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
|
||||||
@@ -1698,6 +1862,66 @@ onUnmounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-custom-words {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-block: 0.875rem;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-custom-words__title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: rgb(var(--v-theme-info));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-custom-words__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-custom-words__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-custom-words__source {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-custom-words__original {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-custom-words__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-custom-words__chip {
|
||||||
|
max-inline-size: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.reorganize-preview-pane__scroll {
|
.reorganize-preview-pane__scroll {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
@@ -1797,11 +2021,13 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-file-row__path {
|
.preview-file-row__path {
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
text-overflow: ellipsis;
|
line-height: 1.4;
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-file-row__card--target .preview-file-row__name {
|
.preview-file-row__card--target .preview-file-row__name {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const props = defineProps({
|
|||||||
type: Array as PropType<Site[]>,
|
type: Array as PropType<Site[]>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
selected: Array as PropType<Number[]>,
|
selected: Array as PropType<number[]>,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义事件
|
// 定义事件
|
||||||
@@ -20,38 +20,66 @@ const emit = defineEmits(['close', 'search', 'reload'])
|
|||||||
const siteFilter = ref('')
|
const siteFilter = ref('')
|
||||||
|
|
||||||
// 已选择站点
|
// 已选择站点
|
||||||
const selectedSites = ref<any[]>(props.selected || [])
|
const selectedSites = ref<number[]>([])
|
||||||
|
|
||||||
|
// 根据当前可用站点清理选中项,避免停用或已删除站点参与计数。
|
||||||
|
function normalizeSelectedSites(selectedSiteIds: number[] = []) {
|
||||||
|
const availableSiteIds = new Set(props.sites.map((site: Site) => site.id))
|
||||||
|
const normalizedSiteIds: number[] = []
|
||||||
|
|
||||||
|
selectedSiteIds.forEach(siteId => {
|
||||||
|
if (availableSiteIds.has(siteId) && !normalizedSiteIds.includes(siteId)) {
|
||||||
|
normalizedSiteIds.push(siteId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return normalizedSiteIds
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.selected,
|
[() => props.selected, () => props.sites],
|
||||||
value => {
|
([value]) => {
|
||||||
if (selectedSites.value.length == 0 && value) {
|
selectedSites.value = normalizeSelectedSites(value || [])
|
||||||
selectedSites.value = value
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 全选/全不选按钮文字
|
// 全选/全不选按钮文字
|
||||||
const checkAllText = computed(() => {
|
const checkAllText = computed(() => {
|
||||||
return selectedSites.value.length < props.sites?.length
|
return selectedSites.value.length < props.sites.length
|
||||||
? t('dialog.searchSite.selectAll')
|
? t('dialog.searchSite.selectAll')
|
||||||
: t('dialog.searchSite.deselectAll')
|
: t('dialog.searchSite.deselectAll')
|
||||||
})
|
})
|
||||||
|
|
||||||
// 全选/全不选
|
// 全选/全不选
|
||||||
const checkAllSitesorNot = () => {
|
const checkAllSitesorNot = () => {
|
||||||
if (selectedSites.value.length < props.sites?.length) {
|
if (selectedSites.value.length < props.sites.length) {
|
||||||
selectedSites.value = props.sites?.map((item: Site) => item.id)
|
selectedSites.value = props.sites.map((item: Site) => item.id)
|
||||||
} else {
|
} else {
|
||||||
selectedSites.value = []
|
selectedSites.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换单个站点的选择状态。
|
||||||
|
function toggleSiteSelection(siteId: number) {
|
||||||
|
const index = selectedSites.value.indexOf(siteId)
|
||||||
|
if (index === -1) {
|
||||||
|
selectedSites.value.push(siteId)
|
||||||
|
} else {
|
||||||
|
selectedSites.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认搜索时只提交当前可用站点。
|
||||||
|
function confirmSearch() {
|
||||||
|
emit('search', normalizeSelectedSites(selectedSites.value))
|
||||||
|
}
|
||||||
|
|
||||||
// 根据筛选条件过滤站点
|
// 根据筛选条件过滤站点
|
||||||
const filteredSites = computed(() => {
|
const filteredSites = computed(() => {
|
||||||
if (!siteFilter.value) return props.sites
|
if (!siteFilter.value) return props.sites
|
||||||
const filter = siteFilter.value.toLowerCase()
|
const filter = siteFilter.value.toLowerCase()
|
||||||
return props.sites?.filter((site: Site) => site.name.toLowerCase().includes(filter))
|
return props.sites.filter((site: Site) => site.name.toLowerCase().includes(filter))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
@@ -107,16 +135,7 @@ const filteredSites = computed(() => {
|
|||||||
'site-hover': isHovering && !selectedSites.includes(site.id),
|
'site-hover': isHovering && !selectedSites.includes(site.id),
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@click="
|
@click="toggleSiteSelection(site.id)"
|
||||||
() => {
|
|
||||||
const index = selectedSites.indexOf(site.id)
|
|
||||||
if (index === -1) {
|
|
||||||
selectedSites.push(site.id)
|
|
||||||
} else {
|
|
||||||
selectedSites.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<VIcon
|
<VIcon
|
||||||
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
|
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
|
||||||
@@ -161,7 +180,7 @@ const filteredSites = computed(() => {
|
|||||||
<VBtn
|
<VBtn
|
||||||
color="primary"
|
color="primary"
|
||||||
:disabled="selectedSites.length === 0"
|
:disabled="selectedSites.length === 0"
|
||||||
@click="emit('search', selectedSites)"
|
@click="confirmSearch"
|
||||||
prepend-icon="mdi-magnify"
|
prepend-icon="mdi-magnify"
|
||||||
class="d-flex align-center justify-center px-5"
|
class="d-flex align-center justify-center px-5"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -50,23 +50,34 @@ async function updateSiteCookie() {
|
|||||||
progressDialog.value = true
|
progressDialog.value = true
|
||||||
progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name })
|
progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name })
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
|
const result: { [key: string]: any } = await api.post(`site/cookie/${cardProps.site?.id}`, {
|
||||||
params: {
|
username: userPwForm.value.username,
|
||||||
username: userPwForm.value.username,
|
password: userPwForm.value.password,
|
||||||
password: userPwForm.value.password,
|
code: userPwForm.value.code,
|
||||||
code: userPwForm.value.code,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))
|
$toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))
|
||||||
emit('done')
|
emit('done')
|
||||||
} else $toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message: result.message }))
|
} else {
|
||||||
|
$toast.error(
|
||||||
|
t('dialog.siteCookieUpdate.failed', {
|
||||||
|
site: cardProps.site?.name,
|
||||||
|
message: result.message || t('dialog.siteCookieUpdate.requestFailed'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error)
|
||||||
|
const detail = error?.response?.data?.detail
|
||||||
|
const message =
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
(typeof detail === 'string' ? detail : error?.message) ||
|
||||||
|
t('dialog.siteCookieUpdate.requestFailed')
|
||||||
|
$toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message }))
|
||||||
|
} finally {
|
||||||
progressDialog.value = false
|
progressDialog.value = false
|
||||||
updateButtonDisable.value = false
|
updateButtonDisable.value = false
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ interface UseLlmProviderDirectoryOptions {
|
|||||||
apiKey: Ref<string>
|
apiKey: Ref<string>
|
||||||
baseUrl: Ref<string>
|
baseUrl: Ref<string>
|
||||||
baseUrlPreset?: Ref<string>
|
baseUrlPreset?: Ref<string>
|
||||||
|
useProxy?: Ref<boolean>
|
||||||
|
userAgent?: Ref<string>
|
||||||
model: Ref<string>
|
model: Ref<string>
|
||||||
maxContextTokens?: Ref<number>
|
maxContextTokens?: Ref<number>
|
||||||
authConnected?: Ref<boolean>
|
authConnected?: Ref<boolean>
|
||||||
@@ -253,6 +255,8 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
|||||||
api_key: normalizeValue(options.apiKey.value) || undefined,
|
api_key: normalizeValue(options.apiKey.value) || undefined,
|
||||||
base_url: normalizeValue(options.baseUrl.value) || undefined,
|
base_url: normalizeValue(options.baseUrl.value) || undefined,
|
||||||
base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined,
|
base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined,
|
||||||
|
use_proxy: options.useProxy?.value,
|
||||||
|
user_agent: normalizeValue(options.userAgent?.value) || undefined,
|
||||||
force_refresh: forceRefresh,
|
force_refresh: forceRefresh,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ export interface WizardData {
|
|||||||
supportAudioOutput: boolean
|
supportAudioOutput: boolean
|
||||||
apiKey: string
|
apiKey: string
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
|
useProxy: boolean
|
||||||
baseUrlPreset: string
|
baseUrlPreset: string
|
||||||
maxContextTokens: number
|
maxContextTokens: number
|
||||||
|
userAgent: string
|
||||||
audioInputProvider: string
|
audioInputProvider: string
|
||||||
audioInputApiKey: string
|
audioInputApiKey: string
|
||||||
audioInputBaseUrl: string
|
audioInputBaseUrl: string
|
||||||
@@ -247,8 +249,10 @@ const wizardData = ref<WizardData>({
|
|||||||
supportAudioOutput: false,
|
supportAudioOutput: false,
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
baseUrl: 'https://api.deepseek.com',
|
baseUrl: 'https://api.deepseek.com',
|
||||||
|
useProxy: true,
|
||||||
baseUrlPreset: '',
|
baseUrlPreset: '',
|
||||||
maxContextTokens: 64,
|
maxContextTokens: 64,
|
||||||
|
userAgent: '',
|
||||||
audioInputProvider: 'openai',
|
audioInputProvider: 'openai',
|
||||||
audioInputApiKey: '',
|
audioInputApiKey: '',
|
||||||
audioInputBaseUrl: '',
|
audioInputBaseUrl: '',
|
||||||
@@ -1444,8 +1448,10 @@ export function useSetupWizard() {
|
|||||||
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
|
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
|
||||||
LLM_API_KEY: wizardData.value.agent.apiKey,
|
LLM_API_KEY: wizardData.value.agent.apiKey,
|
||||||
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
|
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
|
||||||
|
LLM_USE_PROXY: wizardData.value.agent.useProxy,
|
||||||
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
|
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
|
||||||
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
|
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
|
||||||
|
LLM_USER_AGENT: wizardData.value.agent.userAgent || null,
|
||||||
AUDIO_INPUT_PROVIDER: wizardData.value.agent.audioInputProvider || 'openai',
|
AUDIO_INPUT_PROVIDER: wizardData.value.agent.audioInputProvider || 'openai',
|
||||||
AUDIO_INPUT_API_KEY: wizardData.value.agent.audioInputApiKey || null,
|
AUDIO_INPUT_API_KEY: wizardData.value.agent.audioInputApiKey || null,
|
||||||
AUDIO_INPUT_BASE_URL: wizardData.value.agent.audioInputBaseUrl || null,
|
AUDIO_INPUT_BASE_URL: wizardData.value.agent.audioInputBaseUrl || null,
|
||||||
@@ -1557,8 +1563,10 @@ export function useSetupWizard() {
|
|||||||
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
|
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
|
||||||
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
|
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
|
||||||
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
|
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
|
||||||
|
wizardData.value.agent.useProxy = result.data.LLM_USE_PROXY ?? true
|
||||||
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
|
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
|
||||||
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
|
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
|
||||||
|
wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || ''
|
||||||
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
|
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
|
||||||
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
|
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
|
||||||
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''
|
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''
|
||||||
|
|||||||
@@ -1378,6 +1378,18 @@ export default {
|
|||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
clearCache: 'Clear Cache',
|
clearCache: 'Clear Cache',
|
||||||
|
versionStatistic: 'Version Statistics',
|
||||||
|
versionStatisticTitle: 'Installation Version Statistics',
|
||||||
|
totalInstallUsers: 'Install Users',
|
||||||
|
activeToday: 'Active Today',
|
||||||
|
active7Days: 'Active 7 Days',
|
||||||
|
active30Days: 'Active 30 Days',
|
||||||
|
backendVersionStatistic: 'Backend Versions',
|
||||||
|
frontendVersionStatistic: 'Frontend Versions',
|
||||||
|
version: 'Version',
|
||||||
|
users: 'Users',
|
||||||
|
lastUpdated: 'Updated At',
|
||||||
|
noVersionStatisticData: 'No statistics data',
|
||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
custom: 'Custom',
|
custom: 'Custom',
|
||||||
@@ -1444,6 +1456,11 @@ export default {
|
|||||||
llmApiKeyPlaceholder: 'Please enter API key',
|
llmApiKeyPlaceholder: 'Please enter API key',
|
||||||
llmBaseUrl: 'LLM Base URL',
|
llmBaseUrl: 'LLM Base URL',
|
||||||
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
|
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
|
||||||
|
llmUseProxy: 'Use System Proxy',
|
||||||
|
llmUseProxyHint:
|
||||||
|
'When enabled, Agent connections to the current LLM provider use the system proxy from advanced settings.',
|
||||||
|
llmUserAgent: 'User-Agent',
|
||||||
|
llmUserAgentHint: 'User-Agent sent to OpenAI-compatible APIs. Leave empty to use the SDK default.',
|
||||||
llmProviderAuth: 'Provider Authorization',
|
llmProviderAuth: 'Provider Authorization',
|
||||||
llmProviderAuthHint:
|
llmProviderAuthHint:
|
||||||
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
|
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
|
||||||
@@ -1458,15 +1475,16 @@ export default {
|
|||||||
llmProviderCheckAuthStatus: 'Check Authorization Status',
|
llmProviderCheckAuthStatus: 'Check Authorization Status',
|
||||||
audioInputProvider: 'Audio Input Provider',
|
audioInputProvider: 'Audio Input Provider',
|
||||||
audioInputProviderHint:
|
audioInputProviderHint:
|
||||||
'Service used to transcribe incoming audio messages. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
|
'Service used to transcribe incoming audio messages. Supports OpenAI audio, Chat Audio compatible APIs, Xiaomi MiMo, and MiniMax.',
|
||||||
audioProviderOpenAiAudio: 'OpenAI Audio Compatible',
|
audioProviderOpenAiAudio: 'OpenAI Audio Compatible',
|
||||||
audioProviderChatAudio: 'Chat Audio Compatible',
|
audioProviderChatAudio: 'Chat Audio Compatible',
|
||||||
audioProviderMimo: 'Xiaomi MiMo',
|
audioProviderMimo: 'Xiaomi MiMo',
|
||||||
|
audioProviderMinimax: 'MiniMax',
|
||||||
audioInputApiKey: 'Audio Input API Key',
|
audioInputApiKey: 'Audio Input API Key',
|
||||||
audioInputApiKeyHint: 'API key used for audio transcription.',
|
audioInputApiKeyHint: 'API key used for audio transcription.',
|
||||||
audioInputBaseUrl: 'Audio Input Base URL',
|
audioInputBaseUrl: 'Audio Input Base URL',
|
||||||
audioInputBaseUrlHint:
|
audioInputBaseUrlHint:
|
||||||
'Base URL for audio input. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
|
'Base URL for audio input. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1, MiniMax defaults to https://api.minimaxi.com/v1.',
|
||||||
audioInputModel: 'Audio Input Model',
|
audioInputModel: 'Audio Input Model',
|
||||||
audioInputModelHint: 'Model name used to convert audio content into text.',
|
audioInputModelHint: 'Model name used to convert audio content into text.',
|
||||||
audioInputLanguage: 'Recognition Language',
|
audioInputLanguage: 'Recognition Language',
|
||||||
@@ -1474,12 +1492,12 @@ export default {
|
|||||||
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
|
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
|
||||||
audioOutputProvider: 'Audio Output Provider',
|
audioOutputProvider: 'Audio Output Provider',
|
||||||
audioOutputProviderHint:
|
audioOutputProviderHint:
|
||||||
'Service used to generate voice replies. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
|
'Service used to generate voice replies. Supports OpenAI audio, Chat Audio compatible APIs, Xiaomi MiMo, and MiniMax.',
|
||||||
audioOutputApiKey: 'Audio Output API Key',
|
audioOutputApiKey: 'Audio Output API Key',
|
||||||
audioOutputApiKeyHint: 'API key used for speech synthesis.',
|
audioOutputApiKeyHint: 'API key used for speech synthesis.',
|
||||||
audioOutputBaseUrl: 'Audio Output Base URL',
|
audioOutputBaseUrl: 'Audio Output Base URL',
|
||||||
audioOutputBaseUrlHint:
|
audioOutputBaseUrlHint:
|
||||||
'Base URL for audio output. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
|
'Base URL for audio output. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1, MiniMax defaults to https://api.minimaxi.com/v1.',
|
||||||
audioOutputModel: 'Audio Output Model',
|
audioOutputModel: 'Audio Output Model',
|
||||||
audioOutputModelHint: 'Model name used to convert text content into speech.',
|
audioOutputModelHint: 'Model name used to convert text content into speech.',
|
||||||
audioOutputVoice: 'Voice Preset',
|
audioOutputVoice: 'Voice Preset',
|
||||||
@@ -1561,6 +1579,9 @@ export default {
|
|||||||
'Share subscription statistics to popular subscriptions for other MP users to reference',
|
'Share subscription statistics to popular subscriptions for other MP users to reference',
|
||||||
pluginStatisticShare: 'Report Plugin Installation Data',
|
pluginStatisticShare: 'Report Plugin Installation Data',
|
||||||
pluginStatisticShareHint: 'Report plugin installation data to the server for statistics and display purposes',
|
pluginStatisticShareHint: 'Report plugin installation data to the server for statistics and display purposes',
|
||||||
|
usageStatisticShare: 'Report Installation Version Statistics',
|
||||||
|
usageStatisticShareHint:
|
||||||
|
'Report anonymous installation ID and current backend/frontend versions to count users by version',
|
||||||
workflowStatisticShare: 'Share Workflow Data',
|
workflowStatisticShare: 'Share Workflow Data',
|
||||||
workflowStatisticShareHint: 'Share workflow statistics to popular workflows for other MP users to reference',
|
workflowStatisticShareHint: 'Share workflow statistics to popular workflows for other MP users to reference',
|
||||||
bigMemoryMode: 'Large Memory Mode',
|
bigMemoryMode: 'Large Memory Mode',
|
||||||
@@ -2431,6 +2452,7 @@ export default {
|
|||||||
updating: 'Updating {site} Cookie & UA...',
|
updating: 'Updating {site} Cookie & UA...',
|
||||||
success: '{site} Cookie & UA updated successfully!',
|
success: '{site} Cookie & UA updated successfully!',
|
||||||
failed: '{site} update failed: {message}',
|
failed: '{site} update failed: {message}',
|
||||||
|
requestFailed: 'Request failed, please try again later',
|
||||||
updateButton: 'Start Update',
|
updateButton: 'Start Update',
|
||||||
},
|
},
|
||||||
siteAddEdit: {
|
siteAddEdit: {
|
||||||
@@ -2469,11 +2491,19 @@ export default {
|
|||||||
title: 'Plugin Market Settings',
|
title: 'Plugin Market Settings',
|
||||||
repoUrl: 'Plugin Repository URL',
|
repoUrl: 'Plugin Repository URL',
|
||||||
repoPlaceholder: 'Format: https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
|
repoPlaceholder: 'Format: https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
|
||||||
repoHint: 'Multiple URLs separated by lines, only Github repositories are supported',
|
repoHint: 'Separate multiple URLs with new lines or commas',
|
||||||
urlPlaceholder: 'Enter plugin repository URL',
|
urlPlaceholder: 'Enter plugin repository URL',
|
||||||
|
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
|
||||||
|
listMode: 'List',
|
||||||
|
textMode: 'Text',
|
||||||
|
textHint: 'Paste repository URLs one per line or separated by commas.',
|
||||||
|
addRepo: 'Add repository',
|
||||||
noRepos: 'No plugin repository URLs',
|
noRepos: 'No plugin repository URLs',
|
||||||
invalidUrl: 'Please enter a valid URL',
|
invalidUrl: 'Please enter a valid URL',
|
||||||
duplicateUrl: 'This URL already exists',
|
duplicateUrl: 'This URL already exists',
|
||||||
|
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
|
||||||
|
invalidTextIgnored: '{count} invalid URLs ignored',
|
||||||
|
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saveSuccess: 'Plugin repository saved successfully',
|
saveSuccess: 'Plugin repository saved successfully',
|
||||||
@@ -2614,6 +2644,7 @@ export default {
|
|||||||
previewSeasonInfo: 'Season',
|
previewSeasonInfo: 'Season',
|
||||||
previewSeasonLabel: 'Season',
|
previewSeasonLabel: 'Season',
|
||||||
previewEpisodeCount: 'Episodes',
|
previewEpisodeCount: 'Episodes',
|
||||||
|
customWordsApplied: 'Recognition Word Details',
|
||||||
previewAfterColumn: 'After',
|
previewAfterColumn: 'After',
|
||||||
previewBeforeColumn: 'Before',
|
previewBeforeColumn: 'Before',
|
||||||
previewFileNameColumn: 'Filename',
|
previewFileNameColumn: 'Filename',
|
||||||
|
|||||||
@@ -1373,6 +1373,18 @@ export default {
|
|||||||
expand: '展开',
|
expand: '展开',
|
||||||
collapse: '收起',
|
collapse: '收起',
|
||||||
clearCache: '清除缓存',
|
clearCache: '清除缓存',
|
||||||
|
versionStatistic: '版本统计',
|
||||||
|
versionStatisticTitle: '安装版本统计',
|
||||||
|
totalInstallUsers: '安装用户',
|
||||||
|
activeToday: '今日活跃',
|
||||||
|
active7Days: '7日活跃',
|
||||||
|
active30Days: '30日活跃',
|
||||||
|
backendVersionStatistic: '后端版本',
|
||||||
|
frontendVersionStatistic: '前端版本',
|
||||||
|
version: '版本',
|
||||||
|
users: '用户数',
|
||||||
|
lastUpdated: '更新时间',
|
||||||
|
noVersionStatisticData: '暂无统计数据',
|
||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
custom: '自定义',
|
custom: '自定义',
|
||||||
@@ -1436,6 +1448,10 @@ export default {
|
|||||||
llmApiKeyPlaceholder: '请输入API密钥',
|
llmApiKeyPlaceholder: '请输入API密钥',
|
||||||
llmBaseUrl: 'LLM基础URL',
|
llmBaseUrl: 'LLM基础URL',
|
||||||
llmBaseUrlHint: 'LLM API的基础URL地址,用于自定义API端点',
|
llmBaseUrlHint: 'LLM API的基础URL地址,用于自定义API端点',
|
||||||
|
llmUseProxy: '使用系统代理',
|
||||||
|
llmUseProxyHint: '启用后,Agent 连接当前 LLM 提供商时会应用高级设置中的系统代理',
|
||||||
|
llmUserAgent: 'User-Agent',
|
||||||
|
llmUserAgentHint: 'OpenAI 兼容接口请求使用的 User-Agent,留空则使用 SDK 默认值',
|
||||||
llmProviderAuth: '提供商授权',
|
llmProviderAuth: '提供商授权',
|
||||||
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
|
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
|
||||||
llmProviderConnectedAs: '当前已连接:{label}',
|
llmProviderConnectedAs: '当前已连接:{label}',
|
||||||
@@ -1447,26 +1463,29 @@ export default {
|
|||||||
llmProviderOpenAuthPage: '打开授权页面',
|
llmProviderOpenAuthPage: '打开授权页面',
|
||||||
llmProviderCheckAuthStatus: '检查授权状态',
|
llmProviderCheckAuthStatus: '检查授权状态',
|
||||||
audioInputProvider: '音频输入提供商',
|
audioInputProvider: '音频输入提供商',
|
||||||
audioInputProviderHint: '用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
audioInputProviderHint:
|
||||||
|
'用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
|
||||||
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
|
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
|
||||||
audioProviderChatAudio: 'Chat Audio 兼容',
|
audioProviderChatAudio: 'Chat Audio 兼容',
|
||||||
audioProviderMimo: '小米 MiMo',
|
audioProviderMimo: '小米 MiMo',
|
||||||
|
audioProviderMinimax: 'MiniMax',
|
||||||
audioInputApiKey: '音频输入 API密钥',
|
audioInputApiKey: '音频输入 API密钥',
|
||||||
audioInputApiKeyHint: '音频输入转写使用的 API 密钥',
|
audioInputApiKeyHint: '音频输入转写使用的 API 密钥',
|
||||||
audioInputBaseUrl: '音频输入基础URL',
|
audioInputBaseUrl: '音频输入基础URL',
|
||||||
audioInputBaseUrlHint:
|
audioInputBaseUrlHint:
|
||||||
'音频输入接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1',
|
'音频输入接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1,MiniMax 默认 https://api.minimaxi.com/v1',
|
||||||
audioInputModel: '音频输入模型',
|
audioInputModel: '音频输入模型',
|
||||||
audioInputModelHint: '用于将音频内容转换为文字的模型名称',
|
audioInputModelHint: '用于将音频内容转换为文字的模型名称',
|
||||||
audioInputLanguage: '识别语言',
|
audioInputLanguage: '识别语言',
|
||||||
audioInputLanguageHint: '音频转写默认语言,例如 zh、en,留空时按后端默认处理',
|
audioInputLanguageHint: '音频转写默认语言,例如 zh、en,留空时按后端默认处理',
|
||||||
audioOutputProvider: '音频输出提供商',
|
audioOutputProvider: '音频输出提供商',
|
||||||
audioOutputProviderHint: '用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
audioOutputProviderHint:
|
||||||
|
'用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
|
||||||
audioOutputApiKey: '音频输出 API密钥',
|
audioOutputApiKey: '音频输出 API密钥',
|
||||||
audioOutputApiKeyHint: '文字转语音使用的 API 密钥',
|
audioOutputApiKeyHint: '文字转语音使用的 API 密钥',
|
||||||
audioOutputBaseUrl: '音频输出基础URL',
|
audioOutputBaseUrl: '音频输出基础URL',
|
||||||
audioOutputBaseUrlHint:
|
audioOutputBaseUrlHint:
|
||||||
'音频输出接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1',
|
'音频输出接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1,MiniMax 默认 https://api.minimaxi.com/v1',
|
||||||
audioOutputModel: '音频输出模型',
|
audioOutputModel: '音频输出模型',
|
||||||
audioOutputModelHint: '用于将文字内容转换为语音的模型名称',
|
audioOutputModelHint: '用于将文字内容转换为语音的模型名称',
|
||||||
audioOutputVoice: '语音音色',
|
audioOutputVoice: '语音音色',
|
||||||
@@ -1545,6 +1564,8 @@ export default {
|
|||||||
subscribeStatisticShareHint: '分享订阅统计数据到热门订阅,供其他MPer参考',
|
subscribeStatisticShareHint: '分享订阅统计数据到热门订阅,供其他MPer参考',
|
||||||
pluginStatisticShare: '上报插件安装数据',
|
pluginStatisticShare: '上报插件安装数据',
|
||||||
pluginStatisticShareHint: '上报插件安装数据给服务器,用于统计展示插件安装情况',
|
pluginStatisticShareHint: '上报插件安装数据给服务器,用于统计展示插件安装情况',
|
||||||
|
usageStatisticShare: '上报安装版本统计',
|
||||||
|
usageStatisticShareHint: '上报匿名安装ID和当前前后端版本,用于统计各版本安装用户数',
|
||||||
workflowStatisticShare: '分享工作流数据',
|
workflowStatisticShare: '分享工作流数据',
|
||||||
workflowStatisticShareHint: '分享工作流统计数据到热门工作流,供其他MPer参考',
|
workflowStatisticShareHint: '分享工作流统计数据到热门工作流,供其他MPer参考',
|
||||||
bigMemoryMode: '大内存模式',
|
bigMemoryMode: '大内存模式',
|
||||||
@@ -2385,6 +2406,7 @@ export default {
|
|||||||
updating: '正在更新 {site} Cookie & UA...',
|
updating: '正在更新 {site} Cookie & UA...',
|
||||||
success: '{site} 更新Cookie & UA成功!',
|
success: '{site} 更新Cookie & UA成功!',
|
||||||
failed: '{site} 更新失败:{message}',
|
failed: '{site} 更新失败:{message}',
|
||||||
|
requestFailed: '请求失败,请稍后重试',
|
||||||
updateButton: '开始更新',
|
updateButton: '开始更新',
|
||||||
},
|
},
|
||||||
siteAddEdit: {
|
siteAddEdit: {
|
||||||
@@ -2423,11 +2445,19 @@ export default {
|
|||||||
title: '插件市场设置',
|
title: '插件市场设置',
|
||||||
repoUrl: '插件仓库地址',
|
repoUrl: '插件仓库地址',
|
||||||
repoPlaceholder: '格式:https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
|
repoPlaceholder: '格式:https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
|
||||||
repoHint: '多个地址使用换行分隔,仅支持Github仓库',
|
repoHint: '多个地址可使用换行或英文逗号分隔',
|
||||||
urlPlaceholder: '输入插件仓库地址',
|
urlPlaceholder: '输入插件仓库地址',
|
||||||
|
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
|
||||||
|
listMode: '列表维护',
|
||||||
|
textMode: '文本维护',
|
||||||
|
textHint: '直接粘贴仓库地址串,一行一个或使用英文逗号分隔。',
|
||||||
|
addRepo: '添加仓库',
|
||||||
noRepos: '暂无插件仓库地址',
|
noRepos: '暂无插件仓库地址',
|
||||||
invalidUrl: '请输入有效的URL地址',
|
invalidUrl: '请输入有效的URL地址',
|
||||||
duplicateUrl: '该地址已存在',
|
duplicateUrl: '该地址已存在',
|
||||||
|
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
|
||||||
|
invalidTextIgnored: '已忽略 {count} 个无效地址',
|
||||||
|
duplicateTextIgnored: '重复地址会在保存时自动去重。',
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
saveSuccess: '插件仓库保存成功',
|
saveSuccess: '插件仓库保存成功',
|
||||||
@@ -2567,6 +2597,7 @@ export default {
|
|||||||
previewSeasonInfo: '季信息',
|
previewSeasonInfo: '季信息',
|
||||||
previewSeasonLabel: '季',
|
previewSeasonLabel: '季',
|
||||||
previewEpisodeCount: '总集数',
|
previewEpisodeCount: '总集数',
|
||||||
|
customWordsApplied: '识别词应用详情',
|
||||||
previewAfterColumn: '整理后',
|
previewAfterColumn: '整理后',
|
||||||
previewBeforeColumn: '整理前',
|
previewBeforeColumn: '整理前',
|
||||||
previewFileNameColumn: '文件名',
|
previewFileNameColumn: '文件名',
|
||||||
|
|||||||
@@ -1374,6 +1374,18 @@ export default {
|
|||||||
expand: '展開',
|
expand: '展開',
|
||||||
collapse: '收起',
|
collapse: '收起',
|
||||||
clearCache: '清除快取',
|
clearCache: '清除快取',
|
||||||
|
versionStatistic: '版本統計',
|
||||||
|
versionStatisticTitle: '安裝版本統計',
|
||||||
|
totalInstallUsers: '安裝用戶',
|
||||||
|
activeToday: '今日活躍',
|
||||||
|
active7Days: '7日活躍',
|
||||||
|
active30Days: '30日活躍',
|
||||||
|
backendVersionStatistic: '後端版本',
|
||||||
|
frontendVersionStatistic: '前端版本',
|
||||||
|
version: '版本',
|
||||||
|
users: '用戶數',
|
||||||
|
lastUpdated: '更新時間',
|
||||||
|
noVersionStatisticData: '暫無統計數據',
|
||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
custom: '自定義',
|
custom: '自定義',
|
||||||
@@ -1437,6 +1449,10 @@ export default {
|
|||||||
llmApiKeyPlaceholder: '請輸入API密鑰',
|
llmApiKeyPlaceholder: '請輸入API密鑰',
|
||||||
llmBaseUrl: 'LLM基礎URL',
|
llmBaseUrl: 'LLM基礎URL',
|
||||||
llmBaseUrlHint: 'LLM API的基礎URL地址,用於自定義API端點',
|
llmBaseUrlHint: 'LLM API的基礎URL地址,用於自定義API端點',
|
||||||
|
llmUseProxy: '使用系統代理',
|
||||||
|
llmUseProxyHint: '啟用後,Agent 連接目前 LLM 提供商時會套用進階設定中的系統代理',
|
||||||
|
llmUserAgent: 'User-Agent',
|
||||||
|
llmUserAgentHint: 'OpenAI 兼容接口請求使用的 User-Agent,留空則使用 SDK 預設值',
|
||||||
llmProviderAuth: '提供商授權',
|
llmProviderAuth: '提供商授權',
|
||||||
llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。',
|
llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。',
|
||||||
llmProviderConnectedAs: '目前已連接:{label}',
|
llmProviderConnectedAs: '目前已連接:{label}',
|
||||||
@@ -1448,26 +1464,29 @@ export default {
|
|||||||
llmProviderOpenAuthPage: '開啟授權頁面',
|
llmProviderOpenAuthPage: '開啟授權頁面',
|
||||||
llmProviderCheckAuthStatus: '檢查授權狀態',
|
llmProviderCheckAuthStatus: '檢查授權狀態',
|
||||||
audioInputProvider: '音頻輸入提供商',
|
audioInputProvider: '音頻輸入提供商',
|
||||||
audioInputProviderHint: '用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
audioInputProviderHint:
|
||||||
|
'用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
|
||||||
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
|
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
|
||||||
audioProviderChatAudio: 'Chat Audio 兼容',
|
audioProviderChatAudio: 'Chat Audio 兼容',
|
||||||
audioProviderMimo: '小米 MiMo',
|
audioProviderMimo: '小米 MiMo',
|
||||||
|
audioProviderMinimax: 'MiniMax',
|
||||||
audioInputApiKey: '音頻輸入 API密鑰',
|
audioInputApiKey: '音頻輸入 API密鑰',
|
||||||
audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰',
|
audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰',
|
||||||
audioInputBaseUrl: '音頻輸入基礎URL',
|
audioInputBaseUrl: '音頻輸入基礎URL',
|
||||||
audioInputBaseUrlHint:
|
audioInputBaseUrlHint:
|
||||||
'音頻輸入接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1',
|
'音頻輸入接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1,MiniMax 預設 https://api.minimaxi.com/v1',
|
||||||
audioInputModel: '音頻輸入模型',
|
audioInputModel: '音頻輸入模型',
|
||||||
audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱',
|
audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱',
|
||||||
audioInputLanguage: '識別語言',
|
audioInputLanguage: '識別語言',
|
||||||
audioInputLanguageHint: '音頻轉寫預設語言,例如 zh、en,留空時按後端預設處理',
|
audioInputLanguageHint: '音頻轉寫預設語言,例如 zh、en,留空時按後端預設處理',
|
||||||
audioOutputProvider: '音頻輸出提供商',
|
audioOutputProvider: '音頻輸出提供商',
|
||||||
audioOutputProviderHint: '用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
audioOutputProviderHint:
|
||||||
|
'用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
|
||||||
audioOutputApiKey: '音頻輸出 API密鑰',
|
audioOutputApiKey: '音頻輸出 API密鑰',
|
||||||
audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰',
|
audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰',
|
||||||
audioOutputBaseUrl: '音頻輸出基礎URL',
|
audioOutputBaseUrl: '音頻輸出基礎URL',
|
||||||
audioOutputBaseUrlHint:
|
audioOutputBaseUrlHint:
|
||||||
'音頻輸出接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1',
|
'音頻輸出接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1,MiniMax 預設 https://api.minimaxi.com/v1',
|
||||||
audioOutputModel: '音頻輸出模型',
|
audioOutputModel: '音頻輸出模型',
|
||||||
audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱',
|
audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱',
|
||||||
audioOutputVoice: '語音音色',
|
audioOutputVoice: '語音音色',
|
||||||
@@ -1546,6 +1565,8 @@ export default {
|
|||||||
subscribeStatisticShareHint: '分享訂閱統計數據到熱門訂閱,供其他MPer參考',
|
subscribeStatisticShareHint: '分享訂閱統計數據到熱門訂閱,供其他MPer參考',
|
||||||
pluginStatisticShare: '上報插件安裝數據',
|
pluginStatisticShare: '上報插件安裝數據',
|
||||||
pluginStatisticShareHint: '上報插件安裝數據給服務器,用於統計展示插件安裝情況',
|
pluginStatisticShareHint: '上報插件安裝數據給服務器,用於統計展示插件安裝情況',
|
||||||
|
usageStatisticShare: '上報安裝版本統計',
|
||||||
|
usageStatisticShareHint: '上報匿名安裝ID和當前前後端版本,用於統計各版本安裝用戶數',
|
||||||
workflowStatisticShare: '分享工作流數據',
|
workflowStatisticShare: '分享工作流數據',
|
||||||
workflowStatisticShareHint: '分享工作流統計數據到熱門工作流,供其他MPer參考',
|
workflowStatisticShareHint: '分享工作流統計數據到熱門工作流,供其他MPer參考',
|
||||||
bigMemoryMode: '大內存模式',
|
bigMemoryMode: '大內存模式',
|
||||||
@@ -2386,6 +2407,7 @@ export default {
|
|||||||
updating: '正在更新 {site} Cookie & UA...',
|
updating: '正在更新 {site} Cookie & UA...',
|
||||||
success: '{site} 更新Cookie & UA成功!',
|
success: '{site} 更新Cookie & UA成功!',
|
||||||
failed: '{site} 更新失敗:{message}',
|
failed: '{site} 更新失敗:{message}',
|
||||||
|
requestFailed: '請求失敗,請稍後重試',
|
||||||
updateButton: '開始更新',
|
updateButton: '開始更新',
|
||||||
},
|
},
|
||||||
siteAddEdit: {
|
siteAddEdit: {
|
||||||
@@ -2424,11 +2446,19 @@ export default {
|
|||||||
title: '插件市場設置',
|
title: '插件市場設置',
|
||||||
repoUrl: '插件倉庫地址',
|
repoUrl: '插件倉庫地址',
|
||||||
repoPlaceholder: '格式:https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
|
repoPlaceholder: '格式:https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
|
||||||
repoHint: '多個地址使用换行分隔,僅支援Github倉庫',
|
repoHint: '多個地址可使用換行或英文逗號分隔',
|
||||||
urlPlaceholder: '輸入插件倉庫地址',
|
urlPlaceholder: '輸入插件倉庫地址',
|
||||||
|
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
|
||||||
|
listMode: '列表維護',
|
||||||
|
textMode: '文字維護',
|
||||||
|
textHint: '直接貼上倉庫地址串,一行一個或使用英文逗號分隔。',
|
||||||
|
addRepo: '新增倉庫',
|
||||||
noRepos: '暫無插件倉庫地址',
|
noRepos: '暫無插件倉庫地址',
|
||||||
invalidUrl: '請輸入有效的URL地址',
|
invalidUrl: '請輸入有效的URL地址',
|
||||||
duplicateUrl: '該地址已存在',
|
duplicateUrl: '該地址已存在',
|
||||||
|
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
|
||||||
|
invalidTextIgnored: '已忽略 {count} 個無效地址',
|
||||||
|
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
|
||||||
close: '關閉',
|
close: '關閉',
|
||||||
save: '儲存',
|
save: '儲存',
|
||||||
saveSuccess: '插件倉庫儲存成功',
|
saveSuccess: '插件倉庫儲存成功',
|
||||||
@@ -2568,6 +2598,7 @@ export default {
|
|||||||
previewSeasonInfo: '季資訊',
|
previewSeasonInfo: '季資訊',
|
||||||
previewSeasonLabel: '季',
|
previewSeasonLabel: '季',
|
||||||
previewEpisodeCount: '總集數',
|
previewEpisodeCount: '總集數',
|
||||||
|
customWordsApplied: '識別詞應用詳情',
|
||||||
previewAfterColumn: '整理後',
|
previewAfterColumn: '整理後',
|
||||||
previewBeforeColumn: '整理前',
|
previewBeforeColumn: '整理前',
|
||||||
previewFileNameColumn: '文件名',
|
previewFileNameColumn: '文件名',
|
||||||
|
|||||||
@@ -148,13 +148,16 @@ registerRoute(
|
|||||||
url.pathname.includes('/api/v1/') &&
|
url.pathname.includes('/api/v1/') &&
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
!url.pathname.includes('/api/v1/search/') && // 搜索接口结果动态变化,避免缓存导致重复搜索失效
|
!url.pathname.includes('/api/v1/search/') && // 搜索接口结果动态变化,避免缓存导致重复搜索失效
|
||||||
|
!url.pathname.includes('/api/v1/site/cookie/') && // 站点 Cookie 更新是副作用请求,不能缓存
|
||||||
!url.pathname.includes('/api/v1/system/message') && // SSE实时消息流
|
!url.pathname.includes('/api/v1/system/message') && // SSE实时消息流
|
||||||
!url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流
|
!url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流
|
||||||
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
|
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
|
||||||
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
|
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
|
||||||
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
|
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
|
||||||
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
|
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
|
||||||
!url.pathname.includes('/api/v1/dashboard/'), // Dashboard实时监控数据
|
!url.pathname.includes('/api/v1/dashboard/') && // Dashboard实时监控数据
|
||||||
|
!url.pathname.includes('/api/v1/plugin/')&& // 插件接口
|
||||||
|
!url.pathname.includes('/api/v1/subscribe/'), // 订阅接口
|
||||||
new NetworkFirst({
|
new NetworkFirst({
|
||||||
cacheName: `api-cache-${CACHE_VERSION}`,
|
cacheName: `api-cache-${CACHE_VERSION}`,
|
||||||
networkTimeoutSeconds: 5,
|
networkTimeoutSeconds: 5,
|
||||||
|
|||||||
@@ -64,6 +64,149 @@ interface DoubanAppParams {
|
|||||||
fallbackUrl?: string
|
fallbackUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 媒体服务器卡片跳转所需的最小字段集合
|
||||||
|
interface MediaServerLinkTarget {
|
||||||
|
id?: string | number
|
||||||
|
item_id?: string | number
|
||||||
|
itemId?: string | number
|
||||||
|
server_id?: string
|
||||||
|
serverId?: string
|
||||||
|
link?: string
|
||||||
|
server_type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emby hash 路由中的目标参数
|
||||||
|
interface EmbyHashTarget {
|
||||||
|
mediaId: string | null
|
||||||
|
serverId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断链接参数是否为有效值。
|
||||||
|
* @param value 待检查的链接参数
|
||||||
|
*/
|
||||||
|
function getValidLinkValue(value?: string | number | null): string | null {
|
||||||
|
if (value === undefined || value === null) return null
|
||||||
|
const stringValue = String(value).trim()
|
||||||
|
if (!stringValue || ['none', 'null', 'undefined'].includes(stringValue.toLowerCase())) return null
|
||||||
|
return stringValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取媒体服务器条目的真实项目ID。
|
||||||
|
* @param target 媒体服务器跳转目标
|
||||||
|
*/
|
||||||
|
function getTargetItemId(target?: MediaServerLinkTarget): string | null {
|
||||||
|
return getValidLinkValue(target?.item_id ?? target?.itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取媒体服务器条目的真实服务器ID。
|
||||||
|
* @param target 媒体服务器跳转目标
|
||||||
|
*/
|
||||||
|
function getTargetServerId(target?: MediaServerLinkTarget): string | null {
|
||||||
|
return getValidLinkValue(target?.server_id ?? target?.serverId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析媒体服务器网页链接中的 hash 查询参数。
|
||||||
|
* @param playUrl 原始播放链接
|
||||||
|
*/
|
||||||
|
function getHashRouteParams(playUrl: string): { hashPath: string; params: URLSearchParams } | null {
|
||||||
|
const url = new URL(playUrl)
|
||||||
|
const hash = url.hash || ''
|
||||||
|
const queryIndex = hash.indexOf('?')
|
||||||
|
if (queryIndex === -1) return null
|
||||||
|
return {
|
||||||
|
hashPath: hash.slice(0, queryIndex),
|
||||||
|
params: new URLSearchParams(hash.slice(queryIndex + 1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Emby 网页链接中提取 App 跳转需要的媒体ID和服务器ID。
|
||||||
|
* @param playUrl 原始播放链接
|
||||||
|
*/
|
||||||
|
function getEmbyHashTarget(playUrl: string): EmbyHashTarget {
|
||||||
|
const hashRoute = getHashRouteParams(playUrl)
|
||||||
|
if (!hashRoute) return { mediaId: null, serverId: null }
|
||||||
|
|
||||||
|
const serverId = getValidLinkValue(hashRoute.params.get('serverId'))
|
||||||
|
if (hashRoute.hashPath.includes('/videos')) {
|
||||||
|
return {
|
||||||
|
mediaId: getValidLinkValue(hashRoute.params.get('parentId')),
|
||||||
|
serverId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaId: getValidLinkValue(hashRoute.params.get('id')),
|
||||||
|
serverId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用后端返回的真实ID修正Emby网页链接。
|
||||||
|
* @param playUrl 原始播放链接
|
||||||
|
* @param target 媒体服务器跳转目标
|
||||||
|
*/
|
||||||
|
function normalizeEmbyWebUrl(playUrl: string, target?: MediaServerLinkTarget): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(playUrl)
|
||||||
|
const hashRoute = getHashRouteParams(playUrl)
|
||||||
|
if (!hashRoute) return playUrl
|
||||||
|
|
||||||
|
const { hashPath, params } = hashRoute
|
||||||
|
const itemId = getTargetItemId(target)
|
||||||
|
const serverId = getTargetServerId(target)
|
||||||
|
|
||||||
|
if (itemId && (hashPath.includes('/item') || params.has('id'))) {
|
||||||
|
params.set('id', itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemId && (hashPath.includes('/videos') || params.has('parentId'))) {
|
||||||
|
params.set('parentId', itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
params.set('serverId', serverId)
|
||||||
|
} else if (params.has('serverId') && !getValidLinkValue(params.get('serverId'))) {
|
||||||
|
params.delete('serverId')
|
||||||
|
}
|
||||||
|
|
||||||
|
url.hash = `${hashPath}?${params.toString()}`
|
||||||
|
return url.toString()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('修正Emby网页链接失败:', error)
|
||||||
|
return playUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取媒体服务器卡片可用的跳转链接。
|
||||||
|
* @param target 媒体服务器跳转目标
|
||||||
|
*/
|
||||||
|
function getMediaServerPlayUrl(target: MediaServerLinkTarget): string | null {
|
||||||
|
const playUrl = getValidLinkValue(target.link)
|
||||||
|
if (!playUrl) return null
|
||||||
|
|
||||||
|
const serverType = target.server_type?.toLowerCase()
|
||||||
|
if (serverType === 'emby' || serverType === 'zspace') {
|
||||||
|
return normalizeEmbyWebUrl(playUrl, target)
|
||||||
|
}
|
||||||
|
return playUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开媒体服务器卡片对应的播放页面。
|
||||||
|
* @param target 媒体服务器跳转目标
|
||||||
|
*/
|
||||||
|
export async function openMediaServerItem(target: MediaServerLinkTarget): Promise<void> {
|
||||||
|
const playUrl = getMediaServerPlayUrl(target)
|
||||||
|
if (!playUrl) return
|
||||||
|
await openMediaServerWithAutoDetect(playUrl, undefined, target.server_type)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尝试跳转到APP,如果失败则跳转到网页
|
* 尝试跳转到APP,如果失败则跳转到网页
|
||||||
* @param appType APP类型
|
* @param appType APP类型
|
||||||
@@ -408,26 +551,27 @@ function buildEmbyDeepLink(playUrl: string): string {
|
|||||||
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
|
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
|
||||||
|
|
||||||
// 尝试多种格式提取媒体ID
|
// 尝试多种格式提取媒体ID
|
||||||
let mediaId: string | null = null
|
const hashTarget = getEmbyHashTarget(playUrl)
|
||||||
let serverId: string | null = null
|
let mediaId: string | null = hashTarget.mediaId
|
||||||
|
let serverId: string | null = hashTarget.serverId
|
||||||
|
|
||||||
// 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式)
|
// 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式)
|
||||||
const itemHashMatch = playUrl.match(/\/item\?id=([^&]+)/)
|
const itemHashMatch = !mediaId ? playUrl.match(/\/item\?id=([^&]+)/) : null
|
||||||
if (itemHashMatch) {
|
if (!mediaId && itemHashMatch) {
|
||||||
mediaId = itemHashMatch[1]
|
mediaId = itemHashMatch[1]
|
||||||
// 提取serverId
|
// 提取serverId
|
||||||
const serverIdMatch = playUrl.match(/serverId=([^&]+)/)
|
const serverIdMatch = playUrl.match(/serverId=([^&]+)/)
|
||||||
if (serverIdMatch) {
|
if (serverIdMatch) {
|
||||||
serverId = serverIdMatch[1]
|
serverId = getValidLinkValue(serverIdMatch[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式)
|
// 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式)
|
||||||
const videosHashMatch = playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/)
|
const videosHashMatch = !mediaId ? playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/) : null
|
||||||
if (videosHashMatch) {
|
if (!mediaId && videosHashMatch) {
|
||||||
// 对于videos格式,我们使用parentId作为媒体ID
|
// 对于videos格式,我们使用parentId作为媒体ID
|
||||||
mediaId = videosHashMatch[2]
|
mediaId = videosHashMatch[2]
|
||||||
serverId = videosHashMatch[1]
|
serverId = getValidLinkValue(videosHashMatch[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式3: ?id=xxx (通用格式)
|
// 格式3: ?id=xxx (通用格式)
|
||||||
@@ -464,22 +608,22 @@ function buildEmbyDeepLink(playUrl: string): string {
|
|||||||
|
|
||||||
if (mediaId) {
|
if (mediaId) {
|
||||||
let deepLink: string
|
let deepLink: string
|
||||||
|
const encodedMediaId = encodeURIComponent(mediaId)
|
||||||
|
const encodedServerId = serverId ? encodeURIComponent(serverId) : null
|
||||||
|
|
||||||
// 根据设备类型使用不同的深度链接格式
|
// 根据设备类型使用不同的深度链接格式
|
||||||
if (isIOSDevice()) {
|
if (isIOSDevice()) {
|
||||||
// iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}
|
// iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}
|
||||||
if (serverId) {
|
if (encodedServerId) {
|
||||||
deepLink = `emby://items?serverId=${serverId}&itemId=${mediaId}`
|
deepLink = `emby://items?serverId=${encodedServerId}&itemId=${encodedMediaId}`
|
||||||
} else {
|
} else {
|
||||||
// 如果没有serverId,尝试使用服务器地址作为serverId
|
deepLink = `emby://items?itemId=${encodedMediaId}`
|
||||||
deepLink = `emby://items?serverId=${serverAddress}&itemId=${mediaId}`
|
|
||||||
}
|
}
|
||||||
|
} else if (encodedServerId) {
|
||||||
|
// Android格式: emby://items/{SERVER_ID}/{ITEM_ID}
|
||||||
|
deepLink = `emby://items/${encodedServerId}/${encodedMediaId}`
|
||||||
} else {
|
} else {
|
||||||
// Android格式: emby://{服务器地址}/item/{媒体ID}
|
deepLink = `emby://${serverAddress}/item/${encodedMediaId}`
|
||||||
deepLink = `emby://${serverAddress}/item/${mediaId}`
|
|
||||||
if (serverId) {
|
|
||||||
deepLink += `?serverId=${serverId}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Emby深度链接构建成功:', {
|
console.log('Emby深度链接构建成功:', {
|
||||||
|
|||||||
@@ -80,6 +80,39 @@ export function getLogoUrl(logoName: string): string {
|
|||||||
return logoMap[logoName] || ''
|
return logoMap[logoName] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为需要强制走后端代理的 Bangumi 图片。
|
||||||
|
* @param url 图片地址
|
||||||
|
* @returns 是否为 Bangumi 图片地址
|
||||||
|
*/
|
||||||
|
export function isBangumiImageUrl(url: string): boolean {
|
||||||
|
if (!url) return false
|
||||||
|
try {
|
||||||
|
const hostname = new URL(url).hostname.toLowerCase()
|
||||||
|
return hostname === 'lain.bgm.tv' || hostname.endsWith('.lain.bgm.tv')
|
||||||
|
} catch {
|
||||||
|
return url.includes('lain.bgm.tv')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将远程图片地址转换为前端可直接展示的地址。
|
||||||
|
* @param url 原始图片地址
|
||||||
|
* @param useCache 是否使用后端图片缓存
|
||||||
|
* @returns 转换后的图片地址
|
||||||
|
*/
|
||||||
|
export function getDisplayImageUrl(url: string, useCache = false): string {
|
||||||
|
if (!url || !/^https?:\/\//i.test(url)) return url
|
||||||
|
const encodedUrl = encodeURIComponent(url)
|
||||||
|
if (isBangumiImageUrl(url))
|
||||||
|
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodedUrl}${useCache ? '&cache=true' : ''}`
|
||||||
|
if (useCache)
|
||||||
|
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodedUrl}`
|
||||||
|
if (url.includes('doubanio.com'))
|
||||||
|
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodedUrl}`
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有可用的图标名称
|
* 获取所有可用的图标名称
|
||||||
* @returns 图标名称数组
|
* @returns 图标名称数组
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import { useTheme } from 'vuetify'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { hasPermission } from '@/utils/permission'
|
import { hasPermission } from '@/utils/permission'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { openMediaServerWithAutoDetect, openDoubanApp } from '@/utils/appDeepLink'
|
import { openMediaServerItem, openDoubanApp } from '@/utils/appDeepLink'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||||
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
|
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
|
||||||
@@ -417,31 +418,19 @@ function getEpisodeImage(stillPath: string) {
|
|||||||
function getW500Image(url = '') {
|
function getW500Image(url = '') {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
url = url.replace('original', 'w500')
|
url = url.replace('original', 'w500')
|
||||||
// 使用图片缓存
|
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE)
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算Poster地址
|
// 计算Poster地址
|
||||||
const getPosterUrl: Ref<string> = computed(() => {
|
const getPosterUrl: Ref<string> = computed(() => {
|
||||||
const url = mediaDetail.value.poster_path ?? ''
|
const url = mediaDetail.value.poster_path ?? ''
|
||||||
// 使用图片缓存
|
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE)
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
// 如果地址中包含douban则使用中转代理
|
|
||||||
if (url.includes('doubanio.com'))
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算backdrop地址
|
// 计算backdrop地址
|
||||||
const getBackdropUrl: Ref<string> = computed(() => {
|
const getBackdropUrl: Ref<string> = computed(() => {
|
||||||
const url = mediaDetail.value.backdrop_path ?? ''
|
const url = mediaDetail.value.backdrop_path ?? ''
|
||||||
// 使用图片缓存
|
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE)
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取发行国家名称
|
// 获取发行国家名称
|
||||||
@@ -526,7 +515,12 @@ async function handlePlay() {
|
|||||||
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
|
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
// 使用深度链接工具,优先跳转到APP,失败后跳转到网页
|
// 使用深度链接工具,优先跳转到APP,失败后跳转到网页
|
||||||
await openMediaServerWithAutoDetect(result.data.url, undefined, result.data.server_type)
|
await openMediaServerItem({
|
||||||
|
link: result.data.url,
|
||||||
|
item_id: result.data.item_id,
|
||||||
|
server_id: result.data.server_id,
|
||||||
|
server_type: result.data.server_type,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
$toast.error(`获取播放链接失败:${result.message}!`)
|
$toast.error(`获取播放链接失败:${result.message}!`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Person } from '@/api/types'
|
|||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -64,10 +65,7 @@ function getPersonImage() {
|
|||||||
} else {
|
} else {
|
||||||
return personIcon
|
return personIcon
|
||||||
}
|
}
|
||||||
// 使用图片缓存
|
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
|
||||||
return url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将别名数组拆分为、分隔的字符串
|
// 将别名数组拆分为、分隔的字符串
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ const SystemSettings = ref<any>({
|
|||||||
LLM_SUPPORT_AUDIO_OUTPUT: false,
|
LLM_SUPPORT_AUDIO_OUTPUT: false,
|
||||||
LLM_API_KEY: null,
|
LLM_API_KEY: null,
|
||||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||||
|
LLM_USE_PROXY: true,
|
||||||
LLM_BASE_URL_PRESET: null,
|
LLM_BASE_URL_PRESET: null,
|
||||||
|
LLM_MAX_CONTEXT_TOKENS: 64,
|
||||||
|
LLM_USER_AGENT: null,
|
||||||
AUDIO_INPUT_PROVIDER: 'openai',
|
AUDIO_INPUT_PROVIDER: 'openai',
|
||||||
AUDIO_INPUT_API_KEY: null,
|
AUDIO_INPUT_API_KEY: null,
|
||||||
AUDIO_INPUT_BASE_URL: null,
|
AUDIO_INPUT_BASE_URL: null,
|
||||||
@@ -73,7 +76,6 @@ const SystemSettings = ref<any>({
|
|||||||
AI_RECOMMEND_ENABLED: false,
|
AI_RECOMMEND_ENABLED: false,
|
||||||
AI_RECOMMEND_USER_PREFERENCE: null,
|
AI_RECOMMEND_USER_PREFERENCE: null,
|
||||||
AI_RECOMMEND_MAX_ITEMS: 50,
|
AI_RECOMMEND_MAX_ITEMS: 50,
|
||||||
LLM_MAX_CONTEXT_TOKENS: 64,
|
|
||||||
},
|
},
|
||||||
// 高级系统设置
|
// 高级系统设置
|
||||||
Advanced: {
|
Advanced: {
|
||||||
@@ -82,6 +84,7 @@ const SystemSettings = ref<any>({
|
|||||||
GLOBAL_IMAGE_CACHE: false,
|
GLOBAL_IMAGE_CACHE: false,
|
||||||
SUBSCRIBE_STATISTIC_SHARE: true,
|
SUBSCRIBE_STATISTIC_SHARE: true,
|
||||||
PLUGIN_STATISTIC_SHARE: true,
|
PLUGIN_STATISTIC_SHARE: true,
|
||||||
|
USAGE_STATISTIC_SHARE: true,
|
||||||
WORKFLOW_STATISTIC_SHARE: true,
|
WORKFLOW_STATISTIC_SHARE: true,
|
||||||
BIG_MEMORY_MODE: false,
|
BIG_MEMORY_MODE: false,
|
||||||
DB_WAL_ENABLE: false,
|
DB_WAL_ENABLE: false,
|
||||||
@@ -132,6 +135,7 @@ const audioProviderItems = computed(() => [
|
|||||||
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
|
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
|
||||||
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
|
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
|
||||||
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
|
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
|
||||||
|
{ title: t('setting.system.audioProviderMinimax'), value: 'minimax' },
|
||||||
])
|
])
|
||||||
|
|
||||||
// 刮削配置
|
// 刮削配置
|
||||||
@@ -211,7 +215,9 @@ type LlmSettingsSnapshot = {
|
|||||||
LLM_THINKING_LEVEL: string
|
LLM_THINKING_LEVEL: string
|
||||||
LLM_API_KEY: string
|
LLM_API_KEY: string
|
||||||
LLM_BASE_URL: string
|
LLM_BASE_URL: string
|
||||||
|
LLM_USE_PROXY: boolean
|
||||||
LLM_BASE_URL_PRESET: string
|
LLM_BASE_URL_PRESET: string
|
||||||
|
LLM_USER_AGENT: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let llmTestRequestId = 0
|
let llmTestRequestId = 0
|
||||||
@@ -245,6 +251,20 @@ const llmBaseUrlPresetRef = computed({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const llmUseProxyRef = computed({
|
||||||
|
get: () => Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
|
||||||
|
set: value => {
|
||||||
|
SystemSettings.value.Basic.LLM_USE_PROXY = Boolean(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const llmUserAgentRef = computed({
|
||||||
|
get: () => String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
|
||||||
|
set: value => {
|
||||||
|
SystemSettings.value.Basic.LLM_USER_AGENT = value || ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const llmModelRef = computed({
|
const llmModelRef = computed({
|
||||||
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
|
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
|
||||||
set: value => {
|
set: value => {
|
||||||
@@ -290,6 +310,8 @@ const {
|
|||||||
apiKey: llmApiKeyRef,
|
apiKey: llmApiKeyRef,
|
||||||
baseUrl: llmBaseUrlRef,
|
baseUrl: llmBaseUrlRef,
|
||||||
baseUrlPreset: llmBaseUrlPresetRef,
|
baseUrlPreset: llmBaseUrlPresetRef,
|
||||||
|
useProxy: llmUseProxyRef,
|
||||||
|
userAgent: llmUserAgentRef,
|
||||||
model: llmModelRef,
|
model: llmModelRef,
|
||||||
maxContextTokens: llmMaxContextRef,
|
maxContextTokens: llmMaxContextRef,
|
||||||
})
|
})
|
||||||
@@ -350,7 +372,9 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
|
|||||||
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
|
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
|
||||||
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
|
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
|
||||||
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
|
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
|
||||||
|
LLM_USE_PROXY: Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
|
||||||
LLM_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
|
LLM_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
|
||||||
|
LLM_USER_AGENT: String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +390,9 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
|
|||||||
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
|
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
|
||||||
api_key: snapshot.LLM_API_KEY.trim(),
|
api_key: snapshot.LLM_API_KEY.trim(),
|
||||||
base_url: snapshot.LLM_BASE_URL.trim(),
|
base_url: snapshot.LLM_BASE_URL.trim(),
|
||||||
|
use_proxy: snapshot.LLM_USE_PROXY,
|
||||||
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
|
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
|
||||||
|
user_agent: snapshot.LLM_USER_AGENT.trim(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,9 +666,9 @@ async function loadSystemSettings() {
|
|||||||
if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]
|
if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const accelEnabled = Boolean(result.data.RUST_ACCEL_ENABLED)
|
const accelAvailable = Boolean(result.data.RUST_ACCEL_AVAILABLE ?? result.data.RUST_ACCEL_ENABLED)
|
||||||
rustAccelAvailable.value = accelEnabled
|
rustAccelAvailable.value = accelAvailable
|
||||||
if (!accelEnabled) SystemSettings.value.Advanced.RUST_ACCEL = false
|
if (!accelAvailable) SystemSettings.value.Advanced.RUST_ACCEL = false
|
||||||
SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)
|
SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)
|
||||||
await loadLlmProviders()
|
await loadLlmProviders()
|
||||||
}
|
}
|
||||||
@@ -1192,6 +1218,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
</template>
|
</template>
|
||||||
</VCombobox>
|
</VCombobox>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12">
|
||||||
|
<VSwitch
|
||||||
|
v-model="SystemSettings.Basic.LLM_USE_PROXY"
|
||||||
|
:label="t('setting.system.llmUseProxy')"
|
||||||
|
:hint="t('setting.system.llmUseProxyHint')"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="SystemSettings.Basic.LLM_API_KEY"
|
v-model="SystemSettings.Basic.LLM_API_KEY"
|
||||||
@@ -1306,6 +1340,15 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
prepend-inner-icon="mdi-counter"
|
prepend-inner-icon="mdi-counter"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12" md="6">
|
||||||
|
<VTextField
|
||||||
|
v-model="SystemSettings.Basic.LLM_USER_AGENT"
|
||||||
|
:label="t('setting.system.llmUserAgent')"
|
||||||
|
:hint="t('setting.system.llmUserAgentHint')"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-card-account-details-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||||
<VSelect
|
<VSelect
|
||||||
v-model="SystemSettings.Basic.LLM_THINKING_LEVEL"
|
v-model="SystemSettings.Basic.LLM_THINKING_LEVEL"
|
||||||
@@ -1767,6 +1810,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VSwitch
|
||||||
|
v-model="SystemSettings.Advanced.USAGE_STATISTIC_SHARE"
|
||||||
|
:label="t('setting.system.usageStatisticShare')"
|
||||||
|
:hint="t('setting.system.usageStatisticShareHint')"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="SystemSettings.Advanced.WORKFLOW_STATISTIC_SHARE"
|
v-model="SystemSettings.Advanced.WORKFLOW_STATISTIC_SHARE"
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ const baseUrlPresetRef = computed({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const useProxyRef = computed({
|
||||||
|
get: () => wizardData.value.agent.useProxy,
|
||||||
|
set: value => {
|
||||||
|
wizardData.value.agent.useProxy = Boolean(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const userAgentRef = computed({
|
||||||
|
get: () => wizardData.value.agent.userAgent,
|
||||||
|
set: value => {
|
||||||
|
wizardData.value.agent.userAgent = value || ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const modelRef = computed({
|
const modelRef = computed({
|
||||||
get: () => wizardData.value.agent.model,
|
get: () => wizardData.value.agent.model,
|
||||||
set: value => {
|
set: value => {
|
||||||
@@ -92,6 +106,8 @@ const {
|
|||||||
apiKey: apiKeyRef,
|
apiKey: apiKeyRef,
|
||||||
baseUrl: baseUrlRef,
|
baseUrl: baseUrlRef,
|
||||||
baseUrlPreset: baseUrlPresetRef,
|
baseUrlPreset: baseUrlPresetRef,
|
||||||
|
useProxy: useProxyRef,
|
||||||
|
userAgent: userAgentRef,
|
||||||
model: modelRef,
|
model: modelRef,
|
||||||
maxContextTokens: maxContextTokensRef,
|
maxContextTokens: maxContextTokensRef,
|
||||||
authConnected: authConnectedRef,
|
authConnected: authConnectedRef,
|
||||||
@@ -171,6 +187,7 @@ const audioProviderItems = computed(() => [
|
|||||||
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
|
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
|
||||||
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
|
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
|
||||||
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
|
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
|
||||||
|
{ title: t('setting.system.audioProviderMinimax'), value: 'minimax' },
|
||||||
])
|
])
|
||||||
|
|
||||||
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
|
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
|
||||||
@@ -332,6 +349,16 @@ onMounted(async () => {
|
|||||||
</VCombobox>
|
</VCombobox>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
|
<VCol v-if="showBaseUrlField" cols="12">
|
||||||
|
<VSwitch
|
||||||
|
v-model="wizardData.agent.useProxy"
|
||||||
|
:label="t('setting.system.llmUseProxy')"
|
||||||
|
:hint="t('setting.system.llmUseProxyHint')"
|
||||||
|
persistent-hint
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
<VCol v-if="showApiKeyField" cols="12" md="6">
|
<VCol v-if="showApiKeyField" cols="12" md="6">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="wizardData.agent.apiKey"
|
v-model="wizardData.agent.apiKey"
|
||||||
@@ -437,6 +464,16 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
|
<VCol v-if="showBaseUrlField" cols="12" md="6">
|
||||||
|
<VTextField
|
||||||
|
v-model="wizardData.agent.userAgent"
|
||||||
|
:label="t('setting.system.llmUserAgent')"
|
||||||
|
:hint="t('setting.system.llmUserAgentHint')"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-card-account-details-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
<VSelect
|
<VSelect
|
||||||
v-model="wizardData.agent.thinkingLevel"
|
v-model="wizardData.agent.thinkingLevel"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const messages = ref<Message[]>([])
|
|||||||
const currData = ref<Message[]>([])
|
const currData = ref<Message[]>([])
|
||||||
|
|
||||||
// 已加载消息的签名集合
|
// 已加载消息的签名集合
|
||||||
// 使用消息内容签名去重,避免仅按秒级时间戳判断时误吞同一秒内的不同消息。
|
// SSE 消息与数据库消息的字段来源不同(date vs reg_time, null vs {}),签名已归一化处理。
|
||||||
const messageKeys = new Set<string>()
|
const messageKeys = new Set<string>()
|
||||||
|
|
||||||
// 是否完成加载
|
// 是否完成加载
|
||||||
@@ -42,26 +42,34 @@ const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
|
|||||||
let scrollTimer: number | undefined
|
let scrollTimer: number | undefined
|
||||||
let scrollReleaseTimer: number | undefined
|
let scrollReleaseTimer: number | undefined
|
||||||
|
|
||||||
// 获取消息时间
|
// 生成消息去重签名
|
||||||
function getMessageTime(message: Message) {
|
// SSE 消息只有 date 没有 reg_time,数据库消息只有 reg_time 没有 date;
|
||||||
return message.reg_time || message.date || ''
|
// note 在 SSE 侧为 null,数据库侧为 {},需要归一化。
|
||||||
|
function normalizeNote(note: Message['note']): string {
|
||||||
|
if (note == null) return ''
|
||||||
|
if (typeof note === 'string') return note
|
||||||
|
if (typeof note === 'object' && !Array.isArray(note) && Object.keys(note).length === 0) return ''
|
||||||
|
return JSON.stringify(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成消息签名
|
|
||||||
function getMessageKey(message: Message) {
|
function getMessageKey(message: Message) {
|
||||||
return [
|
return [
|
||||||
message.action ?? '',
|
message.action ?? '',
|
||||||
message.userid ?? '',
|
message.userid ?? '',
|
||||||
message.reg_time ?? '',
|
message.reg_time || message.date || '',
|
||||||
message.date ?? '',
|
|
||||||
message.title ?? '',
|
message.title ?? '',
|
||||||
message.text ?? '',
|
message.text ?? '',
|
||||||
message.image ?? '',
|
message.image ?? '',
|
||||||
message.link ?? '',
|
message.link ?? '',
|
||||||
message.note ?? '',
|
normalizeNote(message.note),
|
||||||
].join('::')
|
].join('::')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取消息时间
|
||||||
|
function getMessageTime(message: Message) {
|
||||||
|
return message.reg_time || message.date || ''
|
||||||
|
}
|
||||||
|
|
||||||
// 排序消息列表,确保最新消息始终位于底部
|
// 排序消息列表,确保最新消息始终位于底部
|
||||||
function sortMessages(items: Message[]) {
|
function sortMessages(items: Message[]) {
|
||||||
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
|
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
|
||||||
|
|||||||
Reference in New Issue
Block a user