mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-17 13:42:07 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
258e64bca7 | ||
|
|
e905df014e | ||
|
|
b93f8f2bff | ||
|
|
9aa0a5e1b7 | ||
|
|
ee9f41d015 | ||
|
|
ad6a664cbe | ||
|
|
3387067636 | ||
|
|
07dc3c3e9a | ||
|
|
262b4bebd4 | ||
|
|
6e50cf31de | ||
|
|
14aa75dfae | ||
|
|
348aa4757b | ||
|
|
6e6819acc1 | ||
|
|
51a58aaae0 | ||
|
|
fbde99389e | ||
|
|
5a4e345529 |
@@ -37,7 +37,7 @@
|
||||
|
||||
<!-- iOS Safari PWA 优化 -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
|
||||
|
||||
<!-- iOS Safari 全屏模式 -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.1-1",
|
||||
"version": "2.13.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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 {
|
||||
// ID
|
||||
id?: string | number
|
||||
// 媒体服务器项目ID
|
||||
item_id?: string | number
|
||||
// 媒体服务器ID
|
||||
server_id?: string
|
||||
// 标题
|
||||
title: string
|
||||
// 副标题
|
||||
@@ -1049,6 +1053,10 @@ export interface MediaServerLibrary {
|
||||
server: string
|
||||
// ID
|
||||
id?: string | number
|
||||
// 媒体服务器项目ID
|
||||
item_id?: string | number
|
||||
// 媒体服务器ID
|
||||
server_id?: string
|
||||
// 名称
|
||||
name: string
|
||||
// 路径
|
||||
@@ -1292,7 +1300,7 @@ export interface TransferForm {
|
||||
// 目标存储
|
||||
target_storage: string
|
||||
// 目标路径
|
||||
target_path: string
|
||||
target_path: string | null
|
||||
// TMDB ID
|
||||
tmdbid?: number
|
||||
// 豆瓣 ID
|
||||
@@ -1335,6 +1343,22 @@ export interface ManualTransferPayload extends Omit<TransferForm, '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 {
|
||||
// 总数
|
||||
@@ -1369,6 +1393,14 @@ export interface ManualTransferPreviewItem {
|
||||
episode_end?: number | string
|
||||
// Part
|
||||
part?: string
|
||||
// 原始识别字符串
|
||||
org_string?: string
|
||||
// 应用的自定义识别词
|
||||
apply_words?: string[]
|
||||
// 制作组/字幕组
|
||||
resource_team?: string
|
||||
// 自定义占位符
|
||||
customization?: string
|
||||
}
|
||||
|
||||
// 手动整理预览数据
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaServerPlayItem>,
|
||||
@@ -25,8 +25,8 @@ function imageErrorHandler() {
|
||||
|
||||
// 跳转播放
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -49,8 +49,8 @@ function getDefaultImage() {
|
||||
|
||||
// 跳转播放
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
@@ -464,13 +464,7 @@ function setupIntersectionObserver() {
|
||||
const getImgUrl: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
||||
// 使用图片缓存
|
||||
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
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 移除订阅
|
||||
|
||||
@@ -3,6 +3,7 @@ import personIcon from '@images/misc/person-icon.png'
|
||||
import type { Person } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const personProps = defineProps({
|
||||
person: Object as PropType<Person>,
|
||||
@@ -40,9 +41,7 @@ function getPersonImage() {
|
||||
} else {
|
||||
return personIcon
|
||||
}
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
// 人物姓名
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { PropType } from 'vue'
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -38,8 +38,8 @@ const getImgUrl = computed(() => {
|
||||
|
||||
// 跳转播放
|
||||
async function goPlay(isHovering: boolean | null = false) {
|
||||
if (props.media?.link && isHovering) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
if (props.media && isHovering) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
|
||||
@@ -363,19 +364,13 @@ watch(
|
||||
// 计算backdrop图片地址
|
||||
const backdropUrl = computed(() => {
|
||||
const url = props.media?.backdrop || props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 订阅编辑保存
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
@@ -35,19 +36,13 @@ const dateText = ref(props.media && props.media?.date ? formatDateDifference(pro
|
||||
// 计算backdrop图片地址
|
||||
const backdropUrl = computed(() => {
|
||||
const url = props.media?.backdrop || props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
|
||||
@@ -102,6 +102,15 @@ const frontendVersionStatistics = computed(() => versionStatistic.value?.fronten
|
||||
// 活跃用户统计
|
||||
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) {
|
||||
releaseDialogTitle.value = title
|
||||
@@ -473,19 +482,19 @@ onMounted(() => {
|
||||
<div class="version-stat-summary">
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</div>
|
||||
<div class="version-stat-number">{{ versionStatistic.total_users ?? 0 }}</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">{{ activeUsers.today ?? 0 }}</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">{{ activeUsers.last_7_days ?? 0 }}</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">{{ activeUsers.last_30_days ?? 0 }}</div>
|
||||
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_30_days) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
@@ -502,7 +511,7 @@ onMounted(() => {
|
||||
<td>
|
||||
<code>{{ item.version }}</code>
|
||||
</td>
|
||||
<td class="text-end">{{ item.count }}</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>
|
||||
@@ -524,7 +533,7 @@ onMounted(() => {
|
||||
<td>
|
||||
<code>{{ item.version }}</code>
|
||||
</td>
|
||||
<td class="text-end">{{ item.count }}</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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useToast } from 'vue-toastification'
|
||||
import { VBtn } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -88,10 +89,7 @@ async function unfollowUser() {
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ManualTransferPayload,
|
||||
ManualTransferPreviewData,
|
||||
ManualTransferPreviewItem,
|
||||
ManualTransferTargetPathData,
|
||||
StorageConf,
|
||||
TransferDirectoryConf,
|
||||
TransferForm,
|
||||
@@ -113,6 +114,14 @@ const episodeFormatRecommendState = reactive<{
|
||||
|
||||
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
||||
|
||||
interface ManualTransferTargetPathRequest {
|
||||
fileitem?: FileItem
|
||||
fileitems?: FileItem[]
|
||||
logid?: number
|
||||
logids?: number[]
|
||||
target_storage?: string | null
|
||||
}
|
||||
|
||||
// 生成文件项稳定键,用于去重和状态同步。
|
||||
function getFileItemKey(item?: FileItem) {
|
||||
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||
@@ -265,7 +274,7 @@ const transferForm = reactive<TransferForm>({
|
||||
fileitem: {} as FileItem,
|
||||
logid: 0,
|
||||
target_storage: props.target_storage ?? 'local',
|
||||
target_path: props.target_path ?? '',
|
||||
target_path: normalizeTargetPath(props.target_path),
|
||||
transfer_type: '',
|
||||
min_filesize: 0,
|
||||
scrape: false,
|
||||
@@ -292,6 +301,79 @@ const targetDirectories = computed(() => {
|
||||
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(
|
||||
() => 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(() => {
|
||||
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[])]
|
||||
}
|
||||
|
||||
// 归一化可选目的路径,保证未指定时向接口传递 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
|
||||
function toPreviewNumber(value: unknown) {
|
||||
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 candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)]
|
||||
@@ -620,7 +750,8 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
|
||||
...transferForm,
|
||||
fileitem: sourceItem,
|
||||
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) {
|
||||
@@ -1099,8 +1230,9 @@ async function transfer(background: boolean = false) {
|
||||
emit('done')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDirectories()
|
||||
onMounted(async () => {
|
||||
await loadDirectories()
|
||||
await autoSelectTargetPath()
|
||||
loadStorages()
|
||||
loadEpisodeFormatRuleConfiguration()
|
||||
})
|
||||
@@ -1218,9 +1350,11 @@ onUnmounted(() => {
|
||||
</VRow>
|
||||
<VRow v-show="transferForm.type_name === '电视剧'">
|
||||
<VCol v-if="mediaSource === 'themoviedb'" cols="12" md="6">
|
||||
<VCombobox
|
||||
<VSelect
|
||||
v-model="transferForm.episode_group"
|
||||
:items="episodeGroupOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:item-props="episodeGroupItemProps"
|
||||
:loading="episodeGroupLoading"
|
||||
:disabled="!transferForm.tmdbid"
|
||||
@@ -1439,6 +1573,36 @@ onUnmounted(() => {
|
||||
<span class="preview-overview-card__value">{{ previewEpisodeCountText }}</span>
|
||||
</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 class="reorganize-preview-list">
|
||||
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
|
||||
@@ -1698,6 +1862,66 @@ onUnmounted(() => {
|
||||
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 {
|
||||
display: flex;
|
||||
overflow: hidden auto;
|
||||
@@ -1797,11 +2021,13 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.preview-file-row__path {
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
overflow-wrap: anywhere;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.8125rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview-file-row__card--target .preview-file-row__name {
|
||||
|
||||
@@ -10,7 +10,7 @@ const props = defineProps({
|
||||
type: Array as PropType<Site[]>,
|
||||
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 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(
|
||||
() => props.selected,
|
||||
value => {
|
||||
if (selectedSites.value.length == 0 && value) {
|
||||
selectedSites.value = value
|
||||
}
|
||||
[() => props.selected, () => props.sites],
|
||||
([value]) => {
|
||||
selectedSites.value = normalizeSelectedSites(value || [])
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 全选/全不选按钮文字
|
||||
const checkAllText = computed(() => {
|
||||
return selectedSites.value.length < props.sites?.length
|
||||
return selectedSites.value.length < props.sites.length
|
||||
? t('dialog.searchSite.selectAll')
|
||||
: t('dialog.searchSite.deselectAll')
|
||||
})
|
||||
|
||||
// 全选/全不选
|
||||
const checkAllSitesorNot = () => {
|
||||
if (selectedSites.value.length < props.sites?.length) {
|
||||
selectedSites.value = props.sites?.map((item: Site) => item.id)
|
||||
if (selectedSites.value.length < props.sites.length) {
|
||||
selectedSites.value = props.sites.map((item: Site) => item.id)
|
||||
} else {
|
||||
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(() => {
|
||||
if (!siteFilter.value) return props.sites
|
||||
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>
|
||||
<template>
|
||||
@@ -107,16 +135,7 @@ const filteredSites = computed(() => {
|
||||
'site-hover': isHovering && !selectedSites.includes(site.id),
|
||||
},
|
||||
]"
|
||||
@click="
|
||||
() => {
|
||||
const index = selectedSites.indexOf(site.id)
|
||||
if (index === -1) {
|
||||
selectedSites.push(site.id)
|
||||
} else {
|
||||
selectedSites.splice(index, 1)
|
||||
}
|
||||
}
|
||||
"
|
||||
@click="toggleSiteSelection(site.id)"
|
||||
>
|
||||
<VIcon
|
||||
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
|
||||
@@ -161,7 +180,7 @@ const filteredSites = computed(() => {
|
||||
<VBtn
|
||||
color="primary"
|
||||
:disabled="selectedSites.length === 0"
|
||||
@click="emit('search', selectedSites)"
|
||||
@click="confirmSearch"
|
||||
prepend-icon="mdi-magnify"
|
||||
class="d-flex align-center justify-center px-5"
|
||||
>
|
||||
|
||||
@@ -50,23 +50,34 @@ async function updateSiteCookie() {
|
||||
progressDialog.value = true
|
||||
progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name })
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
|
||||
params: {
|
||||
username: userPwForm.value.username,
|
||||
password: userPwForm.value.password,
|
||||
code: userPwForm.value.code,
|
||||
},
|
||||
const result: { [key: string]: any } = await api.post(`site/cookie/${cardProps.site?.id}`, {
|
||||
username: userPwForm.value.username,
|
||||
password: userPwForm.value.password,
|
||||
code: userPwForm.value.code,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))
|
||||
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
|
||||
updateButtonDisable.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -83,6 +83,7 @@ interface UseLlmProviderDirectoryOptions {
|
||||
apiKey: Ref<string>
|
||||
baseUrl: Ref<string>
|
||||
baseUrlPreset?: Ref<string>
|
||||
useProxy?: Ref<boolean>
|
||||
userAgent?: Ref<string>
|
||||
model: Ref<string>
|
||||
maxContextTokens?: Ref<number>
|
||||
@@ -254,6 +255,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
api_key: normalizeValue(options.apiKey.value) || undefined,
|
||||
base_url: normalizeValue(options.baseUrl.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,
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface WizardData {
|
||||
supportAudioOutput: boolean
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
useProxy: boolean
|
||||
baseUrlPreset: string
|
||||
maxContextTokens: number
|
||||
userAgent: string
|
||||
@@ -248,6 +249,7 @@ const wizardData = ref<WizardData>({
|
||||
supportAudioOutput: false,
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
useProxy: true,
|
||||
baseUrlPreset: '',
|
||||
maxContextTokens: 64,
|
||||
userAgent: '',
|
||||
@@ -1446,6 +1448,7 @@ export function useSetupWizard() {
|
||||
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
|
||||
LLM_API_KEY: wizardData.value.agent.apiKey,
|
||||
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_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
|
||||
LLM_USER_AGENT: wizardData.value.agent.userAgent || null,
|
||||
@@ -1560,6 +1563,7 @@ export function useSetupWizard() {
|
||||
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
|
||||
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
|
||||
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.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
|
||||
wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || ''
|
||||
|
||||
@@ -1456,6 +1456,9 @@ export default {
|
||||
llmApiKeyPlaceholder: 'Please enter API key',
|
||||
llmBaseUrl: 'LLM Base URL',
|
||||
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',
|
||||
@@ -2449,6 +2452,7 @@ export default {
|
||||
updating: 'Updating {site} Cookie & UA...',
|
||||
success: '{site} Cookie & UA updated successfully!',
|
||||
failed: '{site} update failed: {message}',
|
||||
requestFailed: 'Request failed, please try again later',
|
||||
updateButton: 'Start Update',
|
||||
},
|
||||
siteAddEdit: {
|
||||
@@ -2640,6 +2644,7 @@ export default {
|
||||
previewSeasonInfo: 'Season',
|
||||
previewSeasonLabel: 'Season',
|
||||
previewEpisodeCount: 'Episodes',
|
||||
customWordsApplied: 'Recognition Word Details',
|
||||
previewAfterColumn: 'After',
|
||||
previewBeforeColumn: 'Before',
|
||||
previewFileNameColumn: 'Filename',
|
||||
|
||||
@@ -1448,6 +1448,8 @@ export default {
|
||||
llmApiKeyPlaceholder: '请输入API密钥',
|
||||
llmBaseUrl: 'LLM基础URL',
|
||||
llmBaseUrlHint: 'LLM API的基础URL地址,用于自定义API端点',
|
||||
llmUseProxy: '使用系统代理',
|
||||
llmUseProxyHint: '启用后,Agent 连接当前 LLM 提供商时会应用高级设置中的系统代理',
|
||||
llmUserAgent: 'User-Agent',
|
||||
llmUserAgentHint: 'OpenAI 兼容接口请求使用的 User-Agent,留空则使用 SDK 默认值',
|
||||
llmProviderAuth: '提供商授权',
|
||||
@@ -2404,6 +2406,7 @@ export default {
|
||||
updating: '正在更新 {site} Cookie & UA...',
|
||||
success: '{site} 更新Cookie & UA成功!',
|
||||
failed: '{site} 更新失败:{message}',
|
||||
requestFailed: '请求失败,请稍后重试',
|
||||
updateButton: '开始更新',
|
||||
},
|
||||
siteAddEdit: {
|
||||
@@ -2594,6 +2597,7 @@ export default {
|
||||
previewSeasonInfo: '季信息',
|
||||
previewSeasonLabel: '季',
|
||||
previewEpisodeCount: '总集数',
|
||||
customWordsApplied: '识别词应用详情',
|
||||
previewAfterColumn: '整理后',
|
||||
previewBeforeColumn: '整理前',
|
||||
previewFileNameColumn: '文件名',
|
||||
|
||||
@@ -1449,6 +1449,8 @@ export default {
|
||||
llmApiKeyPlaceholder: '請輸入API密鑰',
|
||||
llmBaseUrl: 'LLM基礎URL',
|
||||
llmBaseUrlHint: 'LLM API的基礎URL地址,用於自定義API端點',
|
||||
llmUseProxy: '使用系統代理',
|
||||
llmUseProxyHint: '啟用後,Agent 連接目前 LLM 提供商時會套用進階設定中的系統代理',
|
||||
llmUserAgent: 'User-Agent',
|
||||
llmUserAgentHint: 'OpenAI 兼容接口請求使用的 User-Agent,留空則使用 SDK 預設值',
|
||||
llmProviderAuth: '提供商授權',
|
||||
@@ -2405,6 +2407,7 @@ export default {
|
||||
updating: '正在更新 {site} Cookie & UA...',
|
||||
success: '{site} 更新Cookie & UA成功!',
|
||||
failed: '{site} 更新失敗:{message}',
|
||||
requestFailed: '請求失敗,請稍後重試',
|
||||
updateButton: '開始更新',
|
||||
},
|
||||
siteAddEdit: {
|
||||
@@ -2595,6 +2598,7 @@ export default {
|
||||
previewSeasonInfo: '季資訊',
|
||||
previewSeasonLabel: '季',
|
||||
previewEpisodeCount: '總集數',
|
||||
customWordsApplied: '識別詞應用詳情',
|
||||
previewAfterColumn: '整理後',
|
||||
previewBeforeColumn: '整理前',
|
||||
previewFileNameColumn: '文件名',
|
||||
|
||||
@@ -148,13 +148,16 @@ registerRoute(
|
||||
url.pathname.includes('/api/v1/') &&
|
||||
request.method === 'GET' &&
|
||||
!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/progress/') && // SSE实时进度流
|
||||
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
|
||||
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
|
||||
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
|
||||
!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({
|
||||
cacheName: `api-cache-${CACHE_VERSION}`,
|
||||
networkTimeoutSeconds: 5,
|
||||
|
||||
@@ -64,6 +64,149 @@ interface DoubanAppParams {
|
||||
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,如果失败则跳转到网页
|
||||
* @param appType APP类型
|
||||
@@ -408,26 +551,27 @@ function buildEmbyDeepLink(playUrl: string): string {
|
||||
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
|
||||
|
||||
// 尝试多种格式提取媒体ID
|
||||
let mediaId: string | null = null
|
||||
let serverId: string | null = null
|
||||
const hashTarget = getEmbyHashTarget(playUrl)
|
||||
let mediaId: string | null = hashTarget.mediaId
|
||||
let serverId: string | null = hashTarget.serverId
|
||||
|
||||
// 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式)
|
||||
const itemHashMatch = playUrl.match(/\/item\?id=([^&]+)/)
|
||||
if (itemHashMatch) {
|
||||
const itemHashMatch = !mediaId ? playUrl.match(/\/item\?id=([^&]+)/) : null
|
||||
if (!mediaId && itemHashMatch) {
|
||||
mediaId = itemHashMatch[1]
|
||||
// 提取serverId
|
||||
const serverIdMatch = playUrl.match(/serverId=([^&]+)/)
|
||||
if (serverIdMatch) {
|
||||
serverId = serverIdMatch[1]
|
||||
serverId = getValidLinkValue(serverIdMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式)
|
||||
const videosHashMatch = playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/)
|
||||
if (videosHashMatch) {
|
||||
const videosHashMatch = !mediaId ? playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/) : null
|
||||
if (!mediaId && videosHashMatch) {
|
||||
// 对于videos格式,我们使用parentId作为媒体ID
|
||||
mediaId = videosHashMatch[2]
|
||||
serverId = videosHashMatch[1]
|
||||
serverId = getValidLinkValue(videosHashMatch[1])
|
||||
}
|
||||
|
||||
// 格式3: ?id=xxx (通用格式)
|
||||
@@ -464,22 +608,22 @@ function buildEmbyDeepLink(playUrl: string): string {
|
||||
|
||||
if (mediaId) {
|
||||
let deepLink: string
|
||||
const encodedMediaId = encodeURIComponent(mediaId)
|
||||
const encodedServerId = serverId ? encodeURIComponent(serverId) : null
|
||||
|
||||
// 根据设备类型使用不同的深度链接格式
|
||||
if (isIOSDevice()) {
|
||||
// iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}
|
||||
if (serverId) {
|
||||
deepLink = `emby://items?serverId=${serverId}&itemId=${mediaId}`
|
||||
if (encodedServerId) {
|
||||
deepLink = `emby://items?serverId=${encodedServerId}&itemId=${encodedMediaId}`
|
||||
} else {
|
||||
// 如果没有serverId,尝试使用服务器地址作为serverId
|
||||
deepLink = `emby://items?serverId=${serverAddress}&itemId=${mediaId}`
|
||||
deepLink = `emby://items?itemId=${encodedMediaId}`
|
||||
}
|
||||
} else if (encodedServerId) {
|
||||
// Android格式: emby://items/{SERVER_ID}/{ITEM_ID}
|
||||
deepLink = `emby://items/${encodedServerId}/${encodedMediaId}`
|
||||
} else {
|
||||
// Android格式: emby://{服务器地址}/item/{媒体ID}
|
||||
deepLink = `emby://${serverAddress}/item/${mediaId}`
|
||||
if (serverId) {
|
||||
deepLink += `?serverId=${serverId}`
|
||||
}
|
||||
deepLink = `emby://${serverAddress}/item/${encodedMediaId}`
|
||||
}
|
||||
|
||||
console.log('Emby深度链接构建成功:', {
|
||||
|
||||
@@ -80,6 +80,39 @@ export function getLogoUrl(logoName: string): string {
|
||||
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 图标名称数组
|
||||
|
||||
@@ -14,8 +14,9 @@ import { useTheme } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openMediaServerWithAutoDetect, openDoubanApp } from '@/utils/appDeepLink'
|
||||
import { openMediaServerItem, openDoubanApp } from '@/utils/appDeepLink'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
|
||||
@@ -417,31 +418,19 @@ function getEpisodeImage(stillPath: string) {
|
||||
function getW500Image(url = '') {
|
||||
if (!url) return ''
|
||||
url = url.replace('original', 'w500')
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
// 计算Poster地址
|
||||
const getPosterUrl: Ref<string> = computed(() => {
|
||||
const url = mediaDetail.value.poster_path ?? ''
|
||||
// 使用图片缓存
|
||||
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
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 计算backdrop地址
|
||||
const getBackdropUrl: Ref<string> = computed(() => {
|
||||
const url = mediaDetail.value.backdrop_path ?? ''
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 获取发行国家名称
|
||||
@@ -526,7 +515,12 @@ async function handlePlay() {
|
||||
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
|
||||
if (result?.success) {
|
||||
// 使用深度链接工具,优先跳转到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 {
|
||||
$toast.error(`获取播放链接失败:${result.message}!`)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Person } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -64,10 +65,7 @@ function getPersonImage() {
|
||||
} else {
|
||||
return personIcon
|
||||
}
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
// 将别名数组拆分为、分隔的字符串
|
||||
|
||||
@@ -57,6 +57,7 @@ const SystemSettings = ref<any>({
|
||||
LLM_SUPPORT_AUDIO_OUTPUT: false,
|
||||
LLM_API_KEY: null,
|
||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||
LLM_USE_PROXY: true,
|
||||
LLM_BASE_URL_PRESET: null,
|
||||
LLM_MAX_CONTEXT_TOKENS: 64,
|
||||
LLM_USER_AGENT: null,
|
||||
@@ -214,6 +215,7 @@ type LlmSettingsSnapshot = {
|
||||
LLM_THINKING_LEVEL: string
|
||||
LLM_API_KEY: string
|
||||
LLM_BASE_URL: string
|
||||
LLM_USE_PROXY: boolean
|
||||
LLM_BASE_URL_PRESET: string
|
||||
LLM_USER_AGENT: string
|
||||
}
|
||||
@@ -249,6 +251,13 @@ 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 => {
|
||||
@@ -301,6 +310,7 @@ const {
|
||||
apiKey: llmApiKeyRef,
|
||||
baseUrl: llmBaseUrlRef,
|
||||
baseUrlPreset: llmBaseUrlPresetRef,
|
||||
useProxy: llmUseProxyRef,
|
||||
userAgent: llmUserAgentRef,
|
||||
model: llmModelRef,
|
||||
maxContextTokens: llmMaxContextRef,
|
||||
@@ -362,6 +372,7 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
|
||||
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
|
||||
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
|
||||
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_USER_AGENT: String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
|
||||
}
|
||||
@@ -379,6 +390,7 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
|
||||
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
|
||||
api_key: snapshot.LLM_API_KEY.trim(),
|
||||
base_url: snapshot.LLM_BASE_URL.trim(),
|
||||
use_proxy: snapshot.LLM_USE_PROXY,
|
||||
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
|
||||
user_agent: snapshot.LLM_USER_AGENT.trim(),
|
||||
}
|
||||
@@ -1206,6 +1218,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
</template>
|
||||
</VCombobox>
|
||||
</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">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.LLM_API_KEY"
|
||||
|
||||
@@ -40,6 +40,13 @@ 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 => {
|
||||
@@ -99,6 +106,7 @@ const {
|
||||
apiKey: apiKeyRef,
|
||||
baseUrl: baseUrlRef,
|
||||
baseUrlPreset: baseUrlPresetRef,
|
||||
useProxy: useProxyRef,
|
||||
userAgent: userAgentRef,
|
||||
model: modelRef,
|
||||
maxContextTokens: maxContextTokensRef,
|
||||
@@ -341,6 +349,16 @@ onMounted(async () => {
|
||||
</VCombobox>
|
||||
</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">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.apiKey"
|
||||
|
||||
@@ -15,7 +15,7 @@ const messages = ref<Message[]>([])
|
||||
const currData = ref<Message[]>([])
|
||||
|
||||
// 已加载消息的签名集合
|
||||
// 使用消息内容签名去重,避免仅按秒级时间戳判断时误吞同一秒内的不同消息。
|
||||
// SSE 消息与数据库消息的字段来源不同(date vs reg_time, null vs {}),签名已归一化处理。
|
||||
const messageKeys = new Set<string>()
|
||||
|
||||
// 是否完成加载
|
||||
@@ -42,26 +42,34 @@ const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
|
||||
let scrollTimer: number | undefined
|
||||
let scrollReleaseTimer: number | undefined
|
||||
|
||||
// 获取消息时间
|
||||
function getMessageTime(message: Message) {
|
||||
return message.reg_time || message.date || ''
|
||||
// 生成消息去重签名
|
||||
// SSE 消息只有 date 没有 reg_time,数据库消息只有 reg_time 没有 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) {
|
||||
return [
|
||||
message.action ?? '',
|
||||
message.userid ?? '',
|
||||
message.reg_time ?? '',
|
||||
message.date ?? '',
|
||||
message.reg_time || message.date || '',
|
||||
message.title ?? '',
|
||||
message.text ?? '',
|
||||
message.image ?? '',
|
||||
message.link ?? '',
|
||||
message.note ?? '',
|
||||
normalizeNote(message.note),
|
||||
].join('::')
|
||||
}
|
||||
|
||||
// 获取消息时间
|
||||
function getMessageTime(message: Message) {
|
||||
return message.reg_time || message.date || ''
|
||||
}
|
||||
|
||||
// 排序消息列表,确保最新消息始终位于底部
|
||||
function sortMessages(items: Message[]) {
|
||||
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
|
||||
|
||||
Reference in New Issue
Block a user