feat(music): add MusicAlbumView, MusicArtistView, and MusicDetailView components

- Implement MusicAlbumView for displaying album details, including tracks and artist links.
- Create MusicArtistView to show artist information and their albums.
- Develop MusicDetailView for individual music tracks, integrating album details and related artists.
- Introduce MusicDetailLayout for consistent layout across music-related views.
- Add MusicArtistSlideView for displaying related artists in a slide format.
- Enhance user experience with loading states and error handling in NoDataFound component.
This commit is contained in:
jxxghp
2026-08-08 12:56:22 +08:00
parent 02e3314853
commit 70bb6e1040
36 changed files with 3084 additions and 494 deletions
+115
View File
@@ -0,0 +1,115 @@
import {
buildMusicAlbumRoute,
buildMusicArtistRoute,
buildMusicDetailRoute,
buildMusicResourceRoute,
formatMusicDuration,
getMusicArtistLinks,
getMusicArtistSubtitle,
getMusicKey,
} from '@/utils/music'
import { describe, expect, it } from 'vitest'
describe('music utils', () => {
it('routes a recording to the music detail page', () => {
expect(buildMusicDetailRoute({ source: 'musicbrainz', media_id: 'recording-1', title: '晴天' })).toEqual({
path: '/music/detail',
query: { source: 'musicbrainz', mediaid: 'recording-1', title: '晴天' },
})
})
it('routes an album entity to the album page', () => {
expect(
buildMusicDetailRoute({
source: 'musicbrainz',
media_id: 'release-group-1',
music_type: 'album',
title: '叶惠美',
}),
).toEqual({
path: '/music/album',
query: { source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
})
})
it('routes an artist entity to the artist page', () => {
expect(
buildMusicDetailRoute({
source: 'musicbrainz',
media_id: 'artist-1',
music_type: 'artist',
name: 'Queen',
}),
).toEqual({
path: '/music/artist',
query: { source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
})
})
it('falls back to the search page without a standard identity', () => {
expect(buildMusicDetailRoute({ title: '晴天' })).toEqual({
path: '/music',
query: { query: '晴天' },
})
})
it('builds album and artist routes with the default source', () => {
expect(buildMusicAlbumRoute('release-group-1', '叶惠美')).toEqual({
path: '/music/album',
query: { source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
})
expect(buildMusicArtistRoute('artist-1', 'Queen')).toEqual({
path: '/music/artist',
query: { source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
})
})
it('builds the site resource route from the metadata identity', () => {
expect(
buildMusicResourceRoute({
source: 'musicbrainz',
media_id: 'recording-1',
title: '晴天',
year: '2003',
} as never),
).toMatchObject({
path: '/resource',
query: { keyword: 'musicbrainz:recording-1', type: '音乐' },
})
})
it('keeps the entity type inside the list key so albums and tracks never collide', () => {
const track = getMusicKey({ source: 'musicbrainz', media_id: 'same-id' })
const album = getMusicKey({ source: 'musicbrainz', media_id: 'same-id', music_type: 'album' })
expect(track).not.toEqual(album)
})
it('pairs artist names with their standard ids by position', () => {
expect(getMusicArtistLinks({ artists: ['A', 'B'], artist_ids: ['', 'artist-b'] })).toEqual([
{ name: 'A' },
{ name: 'B', id: 'artist-b' },
])
})
it('falls back to the joined artist text when the list is missing', () => {
expect(getMusicArtistLinks({ artist: 'A / B' })).toEqual([{ name: 'A / B' }])
})
it('formats durations with and without hours', () => {
expect(formatMusicDuration(269)).toBe('4:29')
expect(formatMusicDuration(5)).toBe('0:05')
expect(formatMusicDuration(3725)).toBe('1:02:05')
expect(formatMusicDuration(undefined)).toBe('')
})
it('joins the artist relation, type and disambiguation into a subtitle', () => {
expect(
getMusicArtistSubtitle({
relation: 'member of band',
artist_type: 'Person',
disambiguation: 'guitarist',
}),
).toBe('member of band · Person · guitarist')
})
})
+58 -11
View File
@@ -1,15 +1,22 @@
import type { MediaInfo } from '@/api/types'
import type { MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicEntityType } from '@/api/types'
import type { RouteLocationRaw } from 'vue-router'
export interface MusicRouteTarget {
source?: string
media_source?: string
media_id?: string | number
music_type?: MusicEntityType
title?: string
name?: string
year?: string | number
}
// 音乐体系中一位可跳转的艺术家
export interface MusicArtistLink {
name: string
id?: string
}
/** 返回音乐对象可用于路由和订阅的统一来源。 */
export function getMusicSource(item: MusicRouteTarget): string | undefined {
return item.source || item.media_source
@@ -18,31 +25,42 @@ export function getMusicSource(item: MusicRouteTarget): string | undefined {
/** 返回音乐候选在列表和状态缓存中的稳定身份。 */
export function getMusicKey(item: MusicRouteTarget): string {
const source = getMusicSource(item) || 'music'
return `${source}:${item.media_id || `${item.title || item.name}-${item.year || ''}`}`
const entity = item.music_type || 'recording'
return `${source}:${entity}:${item.media_id || `${item.title || item.name}-${item.year || ''}`}`
}
/** 构造音乐详情路由,缺少标准身份时回退到音乐搜索页。 */
/** 构造专辑详情路由。 */
export function buildMusicAlbumRoute(albumId: string, title?: string, source = 'musicbrainz'): RouteLocationRaw {
return { path: '/music/album', query: { source, mediaid: albumId, title } }
}
/** 构造艺术家详情路由。 */
export function buildMusicArtistRoute(artistId: string, name?: string, source = 'musicbrainz'): RouteLocationRaw {
return { path: '/music/artist', query: { source, mediaid: artistId, title: name } }
}
/** 按音乐实体类型构造详情路由,缺少标准身份时回退到音乐搜索页。 */
export function buildMusicDetailRoute(item: MusicRouteTarget): RouteLocationRaw {
const source = getMusicSource(item)
if (!source || !item.media_id) {
return {
path: '/music',
query: { query: item.title || item.name },
}
const mediaId = item.media_id?.toString()
if (!source || !mediaId) {
return { path: '/music', query: { query: item.title || item.name } }
}
if (item.music_type === 'album') return buildMusicAlbumRoute(mediaId, item.title || item.name, source)
if (item.music_type === 'artist') return buildMusicArtistRoute(mediaId, item.name || item.title, source)
return {
path: '/music/detail',
query: {
source,
mediaid: item.media_id.toString(),
mediaid: mediaId,
title: item.title || item.name,
},
}
}
/** 构造音乐元数据身份对应的站点资源搜索路由。 */
export function buildMusicResourceRoute(item: MediaInfo): RouteLocationRaw | undefined {
const source = getMusicSource(item)
export function buildMusicResourceRoute(item: MediaInfo | MusicAlbumInfo): RouteLocationRaw | undefined {
const source = getMusicSource(item as MusicRouteTarget)
if (!source || !item.media_id) return undefined
return {
path: '/resource',
@@ -56,3 +74,32 @@ export function buildMusicResourceRoute(item: MediaInfo): RouteLocationRaw | und
},
}
}
/** 把音乐信息整理成可点击跳转的艺术家列表,ID 与名称按下标对应。 */
export function getMusicArtistLinks(
item?: Pick<MediaInfo, 'artists' | 'artist_ids' | 'artist'> | MusicAlbumInfo,
): MusicArtistLink[] {
if (!item) return []
const names = item.artists?.length ? item.artists : item.artist ? [item.artist] : []
return names.map((name, index) => {
const id = item.artist_ids?.[index]
return id ? { name, id } : { name }
})
}
/** 把秒数格式化为音乐展示使用的时长文本。 */
export function formatMusicDuration(seconds?: number): string {
if (!seconds || seconds <= 0) return ''
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainder = Math.floor(seconds % 60)
const paddedSeconds = remainder.toString().padStart(2, '0')
if (!hours) return `${minutes}:${paddedSeconds}`
return `${hours}:${minutes.toString().padStart(2, '0')}:${paddedSeconds}`
}
/** 返回艺术家卡片和详情页展示用的副标题。 */
export function getMusicArtistSubtitle(artist?: MusicArtistInfo): string {
if (!artist) return ''
return [artist.relation, artist.artist_type, artist.disambiguation].filter(Boolean).join(' · ')
}