mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 15:10:56 +08:00
feat: 优化音乐卡片与搜索站点筛选
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user