feat: 优化音乐搜索与详情页面 (#647)

Co-authored-by: traeagent <traeagent@users.noreply.github.com>
This commit is contained in:
jxxghp
2026-08-08 14:36:07 +08:00
committed by GitHub
parent 70bb6e1040
commit 693e6fb352
18 changed files with 788 additions and 821 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -54,7 +54,7 @@ function handleNavScroll(evt: Event) {
<ThemeLogoMark />
<h1 class="leading-normal text-xl">
<span class="moviepilot-wordmark">MOVIEPILOT</span> <span class="text-sm text-gray-500">v2</span>
<span class="moviepilot-wordmark">MOVIEPILOT</span> <span class="text-sm text-gray-500">v3</span>
</h1>
</RouterLink>
</slot>

View File

@@ -399,7 +399,12 @@ function setupIntersectionObserver() {
// 计算图片地址
const getImgUrl: Ref<string> = computed(() => {
if (props.media?.type === '音乐' && (!props.media?.poster_path || imageLoadError.value)) return ''
if (props.media?.type === '音乐') {
// 音乐封面优先使用 cover_urlListenBrainz/MusicBrainz 统计接口返回),回退到 poster_path
const musicCover = props.media?.cover_url || props.media?.poster_path
if (!musicCover || imageLoadError.value) return ''
return getDisplayImageUrl(musicCover, globalSettings.GLOBAL_IMAGE_CACHE)
}
if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)

View File

@@ -412,7 +412,6 @@ export default {
},
music: {
title: 'Music Search',
resultsFor: 'Music results for "{query}"',
searchFromGlobal: 'Use the global search at the top to look up a song, album or artist',
searchDescription: 'Search songs, albums or artists',
noResults: 'No matching music found',
@@ -478,7 +477,6 @@ export default {
upcoming: 'Upcoming',
all: 'All',
withCover: 'With Cover Only',
minListenCount: 'Minimum Listens',
},
},
settingTabs: {

View File

@@ -404,7 +404,6 @@ export default {
},
music: {
title: '音乐搜索',
resultsFor: '“{query}”的音乐搜索结果',
searchFromGlobal: '在顶部全局搜索中输入歌曲、专辑或艺术家进入音乐搜索',
searchDescription: '搜索歌曲、专辑或艺术家',
noResults: '没有找到匹配的音乐',
@@ -470,7 +469,6 @@ export default {
upcoming: '即将发行',
all: '全部',
withCover: '仅有封面',
minListenCount: '最低收听次数',
},
},
settingTabs: {

View File

@@ -404,7 +404,6 @@ export default {
},
music: {
title: '音樂搜尋',
resultsFor: '「{query}」的音樂搜尋結果',
searchFromGlobal: '在頂部全域搜尋中輸入歌曲、專輯或藝術家進入音樂搜尋',
searchDescription: '搜尋歌曲、專輯或藝術家',
noResults: '沒有找到符合的音樂',
@@ -470,7 +469,6 @@ export default {
upcoming: '即將發行',
all: '全部',
withCover: '僅有封面',
minListenCount: '最低收聽次數',
},
},
settingTabs: {

View File

@@ -2,6 +2,7 @@ import MusicArtistPage from '@/pages/music-artist.vue'
import { screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
@@ -13,6 +14,12 @@ vi.mock('@/api', () => ({
},
}))
// 桩组件回显标题与“更多”链接,用于校验影视详情页对齐后的瀑布浏览入口
const MediaCardSlideViewStub = defineComponent({
props: ['apipath', 'title', 'linkurl'],
template: '<a v-if="linkurl" :href="linkurl">{{ title }}</a><span v-else>{{ title }}</span>',
})
const artist = {
aliases: ['皇后乐队'],
area: 'United Kingdom',
@@ -36,7 +43,7 @@ function renderArtistPage() {
return renderWithProviders(MusicArtistPage, {
initialRoute: '/music/artist?source=musicbrainz&mediaid=artist-1',
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true } },
})
}

View File

@@ -40,7 +40,7 @@ watch(query, searchMusic, { immediate: true })
<template>
<div class="music-search-page">
<VPageContentTitle :title="query ? t('music.resultsFor', { query }) : t('music.title')" />
<VPageContentTitle :title="query || t('music.title')" />
<VSkeletonLoader v-if="loading" type="card, card, card" />
<VRow v-else-if="results.length">

View File

@@ -196,6 +196,9 @@ async function resolveRefreshSearchParams() {
// 查询TMDBID或标题
const keyword = computed(() => activeSearchParams.value.keyword)
// 媒体 ID 形式的关键词(如 musicbrainz:xxx、tmdb:xxx对用户无意义进度卡片中仅展示标题即可。
const isMediaIdKeyword = computed(() => /^[a-zA-Z]+:/.test(keyword.value || ''))
// 查询类型
const type = computed(() => activeSearchParams.value.type)
@@ -1382,7 +1385,7 @@ onUnmounted(() => {
<div class="progress-copy">
<span class="progress-title">{{ progressText }}</span>
<div v-if="hasSearchTags" class="progress-tags d-flex flex-wrap">
<VChip v-if="keyword" class="search-tag progress-tag" color="primary" size="small" variant="tonal">
<VChip v-if="keyword && !isMediaIdKeyword" class="search-tag progress-tag" color="primary" size="small" variant="tonal">
{{ t('resource.keyword') }}: {{ keyword }}
</VChip>
<VChip v-if="title" class="search-tag progress-tag" color="primary" size="small" variant="tonal">

View File

@@ -224,16 +224,11 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
</template>
<div v-if="primaryArtistId" class="music-section">
<div class="slider-header">
<RouterLink
:to="buildMusicArtistRoute(primaryArtistId, artistLinks[0]?.name, props.source)"
class="slider-title"
>
<span>{{ t('music.artistAlbums') }}</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
</div>
<MediaCardSlideView :apipath="`music/artist/${primaryArtistId}/albums`" />
<MediaCardSlideView
:apipath="`music/artist/${primaryArtistId}/albums`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?title=${encodeURIComponent(t('music.artistAlbums'))}`"
:title="t('music.artistAlbums')"
/>
</div>
<div v-if="primaryArtistId" class="music-section">
@@ -312,6 +307,8 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
.music-section-title {
font-size: 1.25rem;
font-weight: 700;
margin-block: 1.5rem 0.5rem;
line-height: 1.75rem;
margin: 0;
padding-block: 1rem;
}
</style>

View File

@@ -10,8 +10,13 @@ const props = defineProps({
apipath: String,
// 区块标题
title: String,
// 瀑布浏览全部的路由,对齐影视详情页的“更多”入口
linkurl: String,
})
// 提供给 VirtualSlideView 的标题与“更多”链接,避免再渲染一层重复标题
provide('rankingPropsKey', reactive({ ...props }))
// 组件是否已经完成首次加载
const componentLoaded = ref(false)
@@ -64,9 +69,6 @@ onActivated(() => {
<template>
<div ref="containerRef">
<div v-if="props.title && (!componentLoaded || dataList.length > 0)" class="slider-header">
<span class="slider-title">{{ props.title }}</span>
</div>
<VirtualSlideView
v-if="!componentLoaded || dataList.length > 0"
:items="dataList"

View File

@@ -160,13 +160,11 @@ watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true }
</template>
<div v-for="section in albumSections" :key="section.type" class="music-section">
<div class="slider-header">
<RouterLink :to="getAlbumsBrowseRoute(section.type, section.title)" class="slider-title">
<span>{{ section.title }}</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
</div>
<MediaCardSlideView :apipath="`music/artist/${props.mediaid}/albums?album_type=${section.type}`" />
<MediaCardSlideView
:apipath="`music/artist/${props.mediaid}/albums?album_type=${section.type}`"
:linkurl="getAlbumsBrowseRoute(section.type, section.title)"
:title="section.title"
/>
</div>
<div class="music-section">

View File

@@ -235,17 +235,7 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
</template>
<div v-if="albumTracks.length" class="music-section">
<div class="slider-header">
<RouterLink
v-if="music.album_id"
:to="buildMusicAlbumRoute(music.album_id, music.album, props.source)"
class="slider-title"
>
<span>{{ t('music.albumTracks') }}</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
<span v-else class="slider-title">{{ t('music.albumTracks') }}</span>
</div>
<h2 class="music-section-heading">{{ t('music.albumTracks') }}</h2>
<MusicTrackList :tracks="albumTracks" :active-media-id="music.media_id" hide-artists />
</div>
@@ -254,16 +244,11 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
</div>
<div v-if="primaryArtistId" class="music-section">
<div class="slider-header">
<RouterLink
:to="buildMusicArtistRoute(primaryArtistId, artistLinks[0]?.name, props.source)"
class="slider-title"
>
<span>{{ t('music.artistAlbums') }}</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
</div>
<MediaCardSlideView :apipath="`music/artist/${primaryArtistId}/albums`" />
<MediaCardSlideView
:apipath="`music/artist/${primaryArtistId}/albums`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?title=${encodeURIComponent(t('music.artistAlbums'))}`"
:title="t('music.artistAlbums')"
/>
</div>
</MusicDetailLayout>
<NoDataFound v-else error-code="404" :error-title="t('music.noResults')" :error-description="t('error.networkError')">
@@ -321,4 +306,13 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
.music-section {
margin-block-start: 2rem;
}
/* 章节标题对齐影视详情页h2 + 上下留白,避免曲目列表贴着标题 */
.music-section-heading {
font-size: 1.25rem;
font-weight: 700;
line-height: 1.75rem;
margin: 0;
padding-block: 1rem;
}
</style>

View File

@@ -26,7 +26,6 @@ const freshDays = ref(14)
const freshScope = ref<'all' | 'past' | 'future'>('all')
const coverFilter = ref('all')
const minListenCount = ref(0)
const currentKey = ref(0)
const modeOptions = computed(() => ({
@@ -92,20 +91,19 @@ const filterParams = computed(() => {
params.entity = entity.value
params.range_name = rangeName.value
params.sort_by = sortBy.value
params.min_listen_count = Math.max(0, minListenCount.value || 0)
return params
})
watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverFilter, minListenCount], () => {
watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverFilter], () => {
currentKey.value++
})
</script>
<template>
<div class="px-3 music-explore-filters">
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.mode') }}</VLabel>
<VChipGroup v-model="mode" mandatory>
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.mode') }}</VLabel>
<VChipGroup v-model="mode" mandatory class="music-filter-chips">
<VChip v-for="(label, value) in modeOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
@@ -113,25 +111,25 @@ watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverF
</div>
<template v-if="mode === 'chart'">
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.entity') }}</VLabel>
<VChipGroup v-model="entity" mandatory>
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.entity') }}</VLabel>
<VChipGroup v-model="entity" mandatory class="music-filter-chips">
<VChip v-for="(label, value) in entityOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.period') }}</VLabel>
<VChipGroup v-model="rangeName" mandatory>
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.period') }}</VLabel>
<VChipGroup v-model="rangeName" mandatory class="music-filter-chips">
<VChip v-for="(label, value) in rangeOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.sort') }}</VLabel>
<VChipGroup v-model="sortBy" mandatory>
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.sort') }}</VLabel>
<VChipGroup v-model="sortBy" mandatory class="music-filter-chips">
<VChip v-for="(label, value) in sortOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
@@ -140,17 +138,17 @@ watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverF
</template>
<template v-else>
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.sort') }}</VLabel>
<VChipGroup v-model="freshSort" mandatory>
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.sort') }}</VLabel>
<VChipGroup v-model="freshSort" mandatory class="music-filter-chips">
<VChip v-for="(label, value) in freshSortOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.scope') }}</VLabel>
<VChipGroup v-model="freshScope" mandatory>
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.scope') }}</VLabel>
<VChipGroup v-model="freshScope" mandatory class="music-filter-chips">
<VChip v-for="(label, value) in freshScopeOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
@@ -167,34 +165,48 @@ watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverF
</div>
</template>
<div class="d-flex flex-wrap align-center ga-3 mb-3">
<VLabel>{{ t('music.filter.cover') }}</VLabel>
<VChipGroup v-model="coverFilter" mandatory>
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.cover') }}</VLabel>
<VChipGroup v-model="coverFilter" mandatory class="music-filter-chips">
<VChip value="all" filter tile>{{ t('music.filter.all') }}</VChip>
<VChip value="with_cover" filter tile>{{ t('music.filter.withCover') }}</VChip>
</VChipGroup>
<VTextField
v-if="mode === 'chart'"
v-model.number="minListenCount"
:label="t('music.filter.minListenCount')"
type="number"
min="0"
density="compact"
variant="outlined"
hide-details
class="music-listen-count-filter"
/>
</div>
</div>
<MediaCardListView :key="currentKey" apipath="music/explore" :params="filterParams" />
</template>
<style scoped>
.music-listen-count-filter {
max-inline-size: 14rem;
.music-filter-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-block-end: 0.5rem;
}
.music-filter-label {
flex: 0 0 auto;
}
/* 周期等筛选项在小屏幕下允许左右滑动,但不换行 */
.music-filter-chips {
flex: 1 1 0%;
min-inline-size: 0;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.music-filter-chips::-webkit-scrollbar {
display: none;
}
.music-filter-chips :deep(.v-chip) {
flex: 0 0 auto;
}
.music-days-filter {
flex: 0 0 auto;
max-inline-size: 10rem;
}
</style>

View File

@@ -36,15 +36,12 @@ describe('MusicView', () => {
await user.click(screen.getByText('过去一季'))
await user.click(screen.getByText('收听最少'))
await user.click(screen.getByText('仅有封面'))
await user.clear(screen.getByLabelText('最低收听次数'))
await user.type(screen.getByLabelText('最低收听次数'), '100')
const params = screen.getByTestId('music-params')
expect(params).toHaveTextContent('"entity":"album"')
expect(params).toHaveTextContent('"range_name":"quarter"')
expect(params).toHaveTextContent('"sort_by":"listen_count.asc"')
expect(params).toHaveTextContent('"with_cover":true')
expect(params).toHaveTextContent('"min_listen_count":100')
})
it('switches to the official fresh releases mode with its own sort options', async () => {
@@ -55,7 +52,6 @@ describe('MusicView', () => {
expect(screen.getByText('艺术家')).toBeInTheDocument()
expect(screen.getByText('专辑名称')).toBeInTheDocument()
expect(screen.queryByLabelText('最低收听次数')).not.toBeInTheDocument()
await user.click(screen.getByText('艺术家'))
await user.click(screen.getByText('即将发行'))

1413
yarn.lock

File diff suppressed because it is too large Load Diff