mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 02:29:31 +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',
|
||||
|
||||
83
src/composables/useMusicSiteSearch.ts
Normal file
83
src/composables/useMusicSiteSearch.ts
Normal file
@@ -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<Site[]>([])
|
||||
const selectedSites = ref<number[]>([])
|
||||
let dialogController: ReturnType<typeof openSharedDialog> | 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 }
|
||||
}
|
||||
@@ -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: '音乐',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: '音乐' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: '音乐',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: '音乐',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,8 +43,8 @@ watch(query, searchMusic, { immediate: true })
|
||||
<VPageContentTitle :title="query || t('music.title')" />
|
||||
|
||||
<LoadingBanner v-if="loading" class="mt-12" />
|
||||
<VRow v-else-if="results.length">
|
||||
<VCol v-for="item in results" :key="getMusicKey(item)" cols="12" md="6" xl="4">
|
||||
<VRow v-else-if="results.length" class="music-results">
|
||||
<VCol v-for="item in results" :key="getMusicKey(item)" cols="12" md="6" xl="4" class="music-result-col">
|
||||
<MusicCard :music="item" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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: '音乐' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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(',') } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="goResource">
|
||||
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="openMusicSiteSearch">
|
||||
{{ t('music.searchResources') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
|
||||
@@ -7,9 +7,9 @@ import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
// MusicBrainz Artist ID
|
||||
@@ -28,6 +28,21 @@ const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
const isRefreshed = ref(false)
|
||||
const artist = ref<MusicArtistInfo>()
|
||||
|
||||
const { openMusicSiteSearch } = useMusicSiteSearch(sites => {
|
||||
if (!artist.value?.name) return undefined
|
||||
return {
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword: artist.value.name,
|
||||
type: '音乐',
|
||||
title: artist.value.name,
|
||||
area: 'title',
|
||||
result_type: 'torrent',
|
||||
sites: sites.join(','),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// 艺术家作品按 MusicBrainz 的 Release Group 主类型分区展示
|
||||
const albumSections = computed(() => [
|
||||
{ type: 'album', title: t('music.albums') },
|
||||
@@ -58,21 +73,6 @@ async function loadArtistDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 按艺术家名称进入站点资源搜索。 */
|
||||
function goResource() {
|
||||
if (!artist.value?.name) return
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword: artist.value.name,
|
||||
type: '音乐',
|
||||
title: artist.value.name,
|
||||
area: 'title',
|
||||
result_type: 'torrent',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 返回指定专辑类型的浏览列表路由。 */
|
||||
function getAlbumsBrowseRoute(albumType: string, title: string) {
|
||||
return `/browse/music/artist/${props.mediaid}/albums?title=${encodeURIComponent(title)}&album_type=${albumType}`
|
||||
@@ -96,7 +96,7 @@ watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true }
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="goResource">
|
||||
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="openMusicSiteSearch">
|
||||
{{ t('music.searchResources') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
|
||||
@@ -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 })
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="goResource">
|
||||
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="openMusicSiteSearch">
|
||||
{{ t('music.searchResources') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
|
||||
@@ -65,7 +65,8 @@ vi.mock('@/utils/appDeepLink', () => ({
|
||||
}))
|
||||
|
||||
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
|
||||
|
||||
const PersonCardSlideViewStub = defineComponent({
|
||||
@@ -134,9 +135,9 @@ interface RenderDetailOptions {
|
||||
type?: string
|
||||
}
|
||||
|
||||
function installSiteHandlers(sites: Site[] = [], selected: number[] = []) {
|
||||
function installSiteHandlers(sites: Site[] = [], selected: number[] = [], type = '电影') {
|
||||
server.use(
|
||||
http.get(siteListUrl, () => HttpResponse.json(sites)),
|
||||
http.get(type === '电视剧' ? tvSiteListUrl : movieSiteListUrl, () => HttpResponse.json(sites)),
|
||||
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
|
||||
)
|
||||
}
|
||||
@@ -169,7 +170,7 @@ async function renderDetail(options: RenderDetailOptions = {}) {
|
||||
)
|
||||
}
|
||||
options.setupHandlers?.()
|
||||
installSiteHandlers(options.sites, options.selectedSites)
|
||||
installSiteHandlers(options.sites, options.selectedSites, type)
|
||||
|
||||
const result = await renderWithProviders(MediaDetailView, {
|
||||
initialState: {
|
||||
@@ -508,7 +509,7 @@ describe('MediaDetailView detail and actions', () => {
|
||||
it('continues to resource search when site settings fail to load', async () => {
|
||||
await renderDetail()
|
||||
server.use(
|
||||
http.get(siteListUrl, () => HttpResponse.json({ message: '站点失败' }, { status: 500 })),
|
||||
http.get(movieSiteListUrl, () => HttpResponse.json({ message: '站点失败' }, { status: 500 })),
|
||||
http.get(selectedSitesUrl, () => HttpResponse.json({ message: '设置失败' }, { status: 500 })),
|
||||
)
|
||||
|
||||
@@ -522,7 +523,7 @@ describe('MediaDetailView detail and actions', () => {
|
||||
const site = createSubscribeSite({ id: 92, is_active: true, name: '空设置站点' })
|
||||
await renderDetail()
|
||||
server.use(
|
||||
http.get(siteListUrl, () => HttpResponse.json([site])),
|
||||
http.get(movieSiteListUrl, () => HttpResponse.json([site])),
|
||||
http.get(selectedSitesUrl, () => HttpResponse.json({ data: {}, success: true })),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user