mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
fix(images): honor global cache across cards
This commit is contained in:
@@ -2,12 +2,15 @@
|
|||||||
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 { openMediaServerItem } from '@/utils/appDeepLink'
|
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getProxyImageUrl } from '@/utils/imageUtils'
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
media: Object as PropType<MediaServerPlayItem>,
|
media: Object as PropType<MediaServerPlayItem>,
|
||||||
width: String,
|
width: String,
|
||||||
height: String,
|
height: String,
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 图片是否加载完成
|
// 图片是否加载完成
|
||||||
const imageLoaded = ref(false)
|
const imageLoaded = ref(false)
|
||||||
@@ -40,12 +43,10 @@ async function goPlay() {
|
|||||||
const getImgUrl = computed(() => {
|
const getImgUrl = computed(() => {
|
||||||
const image = props.media?.image || ''
|
const image = props.media?.image || ''
|
||||||
if (!image || imageLoadError.value) return noImage
|
if (!image || imageLoadError.value) return noImage
|
||||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
return getProxyImageUrl(image, {
|
||||||
const use_cookies = props.media?.use_cookies
|
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||||
if (use_cookies) {
|
useCookies: props.media?.use_cookies,
|
||||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
})
|
||||||
}
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -63,43 +64,43 @@ const getImgUrl = computed(() => {
|
|||||||
}"
|
}"
|
||||||
@click="goPlay"
|
@click="goPlay"
|
||||||
>
|
>
|
||||||
<template #image>
|
<template #image>
|
||||||
<VImg
|
<VImg
|
||||||
:src="getImgUrl"
|
:src="getImgUrl"
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
class="backdrop-card-image"
|
class="backdrop-card-image"
|
||||||
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
||||||
cover
|
cover
|
||||||
@load="imageLoadHandler"
|
@load="imageLoadHandler"
|
||||||
@error="imageErrorHandler"
|
@error="imageErrorHandler"
|
||||||
>
|
>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="backdrop-card-placeholder">
|
<div class="backdrop-card-placeholder">
|
||||||
<VSkeletonLoader class="backdrop-card-skeleton" />
|
<VSkeletonLoader class="backdrop-card-skeleton" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #default>
|
<template #default>
|
||||||
<VCardText
|
<VCardText
|
||||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||||
>
|
|
||||||
<h1
|
|
||||||
class="mb-1 text-white text-shadow font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ..."
|
|
||||||
>
|
>
|
||||||
{{ props.media?.title }}
|
<h1
|
||||||
</h1>
|
class="mb-1 text-white text-shadow font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||||
<span class="text-shadow text-sm">{{ props.media?.subtitle }}</span>
|
>
|
||||||
</VCardText>
|
{{ props.media?.title }}
|
||||||
</template>
|
</h1>
|
||||||
</VImg>
|
<span class="text-shadow text-sm">{{ props.media?.subtitle }}</span>
|
||||||
</template>
|
</VCardText>
|
||||||
<div class="w-full absolute bottom-0">
|
</template>
|
||||||
<VProgressLinear
|
</VImg>
|
||||||
v-if="props.media?.percent"
|
</template>
|
||||||
:model-value="props.media?.percent"
|
<div class="w-full absolute bottom-0">
|
||||||
bg-color="success"
|
<VProgressLinear
|
||||||
color="success"
|
v-if="props.media?.percent"
|
||||||
/>
|
:model-value="props.media?.percent"
|
||||||
</div>
|
bg-color="success"
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { ApiResponse, DownloadingInfo } from '@/api/types'
|
import type { ApiResponse, DownloadingInfo } from '@/api/types'
|
||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
/** 卡片使用的下载任务信息,兼容接口已经返回但公共类型尚未声明的来源站点。 */
|
/** 卡片使用的下载任务信息,兼容接口已经返回但公共类型尚未声明的来源站点。 */
|
||||||
@@ -17,6 +19,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。
|
// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。
|
||||||
const cardState = ref(true)
|
const cardState = ref(true)
|
||||||
@@ -31,7 +34,10 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasPosterImage = computed(() => Boolean(media.value.poster && !imageLoadError.value))
|
const posterUrl = computed(() =>
|
||||||
|
getDisplayImageUrl(media.value.poster || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||||
|
)
|
||||||
|
const hasPosterImage = computed(() => Boolean(posterUrl.value && !imageLoadError.value))
|
||||||
|
|
||||||
const mediaTitle = computed(() => media.value.title || props.info?.name || props.info?.title || t('common.unknown'))
|
const mediaTitle = computed(() => media.value.title || props.info?.name || props.info?.title || t('common.unknown'))
|
||||||
|
|
||||||
@@ -164,7 +170,7 @@ async function deleteDownload() {
|
|||||||
>
|
>
|
||||||
<div v-if="hasPosterImage" class="downloading-card__poster">
|
<div v-if="hasPosterImage" class="downloading-card__poster">
|
||||||
<VImg
|
<VImg
|
||||||
:src="media.poster"
|
:src="posterUrl"
|
||||||
class="downloading-card__image"
|
class="downloading-card__image"
|
||||||
cover
|
cover
|
||||||
position="center"
|
position="center"
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import type { MediaServerLibrary } from '@/api/types'
|
|||||||
import plex from '@images/misc/plex.png'
|
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, getProxyImageUrl } from '@/utils/imageUtils'
|
||||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -12,6 +13,7 @@ const props = defineProps({
|
|||||||
width: String,
|
width: String,
|
||||||
height: String,
|
height: String,
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// canvas
|
// canvas
|
||||||
const canvasRef = ref<HTMLCanvasElement>()
|
const canvasRef = ref<HTMLCanvasElement>()
|
||||||
@@ -96,11 +98,10 @@ async function goPlay() {
|
|||||||
*/
|
*/
|
||||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||||
if (!url || imageError.value) return getDefaultImage()
|
if (!url || imageError.value) return getDefaultImage()
|
||||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
return getProxyImageUrl(url, {
|
||||||
if (use_cookies) {
|
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||||
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
useCookies: use_cookies,
|
||||||
}
|
})
|
||||||
return imgurl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,10 +116,10 @@ async function drawImages(imageList: string[], use_cookies?: boolean) {
|
|||||||
|
|
||||||
// 为所有图片添加system/img前缀
|
// 为所有图片添加system/img前缀
|
||||||
for (let i = 0; i < IMAGES.length; i++) {
|
for (let i = 0; i < IMAGES.length; i++) {
|
||||||
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
|
IMAGES[i] = getProxyImageUrl(IMAGES[i], {
|
||||||
if (use_cookies) {
|
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||||
IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
useCookies: use_cookies,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// canvas
|
// canvas
|
||||||
@@ -213,33 +214,33 @@ onMounted(async () => {
|
|||||||
}"
|
}"
|
||||||
@click="goPlay"
|
@click="goPlay"
|
||||||
>
|
>
|
||||||
<template #image>
|
<template #image>
|
||||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||||
<VImg
|
<VImg
|
||||||
:src="imgUrl"
|
:src="imgUrl"
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
class="library-card-image"
|
class="library-card-image"
|
||||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||||
cover
|
cover
|
||||||
@load="imageLoadHandler"
|
@load="imageLoadHandler"
|
||||||
@error="imageErrorHandler"
|
@error="imageErrorHandler"
|
||||||
>
|
>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="library-card-placeholder">
|
<div class="library-card-placeholder">
|
||||||
<VSkeletonLoader class="library-card-skeleton" />
|
<VSkeletonLoader class="library-card-skeleton" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="library-card-shade" aria-hidden="true" />
|
<div class="library-card-shade" aria-hidden="true" />
|
||||||
<div v-if="showCountCorner" class="library-card-count-corner">
|
<div v-if="showCountCorner" class="library-card-count-corner">
|
||||||
<span>{{ countLabel }}</span>
|
<span>{{ countLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="library-card-label">
|
<div class="library-card-label">
|
||||||
<span>{{ props.media?.name }}</span>
|
<span>{{ props.media?.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VImg>
|
</VImg>
|
||||||
</template>
|
</template>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -310,7 +311,7 @@ onMounted(async () => {
|
|||||||
background: rgba(8, 13, 22, 72%);
|
background: rgba(8, 13, 22, 72%);
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||||
content: "";
|
content: '';
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
@@ -321,7 +322,7 @@ onMounted(async () => {
|
|||||||
background: rgba(var(--v-theme-primary), 62%);
|
background: rgba(var(--v-theme-primary), 62%);
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||||
content: "";
|
content: '';
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
import type { PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import type { Context } from '@/api/types'
|
import type { Context } from '@/api/types'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { formatMusicDuration } from '@/utils/music'
|
import { formatMusicDuration } from '@/utils/music'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -15,9 +18,7 @@ const props = defineProps({
|
|||||||
const recognizedName = computed(() => props.context?.meta_info?.name || props.context?.meta_info?.title)
|
const recognizedName = computed(() => props.context?.meta_info?.name || props.context?.meta_info?.title)
|
||||||
|
|
||||||
// 是否为音乐识别结果
|
// 是否为音乐识别结果
|
||||||
const isMusic = computed(
|
const isMusic = computed(() => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐')
|
||||||
() => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐',
|
|
||||||
)
|
|
||||||
|
|
||||||
// 音乐封面加载失败时保留方形唱片占位,避免弹窗信息结构塌缩。
|
// 音乐封面加载失败时保留方形唱片占位,避免弹窗信息结构塌缩。
|
||||||
const musicCoverLoadError = ref(false)
|
const musicCoverLoadError = ref(false)
|
||||||
@@ -37,15 +38,28 @@ function openTmdbPage(type: string, tmdbId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 音乐封面优先使用方形封面,仅 W500 图片处理对TMDB类 URL 生效
|
// 音乐封面优先使用方形封面,仅 W500 图片处理对TMDB类 URL 生效
|
||||||
const musicCover = computed(() => getW500Image(
|
const rawMusicCover = computed(() =>
|
||||||
props.context?.media_info?.cover_url || props.context?.media_info?.poster_path || '',
|
getW500Image(props.context?.media_info?.cover_url || props.context?.media_info?.poster_path || ''),
|
||||||
))
|
)
|
||||||
|
const musicCover = computed(() =>
|
||||||
|
getDisplayImageUrl(rawMusicCover.value, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 影视识别海报与音乐封面使用同一全局图片缓存开关。
|
||||||
|
const mediaPoster = computed(() =>
|
||||||
|
getDisplayImageUrl(
|
||||||
|
getW500Image(props.context?.media_info?.poster_path || ''),
|
||||||
|
globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// 音乐艺术家优先使用标准识别结果,远端未命中时回退到文件标签。
|
// 音乐艺术家优先使用标准识别结果,远端未命中时回退到文件标签。
|
||||||
const musicArtist = computed(() => {
|
const musicArtist = computed(() => {
|
||||||
const mediaInfo = props.context?.media_info
|
const mediaInfo = props.context?.media_info
|
||||||
const metaInfo = props.context?.meta_info
|
const metaInfo = props.context?.meta_info
|
||||||
return mediaInfo?.artist || mediaInfo?.artists?.join(' / ') || metaInfo?.artist || metaInfo?.artists?.join(' / ') || ''
|
return (
|
||||||
|
mediaInfo?.artist || mediaInfo?.artists?.join(' / ') || metaInfo?.artist || metaInfo?.artists?.join(' / ') || ''
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 专辑艺术家只在与歌曲艺术家不同时单独展示。
|
// 专辑艺术家只在与歌曲艺术家不同时单独展示。
|
||||||
@@ -127,10 +141,7 @@ watch(musicCover, () => {
|
|||||||
<template>
|
<template>
|
||||||
<div v-show="context">
|
<div v-show="context">
|
||||||
<VCol>
|
<VCol>
|
||||||
<div
|
<div v-if="recognizedName" class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||||
v-if="recognizedName"
|
|
||||||
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
|
|
||||||
>
|
|
||||||
<div v-if="isMusic" class="ma-auto recognized-music-cover">
|
<div v-if="isMusic" class="ma-auto recognized-music-cover">
|
||||||
<VImg
|
<VImg
|
||||||
v-if="musicCover && !musicCoverLoadError"
|
v-if="musicCover && !musicCoverLoadError"
|
||||||
@@ -158,12 +169,12 @@ watch(musicCover, () => {
|
|||||||
<VIcon icon="mdi-album" size="64" color="medium-emphasis" />
|
<VIcon icon="mdi-album" size="64" color="medium-emphasis" />
|
||||||
</VSheet>
|
</VSheet>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="context?.media_info?.poster_path" class="ma-auto">
|
<div v-else-if="mediaPoster" class="ma-auto">
|
||||||
<VImg
|
<VImg
|
||||||
width="10rem"
|
width="10rem"
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
class="object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500"
|
class="object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500"
|
||||||
:src="getW500Image(context?.media_info?.poster_path)"
|
:src="mediaPoster"
|
||||||
cover
|
cover
|
||||||
>
|
>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
@@ -268,7 +279,11 @@ watch(musicCover, () => {
|
|||||||
详情
|
详情
|
||||||
</VChip>
|
</VChip>
|
||||||
<!-- 二级分类 -->
|
<!-- 二级分类 -->
|
||||||
<VChip v-if="!isMusic && context?.media_info?.category" variant="elevated" class="me-1 mb-1 text-white bg-blue-500">
|
<VChip
|
||||||
|
v-if="!isMusic && context?.media_info?.category"
|
||||||
|
variant="elevated"
|
||||||
|
class="me-1 mb-1 text-white bg-blue-500"
|
||||||
|
>
|
||||||
{{ context?.media_info?.category }}
|
{{ context?.media_info?.category }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<!-- TMDBID -->
|
<!-- TMDBID -->
|
||||||
@@ -282,7 +297,11 @@ watch(musicCover, () => {
|
|||||||
</VChip>
|
</VChip>
|
||||||
<!-- meta_info(音乐不显示影视资源信息) -->
|
<!-- meta_info(音乐不显示影视资源信息) -->
|
||||||
<template v-if="!isMusic">
|
<template v-if="!isMusic">
|
||||||
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
|
<VChip
|
||||||
|
v-if="context?.meta_info?.web_source"
|
||||||
|
variant="elevated"
|
||||||
|
class="me-1 mb-1 text-white bg-purple-500"
|
||||||
|
>
|
||||||
{{ context?.meta_info?.web_source }}
|
{{ context?.meta_info?.web_source }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||||
@@ -305,7 +324,11 @@ watch(musicCover, () => {
|
|||||||
>
|
>
|
||||||
{{ context?.meta_info?.audio_encode }}
|
{{ context?.meta_info?.audio_encode }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip v-if="context?.meta_info?.resource_team" variant="elevated" class="me-1 mb-1 text-white bg-cyan-500">
|
<VChip
|
||||||
|
v-if="context?.meta_info?.resource_team"
|
||||||
|
variant="elevated"
|
||||||
|
class="me-1 mb-1 text-white bg-cyan-500"
|
||||||
|
>
|
||||||
{{ context?.meta_info?.resource_team }}
|
{{ context?.meta_info?.resource_team }}
|
||||||
</VChip>
|
</VChip>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { MusicArtistInfo } from '@/api/types'
|
import type { MusicArtistInfo } from '@/api/types'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { buildMusicArtistRoute, getMusicArtistSubtitle } from '@/utils/music'
|
import { buildMusicArtistRoute, getMusicArtistSubtitle } from '@/utils/music'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
artist: Object as PropType<MusicArtistInfo>,
|
artist: Object as PropType<MusicArtistInfo>,
|
||||||
@@ -12,7 +15,10 @@ const props = defineProps({
|
|||||||
// 艺术家图片加载失败后回退到占位图标
|
// 艺术家图片加载失败后回退到占位图标
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
|
||||||
const imageUrl = computed(() => props.artist?.image_url || props.artist?.poster_path || '')
|
const rawImageUrl = computed(() => props.artist?.image_url || props.artist?.poster_path || '')
|
||||||
|
const imageUrl = computed(() =>
|
||||||
|
getDisplayImageUrl(rawImageUrl.value, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||||
|
)
|
||||||
const showImage = computed(() => Boolean(imageUrl.value) && !imageLoadError.value)
|
const showImage = computed(() => Boolean(imageUrl.value) && !imageLoadError.value)
|
||||||
const subtitle = computed(() => getMusicArtistSubtitle(props.artist))
|
const subtitle = computed(() => getMusicArtistSubtitle(props.artist))
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { MediaInfo } from '@/api/types'
|
import type { MediaInfo } from '@/api/types'
|
||||||
import { useUserStore } from '@/stores'
|
import { useGlobalSettingsStore, useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
|
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
|
||||||
import { getCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
|
import { getCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
|
||||||
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
|
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
|
||||||
@@ -22,6 +23,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||||
@@ -70,7 +72,10 @@ const metaItems = computed(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const coverUrl = computed(() => props.music?.cover_url || props.music?.poster_path || '')
|
const rawCoverUrl = computed(() => props.music?.cover_url || props.music?.poster_path || '')
|
||||||
|
const coverUrl = computed(() =>
|
||||||
|
getDisplayImageUrl(rawCoverUrl.value, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||||
|
)
|
||||||
const showCover = computed(() => Boolean(coverUrl.value) && !imageLoadError.value)
|
const showCover = computed(() => Boolean(coverUrl.value) && !imageLoadError.value)
|
||||||
|
|
||||||
/** 生成订阅状态缓存键。 */
|
/** 生成订阅状态缓存键。 */
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
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 { openMediaServerItem } from '@/utils/appDeepLink'
|
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getProxyImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -9,6 +11,7 @@ const props = defineProps({
|
|||||||
width: String,
|
width: String,
|
||||||
height: String,
|
height: String,
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 图片是否加载完成
|
// 图片是否加载完成
|
||||||
const imageLoaded = ref(false)
|
const imageLoaded = ref(false)
|
||||||
@@ -52,13 +55,10 @@ const imageUrl = computed(() => {
|
|||||||
const image = props.media?.image || ''
|
const image = props.media?.image || ''
|
||||||
if (!image || imageLoadError.value) return noImage
|
if (!image || imageLoadError.value) return noImage
|
||||||
|
|
||||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
return getProxyImageUrl(image, {
|
||||||
const useCookies = props.media?.use_cookies
|
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||||
if (useCookies) {
|
useCookies: props.media?.use_cookies,
|
||||||
url += `&use_cookies=${encodeURIComponent(useCookies)}`
|
})
|
||||||
}
|
|
||||||
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { ApiResponse, Plugin } from '@/api/types'
|
import type { ApiResponse, Plugin } from '@/api/types'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
@@ -23,6 +24,7 @@ const props = defineProps({
|
|||||||
height: String,
|
height: String,
|
||||||
count: Number,
|
count: Number,
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['install'])
|
const emit = defineEmits(['install'])
|
||||||
@@ -81,9 +83,10 @@ const iconPath: Ref<string> = computed(() => {
|
|||||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||||
// 如果是网络图片则使用代理后返回
|
// 如果是网络图片则使用代理后返回
|
||||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
return getProxyImageUrl(props.plugin.plugin_icon, {
|
||||||
props.plugin?.plugin_icon,
|
proxy: true,
|
||||||
)}&cache=true`
|
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||||
|
})
|
||||||
|
|
||||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { useToast } from 'vue-toastification'
|
|||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
|
||||||
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
||||||
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
||||||
@@ -32,6 +33,7 @@ const props = defineProps({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['remove', 'save', 'actionDone', 'rating'])
|
const emit = defineEmits(['remove', 'save', 'actionDone', 'rating'])
|
||||||
@@ -193,19 +195,22 @@ const iconPath: Ref<string> = computed(() => {
|
|||||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||||
// 如果是网络图片则使用代理后返回
|
// 如果是网络图片则使用代理后返回
|
||||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
return getProxyImageUrl(props.plugin.plugin_icon, {
|
||||||
props.plugin?.plugin_icon,
|
proxy: true,
|
||||||
)}&cache=true`
|
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||||
|
})
|
||||||
|
|
||||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 插件作者头像路径
|
// 插件作者头像路径
|
||||||
const authorPath: Ref<string> = computed(() => {
|
const authorPath: Ref<string> = computed(() => {
|
||||||
|
if (!props.plugin?.author_url) return ''
|
||||||
// 网络图片则使用代理后返回
|
// 网络图片则使用代理后返回
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
return getProxyImageUrl(`${props.plugin.author_url}.png`, {
|
||||||
props.plugin?.author_url + '.png',
|
proxy: true,
|
||||||
)}&cache=true`
|
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 重置插件
|
// 重置插件
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useConfirm } from '@/composables/useConfirm'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
|
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
|
||||||
const PluginFolderSettingsDialog = defineAsyncComponent(
|
const PluginFolderSettingsDialog = defineAsyncComponent(
|
||||||
@@ -36,6 +38,7 @@ const props = defineProps({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['open', 'delete', 'rename', 'update-config'])
|
const emit = defineEmits(['open', 'delete', 'rename', 'update-config'])
|
||||||
@@ -66,7 +69,7 @@ const defaultGradient =
|
|||||||
|
|
||||||
// 计算背景图片
|
// 计算背景图片
|
||||||
const backgroundImage = computed(() => {
|
const backgroundImage = computed(() => {
|
||||||
return props.folderConfig.background
|
return getDisplayImageUrl(props.folderConfig.background || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算背景渐变
|
// 计算背景渐变
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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 { openMediaServerItem } from '@/utils/appDeepLink'
|
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getProxyImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -10,6 +12,7 @@ const props = defineProps({
|
|||||||
width: String,
|
width: String,
|
||||||
height: String,
|
height: String,
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 图片加载状态
|
// 图片加载状态
|
||||||
const isImageLoaded = ref(false)
|
const isImageLoaded = ref(false)
|
||||||
@@ -34,12 +37,10 @@ function getChipColor(type: string) {
|
|||||||
const getImgUrl = computed(() => {
|
const getImgUrl = computed(() => {
|
||||||
if (imageLoadError.value) return noImage
|
if (imageLoadError.value) return noImage
|
||||||
const image = props.media?.image || ''
|
const image = props.media?.image || ''
|
||||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
return getProxyImageUrl(image, {
|
||||||
const use_cookies = props.media?.use_cookies
|
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||||
if (use_cookies) {
|
useCookies: props.media?.use_cookies,
|
||||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
})
|
||||||
}
|
|
||||||
return url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 跳转播放
|
// 跳转播放
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
@@ -33,6 +34,7 @@ const cardProps = defineProps({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['update', 'remove', 'refresh-stats'])
|
const emit = defineEmits(['update', 'remove', 'refresh-stats'])
|
||||||
@@ -62,11 +64,12 @@ async function getSiteIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
|
const icon = await getCachedSiteIcon(siteId, async () => {
|
||||||
const response = await api.get(`site/icon/${siteId}`)
|
const response = await api.get(`site/icon/${siteId}`)
|
||||||
|
|
||||||
return response?.data?.icon || defaultSiteIcon
|
return response?.data?.icon || defaultSiteIcon
|
||||||
})
|
})
|
||||||
|
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
siteIcon.value = defaultSiteIcon
|
siteIcon.value = defaultSiteIcon
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
|||||||
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ const props = defineProps({
|
|||||||
subtitle: Object as PropType<SubtitleInfo>,
|
subtitle: Object as PropType<SubtitleInfo>,
|
||||||
width: String,
|
width: String,
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 字幕信息
|
// 字幕信息
|
||||||
const subtitle = ref(props.subtitle)
|
const subtitle = ref(props.subtitle)
|
||||||
@@ -25,7 +28,9 @@ const subtitle = ref(props.subtitle)
|
|||||||
// 站点图标
|
// 站点图标
|
||||||
const siteIcon = ref('')
|
const siteIcon = ref('')
|
||||||
|
|
||||||
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
|
const isDownloaded = computed(() =>
|
||||||
|
Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]),
|
||||||
|
)
|
||||||
|
|
||||||
// 查询站点图标
|
// 查询站点图标
|
||||||
async function getSiteIcon() {
|
async function getSiteIcon() {
|
||||||
@@ -35,7 +40,7 @@ async function getSiteIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
|
const icon = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
||||||
|
|
||||||
@@ -45,12 +50,18 @@ async function getSiteIcon() {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load site icon:', error)
|
console.error('Failed to load site icon:', error)
|
||||||
siteIcon.value = ''
|
siteIcon.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 字幕语言图标可能来自站点外链,展示时统一遵循全局图片缓存开关。
|
||||||
|
const languageIconUrl = computed(() =>
|
||||||
|
getDisplayImageUrl(subtitle.value?.language_icon || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||||
|
)
|
||||||
|
|
||||||
// 添加字幕下载成功
|
// 添加字幕下载成功
|
||||||
function addDownloadSuccess(url: string) {
|
function addDownloadSuccess(url: string) {
|
||||||
markSubtitleDownloaded(url)
|
markSubtitleDownloaded(url)
|
||||||
@@ -133,8 +144,8 @@ watch(
|
|||||||
</VChip>
|
</VChip>
|
||||||
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
||||||
<VImg
|
<VImg
|
||||||
v-if="subtitle?.language_icon"
|
v-if="languageIconUrl"
|
||||||
:src="subtitle.language_icon"
|
:src="languageIconUrl"
|
||||||
:alt="subtitle.language"
|
:alt="subtitle.language"
|
||||||
width="14"
|
width="14"
|
||||||
height="14"
|
height="14"
|
||||||
@@ -163,7 +174,10 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex flex-wrap align-center gap-2 mb-2">
|
<div class="d-flex flex-wrap align-center gap-2 mb-2">
|
||||||
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
|
<span
|
||||||
|
v-if="subtitle?.pubdate || subtitle?.date_elapsed"
|
||||||
|
class="d-flex align-center text-sm text-medium-emphasis"
|
||||||
|
>
|
||||||
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
||||||
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -195,7 +209,14 @@ watch(
|
|||||||
<VBtn v-if="subtitle?.report_url" icon size="small" variant="text" color="warning" @click.stop="openReportPage">
|
<VBtn v-if="subtitle?.report_url" icon size="small" variant="text" color="warning" @click.stop="openReportPage">
|
||||||
<VIcon icon="mdi-alert-outline"></VIcon>
|
<VIcon icon="mdi-alert-outline"></VIcon>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn v-if="subtitle?.page_url" icon size="small" variant="text" color="primary" @click.stop="openSubtitleDetail">
|
<VBtn
|
||||||
|
v-if="subtitle?.page_url"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
@click.stop="openSubtitleDetail"
|
||||||
|
>
|
||||||
<VIcon icon="mdi-information-outline"></VIcon>
|
<VIcon icon="mdi-information-outline"></VIcon>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
|||||||
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ const { t } = useI18n()
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
subtitle: Object as PropType<SubtitleInfo>,
|
subtitle: Object as PropType<SubtitleInfo>,
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 字幕信息
|
// 字幕信息
|
||||||
const subtitle = ref(props.subtitle)
|
const subtitle = ref(props.subtitle)
|
||||||
@@ -24,7 +27,9 @@ const subtitle = ref(props.subtitle)
|
|||||||
// 站点图标
|
// 站点图标
|
||||||
const siteIcon = ref('')
|
const siteIcon = ref('')
|
||||||
|
|
||||||
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
|
const isDownloaded = computed(() =>
|
||||||
|
Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]),
|
||||||
|
)
|
||||||
|
|
||||||
// 查询站点图标
|
// 查询站点图标
|
||||||
async function getSiteIcon() {
|
async function getSiteIcon() {
|
||||||
@@ -34,7 +39,7 @@ async function getSiteIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
|
const icon = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
||||||
|
|
||||||
@@ -44,12 +49,18 @@ async function getSiteIcon() {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load site icon:', error)
|
console.error('Failed to load site icon:', error)
|
||||||
siteIcon.value = ''
|
siteIcon.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 字幕语言图标可能来自站点外链,展示时统一遵循全局图片缓存开关。
|
||||||
|
const languageIconUrl = computed(() =>
|
||||||
|
getDisplayImageUrl(subtitle.value?.language_icon || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||||
|
)
|
||||||
|
|
||||||
// 询问并下载字幕
|
// 询问并下载字幕
|
||||||
async function handleAddDownload() {
|
async function handleAddDownload() {
|
||||||
openSharedDialog(
|
openSharedDialog(
|
||||||
@@ -135,8 +146,8 @@ watch(
|
|||||||
</VChip>
|
</VChip>
|
||||||
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
||||||
<VImg
|
<VImg
|
||||||
v-if="subtitle?.language_icon"
|
v-if="languageIconUrl"
|
||||||
:src="subtitle.language_icon"
|
:src="languageIconUrl"
|
||||||
:alt="subtitle.language"
|
:alt="subtitle.language"
|
||||||
width="14"
|
width="14"
|
||||||
height="14"
|
height="14"
|
||||||
@@ -153,12 +164,19 @@ watch(
|
|||||||
{{ subtitle?.title }}
|
{{ subtitle?.title }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="subtitle?.description" class="text-body-2 text-medium-emphasis mb-2 break-all" :title="subtitle.description">
|
<div
|
||||||
|
v-if="subtitle?.description"
|
||||||
|
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||||
|
:title="subtitle.description"
|
||||||
|
>
|
||||||
{{ subtitle.description }}
|
{{ subtitle.description }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||||
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
|
<span
|
||||||
|
v-if="subtitle?.pubdate || subtitle?.date_elapsed"
|
||||||
|
class="d-flex align-center text-sm text-medium-emphasis"
|
||||||
|
>
|
||||||
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
||||||
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { isNullOrEmptyObject } from '@/@core/utils'
|
|||||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
||||||
const TorrentMoreSourcesDialog = defineAsyncComponent(() => import('../dialog/TorrentMoreSourcesDialog.vue'))
|
const TorrentMoreSourcesDialog = defineAsyncComponent(() => import('../dialog/TorrentMoreSourcesDialog.vue'))
|
||||||
@@ -18,6 +20,7 @@ const props = defineProps({
|
|||||||
width: String,
|
width: String,
|
||||||
height: String,
|
height: String,
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 种子信息
|
// 种子信息
|
||||||
const torrent = ref(props.torrent?.torrent_info)
|
const torrent = ref(props.torrent?.torrent_info)
|
||||||
@@ -51,7 +54,7 @@ async function getSiteIcon(site: number | undefined) {
|
|||||||
if (!site) return
|
if (!site) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
siteIcons.value[site] = await getCachedSiteIcon(site, async () => {
|
const icon = await getCachedSiteIcon(site, async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`site/icon/${site}`)
|
const response = await api.get(`site/icon/${site}`)
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ async function getSiteIcon(site: number | undefined) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
siteIcons.value[site] = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
siteIcons.value[site] = ''
|
siteIcons.value[site] = ''
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { Context } from '@/api/types'
|
|||||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
|
|
||||||
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownlo
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
torrent: Object as PropType<Context>,
|
torrent: Object as PropType<Context>,
|
||||||
})
|
})
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 种子信息
|
// 种子信息
|
||||||
const torrent = ref(props.torrent?.torrent_info)
|
const torrent = ref(props.torrent?.torrent_info)
|
||||||
@@ -48,7 +51,7 @@ async function getSiteIcon(site: number | undefined) {
|
|||||||
})
|
})
|
||||||
// 只提交当前站点的响应,避免 Context 快速切换时旧请求覆盖新图标。
|
// 只提交当前站点的响应,避免 Context 快速切换时旧请求覆盖新图标。
|
||||||
if (torrent.value?.site === site) {
|
if (torrent.value?.site === site) {
|
||||||
siteIcon.value = icon
|
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load site icon:', error)
|
console.error('Failed to load site icon:', error)
|
||||||
|
|||||||
+168
-164
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { Subscribe, User } from '@/api/types'
|
import { Subscribe, User } from '@/api/types'
|
||||||
import { useUserStore } from '@/stores'
|
import { useGlobalSettingsStore, useUserStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import avatar1 from '@images/avatars/avatar-1.png'
|
import avatar1 from '@images/avatars/avatar-1.png'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
@@ -35,6 +36,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
const isMobile = computed(() => display.mdAndDown.value)
|
const isMobile = computed(() => display.mdAndDown.value)
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 当前用户的ID
|
// 当前用户的ID
|
||||||
const currentLoginUserId = computed(() => useUserStore().userID)
|
const currentLoginUserId = computed(() => useUserStore().userID)
|
||||||
@@ -64,6 +66,11 @@ const displayName = computed(() => {
|
|||||||
return nickname || props.user.name
|
return nickname || props.user.name
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 远程头像遵循全局图片缓存开关,本地默认头像和 data URL 保持原样。
|
||||||
|
const avatarUrl = computed(() =>
|
||||||
|
getDisplayImageUrl(props.user.avatar || avatar1, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||||
|
)
|
||||||
|
|
||||||
// 按用户查询订阅数量
|
// 按用户查询订阅数量
|
||||||
async function fetchSubscriptions() {
|
async function fetchSubscriptions() {
|
||||||
try {
|
try {
|
||||||
@@ -130,180 +137,177 @@ onMounted(() => {
|
|||||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||||
<div class="user-card-hover-area h-full">
|
<div class="user-card-hover-area h-full">
|
||||||
<VCard
|
<VCard
|
||||||
:class="[
|
:class="['app-hover-lift-card', !props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '']"
|
||||||
'app-hover-lift-card',
|
|
||||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
|
||||||
]"
|
|
||||||
class="user-card flex flex-column h-full"
|
class="user-card flex flex-column h-full"
|
||||||
@click="editUser"
|
@click="editUser"
|
||||||
>
|
>
|
||||||
<div class="user-card__body flex-grow flex-grow-1">
|
<div class="user-card__body flex-grow flex-grow-1">
|
||||||
<!-- 用户头像和基本信息 -->
|
<!-- 用户头像和基本信息 -->
|
||||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<div class="position-relative mr-4">
|
<div class="position-relative mr-4">
|
||||||
<VAvatar
|
<VAvatar
|
||||||
size="72"
|
size="72"
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
:class="[
|
|
||||||
user.is_superuser ? 'admin-avatar' : 'border-4 bg-surface',
|
|
||||||
!user.is_active ? 'grayscale-50 opacity-90' : '',
|
|
||||||
]"
|
|
||||||
:style="user.is_superuser ? 'border: 4px solid rgba(var(--v-theme-warning), 0.3);' : ''"
|
|
||||||
>
|
|
||||||
<VImg :src="user.avatar || avatar1" :alt="user.name" />
|
|
||||||
<div
|
|
||||||
v-if="!user.is_active"
|
|
||||||
class="position-absolute d-flex align-center justify-center rounded-lg bg-surface-variant opacity-20"
|
|
||||||
style="inset: 0"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-account-lock" color="white" />
|
|
||||||
</div>
|
|
||||||
</VAvatar>
|
|
||||||
<div v-if="user.is_superuser" class="admin-crown">
|
|
||||||
<VIcon icon="mdi-crown" color="warning" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VCardTitle class="pa-0 d-flex flex-column">
|
|
||||||
<div class="d-flex flex-column mb-1">
|
|
||||||
<div class="d-flex align-center">
|
|
||||||
<span
|
|
||||||
:class="[
|
:class="[
|
||||||
'text-h6 font-weight-bold truncate',
|
user.is_superuser ? 'admin-avatar' : 'border-4 bg-surface',
|
||||||
user.is_superuser ? 'text-warning' : '',
|
!user.is_active ? 'grayscale-50 opacity-90' : '',
|
||||||
!user.is_active ? 'text-medium-emphasis' : '',
|
|
||||||
]"
|
]"
|
||||||
|
:style="user.is_superuser ? 'border: 4px solid rgba(var(--v-theme-warning), 0.3);' : ''"
|
||||||
>
|
>
|
||||||
{{ displayName }}
|
<VImg :src="avatarUrl" :alt="user.name" />
|
||||||
<VIcon
|
<div
|
||||||
v-if="user.nickname || user.settings?.nickname"
|
v-if="!user.is_active"
|
||||||
icon="mdi-format-quote-close"
|
class="position-absolute d-flex align-center justify-center rounded-lg bg-surface-variant opacity-20"
|
||||||
size="x-small"
|
style="inset: 0"
|
||||||
color="info"
|
>
|
||||||
class="animate-pulse"
|
<VIcon icon="mdi-account-lock" color="white" />
|
||||||
/>
|
</div>
|
||||||
</span>
|
</VAvatar>
|
||||||
</div>
|
<div v-if="user.is_superuser" class="admin-crown">
|
||||||
<div class="d-flex flex-wrap gap-1 overflow-auto">
|
<VIcon icon="mdi-crown" color="warning" />
|
||||||
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>{{
|
|
||||||
t('user.admin')
|
|
||||||
}}</VChip>
|
|
||||||
<VChip v-else size="x-small" label>{{ t('user.normal') }}</VChip>
|
|
||||||
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" label>
|
|
||||||
{{ user.is_active ? t('user.active') : t('user.inactive') }}
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 移动端订阅数据信息 -->
|
|
||||||
<div v-if="isMobile" class="d-flex gap-5 mt-2">
|
|
||||||
<div class="d-flex align-center">
|
|
||||||
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" class="mr-1" />
|
|
||||||
<span class="text-body-2">{{ movieSubscriptions }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-center">
|
|
||||||
<VIcon size="x-small" icon="mdi-television-classic" color="primary" class="mr-1" />
|
|
||||||
<span class="text-body-2">{{ tvShowSubscriptions }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCardTitle>
|
|
||||||
|
|
||||||
<!-- 头部操作按钮 -->
|
|
||||||
<template v-slot:append>
|
|
||||||
<div :class="['d-flex', isMobile ? 'position-absolute top-2 right-2' : '']">
|
|
||||||
<VBtn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:color="user.is_superuser ? 'warning' : 'primary'"
|
|
||||||
variant="text"
|
|
||||||
class="opacity-70 hover:opacity-100 transition-opacity"
|
|
||||||
@click.stop="editUser"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-pencil" />
|
|
||||||
</VBtn>
|
|
||||||
|
|
||||||
<VBtn
|
|
||||||
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
variant="text"
|
|
||||||
class="opacity-70 hover:opacity-100 transition-opacity"
|
|
||||||
@click.stop="removeUser"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-delete" />
|
|
||||||
</VBtn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</VCardItem>
|
|
||||||
|
|
||||||
<!-- 权限显示 -->
|
|
||||||
<div v-if="!user.is_superuser && user.permissions" class="d-flex flex-wrap gap-1 px-7 pb-3">
|
|
||||||
<VChip v-if="user.permissions.discovery" size="x-small" color="purple" variant="outlined" label>
|
|
||||||
{{ t('dialog.userAddEdit.permissions.discovery') }}
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="user.permissions.search" size="x-small" color="blue" variant="outlined" label>
|
|
||||||
{{ t('dialog.userAddEdit.permissions.search') }}
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="user.permissions.subscribe" size="x-small" color="green" variant="outlined" label>
|
|
||||||
{{ t('dialog.userAddEdit.permissions.subscribe') }}
|
|
||||||
</VChip>
|
|
||||||
<VChip v-if="user.permissions.manage" size="x-small" color="orange" variant="outlined" label>
|
|
||||||
{{ t('dialog.userAddEdit.permissions.manage') }}
|
|
||||||
</VChip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 独立的邮箱显示 -->
|
|
||||||
<VDivider class="mx-4" />
|
|
||||||
<div class="user-card__footer">
|
|
||||||
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
|
|
||||||
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
|
|
||||||
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
|
|
||||||
</VCardText>
|
|
||||||
|
|
||||||
<!-- PC端显示订阅统计信息 -->
|
|
||||||
<VCardText v-if="!isMobile" class="px-4 pt-0 pb-4">
|
|
||||||
<div rounded="lg" class="d-flex justify-space-around">
|
|
||||||
<div class="d-flex align-center gap-3">
|
|
||||||
<VAvatar
|
|
||||||
tile
|
|
||||||
rounded="lg"
|
|
||||||
size="large"
|
|
||||||
class="mr-1"
|
|
||||||
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
|
|
||||||
>
|
|
||||||
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
|
|
||||||
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
|
|
||||||
</div>
|
</div>
|
||||||
</VAvatar>
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<span class="text-lg text-medium-emphasis font-weight-bold">{{ movieSubscriptions }}</span>
|
|
||||||
<span class="text-caption text-medium-emphasis">{{ t('user.movieSubscriptions') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div class="d-flex align-center gap-3">
|
|
||||||
<VAvatar
|
<VCardTitle class="pa-0 d-flex flex-column">
|
||||||
tile
|
<div class="d-flex flex-column mb-1">
|
||||||
rounded="lg"
|
<div class="d-flex align-center">
|
||||||
size="large"
|
<span
|
||||||
class="mr-1"
|
:class="[
|
||||||
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
|
'text-h6 font-weight-bold truncate',
|
||||||
>
|
user.is_superuser ? 'text-warning' : '',
|
||||||
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
|
!user.is_active ? 'text-medium-emphasis' : '',
|
||||||
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" />
|
]"
|
||||||
|
>
|
||||||
|
{{ displayName }}
|
||||||
|
<VIcon
|
||||||
|
v-if="user.nickname || user.settings?.nickname"
|
||||||
|
icon="mdi-format-quote-close"
|
||||||
|
size="x-small"
|
||||||
|
color="info"
|
||||||
|
class="animate-pulse"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1 overflow-auto">
|
||||||
|
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>{{
|
||||||
|
t('user.admin')
|
||||||
|
}}</VChip>
|
||||||
|
<VChip v-else size="x-small" label>{{ t('user.normal') }}</VChip>
|
||||||
|
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" label>
|
||||||
|
{{ user.is_active ? t('user.active') : t('user.inactive') }}
|
||||||
|
</VChip>
|
||||||
|
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
|
||||||
</div>
|
</div>
|
||||||
</VAvatar>
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<span class="text-lg text-medium-emphasis">{{ tvShowSubscriptions }}</span>
|
|
||||||
<span class="text-caption text-medium-emphasis">{{ t('user.tvSubscriptions') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- 移动端订阅数据信息 -->
|
||||||
|
<div v-if="isMobile" class="d-flex gap-5 mt-2">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" class="mr-1" />
|
||||||
|
<span class="text-body-2">{{ movieSubscriptions }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<VIcon size="x-small" icon="mdi-television-classic" color="primary" class="mr-1" />
|
||||||
|
<span class="text-body-2">{{ tvShowSubscriptions }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<!-- 头部操作按钮 -->
|
||||||
|
<template v-slot:append>
|
||||||
|
<div :class="['d-flex', isMobile ? 'position-absolute top-2 right-2' : '']">
|
||||||
|
<VBtn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:color="user.is_superuser ? 'warning' : 'primary'"
|
||||||
|
variant="text"
|
||||||
|
class="opacity-70 hover:opacity-100 transition-opacity"
|
||||||
|
@click.stop="editUser"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-pencil" />
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="text"
|
||||||
|
class="opacity-70 hover:opacity-100 transition-opacity"
|
||||||
|
@click.stop="removeUser"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-delete" />
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VCardItem>
|
||||||
|
|
||||||
|
<!-- 权限显示 -->
|
||||||
|
<div v-if="!user.is_superuser && user.permissions" class="d-flex flex-wrap gap-1 px-7 pb-3">
|
||||||
|
<VChip v-if="user.permissions.discovery" size="x-small" color="purple" variant="outlined" label>
|
||||||
|
{{ t('dialog.userAddEdit.permissions.discovery') }}
|
||||||
|
</VChip>
|
||||||
|
<VChip v-if="user.permissions.search" size="x-small" color="blue" variant="outlined" label>
|
||||||
|
{{ t('dialog.userAddEdit.permissions.search') }}
|
||||||
|
</VChip>
|
||||||
|
<VChip v-if="user.permissions.subscribe" size="x-small" color="green" variant="outlined" label>
|
||||||
|
{{ t('dialog.userAddEdit.permissions.subscribe') }}
|
||||||
|
</VChip>
|
||||||
|
<VChip v-if="user.permissions.manage" size="x-small" color="orange" variant="outlined" label>
|
||||||
|
{{ t('dialog.userAddEdit.permissions.manage') }}
|
||||||
|
</VChip>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</div>
|
||||||
</div>
|
<!-- 独立的邮箱显示 -->
|
||||||
|
<VDivider class="mx-4" />
|
||||||
|
<div class="user-card__footer">
|
||||||
|
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
|
||||||
|
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
|
||||||
|
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<!-- PC端显示订阅统计信息 -->
|
||||||
|
<VCardText v-if="!isMobile" class="px-4 pt-0 pb-4">
|
||||||
|
<div rounded="lg" class="d-flex justify-space-around">
|
||||||
|
<div class="d-flex align-center gap-3">
|
||||||
|
<VAvatar
|
||||||
|
tile
|
||||||
|
rounded="lg"
|
||||||
|
size="large"
|
||||||
|
class="mr-1"
|
||||||
|
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
|
||||||
|
>
|
||||||
|
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
|
||||||
|
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
|
||||||
|
</div>
|
||||||
|
</VAvatar>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<span class="text-lg text-medium-emphasis font-weight-bold">{{ movieSubscriptions }}</span>
|
||||||
|
<span class="text-caption text-medium-emphasis">{{ t('user.movieSubscriptions') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center gap-3">
|
||||||
|
<VAvatar
|
||||||
|
tile
|
||||||
|
rounded="lg"
|
||||||
|
size="large"
|
||||||
|
class="mr-1"
|
||||||
|
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
|
||||||
|
>
|
||||||
|
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
|
||||||
|
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" />
|
||||||
|
</div>
|
||||||
|
</VAvatar>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<span class="text-lg text-medium-emphasis">{{ tvShowSubscriptions }}</span>
|
||||||
|
<span class="text-caption text-medium-emphasis">{{ t('user.tvSubscriptions') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -37,8 +37,15 @@ function downloading(overrides: Partial<DownloadingCardInfo> = {}): DownloadingC
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 使用生产插件和指定下载器渲染下载任务卡片。 */
|
/** 使用生产插件和指定下载器渲染下载任务卡片。 */
|
||||||
async function renderCard(info = downloading(), downloaderName = 'qb-main') {
|
async function renderCard(info = downloading(), downloaderName = 'qb-main', globalImageCache = false) {
|
||||||
return renderWithProviders(DownloadingCard, {
|
return renderWithProviders(DownloadingCard, {
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
props: { downloaderName, info },
|
props: { downloaderName, info },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -206,6 +213,30 @@ describe('DownloadingCard display and pause state', () => {
|
|||||||
expect(container.querySelector('.downloading-card__image')).not.toBeInTheDocument()
|
expect(container.querySelector('.downloading-card__image')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the global backend cache for recognized poster images', async () => {
|
||||||
|
const ImageStub = defineComponent({
|
||||||
|
name: 'VImg',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: { src: String },
|
||||||
|
setup: props => () => h('img', { src: props.src }),
|
||||||
|
})
|
||||||
|
const { container } = await renderWithProviders(DownloadingCard, {
|
||||||
|
global: { stubs: { VImg: ImageStub } },
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { GLOBAL_IMAGE_CACHE: true },
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: { downloaderName: 'qb-main', info: downloading() },
|
||||||
|
})
|
||||||
|
const image = container.querySelector<HTMLImageElement>('img')
|
||||||
|
|
||||||
|
expect(image?.src).toContain('system/cache/image?url=')
|
||||||
|
expect(image?.src).toContain(encodeURIComponent(downloading().media.poster))
|
||||||
|
})
|
||||||
|
|
||||||
it('hides a failed poster and retries when the task receives a new poster', async () => {
|
it('hides a failed poster and retries when the task receives a new poster', async () => {
|
||||||
const VImgStub = defineComponent({
|
const VImgStub = defineComponent({
|
||||||
name: 'VImg',
|
name: 'VImg',
|
||||||
|
|||||||
@@ -84,4 +84,27 @@ describe('MediaInfoCard', () => {
|
|||||||
expect(screen.queryByText('科幻')).not.toBeInTheDocument()
|
expect(screen.queryByText('科幻')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByText('冒险')).not.toBeInTheDocument()
|
expect(screen.queryByText('冒险')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the global backend cache for recognized music covers', async () => {
|
||||||
|
const cover = 'https://coverartarchive.org/release-group/album-2/front-500'
|
||||||
|
const { container } = await renderWithProviders(MediaInfoCard, {
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { GLOBAL_IMAGE_CACHE: true },
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
context: {
|
||||||
|
meta_info: { title: '缓存测试歌曲', type: '音乐' },
|
||||||
|
media_info: { cover_url: cover, title: '缓存测试歌曲', type: '音乐' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const image = container.querySelector<HTMLImageElement>('.v-img__img')
|
||||||
|
expect(image?.src).toContain('system/cache/image?url=')
|
||||||
|
expect(image?.src).toContain(encodeURIComponent(cover))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MediaServerPlayItem } from '@/api/types'
|
import type { MediaServerPlayItem } from '@/api/types'
|
||||||
|
import BackdropCard from '@/components/cards/BackdropCard.vue'
|
||||||
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
||||||
import PosterCard from '@/components/cards/PosterCard.vue'
|
import PosterCard from '@/components/cards/PosterCard.vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
@@ -36,4 +37,44 @@ describe.each([
|
|||||||
|
|
||||||
expect(container.querySelector('img')).toHaveAttribute('crossorigin', 'anonymous')
|
expect(container.querySelector('img')).toHaveAttribute('crossorigin', 'anonymous')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('passes the global cache switch to the required backend proxy', async () => {
|
||||||
|
const { container } = await renderWithProviders(component, {
|
||||||
|
global: { stubs: { VImg: VImgStub } },
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { GLOBAL_IMAGE_CACHE: true },
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: { media: { ...media, use_cookies: true } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const image = container.querySelector<HTMLImageElement>('img')
|
||||||
|
expect(image?.src).toContain('system/img/0?imgurl=')
|
||||||
|
expect(image?.src).toContain('&cache=true')
|
||||||
|
expect(image?.src).toContain('&use_cookies=true')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BackdropCard image request mode', () => {
|
||||||
|
it('passes the global cache switch to the required backend proxy', async () => {
|
||||||
|
const { container } = await renderWithProviders(BackdropCard, {
|
||||||
|
global: { stubs: { VImg: VImgStub } },
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { GLOBAL_IMAGE_CACHE: true },
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: { media: { ...media, use_cookies: true } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const image = container.querySelector<HTMLImageElement>('img')
|
||||||
|
expect(image?.src).toContain('system/img/0?imgurl=')
|
||||||
|
expect(image?.src).toContain('&cache=true')
|
||||||
|
expect(image?.src).toContain('&use_cookies=true')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { MediaInfo, MusicArtistInfo } from '@/api/types'
|
||||||
|
import MusicArtistCard from '@/components/cards/MusicArtistCard.vue'
|
||||||
|
import MusicCard from '@/components/cards/MusicCard.vue'
|
||||||
|
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { defineComponent, h } from 'vue'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const ImageStub = defineComponent({
|
||||||
|
name: 'VImg',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: { src: String },
|
||||||
|
setup:
|
||||||
|
(props, { slots }) =>
|
||||||
|
() =>
|
||||||
|
h('img', { src: props.src }, slots.default?.()),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cachedImageState = {
|
||||||
|
globalSettings: {
|
||||||
|
data: { GLOBAL_IMAGE_CACHE: true },
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
permissions: { search: false, subscribe: false },
|
||||||
|
superUser: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 断言组件内所有远程音乐图片都已转换为后端全局缓存地址。 */
|
||||||
|
function expectCachedImages(container: Element, source: string) {
|
||||||
|
const images = [...container.querySelectorAll<HTMLImageElement>('img')]
|
||||||
|
expect(images.length).toBeGreaterThan(0)
|
||||||
|
for (const image of images) {
|
||||||
|
expect(image.src).toContain('system/cache/image?url=')
|
||||||
|
expect(image.src).toContain(encodeURIComponent(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('music image cache integration', () => {
|
||||||
|
it('caches recording and album covers in the dedicated music card', async () => {
|
||||||
|
const cover = 'https://coverartarchive.org/release-group/album-1/front-500'
|
||||||
|
const music = {
|
||||||
|
cover_url: cover,
|
||||||
|
media_id: 'recording-1',
|
||||||
|
music_type: 'recording',
|
||||||
|
source: 'musicbrainz',
|
||||||
|
title: '测试单曲',
|
||||||
|
type: '音乐',
|
||||||
|
} as MediaInfo
|
||||||
|
|
||||||
|
const { container } = await renderWithProviders(MusicCard, {
|
||||||
|
global: { stubs: { VImg: ImageStub } },
|
||||||
|
initialState: cachedImageState,
|
||||||
|
props: { music },
|
||||||
|
})
|
||||||
|
|
||||||
|
expectCachedImages(container, cover)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caches artist portraits in artist cards', async () => {
|
||||||
|
const portrait = 'https://images.example.com/artists/artist-1.jpg'
|
||||||
|
const artist = {
|
||||||
|
image_url: portrait,
|
||||||
|
media_id: 'artist-1',
|
||||||
|
music_type: 'artist',
|
||||||
|
name: '测试艺术家',
|
||||||
|
source: 'musicbrainz',
|
||||||
|
type: '音乐',
|
||||||
|
} as MusicArtistInfo
|
||||||
|
|
||||||
|
const { container } = await renderWithProviders(MusicArtistCard, {
|
||||||
|
global: { stubs: { VImg: ImageStub } },
|
||||||
|
initialState: cachedImageState,
|
||||||
|
props: { artist },
|
||||||
|
})
|
||||||
|
|
||||||
|
expectCachedImages(container, portrait)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caches both background and foreground images in the shared music detail layout', async () => {
|
||||||
|
const cover = 'https://coverartarchive.org/release-group/album-2/front-500'
|
||||||
|
const { container } = await renderWithProviders(MusicDetailLayout, {
|
||||||
|
global: { stubs: { VImg: ImageStub } },
|
||||||
|
initialState: cachedImageState,
|
||||||
|
props: { cover, title: '测试专辑' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expectCachedImages(container, cover)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -96,9 +96,20 @@ const defaultFolderConfig = {
|
|||||||
showIcon: true,
|
showIcon: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderFolder(sortable = false, folderConfig: Record<string, unknown> = defaultFolderConfig) {
|
async function renderFolder(
|
||||||
|
sortable = false,
|
||||||
|
folderConfig: Record<string, unknown> = defaultFolderConfig,
|
||||||
|
globalImageCache = false,
|
||||||
|
) {
|
||||||
return renderWithProviders(PluginFolderCard, {
|
return renderWithProviders(PluginFolderCard, {
|
||||||
global: { stubs: passthroughStubs },
|
global: { stubs: passthroughStubs },
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
folderConfig,
|
folderConfig,
|
||||||
folderName: '媒体工具',
|
folderName: '媒体工具',
|
||||||
@@ -142,8 +153,8 @@ describe('PluginFolderCard', () => {
|
|||||||
expect(defaults.container.querySelector('.plugin-folder-card__bg')).toBeInTheDocument()
|
expect(defaults.container.querySelector('.plugin-folder-card__bg')).toBeInTheDocument()
|
||||||
|
|
||||||
defaults.unmount()
|
defaults.unmount()
|
||||||
const image = await renderFolder(false, { background: 'https://example.com/folder.jpg', showIcon: false })
|
const image = await renderFolder(false, { background: 'https://example.com/folder.jpg', showIcon: false }, true)
|
||||||
expect(image.container.querySelector('v-img-stub[src="https://example.com/folder.jpg"]')).toBeInTheDocument()
|
expect(image.container.querySelector('v-img-stub')?.getAttribute('src')).toContain('system/cache/image?url=')
|
||||||
expect(image.container.querySelector('.plugin-folder-card__icon-container')).not.toBeInTheDocument()
|
expect(image.container.querySelector('.plugin-folder-card__icon-container')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -155,14 +155,21 @@ describe('SubscribeCard display and progress', () => {
|
|||||||
|
|
||||||
it('falls back from backdrop to poster for music subscriptions, then to the album placeholder', async () => {
|
it('falls back from backdrop to poster for music subscriptions, then to the album placeholder', async () => {
|
||||||
// 仅海报:背景图回退到海报
|
// 仅海报:背景图回退到海报
|
||||||
const { container: posterOnly } = await renderCard({
|
const { container: posterOnly } = await renderCard(
|
||||||
backdrop: undefined,
|
{
|
||||||
poster: 'https://images.example.com/music-poster.jpg',
|
backdrop: undefined,
|
||||||
type: '音乐',
|
poster: 'https://images.example.com/music-poster.jpg',
|
||||||
})
|
type: '音乐',
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
)
|
||||||
const posterOnlyImage = posterOnly.querySelector<HTMLImageElement>('img')
|
const posterOnlyImage = posterOnly.querySelector<HTMLImageElement>('img')
|
||||||
expect(posterOnlyImage).not.toBeNull()
|
expect(posterOnlyImage).not.toBeNull()
|
||||||
expect((posterOnlyImage as HTMLImageElement).src).toContain('music-poster.jpg')
|
expect((posterOnlyImage as HTMLImageElement).src).toContain('system/cache/image?url=')
|
||||||
|
expect((posterOnlyImage as HTMLImageElement).src).toContain(
|
||||||
|
encodeURIComponent('https://images.example.com/music-poster.jpg'),
|
||||||
|
)
|
||||||
|
|
||||||
// 背景图与海报都缺失:渲染与音乐媒体卡片一致的胶片占位背景
|
// 背景图与海报都缺失:渲染与音乐媒体卡片一致的胶片占位背景
|
||||||
const { container } = await renderCard({ backdrop: undefined, poster: undefined, type: '音乐' })
|
const { container } = await renderCard({ backdrop: undefined, poster: undefined, type: '音乐' })
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { getDisplayImageUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('image URL helpers', () => {
|
||||||
|
it('keeps ordinary remote images direct until global caching is enabled', () => {
|
||||||
|
const image = 'https://images.example.com/album cover.jpg'
|
||||||
|
|
||||||
|
expect(getDisplayImageUrl(image, false)).toBe(image)
|
||||||
|
expect(getDisplayImageUrl(image, true)).toContain(`system/cache/image?url=${encodeURIComponent(image)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes cache and cookie controls through the mandatory image proxy', () => {
|
||||||
|
const image = 'https://media.example.com/private/poster.jpg'
|
||||||
|
const proxied = getProxyImageUrl(image, { proxy: true, useCache: true, useCookies: true })
|
||||||
|
|
||||||
|
expect(proxied).toContain(`system/img/1?imgurl=${encodeURIComponent(image)}`)
|
||||||
|
expect(proxied).toContain('&cache=true')
|
||||||
|
expect(proxied).toContain('&use_cookies=true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not proxy local, data, or empty image sources', () => {
|
||||||
|
expect(getProxyImageUrl('/images/local.png', { useCache: true })).toBe('/images/local.png')
|
||||||
|
expect(getProxyImageUrl('data:image/png;base64,abc', { useCache: true })).toBe('data:image/png;base64,abc')
|
||||||
|
expect(getProxyImageUrl('', { useCache: true })).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
+25
-6
@@ -95,6 +95,28 @@ export function isBangumiImageUrl(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后端图片代理参数。 */
|
||||||
|
export interface ImageProxyOptions {
|
||||||
|
proxy?: boolean
|
||||||
|
useCache?: boolean
|
||||||
|
useCookies?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成后端图片代理地址,供必须代理的跨域或鉴权图片统一传递全局缓存开关。
|
||||||
|
* @param url 原始图片地址
|
||||||
|
* @param options 代理、磁盘缓存和媒体服务器 Cookie 选项
|
||||||
|
* @returns 后端图片代理地址
|
||||||
|
*/
|
||||||
|
export function getProxyImageUrl(url: string, options: ImageProxyOptions = {}): string {
|
||||||
|
if (!url || !/^https?:\/\//i.test(url)) return url
|
||||||
|
const encodedUrl = encodeURIComponent(url)
|
||||||
|
const proxy = options.proxy ? 1 : 0
|
||||||
|
const cacheParam = options.useCache ? '&cache=true' : ''
|
||||||
|
const cookiesParam = options.useCookies ? '&use_cookies=true' : ''
|
||||||
|
return `${import.meta.env.VITE_API_BASE_URL}system/img/${proxy}?imgurl=${encodedUrl}${cacheParam}${cookiesParam}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将远程图片地址转换为前端可直接展示的地址。
|
* 将远程图片地址转换为前端可直接展示的地址。
|
||||||
* @param url 原始图片地址
|
* @param url 原始图片地址
|
||||||
@@ -104,12 +126,9 @@ export function isBangumiImageUrl(url: string): boolean {
|
|||||||
export function getDisplayImageUrl(url: string, useCache = false): string {
|
export function getDisplayImageUrl(url: string, useCache = false): string {
|
||||||
if (!url || !/^https?:\/\//i.test(url)) return url
|
if (!url || !/^https?:\/\//i.test(url)) return url
|
||||||
const encodedUrl = encodeURIComponent(url)
|
const encodedUrl = encodeURIComponent(url)
|
||||||
if (isBangumiImageUrl(url))
|
if (isBangumiImageUrl(url)) return getProxyImageUrl(url, { proxy: true, useCache })
|
||||||
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 (useCache)
|
if (url.includes('doubanio.com')) return getProxyImageUrl(url)
|
||||||
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
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
|
|
||||||
// 音乐详情体系(单曲、专辑、艺术家)共用的头部与背景布局
|
// 音乐详情体系(单曲、专辑、艺术家)共用的头部与背景布局
|
||||||
@@ -20,6 +22,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 透明与毛玻璃主题下背景图需要改用遮罩淡出,与影视详情页保持一致
|
// 透明与毛玻璃主题下背景图需要改用遮罩淡出,与影视详情页保持一致
|
||||||
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
||||||
@@ -28,14 +31,14 @@ const isGlassTheme = computed(() => theme.name.value === 'glass')
|
|||||||
// 封面加载失败后回退到占位图标
|
// 封面加载失败后回退到占位图标
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
|
||||||
const showCover = computed(() => Boolean(props.cover) && !imageLoadError.value)
|
const displayCover = computed(() =>
|
||||||
|
getDisplayImageUrl(props.cover || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||||
watch(
|
|
||||||
() => props.cover,
|
|
||||||
() => {
|
|
||||||
imageLoadError.value = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
const showCover = computed(() => Boolean(displayCover.value) && !imageLoadError.value)
|
||||||
|
|
||||||
|
watch(displayCover, () => {
|
||||||
|
imageLoadError.value = false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -48,14 +51,14 @@ watch(
|
|||||||
>
|
>
|
||||||
<template v-if="showCover">
|
<template v-if="showCover">
|
||||||
<div class="vue-music-back vue-music-back-image absolute left-0 top-0 w-full h-96">
|
<div class="vue-music-back vue-music-back-image absolute left-0 top-0 w-full h-96">
|
||||||
<VImg class="h-96" position="top" :src="props.cover" cover />
|
<VImg class="h-96" position="top" :src="displayCover" cover />
|
||||||
</div>
|
</div>
|
||||||
<div class="vue-music-back vue-music-back-overlay absolute left-0 top-0 w-full h-96" />
|
<div class="vue-music-back vue-music-back-overlay absolute left-0 top-0 w-full h-96" />
|
||||||
</template>
|
</template>
|
||||||
<div class="music-page">
|
<div class="music-page">
|
||||||
<div class="music-header">
|
<div class="music-header">
|
||||||
<div class="music-poster" :class="{ 'music-poster--rounded': props.rounded }">
|
<div class="music-poster" :class="{ 'music-poster--rounded': props.rounded }">
|
||||||
<VImg v-if="showCover" :src="props.cover" cover aspect-ratio="1" @error="imageLoadError = true">
|
<VImg v-if="showCover" :src="displayCover" cover aspect-ratio="1" @error="imageLoadError = true">
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<VSkeletonLoader class="h-100 w-100" />
|
<VSkeletonLoader class="h-100 w-100" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user