mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 16:13:28 +08:00
feat: 优化音乐搜索与详情页面 (#647)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
This commit is contained in:
Binary file not shown.
BIN
.trae-html-share-packages/index.html.zip
Normal file
BIN
.trae-html-share-packages/index.html.zip
Normal file
Binary file not shown.
BIN
.trae-html-share-packages/public/offline.html.zip
Normal file
BIN
.trae-html-share-packages/public/offline.html.zip
Normal file
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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_url(ListenBrainz/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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -404,7 +404,6 @@ export default {
|
||||
},
|
||||
music: {
|
||||
title: '音乐搜索',
|
||||
resultsFor: '“{query}”的音乐搜索结果',
|
||||
searchFromGlobal: '在顶部全局搜索中输入歌曲、专辑或艺术家进入音乐搜索',
|
||||
searchDescription: '搜索歌曲、专辑或艺术家',
|
||||
noResults: '没有找到匹配的音乐',
|
||||
@@ -470,7 +469,6 @@ export default {
|
||||
upcoming: '即将发行',
|
||||
all: '全部',
|
||||
withCover: '仅有封面',
|
||||
minListenCount: '最低收听次数',
|
||||
},
|
||||
},
|
||||
settingTabs: {
|
||||
|
||||
@@ -404,7 +404,6 @@ export default {
|
||||
},
|
||||
music: {
|
||||
title: '音樂搜尋',
|
||||
resultsFor: '「{query}」的音樂搜尋結果',
|
||||
searchFromGlobal: '在頂部全域搜尋中輸入歌曲、專輯或藝術家進入音樂搜尋',
|
||||
searchDescription: '搜尋歌曲、專輯或藝術家',
|
||||
noResults: '沒有找到符合的音樂',
|
||||
@@ -470,7 +469,6 @@ export default {
|
||||
upcoming: '即將發行',
|
||||
all: '全部',
|
||||
withCover: '僅有封面',
|
||||
minListenCount: '最低收聽次數',
|
||||
},
|
||||
},
|
||||
settingTabs: {
|
||||
|
||||
@@ -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 } },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('即将发行'))
|
||||
|
||||
Reference in New Issue
Block a user