From 6db19ee6f7d0b3c9e1fb6a960de1cd097dafbaa3 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 9 Aug 2026 18:12:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=8E=E6=90=9C=E7=B4=A2=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/cards/MediaCard.vue | 5 +- src/components/cards/MusicCard.vue | 398 ++++++++++++++---- .../cards/__tests__/MediaCard.spec.ts | 17 +- src/composables/useMusicSiteSearch.ts | 83 ++++ src/pages/__tests__/music-album.spec.ts | 36 ++ src/pages/__tests__/music-artist.spec.ts | 32 +- src/pages/__tests__/music-detail.spec.ts | 26 +- src/pages/__tests__/music.spec.ts | 42 +- src/pages/music.vue | 18 +- src/utils/__tests__/music.spec.ts | 17 +- src/utils/music.ts | 6 +- src/views/discover/MediaDetailView.vue | 5 +- src/views/discover/MusicAlbumView.vue | 14 +- src/views/discover/MusicArtistView.vue | 34 +- src/views/discover/MusicDetailView.vue | 14 +- .../__tests__/MediaDetailView.spec.ts | 13 +- 16 files changed, 602 insertions(+), 158 deletions(-) create mode 100644 src/composables/useMusicSiteSearch.ts diff --git a/src/components/cards/MediaCard.vue b/src/components/cards/MediaCard.vue index b32fd604..8c8e568f 100644 --- a/src/components/cards/MediaCard.vue +++ b/src/components/cards/MediaCard.vue @@ -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) diff --git a/src/components/cards/MusicCard.vue b/src/components/cards/MusicCard.vue index d6c63ca7..fd29c85f 100644 --- a/src/components/cards/MusicCard.vue +++ b/src/components/cards/MusicCard.vue @@ -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) diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts index c004310e..5dfe41cb 100644 --- a/src/components/cards/__tests__/MediaCard.spec.ts +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -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[], selected: number[]) { +function installSearchHandlers( + sites: Record[], + 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', diff --git a/src/composables/useMusicSiteSearch.ts b/src/composables/useMusicSiteSearch.ts new file mode 100644 index 00000000..94988113 --- /dev/null +++ b/src/composables/useMusicSiteSearch.ts @@ -0,0 +1,83 @@ +import api from '@/api' +import type { Site } from '@/api/types' +import { openSharedDialog } from '@/composables/useSharedDialog' +import type { RouteLocationRaw } from 'vue-router' + +const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue')) + +type MusicResourceRouteBuilder = (sites: number[]) => RouteLocationRaw | undefined + +/** + * 统一处理音乐资源搜索前的站点加载、选择与跳转。 + * + * @param buildRoute 根据已选站点构造资源搜索路由 + */ +export function useMusicSiteSearch(buildRoute: MusicResourceRouteBuilder) { + const router = useRouter() + const musicSites = ref([]) + const selectedSites = ref([]) + let dialogController: ReturnType | undefined + + /** 查询已配置且支持音乐搜索的启用站点。 */ + async function queryMusicSites() { + musicSites.value = [] + try { + const sites: Site[] = await api.get('site/media/music') + musicSites.value = sites.filter(site => site.is_active) + } catch (error) { + console.error(error) + } + } + + /** 查询用户默认选择的索引站点。 */ + async function querySelectedSites() { + selectedSites.value = [] + try { + const result: { data?: { value?: number[] } } = await api.get('system/setting/public/IndexerSites') + selectedSites.value = result.data?.value ?? [] + } catch (error) { + console.error(error) + } + } + + /** 重新加载音乐站点,并同步更新已打开对话框的属性。 */ + async function reloadMusicSites() { + await queryMusicSites() + dialogController?.updateProps({ + sites: musicSites.value, + selected: selectedSites.value, + }) + } + + /** 使用用户确认的站点进入音乐资源搜索页。 */ + function searchSelectedSites(sites: number[]) { + if (!sites.length) return + selectedSites.value = sites + const target = buildRoute(sites) + if (target) router.push(target) + } + + /** 打开只包含音乐站点的站点选择对话框。 */ + function openMusicSiteDialog() { + dialogController = openSharedDialog( + SearchSiteDialog, + { + sites: musicSites.value, + selected: selectedSites.value, + }, + { + reload: reloadMusicSites, + search: searchSelectedSites, + }, + { closeOn: ['close', 'search'] }, + ) + } + + /** 加载音乐站点和默认选择后打开站点选择对话框。 */ + async function openMusicSiteSearch() { + await Promise.all([queryMusicSites(), querySelectedSites()]) + openMusicSiteDialog() + } + + return { openMusicSiteSearch } +} diff --git a/src/pages/__tests__/music-album.spec.ts b/src/pages/__tests__/music-album.spec.ts index 6971e786..6d525608 100644 --- a/src/pages/__tests__/music-album.spec.ts +++ b/src/pages/__tests__/music-album.spec.ts @@ -7,10 +7,15 @@ const mocks = vi.hoisted(() => ({ apiGet: vi.fn(), apiPost: vi.fn(), apiDelete: vi.fn(), + openSharedDialog: vi.fn(), toastError: vi.fn(), toastSuccess: vi.fn(), })) +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + vi.mock('@/api', () => ({ default: { get: (...args: unknown[]) => mocks.apiGet(...args), @@ -75,10 +80,16 @@ const album = { year: 1975, } +const musicSite = { id: 13, is_active: true, name: '专辑站点', url: 'https://album-music.example' } + /** 按请求路径分派专辑详情与订阅状态查询。 */ function mockAlbumRequests(subscribed = false) { mocks.apiGet.mockImplementation((path: string) => { if (path === 'music/album/release-group-1') return Promise.resolve(album) + if (path === 'site/media/music') return Promise.resolve([musicSite]) + if (path === 'system/setting/public/IndexerSites') { + return Promise.resolve({ data: { value: [13] }, success: true }) + } if (path.startsWith('subscribe/media/')) { return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } }) } @@ -101,6 +112,8 @@ describe('music album page', () => { mocks.apiGet.mockReset() mocks.apiPost.mockReset() mocks.apiDelete.mockReset() + mocks.openSharedDialog.mockReset() + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) mockAlbumRequests() }) @@ -161,4 +174,27 @@ describe('music album page', () => { expect(await screen.findByRole('button', { name: '取消订阅' })).toBeInTheDocument() }) + + it('selects a music-capable site before searching album resources', async () => { + const { router } = await renderAlbumPage() + + await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' })) + + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [ + unknown, + { sites: Array<{ id: number }> }, + { search: (sites: number[]) => void }, + ] + expect(dialogProps.sites).toEqual([musicSite]) + expect(router.currentRoute.value.path).toBe('/music/album') + dialogEvents.search([13]) + + await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource')) + expect(router.currentRoute.value.query).toMatchObject({ + keyword: 'musicbrainz:release-group-1', + sites: '13', + type: '音乐', + }) + }) }) diff --git a/src/pages/__tests__/music-artist.spec.ts b/src/pages/__tests__/music-artist.spec.ts index 861eb72d..95595dd0 100644 --- a/src/pages/__tests__/music-artist.spec.ts +++ b/src/pages/__tests__/music-artist.spec.ts @@ -1,11 +1,16 @@ import MusicArtistPage from '@/pages/music-artist.vue' -import { screen, waitFor } from '@testing-library/vue' +import { fireEvent, 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(), + openSharedDialog: vi.fn(), +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), })) vi.mock('@/api', () => ({ @@ -38,6 +43,8 @@ const artist = { type: '音乐', } +const musicSite = { id: 14, is_active: true, name: '艺术家站点', url: 'https://artist-music.example' } + /** 渲染艺术家详情页,统一提供超级用户权限与路由身份。 */ function renderArtistPage() { return renderWithProviders(MusicArtistPage, { @@ -50,8 +57,14 @@ function renderArtistPage() { describe('music artist page', () => { beforeEach(() => { mocks.apiGet.mockReset() + mocks.openSharedDialog.mockReset() + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) mocks.apiGet.mockImplementation((path: string) => { if (path === 'music/artist/artist-1') return Promise.resolve(artist) + if (path === 'site/media/music') return Promise.resolve([musicSite]) + if (path === 'system/setting/public/IndexerSites') { + return Promise.resolve({ data: { value: [14] }, success: true }) + } return Promise.resolve([]) }) }) @@ -88,13 +101,22 @@ describe('music artist page', () => { expect(screen.getByText('official homepage')).toBeInTheDocument() }) - it('routes the resource search action to the site resource page', async () => { + it('selects a music-capable site before searching artist resources', async () => { const { router } = await renderArtistPage() - const searchButton = await screen.findByRole('button', { name: '搜索资源' }) - searchButton.click() + await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' })) + + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [ + unknown, + { sites: Array<{ id: number }> }, + { search: (sites: number[]) => void }, + ] + expect(dialogProps.sites).toEqual([musicSite]) + expect(router.currentRoute.value.path).toBe('/music/artist') + dialogEvents.search([14]) await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource')) - expect(router.currentRoute.value.query).toMatchObject({ keyword: 'Queen', type: '音乐' }) + expect(router.currentRoute.value.query).toMatchObject({ keyword: 'Queen', sites: '14', type: '音乐' }) }) }) diff --git a/src/pages/__tests__/music-detail.spec.ts b/src/pages/__tests__/music-detail.spec.ts index 0d5e9e60..7f9b8dfc 100644 --- a/src/pages/__tests__/music-detail.spec.ts +++ b/src/pages/__tests__/music-detail.spec.ts @@ -7,10 +7,15 @@ const mocks = vi.hoisted(() => ({ apiGet: vi.fn(), apiPost: vi.fn(), apiDelete: vi.fn(), + openSharedDialog: vi.fn(), toastError: vi.fn(), toastSuccess: vi.fn(), })) +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + vi.mock('@/api', () => ({ default: { get: (...args: unknown[]) => mocks.apiGet(...args), @@ -72,6 +77,8 @@ const album = { type: '音乐', } +const musicSite = { id: 12, is_active: true, name: '音乐详情站点', url: 'https://detail-music.example' } + /** 按请求路径分派单曲详情、专辑详情和订阅状态查询。 */ function mockDetailRequests(subscribed = false) { mocks.apiPost.mockImplementation((path: string) => { @@ -80,6 +87,10 @@ function mockDetailRequests(subscribed = false) { }) mocks.apiGet.mockImplementation((path: string) => { if (path === 'music/album/release-group-1') return Promise.resolve(album) + if (path === 'site/media/music') return Promise.resolve([musicSite]) + if (path === 'system/setting/public/IndexerSites') { + return Promise.resolve({ data: { value: [12] }, success: true }) + } if (path.startsWith('subscribe/media/')) { return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } }) } @@ -101,6 +112,8 @@ describe('music detail page', () => { mocks.apiGet.mockReset() mocks.apiPost.mockReset() mocks.apiDelete.mockReset() + mocks.openSharedDialog.mockReset() + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) mockDetailRequests() }) @@ -183,14 +196,25 @@ describe('music detail page', () => { expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-1' }) }) - it('routes the resource search action to the site resource page', async () => { + it('selects a music-capable site before routing the resource search', async () => { const { router } = await renderMusicDetailPage() await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' })) + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [ + unknown, + { sites: Array<{ id: number }> }, + { search: (sites: number[]) => void }, + ] + expect(dialogProps.sites).toEqual([musicSite]) + expect(router.currentRoute.value.path).toBe('/music/detail') + dialogEvents.search([12]) + await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource')) expect(router.currentRoute.value.query).toMatchObject({ keyword: 'musicbrainz:recording-1', + sites: '12', type: '音乐', }) }) diff --git a/src/pages/__tests__/music.spec.ts b/src/pages/__tests__/music.spec.ts index 534042e2..c6f1bbcf 100644 --- a/src/pages/__tests__/music.spec.ts +++ b/src/pages/__tests__/music.spec.ts @@ -7,10 +7,15 @@ const mocks = vi.hoisted(() => ({ apiGet: vi.fn(), apiPost: vi.fn(), apiDelete: vi.fn(), + openSharedDialog: vi.fn(), toastError: vi.fn(), toastSuccess: vi.fn(), })) +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + vi.mock('@/api', () => ({ default: { get: (...args: unknown[]) => mocks.apiGet(...args), @@ -44,10 +49,16 @@ const musicResult = { year: 2003, } +const musicSite = { id: 11, is_active: true, name: '音乐站点', url: 'https://music.example' } + /** 按请求路径分派音乐搜索与订阅状态查询。 */ function mockSearchAndSubscribeState(subscribed: boolean) { mocks.apiGet.mockImplementation((path: string) => { if (path === 'media/search') return Promise.resolve([musicResult]) + if (path === 'site/media/music') return Promise.resolve([musicSite]) + if (path === 'system/setting/public/IndexerSites') { + return Promise.resolve({ data: { value: [11, 99] }, success: true }) + } if (path.startsWith('subscribe/media/')) { return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } }) } @@ -69,6 +80,8 @@ describe('music page', () => { mocks.apiGet.mockReset() mocks.apiPost.mockReset() mocks.apiDelete.mockReset() + mocks.openSharedDialog.mockReset() + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) mockSearchAndSubscribeState(false) mocks.apiPost.mockResolvedValue({ data: { id: 1 }, success: true }) }) @@ -89,6 +102,7 @@ describe('music page', () => { await renderMusicPage() expect(await screen.findByText('晴天')).toBeInTheDocument() + expect(screen.getByText('单曲')).toBeInTheDocument() expect(screen.getByText('周杰伦')).toBeInTheDocument() expect(screen.getByText('叶惠美')).toBeInTheDocument() expect(screen.getByText('2003-07-31')).toBeInTheDocument() @@ -96,6 +110,18 @@ describe('music page', () => { expect(screen.getByText('Album')).toBeInTheDocument() }) + it('uses the shared themed lift interaction for result cards', async () => { + const { container } = await renderMusicPage() + const hoverArea = await waitFor(() => container.querySelector('.music-card-hover-area')) + const card = container.querySelector('.music-card')! + + await fireEvent.mouseEnter(hoverArea!) + await waitFor(() => expect(card).toHaveClass('app-hover-lift-card--hovering')) + + await fireEvent.mouseLeave(hoverArea!) + await waitFor(() => expect(card).not.toHaveClass('app-hover-lift-card--hovering')) + }) + it('offers a subscribe action when the music is not subscribed yet', async () => { await renderMusicPage() @@ -155,14 +181,28 @@ describe('music page', () => { expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' }) }) - it('routes the resource search action to the site resource page', async () => { + it('selects a music-capable site before routing the resource search', async () => { const { router } = await renderMusicPage() await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' })) + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + expect(mocks.apiGet).toHaveBeenCalledWith('site/media/music') + expect(router.currentRoute.value.path).toBe('/music') + + const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [ + unknown, + { selected: number[]; sites: Array<{ id: number }> }, + { search: (sites: number[]) => void }, + ] + expect(dialogProps.sites).toEqual([musicSite]) + expect(dialogProps.selected).toEqual([11, 99]) + dialogEvents.search([11]) + await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource')) expect(router.currentRoute.value.query).toMatchObject({ keyword: 'musicbrainz:recording-1', + sites: '11', type: '音乐', }) }) diff --git a/src/pages/music.vue b/src/pages/music.vue index 6889c5ac..7fbfe28b 100644 --- a/src/pages/music.vue +++ b/src/pages/music.vue @@ -43,8 +43,8 @@ watch(query, searchMusic, { immediate: true }) - - + + @@ -58,4 +58,18 @@ watch(query, searchMusic, { immediate: true }) max-width: 1440px; margin-inline: auto; } + +@media (width <= 600px) { + .music-search-page { + padding-inline: 0.5rem; + } + + .music-results { + margin-block: -0.375rem; + } + + .music-result-col { + padding-block: 0.375rem; + } +} diff --git a/src/utils/__tests__/music.spec.ts b/src/utils/__tests__/music.spec.ts index 648dbeaa..2f8463e2 100644 --- a/src/utils/__tests__/music.spec.ts +++ b/src/utils/__tests__/music.spec.ts @@ -66,15 +66,18 @@ describe('music utils', () => { it('builds the site resource route from the metadata identity', () => { expect( - buildMusicResourceRoute({ - source: 'musicbrainz', - media_id: 'recording-1', - title: '晴天', - year: '2003', - } as never), + buildMusicResourceRoute( + { + source: 'musicbrainz', + media_id: 'recording-1', + title: '晴天', + year: '2003', + } as never, + [11, 12], + ), ).toMatchObject({ path: '/resource', - query: { keyword: 'musicbrainz:recording-1', type: '音乐' }, + query: { keyword: 'musicbrainz:recording-1', sites: '11,12', type: '音乐' }, }) }) diff --git a/src/utils/music.ts b/src/utils/music.ts index 145a06f4..5460aa39 100644 --- a/src/utils/music.ts +++ b/src/utils/music.ts @@ -59,7 +59,10 @@ export function buildMusicDetailRoute(item: MusicRouteTarget): RouteLocationRaw } /** 构造音乐元数据身份对应的站点资源搜索路由。 */ -export function buildMusicResourceRoute(item: MediaInfo | MusicAlbumInfo): RouteLocationRaw | undefined { +export function buildMusicResourceRoute( + item: MediaInfo | MusicAlbumInfo, + sites: number[] = [], +): RouteLocationRaw | undefined { const source = getMusicSource(item as MusicRouteTarget) if (!source || !item.media_id) return undefined return { @@ -71,6 +74,7 @@ export function buildMusicResourceRoute(item: MediaInfo | MusicAlbumInfo): Route year: item.year, area: 'title', result_type: 'torrent', + ...(sites.length ? { sites: sites.join(',') } : {}), }, } } diff --git a/src/views/discover/MediaDetailView.vue b/src/views/discover/MediaDetailView.vue index f6dd6847..b291c49f 100644 --- a/src/views/discover/MediaDetailView.vue +++ b/src/views/discover/MediaDetailView.vue @@ -176,10 +176,11 @@ function openSearchSiteDialog() { ) } -// 查询所有站点 +// 查询与当前媒体类型兼容的站点 async function querySites() { try { - const data: Site[] = await api.get('site/') + const mediaType = mediaDetail.value.type === '电视剧' ? 'tv' : 'movie' + const data: Site[] = await api.get(`site/media/${mediaType}`) // 过滤站点,只有启用的站点才显示 allSites.value = data.filter(item => item.is_active) diff --git a/src/views/discover/MusicAlbumView.vue b/src/views/discover/MusicAlbumView.vue index 5971c8c4..1fcc135c 100644 --- a/src/views/discover/MusicAlbumView.vue +++ b/src/views/discover/MusicAlbumView.vue @@ -7,6 +7,7 @@ import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue' import MusicTrackList from '@/components/music/MusicTrackList.vue' import NoDataFound from '@/components/states/NoDataFound.vue' import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe' +import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch' import { useUserStore } from '@/stores' import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { buildMusicArtistRoute, buildMusicResourceRoute, formatMusicDuration, getMusicArtistLinks } from '@/utils/music' @@ -70,6 +71,10 @@ const subscribeActions = useMediaSubscribe({ getSubscribeStatusKey, }) +const { openMusicSiteSearch } = useMusicSiteSearch(sites => + album.value ? buildMusicResourceRoute(album.value, sites) : undefined, +) + /** 加载专辑详情、曲目列表和发行版本。 */ async function loadAlbumDetail() { if (!props.source || !props.mediaid) return @@ -95,13 +100,6 @@ async function checkSubscribeStatus() { } } -/** 进入站点资源精确搜索。 */ -function goResource() { - if (!album.value) return - const target = buildMusicResourceRoute(album.value) - if (target) router.push(target) -} - /** 打开艺术家详情页。 */ function goArtist(artistId?: string, name?: string) { if (!artistId) return @@ -144,7 +142,7 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true }) diff --git a/src/views/discover/MusicDetailView.vue b/src/views/discover/MusicDetailView.vue index 9fb0c406..f6e831b9 100644 --- a/src/views/discover/MusicDetailView.vue +++ b/src/views/discover/MusicDetailView.vue @@ -7,6 +7,7 @@ import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue' import MusicTrackList from '@/components/music/MusicTrackList.vue' import NoDataFound from '@/components/states/NoDataFound.vue' import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe' +import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch' import { useUserStore } from '@/stores' import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { @@ -71,6 +72,10 @@ const subscribeActions = useMediaSubscribe({ getSubscribeStatusKey, }) +const { openMusicSiteSearch } = useMusicSiteSearch(sites => + music.value ? buildMusicResourceRoute(music.value, sites) : undefined, +) + /** 加载单曲详情,并按所属专辑补全曲目列表。 */ async function loadMusicDetail() { if (!props.source || !props.mediaid) return @@ -116,13 +121,6 @@ async function checkSubscribeStatus() { } } -/** 进入站点资源精确搜索。 */ -function goResource() { - if (!music.value) return - const target = buildMusicResourceRoute(music.value) - if (target) router.push(target) -} - /** 打开所属专辑详情页。 */ function goAlbum() { if (!music.value?.album_id) return @@ -166,7 +164,7 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })