fix(images): honor global cache across cards

This commit is contained in:
jxxghp
2026-08-09 22:20:51 +08:00
parent 73840e3480
commit 6ca606a864
26 changed files with 692 additions and 332 deletions

View File

@@ -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>

View File

@@ -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"

View File

@@ -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%;

View File

@@ -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>

View File

@@ -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))

View File

@@ -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)
/** 生成订阅状态缓存键。 */

View File

@@ -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,
})
})
/**

View File

@@ -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}`
})

View File

@@ -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,
})
})
// 重置插件

View File

@@ -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)
})
// 计算背景渐变

View File

@@ -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,
})
})
// 跳转播放

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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] = ''

View File

@@ -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)

View File

@@ -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>

View File

@@ -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',

View File

@@ -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))
})
})

View File

@@ -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')
})
})

View 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)
})
})

View File

@@ -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()
})

View File

@@ -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: '音乐' })

View 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('')
})
})

View File

@@ -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
}

View File

@@ -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>