feat: 优化音乐卡片与搜索站点筛选

This commit is contained in:
jxxghp
2026-08-09 18:12:29 +08:00
parent d1d46353dd
commit 6db19ee6f7
16 changed files with 602 additions and 158 deletions
+3 -2
View File
@@ -122,10 +122,11 @@ function openSearchSiteDialog() {
)
}
// 查询所有站点
// 查询与当前媒体类型兼容的站点
async function querySites() {
try {
const data: Site[] = await api.get('site/')
const mediaType = props.media?.type === '电视剧' ? 'tv' : props.media?.type === '音乐' ? 'music' : 'movie'
const data: Site[] = await api.get(`site/media/${mediaType}`)
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active)
+306 -92
View File
@@ -4,6 +4,7 @@ import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { getCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
import {
buildMusicAlbumRoute,
buildMusicArtistRoute,
@@ -34,23 +35,44 @@ const isSubscribed = ref(false)
// 可点击跳转的艺术家
const artistLinks = computed(() => getMusicArtistLinks(props.music))
// 卡片上展示的元数据标签,只保留 MusicBrainz 实际返回的字段
const metaChips = computed(() => {
const chips: string[] = []
// 音乐实体标签和图标
const entityMeta = computed(() => {
const entities = {
album: { icon: 'mdi-album', label: t('music.entityAlbum') },
artist: { icon: 'mdi-account-music', label: t('music.entityArtist') },
recording: { icon: 'mdi-music-note', label: t('music.entityRecording') },
}
return entities[props.music?.music_type || 'recording']
})
// 卡片上展示的元数据,只保留 MusicBrainz 实际返回的字段
const metaItems = computed(() => {
const items: { hideOnNarrow?: boolean; icon: string; label: string }[] = []
const category = props.music?.category || props.music?.album_type
if (category) chips.push(category)
if (category) items.push({ hideOnNarrow: true, icon: 'mdi-label-outline', label: category })
const releaseDate = props.music?.release_date || props.music?.year?.toString()
if (releaseDate) chips.push(releaseDate)
if (releaseDate) items.push({ icon: 'mdi-calendar-blank-outline', label: releaseDate })
const duration = formatMusicDuration(props.music?.duration)
if (duration) chips.push(duration)
if (props.music?.track_number) chips.push(t('music.trackNumber', { number: props.music.track_number }))
if (duration) items.push({ icon: 'mdi-clock-outline', label: duration })
if (props.music?.track_number)
items.push({
hideOnNarrow: true,
icon: 'mdi-counter',
label: t('music.trackNumber', { number: props.music.track_number }),
})
if (props.music?.listen_count)
chips.push(t('music.listenCountValue', { count: props.music.listen_count.toLocaleString() }))
return chips
items.push({
hideOnNarrow: true,
icon: 'mdi-chart-line',
label: t('music.listenCountValue', { count: props.music.listen_count.toLocaleString() }),
})
return items
})
const coverUrl = computed(() => props.music?.cover_url || props.music?.poster_path || '')
const showCover = computed(() => Boolean(coverUrl.value) && !imageLoadError.value)
/** 生成订阅状态缓存键。 */
function getSubscribeStatusKey() {
return `${getMediaSubscribeId(props.music)}::all`
}
@@ -62,6 +84,10 @@ const subscribeActions = useMediaSubscribe({
getSubscribeStatusKey,
})
const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
props.music ? buildMusicResourceRoute(props.music, sites) : undefined,
)
/** 查询当前音乐是否已订阅,用于决定心形图标是实心还是空心。 */
async function checkSubscribeStatus() {
if (!canSubscribe.value || !props.music?.media_id) return
@@ -92,12 +118,12 @@ function goArtist(artistId?: string, name?: string) {
router.push(buildMusicArtistRoute(artistId, name, props.music?.source))
}
/** 使用音乐元数据身份进入站点资源精确搜索页。 */
function goResource() {
if (!props.music) return
const target = buildMusicResourceRoute(props.music)
if (target) router.push(target)
}
watch(
() => coverUrl.value,
() => {
imageLoadError.value = false
},
)
watch(() => props.music?.media_id, checkSubscribeStatus)
@@ -105,99 +131,208 @@ onMounted(checkSubscribeStatus)
</script>
<template>
<VCard class="music-card h-100 cursor-pointer" @click="goDetail">
<div class="d-flex pa-4 ga-4">
<VImg
v-if="coverUrl && !imageLoadError"
:src="coverUrl"
width="104"
height="104"
cover
rounded="lg"
class="flex-grow-0 music-card-cover"
@error="imageLoadError = true"
/>
<VSheet v-else width="104" height="104" rounded="lg" class="d-flex align-center justify-center flex-grow-0">
<VIcon icon="mdi-album" size="48" color="medium-emphasis" />
</VSheet>
<VHover>
<template #default="hover">
<div v-bind="hover.props" class="music-card-hover-area h-100">
<VCard
class="music-card app-hover-lift-card h-100 cursor-pointer"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
@click="goDetail"
>
<div class="music-card-content">
<div class="music-card-cover-shell">
<VImg v-if="showCover" :src="coverUrl" cover class="music-card-cover" @error="imageLoadError = true">
<template #placeholder>
<VSkeletonLoader class="h-100" />
</template>
</VImg>
<VIcon v-else :icon="entityMeta.icon" size="44" color="medium-emphasis" />
<div class="music-card-body flex-grow-1">
<div class="d-flex align-start ga-2">
<div class="music-card-title text-h6">{{ props.music?.title }}</div>
<VChip v-if="props.music?.version" size="x-small" variant="tonal" class="flex-grow-0 mt-1">
{{ props.music.version }}
</VChip>
</div>
<VChip :prepend-icon="entityMeta.icon" size="x-small" variant="flat" class="music-card-entity">
{{ entityMeta.label }}
</VChip>
</div>
<div class="text-body-2 text-medium-emphasis music-card-artists">
<template v-if="artistLinks.length">
<template v-for="(artist, index) in artistLinks" :key="`${artist.name}-${index}`">
<span v-if="index > 0"> / </span>
<a
v-if="artist.id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goArtist(artist.id, artist.name)"
@keydown.enter.stop="goArtist(artist.id, artist.name)"
>{{ artist.name }}</a
>
<span v-else>{{ artist.name }}</span>
</template>
</template>
<span v-else>{{ t('common.unknown') }}</span>
</div>
<div class="music-card-body">
<div class="music-card-heading">
<div class="music-card-title" :title="props.music?.title">{{ props.music?.title }}</div>
<VChip v-if="props.music?.version" size="x-small" variant="tonal" class="music-card-version">
{{ props.music.version }}
</VChip>
</div>
<div v-if="props.music?.album" class="text-caption text-medium-emphasis mt-1 music-card-album">
{{ t('music.album') }}
<a
v-if="props.music.album_id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goAlbum"
@keydown.enter.stop="goAlbum"
>{{ props.music.album }}</a
>
<span v-else>{{ props.music.album }}</span>
</div>
<div class="music-card-supporting text-medium-emphasis">
<VIcon icon="mdi-account-music" size="16" />
<div class="music-card-artists">
<template v-if="artistLinks.length">
<template v-for="(artist, index) in artistLinks" :key="`${artist.name}-${index}`">
<span v-if="index > 0"> / </span>
<a
v-if="artist.id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goArtist(artist.id, artist.name)"
@keydown.enter.stop="goArtist(artist.id, artist.name)"
>{{ artist.name }}</a
>
<span v-else>{{ artist.name }}</span>
</template>
</template>
<span v-else>{{ t('common.unknown') }}</span>
</div>
</div>
<div class="d-flex flex-wrap ga-2 mt-3">
<VChip v-for="chip in metaChips" :key="chip" size="small" variant="tonal">{{ chip }}</VChip>
</div>
<div v-if="props.music?.album" class="music-card-supporting text-medium-emphasis">
<VIcon icon="mdi-album" size="16" />
<div class="music-card-album">
<span>{{ t('music.album') }}</span>
<a
v-if="props.music.album_id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goAlbum"
@keydown.enter.stop="goAlbum"
>{{ props.music.album }}</a
>
<span v-else>{{ props.music.album }}</span>
</div>
</div>
<div class="music-card-footer">
<div class="music-card-meta">
<VChip
v-for="item in metaItems"
:key="`${item.icon}-${item.label}`"
:prepend-icon="item.icon"
:class="{ 'music-card-meta-item--narrow-optional': item.hideOnNarrow }"
size="x-small"
variant="tonal"
>
{{ item.label }}
</VChip>
</div>
<div class="music-card-actions">
<IconBtn
v-if="canSubscribe"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'medium-emphasis'"
:aria-label="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
:title="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
size="small"
variant="tonal"
@click.stop="subscribeActions.handleSubscribe()"
/>
<IconBtn
v-if="canSearch"
icon="mdi-magnify"
color="primary"
:aria-label="t('music.searchResources')"
:title="t('music.searchResources')"
size="small"
variant="tonal"
@click.stop="openMusicSiteSearch"
/>
</div>
</div>
</div>
</div>
</VCard>
</div>
<div class="music-card-actions d-flex flex-column align-center ga-1">
<IconBtn
v-if="canSubscribe"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'medium-emphasis'"
:aria-label="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
@click.stop="subscribeActions.handleSubscribe()"
/>
<IconBtn
v-if="canSearch"
icon="mdi-magnify"
color="medium-emphasis"
:aria-label="t('music.searchResources')"
@click.stop="goResource"
/>
</div>
</div>
</VCard>
</template>
</VHover>
</template>
<style scoped>
.music-card-hover-area {
inline-size: 100%;
}
.music-card {
min-block-size: 144px;
overflow: hidden;
}
.music-card-content {
display: grid;
block-size: 100%;
gap: 1rem;
grid-template-columns: 112px minmax(0, 1fr);
padding: 1rem;
}
.music-card-cover-shell {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
align-self: start;
justify-content: center;
aspect-ratio: 1;
background: rgba(var(--v-theme-on-surface), 0.06);
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 8px;
inline-size: 112px;
}
.music-card-cover {
block-size: 100%;
inline-size: 100%;
transition: transform 0.3s ease;
}
.music-card.app-hover-lift-card--hovering .music-card-cover {
transform: scale(1.035);
}
.music-card-entity {
position: absolute;
z-index: 1;
background: rgba(var(--v-theme-surface), 0.88) !important;
color: rgb(var(--v-theme-on-surface)) !important;
inset-block-end: 0.5rem;
inset-inline-start: 0.5rem;
max-inline-size: calc(100% - 1rem);
}
.music-card-body {
display: flex;
flex-direction: column;
min-inline-size: 0;
}
.music-card-heading {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.music-card-title {
overflow: hidden;
display: -webkit-box;
color: rgb(var(--v-theme-on-surface));
font-size: 1rem;
font-weight: 600;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
letter-spacing: 0;
line-height: 1.4;
}
.music-card-version {
flex: 0 0 auto;
margin-block-start: 0.125rem;
}
.music-card-supporting {
display: flex;
align-items: center;
font-size: 0.8125rem;
gap: 0.375rem;
line-height: 1.35;
margin-block-start: 0.35rem;
}
.music-card-artists,
@@ -208,16 +343,95 @@ onMounted(checkSubscribeStatus)
}
.music-card-link {
color: rgb(var(--v-theme-primary));
color: inherit;
cursor: pointer;
font-weight: 500;
text-decoration: none;
transition: color 0.2s ease;
}
.music-card-link:hover {
text-decoration: underline;
color: rgb(var(--v-theme-primary));
}
.music-card-footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
margin-block-start: auto;
padding-block-start: 0.65rem;
}
.music-card-meta,
.music-card-actions {
display: flex;
align-items: center;
}
.music-card-meta {
flex: 1 1 auto;
flex-wrap: wrap;
gap: 0.3rem;
min-inline-size: 0;
}
.music-card-actions {
flex: 0 0 auto;
gap: 0.25rem;
}
@media (width <= 600px) {
.music-card {
min-block-size: 120px;
}
.music-card-content {
gap: 0.75rem;
grid-template-columns: 88px minmax(0, 1fr);
padding: 0.75rem;
}
.music-card-cover-shell {
inline-size: 88px;
}
.music-card-body {
position: relative;
}
.music-card-heading {
align-items: center;
min-block-size: 40px;
padding-inline-end: 5.5rem;
}
.music-card-entity {
inset-block-end: 0.375rem;
inset-inline-start: 0.375rem;
max-inline-size: calc(100% - 0.75rem);
}
.music-card-footer {
display: block;
padding-block-start: 0.5rem;
}
.music-card-actions {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
}
.music-card-actions :deep(.v-btn) {
block-size: 40px;
inline-size: 40px;
}
}
@media (width <= 360px) {
.music-card-meta-item--narrow-optional {
display: none;
}
}
</style>
@@ -27,7 +27,8 @@ vi.mock('@/router', () => ({
}))
const API_BASE_URL = 'http://localhost/api/v1/'
const siteListUrl = new URL('site/', API_BASE_URL).href
const movieSiteListUrl = new URL('site/media/movie', API_BASE_URL).href
const tvSiteListUrl = new URL('site/media/tv', API_BASE_URL).href
const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href
let intersectionObservers: IntersectionObserverMock[] = []
@@ -142,9 +143,13 @@ function getStatusObservers() {
}
/** 安装站点列表及已选站点的搜索请求处理器。 */
function installSearchHandlers(sites: Record<string, unknown>[], selected: number[]) {
function installSearchHandlers(
sites: Record<string, unknown>[],
selected: number[],
mediaType: 'movie' | 'tv' = 'movie',
) {
server.use(
http.get(siteListUrl, () => HttpResponse.json(sites)),
http.get(mediaType === 'tv' ? tvSiteListUrl : movieSiteListUrl, () => HttpResponse.json(sites)),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
)
}
@@ -359,7 +364,7 @@ describe('MediaCard', () => {
})
it('routes directly to resource search when no active sites are available', async () => {
installSearchHandlers([], [3, 5])
installSearchHandlers([], [3, 5], 'tv')
const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' })
const { container } = await renderCard(media)
@@ -385,7 +390,7 @@ describe('MediaCard', () => {
it('falls back to global search when site settings cannot provide active selections', async () => {
server.use(
http.get(siteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
http.get(movieSiteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
http.get(selectedSitesUrl, () => HttpResponse.json({ success: true })),
)
const media = createMediaInfo({ title: '站点失败搜索', tmdb_id: 9503 })
@@ -444,7 +449,7 @@ describe('MediaCard', () => {
it('opens active sites with an empty selection when the saved setting fails', async () => {
server.use(
http.get(siteListUrl, () =>
http.get(movieSiteListUrl, () =>
HttpResponse.json([
{
domain: 'fallback.example',