feat: 优化媒体卡片与版本标志 (#650)

Co-authored-by: traeagent <traeagent@users.noreply.github.com>
This commit is contained in:
jxxghp
2026-08-08 15:33:55 +08:00
committed by GitHub
co-authored by traeagent
parent 1064156062
commit 777c4ec7e3
7 changed files with 55 additions and 42 deletions
Binary file not shown.
Binary file not shown.
+21 -8
View File
@@ -6,7 +6,6 @@ let mediaCardIdSeed = 0
</script> </script>
<script lang="ts" setup> <script lang="ts" setup>
import noImage from '@images/no-image.jpeg'
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils' import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
import api from '@/api' import api from '@/api'
import { formatRating } from '@/@core/utils/formatters' import { formatRating } from '@/@core/utils/formatters'
@@ -397,16 +396,30 @@ function setupIntersectionObserver() {
} }
} }
// 媒体占位图标:电影/电视剧/音乐各自使用对应图标,缺失封面时统一渲染图标 + 底色占位
const placeholderIcon = computed(() => {
switch (props.media?.type) {
case '音乐':
return 'mdi-album'
case '电视剧':
return 'mdi-television-classic'
case '电影':
default:
return 'mdi-movie-open-outline'
}
})
// 计算图片地址 // 计算图片地址
const getImgUrl: Ref<string> = computed(() => { const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return ''
if (props.media?.type === '音乐') { if (props.media?.type === '音乐') {
// 音乐封面优先使用 cover_urlListenBrainz/MusicBrainz 统计接口返回),回退到 poster_path // 音乐封面优先使用 cover_urlListenBrainz/MusicBrainz 统计接口返回),回退到 poster_path
const musicCover = props.media?.cover_url || props.media?.poster_path const musicCover = props.media?.cover_url || props.media?.poster_path
if (!musicCover || imageLoadError.value) return '' if (!musicCover) return ''
return getDisplayImageUrl(musicCover, globalSettings.GLOBAL_IMAGE_CACHE) return getDisplayImageUrl(musicCover, globalSettings.GLOBAL_IMAGE_CACHE)
} }
if (imageLoadError.value) return noImage const url = props.media?.poster_path?.replace('original', 'w500')
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage if (!url) return ''
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE) return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
}) })
@@ -481,10 +494,10 @@ onBeforeUnmount(() => {
@click.stop="handleMediaCardClick(hover.isHovering)" @click.stop="handleMediaCardClick(hover.isHovering)"
> >
<div <div
v-if="props.media?.type === '音乐' && !getImgUrl" v-if="!getImgUrl"
class="music-card-placeholder d-flex align-center justify-center" class="media-card-placeholder d-flex align-center justify-center"
> >
<VIcon icon="mdi-album" size="64" color="medium-emphasis" /> <VIcon :icon="placeholderIcon" size="64" color="medium-emphasis" />
</div> </div>
<VImg <VImg
v-else v-else
@@ -578,7 +591,7 @@ onBeforeUnmount(() => {
inline-size: 100%; inline-size: 100%;
} }
.music-card-placeholder { .media-card-placeholder {
aspect-ratio: 2 / 3; aspect-ratio: 2 / 3;
background: rgb(var(--v-theme-surface-variant)); background: rgb(var(--v-theme-surface-variant));
block-size: 100%; block-size: 100%;
+31 -31
View File
@@ -12,7 +12,6 @@ import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils' import { getDisplayImageUrl } from '@/utils/imageUtils'
import { buildMusicDetailRoute } from '@/utils/music' import { buildMusicDetailRoute } from '@/utils/music'
import noImage from '@images/no-image.jpeg'
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue')) const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue')) const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
@@ -421,40 +420,41 @@ watch(
}, },
) )
// 音乐订阅:背景图与海报可能只存在其一,两者都缺失时不再使用通用占位图, // 媒体占位图标:电影/电视剧/音乐各自使用对应图标,缺失封面时统一渲染图标 + 底色占位
// 改为渲染与音乐媒体卡片一致的胶片占位背景(专辑图标),避免出现空白图片区。 const placeholderIcon = computed(() => {
const isMusicSubscribe = computed(() => props.media?.type === '音乐') switch (props.media?.type) {
case '音乐':
return 'mdi-album'
case '电视剧':
return 'mdi-television-classic'
case '电影':
default:
return 'mdi-movie-open-outline'
}
})
// 计算backdrop图片地址 // 计算backdrop图片地址
const backdropUrl = computed(() => { const backdropUrl = computed(() => {
if (isMusicSubscribe.value) { if (backdropLoadError.value) return ''
if (backdropLoadError.value) return '' const url = props.media?.backdrop || props.media?.poster
const url = props.media?.backdrop || props.media?.poster if (!url) return ''
if (!url) return '' return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
}
const url = backdropLoadError.value ? noImage : props.media?.backdrop || props.media?.poster || noImage
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
}) })
// 计算海报图片地址 // 计算海报图片地址
const posterUrl = computed(() => { const posterUrl = computed(() => {
if (isMusicSubscribe.value) { if (posterLoadError.value) return ''
if (posterLoadError.value) return '' const url = props.media?.poster || props.media?.backdrop
const url = props.media?.poster || props.media?.backdrop if (!url) return ''
if (!url) return '' return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
}
const url = posterLoadError.value ? noImage : props.media?.poster || noImage
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
}) })
// 音乐订阅缺失封面时展示胶片占位背景,对齐音乐媒体卡片 // 缺失封面时展示媒体占位背景(图标 + 底色),对齐音乐媒体卡片
const showMusicPlaceholder = computed(() => isMusicSubscribe.value && !backdropUrl.value) const showPlaceholder = computed(() => !backdropUrl.value)
// 占位背景出现时同步标记图片已加载,让卡片正文与徽标正常渲染 // 占位背景出现时同步标记图片已加载,让卡片正文与徽标正常渲染
watch( watch(
showMusicPlaceholder, showPlaceholder,
show => { show => {
if (show) imageLoaded.value = true if (show) imageLoaded.value = true
}, },
@@ -539,10 +539,10 @@ function handleCardClick() {
</div> </div>
<template #image v-if="display.smAndUp.value"> <template #image v-if="display.smAndUp.value">
<div <div
v-if="showMusicPlaceholder" v-if="showPlaceholder"
class="subscribe-card-music-placeholder d-flex align-center justify-center" class="subscribe-card-placeholder d-flex align-center justify-center"
> >
<VIcon icon="mdi-album" size="64" color="medium-emphasis" /> <VIcon :icon="placeholderIcon" size="64" color="medium-emphasis" />
</div> </div>
<VImg <VImg
v-else v-else
@@ -567,10 +567,10 @@ function handleCardClick() {
<template v-if="display.xs.value"> <template v-if="display.xs.value">
<div class="subscribe-card-mobile-media"> <div class="subscribe-card-mobile-media">
<div <div
v-if="showMusicPlaceholder" v-if="showPlaceholder"
class="subscribe-card-music-placeholder d-flex align-center justify-center" class="subscribe-card-placeholder d-flex align-center justify-center"
> >
<VIcon icon="mdi-album" size="64" color="medium-emphasis" /> <VIcon :icon="placeholderIcon" size="64" color="medium-emphasis" />
</div> </div>
<VImg <VImg
v-else v-else
@@ -972,8 +972,8 @@ function handleCardClick() {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%); background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
} }
/* 音乐订阅缺失封面时的胶片占位背景,对齐音乐媒体卡片 */ /* 缺失封面时的媒体占位背景(图标 + 底色),对齐音乐媒体卡片 */
.subscribe-card-music-placeholder { .subscribe-card-placeholder {
block-size: 100%; block-size: 100%;
inline-size: 100%; inline-size: 100%;
background: rgb(var(--v-theme-surface-variant)); background: rgb(var(--v-theme-surface-variant));
@@ -354,7 +354,7 @@ describe('MediaCard', () => {
const { container } = await renderCard(media) const { container } = await renderCard(media)
expect(container.querySelector('.music-card-placeholder .v-icon')).not.toBeNull() expect(container.querySelector('.media-card-placeholder .v-icon')).not.toBeNull()
expect(container.querySelector('img[src*="no-image"]')).toBeNull() expect(container.querySelector('img[src*="no-image"]')).toBeNull()
}) })
@@ -150,7 +150,7 @@ describe('SubscribeCard display and progress', () => {
await fireEvent.error(image as HTMLImageElement) await fireEvent.error(image as HTMLImageElement)
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('no-image')) await waitFor(() => expect(container.querySelector('.subscribe-card-placeholder')).toBeInTheDocument())
}) })
it('falls back from backdrop to poster for music subscriptions, then to the album placeholder', async () => { it('falls back from backdrop to poster for music subscriptions, then to the album placeholder', async () => {
@@ -166,7 +166,7 @@ describe('SubscribeCard display and progress', () => {
// 背景图与海报都缺失:渲染与音乐媒体卡片一致的胶片占位背景 // 背景图与海报都缺失:渲染与音乐媒体卡片一致的胶片占位背景
const { container } = await renderCard({ backdrop: undefined, poster: undefined, type: '音乐' }) const { container } = await renderCard({ backdrop: undefined, poster: undefined, type: '音乐' })
const placeholder = container.querySelector('.subscribe-card-music-placeholder') const placeholder = container.querySelector('.subscribe-card-placeholder')
expect(placeholder).toBeInTheDocument() expect(placeholder).toBeInTheDocument()
expect(placeholder?.querySelector('.v-icon, [class*="mdi-album"]')).not.toBeNull() expect(placeholder?.querySelector('.v-icon, [class*="mdi-album"]')).not.toBeNull()
expect(container.querySelector('img')).toBeNull() expect(container.querySelector('img')).toBeNull()