mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 10:34:35 +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 noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getProxyImageUrl } from '@/utils/imageUtils'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaServerPlayItem>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
@@ -40,12 +43,10 @@ async function goPlay() {
|
||||
const getImgUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const use_cookies = props.media?.use_cookies
|
||||
if (use_cookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return url
|
||||
return getProxyImageUrl(image, {
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
useCookies: props.media?.use_cookies,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -63,43 +64,43 @@ const getImgUrl = computed(() => {
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="getImgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="backdrop-card-image"
|
||||
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="backdrop-card-placeholder">
|
||||
<VSkeletonLoader class="backdrop-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<VCardText
|
||||
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 ..."
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="getImgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="backdrop-card-image"
|
||||
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="backdrop-card-placeholder">
|
||||
<VSkeletonLoader class="backdrop-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<span class="text-shadow text-sm">{{ props.media?.subtitle }}</span>
|
||||
</VCardText>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear
|
||||
v-if="props.media?.percent"
|
||||
:model-value="props.media?.percent"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
<h1
|
||||
class="mb-1 text-white text-shadow font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<span class="text-shadow text-sm">{{ props.media?.subtitle }}</span>
|
||||
</VCardText>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear
|
||||
v-if="props.media?.percent"
|
||||
:model-value="props.media?.percent"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, DownloadingInfo } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
/** 卡片使用的下载任务信息,兼容接口已经返回但公共类型尚未声明的来源站点。 */
|
||||
@@ -17,6 +19,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。
|
||||
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'))
|
||||
|
||||
@@ -164,7 +170,7 @@ async function deleteDownload() {
|
||||
>
|
||||
<div v-if="hasPosterImage" class="downloading-card__poster">
|
||||
<VImg
|
||||
:src="media.poster"
|
||||
:src="posterUrl"
|
||||
class="downloading-card__image"
|
||||
cover
|
||||
position="center"
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { MediaServerLibrary } from '@/api/types'
|
||||
import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -12,6 +13,7 @@ const props = defineProps({
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// canvas
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
@@ -96,11 +98,10 @@ async function goPlay() {
|
||||
*/
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url || imageError.value) return getDefaultImage()
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
if (use_cookies) {
|
||||
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return imgurl
|
||||
return getProxyImageUrl(url, {
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
useCookies: use_cookies,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,10 +116,10 @@ async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
for (let i = 0; i < IMAGES.length; i++) {
|
||||
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
|
||||
if (use_cookies) {
|
||||
IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
IMAGES[i] = getProxyImageUrl(IMAGES[i], {
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
useCookies: use_cookies,
|
||||
})
|
||||
}
|
||||
|
||||
// canvas
|
||||
@@ -213,33 +214,33 @@ onMounted(async () => {
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="library-card-shade" aria-hidden="true" />
|
||||
<div v-if="showCountCorner" class="library-card-count-corner">
|
||||
<span>{{ countLabel }}</span>
|
||||
</div>
|
||||
<div class="library-card-label">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="library-card-shade" aria-hidden="true" />
|
||||
<div v-if="showCountCorner" class="library-card-count-corner">
|
||||
<span>{{ countLabel }}</span>
|
||||
</div>
|
||||
<div class="library-card-label">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -310,7 +311,7 @@ onMounted(async () => {
|
||||
background: rgba(8, 13, 22, 72%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
content: '';
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
@@ -321,7 +322,7 @@ onMounted(async () => {
|
||||
background: rgba(var(--v-theme-primary), 62%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
content: '';
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
import type { PropType } from 'vue'
|
||||
import type { Context } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { formatMusicDuration } from '@/utils/music'
|
||||
|
||||
const { t } = useI18n()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -15,9 +18,7 @@ const props = defineProps({
|
||||
const recognizedName = computed(() => props.context?.meta_info?.name || props.context?.meta_info?.title)
|
||||
|
||||
// 是否为音乐识别结果
|
||||
const isMusic = computed(
|
||||
() => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐',
|
||||
)
|
||||
const isMusic = computed(() => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐')
|
||||
|
||||
// 音乐封面加载失败时保留方形唱片占位,避免弹窗信息结构塌缩。
|
||||
const musicCoverLoadError = ref(false)
|
||||
@@ -37,15 +38,28 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
}
|
||||
|
||||
// 音乐封面优先使用方形封面,仅 W500 图片处理对TMDB类 URL 生效
|
||||
const musicCover = computed(() => getW500Image(
|
||||
props.context?.media_info?.cover_url || props.context?.media_info?.poster_path || '',
|
||||
))
|
||||
const rawMusicCover = computed(() =>
|
||||
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 mediaInfo = props.context?.media_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>
|
||||
<div v-show="context">
|
||||
<VCol>
|
||||
<div
|
||||
v-if="recognizedName"
|
||||
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
|
||||
>
|
||||
<div 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">
|
||||
<VImg
|
||||
v-if="musicCover && !musicCoverLoadError"
|
||||
@@ -158,12 +169,12 @@ watch(musicCover, () => {
|
||||
<VIcon icon="mdi-album" size="64" color="medium-emphasis" />
|
||||
</VSheet>
|
||||
</div>
|
||||
<div v-else-if="context?.media_info?.poster_path" class="ma-auto">
|
||||
<div v-else-if="mediaPoster" class="ma-auto">
|
||||
<VImg
|
||||
width="10rem"
|
||||
aspect-ratio="2/3"
|
||||
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
|
||||
>
|
||||
<template #placeholder>
|
||||
@@ -268,7 +279,11 @@ watch(musicCover, () => {
|
||||
详情
|
||||
</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 }}
|
||||
</VChip>
|
||||
<!-- TMDBID -->
|
||||
@@ -282,7 +297,11 @@ watch(musicCover, () => {
|
||||
</VChip>
|
||||
<!-- meta_info(音乐不显示影视资源信息) -->
|
||||
<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 }}
|
||||
</VChip>
|
||||
<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 }}
|
||||
</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 }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MusicArtistInfo } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { buildMusicArtistRoute, getMusicArtistSubtitle } from '@/utils/music'
|
||||
|
||||
const router = useRouter()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
const props = defineProps({
|
||||
artist: Object as PropType<MusicArtistInfo>,
|
||||
@@ -12,7 +15,10 @@ const props = defineProps({
|
||||
// 艺术家图片加载失败后回退到占位图标
|
||||
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 subtitle = computed(() => getMusicArtistSubtitle(props.artist))
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useGlobalSettingsStore, useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
|
||||
import { getCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
|
||||
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
|
||||
@@ -22,6 +23,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
@@ -70,7 +72,10 @@ const metaItems = computed(() => {
|
||||
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)
|
||||
|
||||
/** 生成订阅状态缓存键。 */
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getProxyImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -9,6 +11,7 @@ const props = defineProps({
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
@@ -52,13 +55,10 @@ const imageUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const useCookies = props.media?.use_cookies
|
||||
if (useCookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(useCookies)}`
|
||||
}
|
||||
|
||||
return url
|
||||
return getProxyImageUrl(image, {
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
useCookies: props.media?.use_cookies,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
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 { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
@@ -23,6 +24,7 @@ const props = defineProps({
|
||||
height: String,
|
||||
count: Number,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['install'])
|
||||
@@ -81,9 +83,10 @@ const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
props.plugin?.plugin_icon,
|
||||
)}&cache=true`
|
||||
return getProxyImageUrl(props.plugin.plugin_icon, {
|
||||
proxy: true,
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
})
|
||||
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
})
|
||||
|
||||
@@ -3,13 +3,14 @@ import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
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 { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
||||
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
||||
@@ -32,6 +33,7 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save', 'actionDone', 'rating'])
|
||||
@@ -193,19 +195,22 @@ const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
props.plugin?.plugin_icon,
|
||||
)}&cache=true`
|
||||
return getProxyImageUrl(props.plugin.plugin_icon, {
|
||||
proxy: true,
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
})
|
||||
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
})
|
||||
|
||||
// 插件作者头像路径
|
||||
const authorPath: Ref<string> = computed(() => {
|
||||
if (!props.plugin?.author_url) return ''
|
||||
// 网络图片则使用代理后返回
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
props.plugin?.author_url + '.png',
|
||||
)}&cache=true`
|
||||
return getProxyImageUrl(`${props.plugin.author_url}.png`, {
|
||||
proxy: true,
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
})
|
||||
})
|
||||
|
||||
// 重置插件
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
|
||||
const PluginFolderSettingsDialog = defineAsyncComponent(
|
||||
@@ -36,6 +38,7 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['open', 'delete', 'rename', 'update-config'])
|
||||
@@ -66,7 +69,7 @@ const defaultGradient =
|
||||
|
||||
// 计算背景图片
|
||||
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 noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getProxyImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -10,6 +12,7 @@ const props = defineProps({
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 图片加载状态
|
||||
const isImageLoaded = ref(false)
|
||||
@@ -34,12 +37,10 @@ function getChipColor(type: string) {
|
||||
const getImgUrl = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
const image = props.media?.image || ''
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const use_cookies = props.media?.use_cookies
|
||||
if (use_cookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return url
|
||||
return getProxyImageUrl(image, {
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
useCookies: props.media?.use_cookies,
|
||||
})
|
||||
})
|
||||
|
||||
// 跳转播放
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
@@ -33,6 +34,7 @@ const cardProps = defineProps({
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update', 'remove', 'refresh-stats'])
|
||||
@@ -62,11 +64,12 @@ async function getSiteIcon() {
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
|
||||
const icon = await getCachedSiteIcon(siteId, async () => {
|
||||
const response = await api.get(`site/icon/${siteId}`)
|
||||
|
||||
return response?.data?.icon || defaultSiteIcon
|
||||
})
|
||||
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
} catch (error) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
console.error(error)
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
||||
|
||||
@@ -18,6 +20,7 @@ const props = defineProps({
|
||||
subtitle: Object as PropType<SubtitleInfo>,
|
||||
width: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 字幕信息
|
||||
const subtitle = ref(props.subtitle)
|
||||
@@ -25,7 +28,9 @@ const subtitle = ref(props.subtitle)
|
||||
// 站点图标
|
||||
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() {
|
||||
@@ -35,7 +40,7 @@ async function getSiteIcon() {
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||
const icon = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
||||
|
||||
@@ -45,12 +50,18 @@ async function getSiteIcon() {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 字幕语言图标可能来自站点外链,展示时统一遵循全局图片缓存开关。
|
||||
const languageIconUrl = computed(() =>
|
||||
getDisplayImageUrl(subtitle.value?.language_icon || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||
)
|
||||
|
||||
// 添加字幕下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
markSubtitleDownloaded(url)
|
||||
@@ -133,8 +144,8 @@ watch(
|
||||
</VChip>
|
||||
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
||||
<VImg
|
||||
v-if="subtitle?.language_icon"
|
||||
:src="subtitle.language_icon"
|
||||
v-if="languageIconUrl"
|
||||
:src="languageIconUrl"
|
||||
:alt="subtitle.language"
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -163,7 +174,10 @@ watch(
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
||||
</span>
|
||||
@@ -195,7 +209,14 @@ watch(
|
||||
<VBtn v-if="subtitle?.report_url" icon size="small" variant="text" color="warning" @click.stop="openReportPage">
|
||||
<VIcon icon="mdi-alert-outline"></VIcon>
|
||||
</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>
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
||||
|
||||
@@ -17,6 +19,7 @@ const { t } = useI18n()
|
||||
const props = defineProps({
|
||||
subtitle: Object as PropType<SubtitleInfo>,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 字幕信息
|
||||
const subtitle = ref(props.subtitle)
|
||||
@@ -24,7 +27,9 @@ const subtitle = ref(props.subtitle)
|
||||
// 站点图标
|
||||
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() {
|
||||
@@ -34,7 +39,7 @@ async function getSiteIcon() {
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||
const icon = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
||||
|
||||
@@ -44,12 +49,18 @@ async function getSiteIcon() {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 字幕语言图标可能来自站点外链,展示时统一遵循全局图片缓存开关。
|
||||
const languageIconUrl = computed(() =>
|
||||
getDisplayImageUrl(subtitle.value?.language_icon || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||
)
|
||||
|
||||
// 询问并下载字幕
|
||||
async function handleAddDownload() {
|
||||
openSharedDialog(
|
||||
@@ -135,8 +146,8 @@ watch(
|
||||
</VChip>
|
||||
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
||||
<VImg
|
||||
v-if="subtitle?.language_icon"
|
||||
:src="subtitle.language_icon"
|
||||
v-if="languageIconUrl"
|
||||
:src="languageIconUrl"
|
||||
:alt="subtitle.language"
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -153,12 +164,19 @@ watch(
|
||||
{{ subtitle?.title }}
|
||||
</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 }}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
||||
const TorrentMoreSourcesDialog = defineAsyncComponent(() => import('../dialog/TorrentMoreSourcesDialog.vue'))
|
||||
@@ -18,6 +20,7 @@ const props = defineProps({
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 种子信息
|
||||
const torrent = ref(props.torrent?.torrent_info)
|
||||
@@ -51,7 +54,7 @@ async function getSiteIcon(site: number | undefined) {
|
||||
if (!site) return
|
||||
|
||||
try {
|
||||
siteIcons.value[site] = await getCachedSiteIcon(site, async () => {
|
||||
const icon = await getCachedSiteIcon(site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${site}`)
|
||||
|
||||
@@ -61,6 +64,7 @@ async function getSiteIcon(site: number | undefined) {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
siteIcons.value[site] = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
siteIcons.value[site] = ''
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { Context } from '@/api/types'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
|
||||
|
||||
@@ -13,6 +15,7 @@ const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownlo
|
||||
const props = defineProps({
|
||||
torrent: Object as PropType<Context>,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 种子信息
|
||||
const torrent = ref(props.torrent?.torrent_info)
|
||||
@@ -48,7 +51,7 @@ async function getSiteIcon(site: number | undefined) {
|
||||
})
|
||||
// 只提交当前站点的响应,避免 Context 快速切换时旧请求覆盖新图标。
|
||||
if (torrent.value?.site === site) {
|
||||
siteIcon.value = icon
|
||||
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
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 { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -35,6 +36,7 @@ const props = defineProps({
|
||||
|
||||
const display = useDisplay()
|
||||
const isMobile = computed(() => display.mdAndDown.value)
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 当前用户的ID
|
||||
const currentLoginUserId = computed(() => useUserStore().userID)
|
||||
@@ -64,6 +66,11 @@ const displayName = computed(() => {
|
||||
return nickname || props.user.name
|
||||
})
|
||||
|
||||
// 远程头像遵循全局图片缓存开关,本地默认头像和 data URL 保持原样。
|
||||
const avatarUrl = computed(() =>
|
||||
getDisplayImageUrl(props.user.avatar || avatar1, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||
)
|
||||
|
||||
// 按用户查询订阅数量
|
||||
async function fetchSubscriptions() {
|
||||
try {
|
||||
@@ -130,180 +137,177 @@ onMounted(() => {
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="user-card-hover-area h-full">
|
||||
<VCard
|
||||
:class="[
|
||||
'app-hover-lift-card',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
:class="['app-hover-lift-card', !props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '']"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<div class="user-card__body flex-grow flex-grow-1">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
<template v-slot:prepend>
|
||||
<div class="position-relative mr-4">
|
||||
<VAvatar
|
||||
size="72"
|
||||
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
|
||||
<div class="user-card__body flex-grow flex-grow-1">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
<template v-slot:prepend>
|
||||
<div class="position-relative mr-4">
|
||||
<VAvatar
|
||||
size="72"
|
||||
rounded="lg"
|
||||
:class="[
|
||||
'text-h6 font-weight-bold truncate',
|
||||
user.is_superuser ? 'text-warning' : '',
|
||||
!user.is_active ? 'text-medium-emphasis' : '',
|
||||
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);' : ''"
|
||||
>
|
||||
{{ 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>
|
||||
|
||||
<!-- 移动端订阅数据信息 -->
|
||||
<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" />
|
||||
<VImg :src="avatarUrl" :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>
|
||||
</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" />
|
||||
</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="[
|
||||
'text-h6 font-weight-bold truncate',
|
||||
user.is_superuser ? 'text-warning' : '',
|
||||
!user.is_active ? 'text-medium-emphasis' : '',
|
||||
]"
|
||||
>
|
||||
{{ 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>
|
||||
</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 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>
|
||||
</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>
|
||||
</div>
|
||||
</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, {
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
|
||||
initialized: true,
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
props: { downloaderName, info },
|
||||
})
|
||||
}
|
||||
@@ -206,6 +213,30 @@ describe('DownloadingCard display and pause state', () => {
|
||||
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 () => {
|
||||
const VImgStub = defineComponent({
|
||||
name: 'VImg',
|
||||
|
||||
@@ -84,4 +84,27 @@ describe('MediaInfoCard', () => {
|
||||
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 BackdropCard from '@/components/cards/BackdropCard.vue'
|
||||
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
||||
import PosterCard from '@/components/cards/PosterCard.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
@@ -36,4 +37,44 @@ describe.each([
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
92
src/components/cards/__tests__/MusicImageCache.spec.ts
Normal file
92
src/components/cards/__tests__/MusicImageCache.spec.ts
Normal file
@@ -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,
|
||||
}
|
||||
|
||||
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, {
|
||||
global: { stubs: passthroughStubs },
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
|
||||
initialized: true,
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
folderConfig,
|
||||
folderName: '媒体工具',
|
||||
@@ -142,8 +153,8 @@ describe('PluginFolderCard', () => {
|
||||
expect(defaults.container.querySelector('.plugin-folder-card__bg')).toBeInTheDocument()
|
||||
|
||||
defaults.unmount()
|
||||
const image = await renderFolder(false, { background: 'https://example.com/folder.jpg', showIcon: false })
|
||||
expect(image.container.querySelector('v-img-stub[src="https://example.com/folder.jpg"]')).toBeInTheDocument()
|
||||
const image = await renderFolder(false, { background: 'https://example.com/folder.jpg', showIcon: false }, true)
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
// 仅海报:背景图回退到海报
|
||||
const { container: posterOnly } = await renderCard({
|
||||
backdrop: undefined,
|
||||
poster: 'https://images.example.com/music-poster.jpg',
|
||||
type: '音乐',
|
||||
})
|
||||
const { container: posterOnly } = await renderCard(
|
||||
{
|
||||
backdrop: undefined,
|
||||
poster: 'https://images.example.com/music-poster.jpg',
|
||||
type: '音乐',
|
||||
},
|
||||
{},
|
||||
true,
|
||||
)
|
||||
const posterOnlyImage = posterOnly.querySelector<HTMLImageElement>('img')
|
||||
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: '音乐' })
|
||||
|
||||
26
src/utils/__tests__/imageUtils.spec.ts
Normal file
26
src/utils/__tests__/imageUtils.spec.ts
Normal file
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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 原始图片地址
|
||||
@@ -104,12 +126,9 @@ export function isBangumiImageUrl(url: string): boolean {
|
||||
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}`
|
||||
if (isBangumiImageUrl(url)) return getProxyImageUrl(url, { proxy: true, useCache })
|
||||
if (useCache) return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodedUrl}`
|
||||
if (url.includes('doubanio.com')) return getProxyImageUrl(url)
|
||||
return url
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { useTheme } from 'vuetify'
|
||||
|
||||
// 音乐详情体系(单曲、专辑、艺术家)共用的头部与背景布局
|
||||
@@ -20,6 +22,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const theme = useTheme()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 透明与毛玻璃主题下背景图需要改用遮罩淡出,与影视详情页保持一致
|
||||
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
||||
@@ -28,14 +31,14 @@ const isGlassTheme = computed(() => theme.name.value === 'glass')
|
||||
// 封面加载失败后回退到占位图标
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const showCover = computed(() => Boolean(props.cover) && !imageLoadError.value)
|
||||
|
||||
watch(
|
||||
() => props.cover,
|
||||
() => {
|
||||
imageLoadError.value = false
|
||||
},
|
||||
const displayCover = computed(() =>
|
||||
getDisplayImageUrl(props.cover || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||
)
|
||||
const showCover = computed(() => Boolean(displayCover.value) && !imageLoadError.value)
|
||||
|
||||
watch(displayCover, () => {
|
||||
imageLoadError.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,14 +51,14 @@ watch(
|
||||
>
|
||||
<template v-if="showCover">
|
||||
<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 class="vue-music-back vue-music-back-overlay absolute left-0 top-0 w-full h-96" />
|
||||
</template>
|
||||
<div class="music-page">
|
||||
<div class="music-header">
|
||||
<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>
|
||||
<VSkeletonLoader class="h-100 w-100" />
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user