feat(music): support album subscription semantics

This commit is contained in:
jxxghp
2026-08-09 20:43:33 +08:00
parent dd6f644f05
commit 98a9f87768
11 changed files with 182 additions and 21 deletions
+9 -5
View File
@@ -25,6 +25,7 @@ const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
const isActionableMedia = computed(() => props.music?.music_type !== 'artist')
// 封面加载失败后改用专辑占位图标
const imageLoadError = ref(false)
@@ -90,7 +91,7 @@ const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
/** 查询当前音乐是否已订阅,用于决定心形图标是实心还是空心。 */
async function checkSubscribeStatus() {
if (!canSubscribe.value || !props.music?.media_id) return
if (!isActionableMedia.value || !canSubscribe.value || !props.music?.media_id) return
try {
isSubscribed.value = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(), () =>
subscribeActions.checkSubscribe(null),
@@ -161,7 +162,7 @@ onMounted(checkSubscribeStatus)
</VChip>
</div>
<div class="music-card-supporting text-medium-emphasis">
<div v-if="props.music?.music_type !== 'artist'" class="music-card-supporting text-medium-emphasis">
<VIcon icon="mdi-account-music" size="16" />
<div class="music-card-artists">
<template v-if="artistLinks.length">
@@ -183,7 +184,10 @@ onMounted(checkSubscribeStatus)
</div>
</div>
<div v-if="props.music?.album" class="music-card-supporting text-medium-emphasis">
<div
v-if="props.music?.album && props.music?.music_type !== 'album'"
class="music-card-supporting text-medium-emphasis"
>
<VIcon icon="mdi-album" size="16" />
<div class="music-card-album">
<span>{{ t('music.album') }}</span>
@@ -216,7 +220,7 @@ onMounted(checkSubscribeStatus)
<div class="music-card-actions">
<IconBtn
v-if="canSubscribe"
v-if="canSubscribe && isActionableMedia"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'medium-emphasis'"
:aria-label="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
@@ -226,7 +230,7 @@ onMounted(checkSubscribeStatus)
@click.stop="subscribeActions.handleSubscribe()"
/>
<IconBtn
v-if="canSearch"
v-if="canSearch && isActionableMedia"
icon="mdi-magnify"
color="primary"
:aria-label="t('music.searchResources')"
+21 -3
View File
@@ -136,6 +136,12 @@ const subscribeProgressText = computed(() => {
return `${downloadedEpisode.value} / ${total}`
})
// 专辑订阅按整包完成,不伪造分集进度;这里只展示整专规模,提醒用户该目标包含多首曲目。
const musicAlbumTrackText = computed(() => {
if (props.media?.type !== '音乐' || props.media?.music_type !== 'album' || !props.media?.total_tracks) return ''
return `${t('music.entityAlbum')} · ${t('music.trackCount', { count: props.media.total_tracks })}`
})
// 订阅卡片 hover 文案:
// - 普通订阅:「已下载 X · 共 Y 集」
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
@@ -634,8 +640,11 @@ function handleCardClick() {
:data-subscribe-state-icon="compactStateDisplay.icon"
size="16"
/>
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
{{ subscribeProgressText }}
<span
v-if="subscribeProgressText || musicAlbumTrackText"
class="subscribe-card-mobile-progress-text"
>
{{ subscribeProgressText || musicAlbumTrackText }}
</span>
</div>
@@ -699,7 +708,9 @@ function handleCardClick() {
</div>
</div>
</VCardText>
<VCardText class="absolute inset-x-0 bottom-2 z-10 flex min-w-0 justify-space-between align-center flex-wrap px-3">
<VCardText
class="absolute inset-x-0 bottom-2 z-10 flex min-w-0 justify-space-between align-center flex-wrap px-3"
>
<div class="flex min-w-0 max-w-full align-center">
<VIcon
v-if="props.media?.total_episode && props.sortable"
@@ -722,6 +733,13 @@ function handleCardClick() {
{{ subscribeProgressTooltip }}
</VTooltip>
</div>
<div
v-else-if="musicAlbumTrackText"
class="flex flex-shrink-0 align-center text-subtitle-2 me-2 text-white"
>
<VIcon icon="mdi-album" size="small" class="me-1" />
{{ musicAlbumTrackText }}
</div>
<VIcon
v-if="props.media?.username && props.sortable"
icon="mdi-account"
@@ -138,7 +138,7 @@ describe('SubscribeCard display and progress', () => {
expect((image as HTMLImageElement).src).toContain('system/cache/image?url=')
expect((image as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop || ''))
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
})
it('uses the poster as the background fallback and replaces failed images with the placeholder', async () => {
@@ -172,6 +172,21 @@ describe('SubscribeCard display and progress', () => {
expect(container.querySelector('img')).toBeNull()
})
it.each([480, 1024])('shows whole-album track count without fake episode progress at %ipx', async width => {
setViewport(width)
await renderCard({
music_type: 'album',
name: '叶惠美',
total_episode: undefined,
total_tracks: 11,
type: '音乐',
})
expect(screen.getByText('专辑 · 11 首')).toBeInTheDocument()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
})
it.each([
['regular progress', 10, 4, '6 / 10', '60'],
['negative missing episodes', 10, -2, '10 / 10', '100'],
@@ -181,7 +196,7 @@ describe('SubscribeCard display and progress', () => {
await renderCard({ lack_episode: lackEpisode, season: 2, total_episode: totalEpisode, type: '电视剧' })
if (expectedText) expect(screen.getByText(expectedText)).toBeInTheDocument()
else expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
else expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
if (expectedProgress) expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', expectedProgress)
else expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()